cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

cat/module-path
012 / internal / ui / transfer.go
transfer.go 9.1K
1package ui23import (4 "context"5 "errors"6 "fmt"7 "os"8 "path/filepath"9 "slices"10 "strings"1112 tea "charm.land/bubbletea/v2"1314 "src.rest/cat/012/internal/confine"15 "src.rest/cat/012/internal/fileio"16 "src.rest/cat/012/internal/ui/picker"17 "src.rest/cat/012/internal/ui/transfer"18)1920// Data comes in through File > Import (a picker of the files around),21// File > Open or the command line, and goes out through File > Download.22// Imports run in the background: the mode indicator says WAIT, the23// status line counts rows with a progress bar, and Esc cancels. An24// imported sheet remembers its source, so Save offers to save it as a25// .012 file or export it back. The import running and its progress are26// package transfer's; here is what the model does with them.2728func init() {29 register(&command{id: "file.import", macro: macroNever, title: "Import",30 desc: "Import a " + importNouns() + " file as new sheets, or in place of this sheet or spreadsheet",31 run: (*Model).openImport})32}3334// importNouns lists the formats 012 imports: "CSV, TSV, ... or 1-2-3".35func importNouns() string {36 var nouns []string37 for _, k := range fileio.Kinds() {38 nouns = append(nouns, k.Noun())39 }40 return strings.Join(nouns[:len(nouns)-1], ", ") + " or " + nouns[len(nouns)-1]41}4243// Import sets a file to import when the program starts, e.g. from44// "012 sales.csv".45func (m *Model) Import(name string) {46 m.xfer.Startup = name47}4849// startupCmd starts the import set with Import, if any.50func (m *Model) startupCmd() tea.Cmd {51 name := m.xfer.Startup52 if name == "" {53 return nil54 }55 m.xfer.Startup = ""56 return m.startImport(name, fileio.Options{}, transfer.Book)57}5859// importable lists the files in the directory names are relative to60// (the current one, or the served one) that 012 can import, by name,61// with their sizes.62func importable(root confine.Root) ([]string, map[string]int64) {63 dir, err := root.Resolve(".")64 if err != nil {65 return nil, nil66 }67 entries, _ := os.ReadDir(dir)68 var out []string69 sizes := map[string]int64{}70 for _, e := range entries {71 if _, ok := fileio.KindOf(e.Name()); ok && e.Type().IsRegular() && !strings.HasPrefix(e.Name(), ".") {72 out = append(out, e.Name())73 if info, err := e.Info(); err == nil {74 sizes[e.Name()] = info.Size()75 }76 }77 }78 slices.SortFunc(out, func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) })79 return out, sizes80}8182// openImport opens the import Picker: the importable files here, with83// their type and size. A typed path that isn't listed can be imported84// too.85func (m *Model) openImport() tea.Cmd {86 var items []picker.Item87 names, sizes := importable(m.root)88 lw := 0 // types line up, and sizes after them89 for _, name := range names {90 k, _ := fileio.KindOf(name)91 lw = max(lw, len(k.Label()))92 }93 for _, name := range names {94 k, _ := fileio.KindOf(name)95 detail, desc := k.Label(), "Import "+name+", "+strings.ToLower(k.Label()[:1])+k.Label()[1:]96 if size, ok := sizes[name]; ok {97 detail = fmt.Sprintf("%-*s %6s", lw, k.Label(), fileSize(size))98 desc += ", " + fileSize(size)99 }100 items = append(items, picker.Item{Title: name, Name: len(name), Detail: detail, Desc: desc,101 Pick: func() tea.Cmd {102 m.closeOverlay()103 return m.askImportPlace(name)104 }})105 }106 p := m.newPicker("Import", "Type to filter, or a path", 72, items)107 p.Action = "import"108 p.Enter = func(query string) (tea.Cmd, bool) {109 // A path, or a name that matched nothing: import it by name.110 if query == "" || !strings.ContainsRune(query, filepath.Separator) && len(p.Shown()) > 0 {111 return nil, false112 }113 m.closeOverlay()114 if _, ok := fileio.KindOf(query); !ok {115 m.fail(fmt.Sprintf("Can't import %s: 012 imports %s", query, importExts()))116 return nil, true117 }118 return m.askImportPlace(query), true119 }120 m.openOverlay(p)121 return nil122}123124// importExts lists the extensions 012 imports, for error messages.125func importExts() string {126 var exts []string127 for _, k := range fileio.Kinds() {128 exts = append(exts, k.Ext())129 }130 return strings.Join(exts[:len(exts)-1], ", ") + " and " + exts[len(exts)-1] + " files"131}132133// fileSize writes a size the way ls -h does.134func fileSize(n int64) string {135 switch {136 case n < 1000:137 return fmt.Sprintf("%d B", n)138 case n < 1000*1000:139 return fmt.Sprintf("%.0f KB", float64(n)/1000)140 case n < 1000*1000*1000:141 return fmt.Sprintf("%.1f MB", float64(n)/1e6)142 }143 return fmt.Sprintf("%.1f GB", float64(n)/1e9)144}145146// confirmImport imports name, first asking when that would replace147// unsaved changes.148func (m *Model) confirmImport(name string, opt fileio.Options) tea.Cmd {149 if !m.changed {150 return m.startImport(name, opt, transfer.Book)151 }152 m.ask(question{153 msg: "Importing replaces this spreadsheet, which has unsaved changes.",154 warn: true,155 choices: []choice{156 {key: "enter", label: "Import", run: func(m *Model) tea.Cmd { return m.startImport(name, opt, transfer.Book) }},157 {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},158 },159 })160 return nil161}162163// startImport reads name in the background.164func (m *Model) startImport(name string, opt fileio.Options, place transfer.Place) tea.Cmd {165 m.note = ""166 path, ok := m.path("import", name)167 if !ok {168 return nil169 }170 if opt.Locale == nil {171 opt.Locale = m.locale()172 }173 return m.xfer.Start(name, path, opt, place, m.spans.Parent())174}175176// importing handles input while an import runs: Esc cancels it, and177// everything else waits.178func (m *Model) importing(msg tea.Msg) (tea.Cmd, bool) {179 if !m.xfer.Busy() {180 return nil, false181 }182 switch msg := msg.(type) {183 case tea.KeyPressMsg:184 if msg.String() == "esc" {185 m.cancelImport()186 }187 return nil, true188 case tea.MouseClickMsg:189 if msg.Mouse().Y == contextLine {190 m.cancelImport() // the Esc chip191 }192 return nil, true193 case tea.MouseMsg, tea.PasteMsg:194 return nil, true195 }196 return nil, false197}198199// cancelImport stops the import at once; its result, if it still200// arrives, is ignored.201func (m *Model) cancelImport() {202 m.note = "Import of " + filepath.Base(m.xfer.Cancel()) + " cancelled"203}204205// handleTransfer handles import and export messages.206func (m *Model) handleTransfer(msg tea.Msg) tea.Cmd {207 switch msg := msg.(type) {208 case transfer.TickMsg, transfer.ImportedMsg:209 res, done, cmd := m.xfer.Update(msg)210 if done {211 return m.handleImported(res)212 }213 return cmd214 case exportedMsg:215 if msg.err != nil {216 m.fail(fmt.Sprintf("Couldn't download %s: %v", msg.name, msg.err))217 return nil218 }219 m.note = "Downloaded " + filepath.Base(msg.name) + " (" + transfer.Rows(msg.res.Rows) + ")"220 if len(msg.res.Notes) > 0 {221 m.note += "; " + strings.Join(msg.res.Notes, "; ")222 }223 }224 return nil225}226227func (m *Model) handleImported(msg transfer.ImportedMsg) tea.Cmd {228 var need *fileio.ErrNeedTable229 switch {230 case errors.Is(msg.Err, context.Canceled):231 m.note = "Import of " + filepath.Base(msg.Name) + " cancelled"232 return nil233 case errors.As(msg.Err, &need):234 m.openTablePicker(msg.Name, need.Tables, msg.Place)235 return nil236 case msg.Err != nil:237 m.fail(fmt.Sprintf("Couldn't import %s: %v", filepath.Base(msg.Name), msg.Err))238 return nil239 }240 if msg.Stream {241 m.streamed(msg)242 return m.term.notify("Read standard input")243 }244 what := filepath.Base(msg.Name)245 switch {246 case msg.Opt.Table != "":247 what = msg.Opt.Table + " from " + what248 case msg.Opt.Query != "":249 what = "the query from " + what250 }251 if m.placeImport(msg, what) {252 return m.term.notify("Imported " + what)253 }254 m.reset(msg.Res.Sheet, "")255 m.xfer.Source, m.xfer.Kind = msg.Name, msg.Res.Kind256 m.note = "Imported " + what + " (" + transfer.Rows(msg.Res.Rows) + ")"257 if len(msg.Res.Notes) > 0 {258 m.note += "; " + strings.Join(msg.Res.Notes, "; ")259 }260 // A long import may finish while the terminal is in the background.261 return m.term.notify("Imported " + what)262}263264// openTablePicker asks which table of a SQLite database to import, or265// for a query.266func (m *Model) openTablePicker(name string, tables []fileio.TableInfo, place transfer.Place) {267 var items []picker.Item268 for _, t := range tables {269 detail := transfer.Rows(t.Rows) + ", " + plural(len(t.Cols), "1 column", fmt.Sprintf("%d columns", len(t.Cols)))270 if t.View {271 detail = "view, " + detail272 }273 desc := "Import " + t.Name + ": " + strings.Join(t.Cols, ", ")274 items = append(items, picker.Item{Title: t.Name, Name: len(t.Name), Detail: detail, Desc: desc,275 Pick: func() tea.Cmd {276 m.closeOverlay()277 return m.startImport(name, fileio.Options{Table: t.Name}, place)278 }})279 }280 first := "table"281 if len(tables) > 0 {282 first = tables[0].Name283 }284 items = append(items, picker.Item{Title: "Run a query", Name: len("Run a query"), Detail: "SELECT …",285 Desc: "Type a SQL query and import its results",286 Pick: func() tea.Cmd {287 m.closeOverlay()288 m.openText("SQL query:", "SELECT * FROM "+quoteSQL(first), func(m *Model, text string) tea.Cmd {289 if text == "" {290 return nil291 }292 return m.startImport(name, fileio.Options{Query: text}, place)293 })294 m.prompt.indicator = "SQL"295 return nil296 }})297 p := m.newPicker("Import from "+filepath.Base(name), "Type to filter tables", 72, items)298 p.Action = "import"299 m.openOverlay(p)300}301302// quoteSQL quotes a table name when it needs it.303func quoteSQL(s string) string {304 if fileio.TableName(s) == s {305 return s306 }307 return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`308}