cat / 012
clone

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

Add File > Import and Download, with progress while importing

Dami committed cb70a1a
commit
cb70a1abb9c8c70133f3edefa07c873de73d179a
parent
a9b8b20ecea64560b301c8b31c1a81437c9f6a1c
author
Dami <dami.etoile@gmail.com>
14 files changed, +913 -16
MREADME.md22+-
Mcmd/012/main.go17+-
Minternal/fileio/fileio.go7+
Minternal/ui/commands.go3+
Minternal/ui/menu.go6+-
Minternal/ui/menubar.go8+-
Minternal/ui/model.go8+-
Minternal/ui/palette.go9+
Minternal/ui/palette_test.go4+-
Minternal/ui/panel.go13+
Minternal/ui/shell_test.go7+-
Minternal/ui/theme.go16+-
Ainternal/ui/transfer.go514++++++++++++++++++++++++++++++
Ainternal/ui/transfer_test.go295+++++++++++++++++
M README.md +20 -2
@@ -6,6 +6,7 @@ behavior, built on Bubble Tea v2.
66 ```sh77 make run # build (pure Go, CGO_ENABLED=0) and start88 ./bin/012 budget.0129+./bin/012 sales.xlsx # or .csv, .tsv, .sqlite, .parquet, .wk1: imported910 ```1011 1112 ## Keys
@@ -42,7 +43,7 @@ Inside the grid, 012 works like Google Sheets.
4243 | Ctrl+B, Ctrl+I, Ctrl+U, Alt+Shift+5 | Bold, italic, underline, strikethrough |4344 | Ctrl+Shift+L / E / R | Align left, center, right |4445 | Ctrl+\ | Clear formatting |45-| Ctrl+S, Ctrl+O, Ctrl+Q | Save, open, quit |46+| Ctrl+S, Ctrl+O, Ctrl+Q | Save, open (imports other formats), quit |4647 | Ctrl+G or F5 | Go to a cell, a range or a named range |4748 | Alt+F, Alt+E, Alt+V, Alt+I, Alt+O, Alt+H, F10, click a title | Open a menu (arrows move, Enter runs, Esc closes) |4849 | Ctrl+K, Alt+/, Ctrl+Shift+P | Search the menus: find and run any command |
@@ -65,6 +66,22 @@ The top three lines are the menu bar and mode indicator, the formula bar
6566 (name box, then the cell's contents or the entry being typed) and the6667 context line (prompts, key hints, formula errors).6768 69+## Files70+71+Sheets save as `.012` files (JSON, one line per cell). Other formats come72+in through File > Import, File > Open or the command line, and go out73+through File > Download. Imports run in the background with a progress74+bar; Esc cancels. Saving an imported sheet asks whether to save it as a75+`.012` file or download it back in its format.76+77+| Format | Import | Download |78+|---|---|---|79+| CSV, TSV | Delimiter (`,` `;` tab `\|`), UTF-8 BOM, UTF-16 and Windows-1252 detected; entries become numbers, dates, currency and percentages as if typed; formulas stay text | Values as shown, as Sheets' Download does |80+| Excel `.xlsx` | First sheet: values, formulas, number formats, bold, italic, underline, strikethrough, alignment, column widths. Formulas 012 can't read (other sheets, unknown functions) keep their values | The same, with formulas in Excel's syntax and their results cached. JEV functions and `#AND#` save as values |81+| SQLite | Pick a table or view, or type a query; a header row names the columns | The sheet or the selection as a table, first row as column names; a table of that name is replaced |82+| Parquet | Every column, with dates and timestamps; lists joined with commas | |83+| Lotus 1-2-3 `.wk1`, `.wks` | Numbers, labels with their alignment, formats, column widths, formulas translated (references, operators, `@SUM`, `@AVG`, `@IF`, `@ROUND`, `@PMT` and 60 more) or kept as values | |84+6885 ## JEV functions6986 7087 With a TypeSafe API key, four functions ask the hosted JEV model about a
@@ -94,6 +111,7 @@ checks the real service.
94111 ```95112 cmd/012 entry point96113 internal/sheet engine: addresses, Pratt parser, evaluator, recalc, file format114+internal/fileio import and export: CSV, TSV, XLSX, SQLite, Parquet, Lotus .wk197115 internal/ui Bubble Tea model: modes, menu, prompts, rendering98116 e2e/ end-to-end tests: real binary on a pty, rendered by libghostty-vt99117 oracle/ differential tests of formulas and formats against excelize
@@ -110,5 +128,5 @@ make oracle # compare formulas and number formats with excelize
110128 111129 `make e2e` needs Zig 0.16+ and `pkg-config`. It is a separate Go module so the112130 cgo dependency never reaches the main binary; so is `oracle`, which keeps113-excelize out of it. `oracle/oracle_test.go` lists the formulas skipped131+its calculation engine out of the tests. `oracle/oracle_test.go` lists the formulas skipped114132 because Sheets and Excel disagree or excelize departs from Excel.
M cmd/012/main.go +16 -1
M internal/fileio/fileio.go +7 -0
M internal/ui/commands.go +3 -0
M internal/ui/menu.go +5 -1
M internal/ui/menubar.go +7 -1
M internal/ui/model.go +7 -1
M internal/ui/palette.go +9 -0
M internal/ui/palette_test.go +2 -2
M internal/ui/panel.go +13 -0
@@ -46,6 +46,9 @@ func (m *Model) View() tea.View {
4646 // Terminals that support it (OSC 9;4) show activity in the tab.4747 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, 0)4848 }49+ if bar := m.progressBar(); bar != nil {50+ v.ProgressBar = bar51+ }4952 if m.changed {5053 v.WindowTitle += " (modified)"5154 }
@@ -57,6 +60,9 @@ func (m *Model) View() tea.View {
5760 }5861 5962 func (m *Model) displayName() string {63+ if m.filename == "" && m.xfer.source != "" {64+ return filepath.Base(m.xfer.source)65+ }6066 if m.filename == "" {6167 return "untitled"6268 }
@@ -66,6 +72,8 @@ func (m *Model) displayName() string {
6672 // indicator is the mode shown at the top right.6773 func (m *Model) indicator() string {6874 switch {75+ case m.xfer.job != nil:76+ return "WAIT"6977 case m.overlay != nil:7078 return m.overlay.indicator()7179 case m.mode == modePrompt:
@@ -115,6 +123,8 @@ func (m *Model) formulaBar() string {
115123 func (m *Model) contextLineText() string {116124 var left, right string117125 switch {126+ case m.xfer.job != nil:127+ left = m.importLine()118128 case m.drag == dragResize:119129 left = m.th.key.Render("Column "+sheet.ColName(m.resizeCol)) + m.th.muted.Render(" width ") +120130 strconv.Itoa(m.sheet.ColWidth(m.resizeCol)) + m.th.muted.Render(" double-click the border to fit")
@@ -227,6 +237,9 @@ func (m *Model) statusLine() string {
227237 if m.mode == modeError {228238 return m.th.error.Render(m.errMsg) + m.th.muted.Render(" press any key")229239 }240+ if m.xfer.job != nil {241+ return m.importStatus()242+ }230243 desc, keys, floating := m.assistStatus()231244 if m.overlay != nil {232245 desc, keys, floating = "", "", true
M internal/ui/shell_test.go +4 -3
M internal/ui/theme.go +11 -5
@@ -34,11 +34,13 @@ type theme struct {
3434 // errorMark is layered on an error's text, so errors show beyond3535 // color: a curly underline, in the error color where the terminal3636 // supports colored underlines.37- errorMark lipgloss.Style38- link lipgloss.Style // a cell's URL or HYPERLINK label, layered on the cell's role39- found lipgloss.Style // cells matching an open search40- traced lipgloss.Style // precedents or dependents being traced41- argument lipgloss.Style // the argument at the caret in a function's signature37+ errorMark lipgloss.Style38+ link lipgloss.Style // a cell's URL or HYPERLINK label, layered on the cell's role39+ found lipgloss.Style // cells matching an open search40+ traced lipgloss.Style // precedents or dependents being traced41+ argument lipgloss.Style // the argument at the caret in a function's signature42+ progress lipgloss.Style // the done part of an import's progress bar43+ progressTodo lipgloss.Style // the rest of the progress bar4244 // copied marks the range on the clipboard, like Sheets' dashed border:4345 // a dashed underline across every cell, layered on the cell's own4446 // style, with its own text color where the cell has none.
@@ -107,11 +109,13 @@ func newTheme(dark bool) theme {
107109 // colored backgrounds stays at or above roughly 4.5:1.108110 headerBg, headerFg := lipgloss.BrightBlack, lipgloss.BrightWhite109111 selFg, muted, match := lipgloss.Black, lipgloss.BrightBlack, lipgloss.Yellow112+ bar := lipgloss.Cyan110113 filterFg := lipgloss.Yellow111114 if !dark {112115 headerBg, headerFg = lipgloss.White, lipgloss.Black113116 selFg, muted, match = lipgloss.BrightWhite, lipgloss.Black, lipgloss.Blue114117 filterFg = lipgloss.Blue118+ bar = lipgloss.Blue115119 }116120 // Links are blue, as in Sheets; plain blue is too dark on a dark117121 // background.
@@ -145,6 +149,8 @@ func newTheme(dark bool) theme {
145149 found: lipgloss.NewStyle().Background(lipgloss.Yellow).Foreground(lipgloss.Black),146150 traced: lipgloss.NewStyle().Background(lipgloss.Green).Foreground(lipgloss.Black),147151 argument: lipgloss.NewStyle().Bold(true).Underline(true),152+ progress: lipgloss.NewStyle().Foreground(bar),153+ progressTodo: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),148154 copied: lipgloss.NewStyle().Foreground(lipgloss.Magenta).149155 UnderlineStyle(lipgloss.UnderlineDashed).UnderlineSpaces(true),150156 frozenLine: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),
A internal/ui/transfer.go +514 -0
@@ -0,0 +1,514 @@
1+package ui2+3+import (4+ "context"5+ "errors"6+ "fmt"7+ "os"8+ "path/filepath"9+ "slices"10+ "strings"11+ "time"12+13+ tea "charm.land/bubbletea/v2"14+ "github.com/charmbracelet/x/ansi"15+16+ "012/internal/fileio"17+ "012/internal/sheet"18+)19+20+// 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.26+27+// transfer is the model's import and export state.28+type transfer struct {29+ job *importJob30+ lastID int31+ source string // the file the sheet was imported from, if any32+ kind fileio.Kind // its format33+ startup string // a file to import as the program starts34+}35+36+// importJob is an import in progress.37+type importJob struct {38+ id int39+ name string40+ prog *fileio.Progress41+ cancel context.CancelFunc42+}43+44+type importedMsg struct {45+ id int46+ name string47+ opt fileio.Options48+ res *fileio.Result49+ err error50+}51+52+type importTickMsg struct{ id int }53+54+type exportedMsg struct {55+ name string56+ kind fileio.Kind57+ res *fileio.ExportResult58+ err error59+}60+61+// importTick is how often the progress display refreshes.62+const importTick = 100 * time.Millisecond63+64+func init() {65+ register(&command{id: "file.import", title: "Import",66+ desc: "Import a CSV, TSV, Excel, SQLite, Parquet or 1-2-3 file, replacing this sheet",67+ run: (*Model).openImport})68+ for _, k := range fileio.Kinds() {69+ if !k.CanExport() {70+ continue71+ }72+ register(&command{73+ id: "file.download." + strings.ToLower(k.String()),74+ title: "Download as " + k.String(),75+ desc: downloadDesc(k),76+ run: func(m *Model) tea.Cmd { return m.openDownload(k) },77+ })78+ }79+}80+81+func downloadDesc(k fileio.Kind) string {82+ switch k {83+ case fileio.CSV, fileio.TSV:84+ return "Save the values as shown, as " + strings.ToLower(k.Label()) + " (" + k.Ext() + ")"85+ case fileio.XLSX:86+ return "Save as an Excel workbook (.xlsx) with formulas, formats and widths"87+ case fileio.SQLite:88+ return "Save the sheet, or the selection, as a table in a SQLite database"89+ }90+ return ""91+}92+93+// Import sets a file to import when the program starts, e.g. from94+// "012 sales.csv".95+func (m *Model) Import(name string) {96+ m.xfer.startup = name97+}98+99+// startupCmd starts the import set with Import, if any.100+func (m *Model) startupCmd() tea.Cmd {101+ name := m.xfer.startup102+ if name == "" {103+ return nil104+ }105+ m.xfer.startup = ""106+ return m.startImport(name, fileio.Options{})107+}108+109+// importable lists the files in the current directory that 012 can110+// import, by name.111+func importable() []string {112+ entries, _ := os.ReadDir(".")113+ var out []string114+ for _, e := range entries {115+ if _, ok := fileio.KindOf(e.Name()); ok && e.Type().IsRegular() && !strings.HasPrefix(e.Name(), ".") {116+ out = append(out, e.Name())117+ }118+ }119+ slices.SortFunc(out, func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) })120+ return out121+}122+123+// openImport opens the import picker: the importable files here, with124+// their type and size. A typed path that isn't listed can be imported125+// too.126+func (m *Model) openImport() tea.Cmd {127+ var items []pickItem128+ for _, name := range importable() {129+ k, _ := fileio.KindOf(name)130+ detail, desc := k.Label(), "Import "+name+", "+strings.ToLower(k.Label()[:1])+k.Label()[1:]131+ if st, err := os.Stat(name); err == nil {132+ detail += " " + fileSize(st.Size())133+ desc += ", " + fileSize(st.Size()) + ", changed " + st.ModTime().Format("Jan 2 15:04")134+ }135+ items = append(items, pickItem{title: name, name: len(name), detail: detail, desc: desc,136+ pick: func(m *Model) tea.Cmd {137+ m.closeOverlay()138+ return m.confirmImport(name, fileio.Options{})139+ }})140+ }141+ p := newPicker(m, "Import", "Type to filter, or a path", 72, items)142+ p.action = "import"143+ p.enter = func(m *Model, query string) (tea.Cmd, bool) {144+ // A path, or a name that matched nothing: import it by name.145+ if query == "" || !strings.ContainsRune(query, filepath.Separator) && len(p.shown) > 0 {146+ return nil, false147+ }148+ m.closeOverlay()149+ if _, ok := fileio.KindOf(query); !ok {150+ m.fail(fmt.Sprintf("Can't import %s: 012 imports %s", query, importExts()))151+ return nil, true152+ }153+ return m.confirmImport(query, fileio.Options{}), true154+ }155+ m.openOverlay(p)156+ return nil157+}158+159+// importExts lists the extensions 012 imports, for error messages.160+func importExts() string {161+ var exts []string162+ for _, k := range fileio.Kinds() {163+ exts = append(exts, k.Ext())164+ }165+ return strings.Join(exts[:len(exts)-1], ", ") + " and " + exts[len(exts)-1] + " files"166+}167+168+// fileSize writes a size the way ls -h does.169+func fileSize(n int64) string {170+ switch {171+ case n < 1000:172+ return fmt.Sprintf("%d B", n)173+ case n < 1000*1000:174+ return fmt.Sprintf("%.0f KB", float64(n)/1000)175+ case n < 1000*1000*1000:176+ return fmt.Sprintf("%.1f MB", float64(n)/1e6)177+ }178+ return fmt.Sprintf("%.1f GB", float64(n)/1e9)179+}180+181+// confirmImport imports name, first asking when that would replace182+// unsaved changes.183+func (m *Model) confirmImport(name string, opt fileio.Options) tea.Cmd {184+ if !m.changed {185+ return m.startImport(name, opt)186+ }187+ m.openOverlay(&choiceBar{188+ msg: "Importing replaces this sheet, which has unsaved changes.",189+ warn: true,190+ choices: []choice{191+ {key: "enter", label: "Import", run: func(m *Model) tea.Cmd { return m.startImport(name, opt) }},192+ {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},193+ },194+ })195+ return nil196+}197+198+// startImport reads name in the background.199+func (m *Model) startImport(name string, opt fileio.Options) tea.Cmd {200+ if m.xfer.job != nil {201+ m.xfer.job.cancel()202+ }203+ ctx, cancel := context.WithCancel(context.Background())204+ m.xfer.lastID++205+ job := &importJob{id: m.xfer.lastID, name: name, prog: fileio.NewProgress(), cancel: cancel}206+ opt.Progress = job.prog207+ m.xfer.job = job208+ m.note = ""209+ return tea.Batch(210+ func() tea.Msg {211+ res, err := fileio.Import(ctx, name, opt)212+ return importedMsg{id: job.id, name: name, opt: opt, res: res, err: err}213+ },214+ tickImport(job.id),215+ )216+}217+218+func tickImport(id int) tea.Cmd {219+ return tea.Tick(importTick, func(time.Time) tea.Msg { return importTickMsg{id} })220+}221+222+// importing handles input while an import runs: Esc cancels it, and223+// everything else waits.224+func (m *Model) importing(msg tea.Msg) (tea.Cmd, bool) {225+ if m.xfer.job == nil {226+ return nil, false227+ }228+ switch msg := msg.(type) {229+ case tea.KeyPressMsg:230+ if msg.String() == "esc" {231+ m.xfer.job.cancel()232+ }233+ return nil, true234+ case tea.MouseClickMsg:235+ if msg.Mouse().Y == contextLine {236+ m.xfer.job.cancel() // the Esc chip237+ }238+ return nil, true239+ case tea.MouseMsg, tea.PasteMsg:240+ return nil, true241+ }242+ return nil, false243+}244+245+// handleTransfer handles import and export messages.246+func (m *Model) handleTransfer(msg tea.Msg) tea.Cmd {247+ switch msg := msg.(type) {248+ case importTickMsg:249+ if m.xfer.job != nil && m.xfer.job.id == msg.id {250+ return tickImport(msg.id)251+ }252+ case importedMsg:253+ if m.xfer.job == nil || m.xfer.job.id != msg.id {254+ return nil255+ }256+ m.xfer.job.cancel()257+ m.xfer.job = nil258+ return m.handleImported(msg)259+ case exportedMsg:260+ if msg.err != nil {261+ m.fail(fmt.Sprintf("Couldn't download %s: %v", msg.name, msg.err))262+ return nil263+ }264+ m.note = "Downloaded " + filepath.Base(msg.name) + " (" + countRows(msg.res.Rows) + ")"265+ if len(msg.res.Notes) > 0 {266+ m.note += "; " + strings.Join(msg.res.Notes, "; ")267+ }268+ }269+ return nil270+}271+272+func countRows(n int) string {273+ if n == 1 {274+ return "1 row"275+ }276+ return thousands(n) + " rows"277+}278+279+func thousands(n int) string {280+ s := fmt.Sprint(n)281+ for i := len(s) - 3; i > 0; i -= 3 {282+ s = s[:i] + "," + s[i:]283+ }284+ return s285+}286+287+func (m *Model) handleImported(msg importedMsg) tea.Cmd {288+ var need *fileio.ErrNeedTable289+ switch {290+ case errors.Is(msg.err, context.Canceled):291+ m.note = "Import of " + filepath.Base(msg.name) + " cancelled"292+ return nil293+ case errors.As(msg.err, &need):294+ m.openTablePicker(msg.name, need.Tables)295+ return nil296+ case msg.err != nil:297+ m.fail(fmt.Sprintf("Couldn't import %s: %v", filepath.Base(msg.name), msg.err))298+ return nil299+ }300+ jev := m.jev301+ m.reset(msg.res.Sheet, "")302+ m.jev = jev303+ m.xfer.source, m.xfer.kind = msg.name, msg.res.Kind304+ what := filepath.Base(msg.name)305+ switch {306+ case msg.opt.Table != "":307+ what = msg.opt.Table + " from " + what308+ case msg.opt.Query != "":309+ what = "the query from " + what310+ }311+ m.note = "Imported " + what + " (" + countRows(msg.res.Rows) + ")"312+ if len(msg.res.Notes) > 0 {313+ m.note += "; " + strings.Join(msg.res.Notes, "; ")314+ }315+ return nil316+}317+318+// openTablePicker asks which table of a SQLite database to import, or319+// for a query.320+func (m *Model) openTablePicker(name string, tables []fileio.TableInfo) {321+ var items []pickItem322+ for _, t := range tables {323+ detail := countRows(t.Rows) + ", " + plural(len(t.Cols), "1 column", fmt.Sprintf("%d columns", len(t.Cols)))324+ if t.View {325+ detail = "view, " + detail326+ }327+ desc := "Import " + t.Name + ": " + strings.Join(t.Cols, ", ")328+ items = append(items, pickItem{title: t.Name, name: len(t.Name), detail: detail, desc: desc,329+ pick: func(m *Model) tea.Cmd {330+ m.closeOverlay()331+ return m.startImport(name, fileio.Options{Table: t.Name})332+ }})333+ }334+ first := "table"335+ if len(tables) > 0 {336+ first = tables[0].Name337+ }338+ items = append(items, pickItem{title: "Run a query", name: len("Run a query"), detail: "SELECT …",339+ desc: "Type a SQL query and import its results",340+ pick: func(m *Model) tea.Cmd {341+ m.closeOverlay()342+ m.openText("SQL query:", "SELECT * FROM "+quoteSQL(first), func(m *Model, text string) tea.Cmd {343+ if text == "" {344+ return nil345+ }346+ return m.startImport(name, fileio.Options{Query: text})347+ })348+ m.prompt.indicator = "SQL"349+ return nil350+ }})351+ p := newPicker(m, "Import from "+filepath.Base(name), "Type to filter tables", 72, items)352+ p.action = "import"353+ m.openOverlay(p)354+}355+356+// quoteSQL quotes a table name when it needs it.357+func quoteSQL(s string) string {358+ if fileio.TableName(s) == s {359+ return s360+ }361+ return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`362+}363+364+// importLine is the context line during an import.365+func (m *Model) importLine() string {366+ return "Importing " + filepath.Base(m.xfer.job.name) + "… " + m.keyHints("Esc", "cancel")367+}368+369+// importStatus is the status line during an import: the file, the rows370+// read, and a bar when the total is known.371+func (m *Model) importStatus() string {372+ job := m.xfer.job373+ rows, frac := job.prog.Get()374+ left := m.th.key.Render("Importing " + filepath.Base(job.name))375+ right := countRows(rows)376+ if frac >= 0 {377+ pct := fmt.Sprintf(" %3d%%", int(frac*100))378+ w := clamp(m.width-ansi.StringWidth(left)-ansi.StringWidth(right)-len(pct)-6, 0, 30)379+ if w >= 8 {380+ done := int(frac * float64(w))381+ right += " " + m.th.progress.Render(strings.Repeat("━", done)) +382+ m.th.progressTodo.Render(strings.Repeat("─", w-done)) + pct383+ } else {384+ right += pct385+ }386+ }387+ return m.spread(left, right)388+}389+390+// progressBar is the terminal's own progress indicator (OSC 9;4), shown391+// in the tab or title bar where supported.392+func (m *Model) progressBar() *tea.ProgressBar {393+ if m.xfer.job == nil {394+ return nil395+ }396+ if _, frac := m.xfer.job.prog.Get(); frac >= 0 {397+ return tea.NewProgressBar(tea.ProgressBarDefault, int(frac*100))398+ }399+ return tea.NewProgressBar(tea.ProgressBarIndeterminate, 0)400+}401+402+// displayBase is the name downloads and saves start from: the sheet's403+// file or the imported file, without its extension.404+func (m *Model) displayBase() string {405+ name := m.filename406+ if name == "" {407+ name = m.xfer.source408+ }409+ if name == "" {410+ return "SHEET1"411+ }412+ return strings.TrimSuffix(name, filepath.Ext(name))413+}414+415+// openDownload asks where to download the sheet as k.416+func (m *Model) openDownload(k fileio.Kind) tea.Cmd {417+ if _, ok := m.sheet.UsedRange(); !ok {418+ m.note = "Nothing to download: the sheet is empty"419+ return nil420+ }421+ r := sheet.Rect{}422+ label := "Download as " + k.String() + ":"423+ if k == fileio.SQLite && m.hasRange() {424+ r = m.selection()425+ label = "Download " + r.String() + " as SQLite:"426+ }427+ m.openText(label, m.displayBase()+k.Ext(), func(m *Model, text string) tea.Cmd {428+ if text == "" {429+ return nil430+ }431+ name := text432+ if filepath.Ext(name) == "" {433+ name += k.Ext()434+ }435+ if k == fileio.SQLite {436+ m.openTableName(name, r)437+ return nil438+ }439+ return m.confirmReplace(filepath.Base(name)+" exists.", func(m *Model) tea.Cmd {440+ return m.download(name, k, r, "")441+ }, exists(name))442+ })443+ return nil444+}445+446+func exists(name string) bool {447+ _, err := os.Stat(name)448+ return err == nil449+}450+451+// openTableName asks for the table to write in a SQLite database.452+func (m *Model) openTableName(name string, r sheet.Rect) {453+ m.openText("Table in "+filepath.Base(name)+":", fileio.TableName(filepath.Base(m.displayBase())), func(m *Model, table string) tea.Cmd {454+ if table == "" {455+ return nil456+ }457+ has := false458+ if exists(name) {459+ ts, err := fileio.Tables(context.Background(), name)460+ if err != nil {461+ m.fail(fmt.Sprintf("Couldn't open %s: %v", filepath.Base(name), err))462+ return nil463+ }464+ for _, t := range ts {465+ has = has || strings.EqualFold(t.Name, table)466+ }467+ }468+ return m.confirmReplace("Table "+table+" exists in "+filepath.Base(name)+".", func(m *Model) tea.Cmd {469+ return m.download(name, fileio.SQLite, r, table)470+ }, has)471+ })472+}473+474+// confirmReplace runs do, first asking on the context line when it475+// would replace something.476+func (m *Model) confirmReplace(msg string, do func(*Model) tea.Cmd, replaces bool) tea.Cmd {477+ if !replaces {478+ return do(m)479+ }480+ m.openOverlay(&choiceBar{481+ msg: msg,482+ warn: true,483+ choices: []choice{484+ {key: "enter", label: "Replace", run: do},485+ {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},486+ },487+ })488+ return nil489+}490+491+// download snapshots the sheet and writes it in the background.492+func (m *Model) download(name string, k fileio.Kind, r sheet.Rect, table string) tea.Cmd {493+ snap := fileio.Snap(m.sheet, r, filepath.Base(m.displayBase()))494+ return func() tea.Msg {495+ res, err := fileio.Export(context.Background(), name, k, snap, fileio.ExportOptions{Table: table})496+ return exportedMsg{name: name, kind: k, res: res, err: err}497+ }498+}499+500+// saveImported is Save for a sheet imported from another format: save it501+// as a .012 file (keeping formulas and formatting), or export it back.502+func (m *Model) saveImported() tea.Cmd {503+ k := m.xfer.kind504+ choices := []choice{{key: "enter", label: "Save as " + filepath.Base(m.displayBase()) + sheet.FileExt, run: (*Model).openSave}}505+ if k.CanExport() {506+ choices = append(choices, choice{key: "e", label: "Download as " + k.String(), run: func(m *Model) tea.Cmd { return m.openDownload(k) }})507+ }508+ choices = append(choices, choice{key: "esc", label: "Cancel", run: func(m *Model) tea.Cmd {509+ m.quitAfterSave = false510+ return nil511+ }})512+ m.openOverlay(&choiceBar{msg: filepath.Base(m.xfer.source) + " was imported.", choices: choices})513+ return nil514+}
A internal/ui/transfer_test.go +295 -0
@@ -0,0 +1,295 @@
1+package ui2+3+import (4+ "context"5+ "database/sql"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+11+ tea "charm.land/bubbletea/v2"12+13+ "012/internal/fileio"14+ "012/internal/sheet"15+)16+17+func writeFile(t *testing.T, name, text string) {18+ t.Helper()19+ os.MkdirAll(filepath.Dir(name), 0o755)20+ if err := os.WriteFile(name, []byte(text), 0o644); err != nil {21+ t.Fatal(err)22+ }23+}24+25+func pickerTitles(t *testing.T, m *Model) []string {26+ var out []string27+ for _, pm := range openPicker(t, m).shown {28+ out = append(out, pm.item.title+" | "+pm.item.detail)29+ }30+ return out31+}32+33+func TestImportPicker(t *testing.T) {34+ t.Chdir(t.TempDir())35+ writeFile(t, "sales.csv", "Region,Total\nNorth,\"$1,200\"\n")36+ writeFile(t, "notes.txt", "not importable")37+ writeFile(t, "Old.WK1", "x")38+ writeFile(t, "sub/deep.tsv", "a\tb\n")39+ m := newModel()40+ m.runCommand("file.import")41+ got := strings.Join(pickerTitles(t, m), "\n")42+ want := "Old.WK1 | Lotus 1-2-3 worksheet 1 B\nsales.csv | Comma-separated values 28 B"43+ if got != want {44+ t.Errorf("picker lists\n%s\nwant\n%s", got, want)45+ }46+ if !strings.Contains(screen(m), "Import") || !strings.Contains(line(m, m.height-1), "Import sales.csv") &&47+ !strings.Contains(line(m, m.height-1), "Import Old.WK1") {48+ t.Errorf("screen:\n%s", screen(m))49+ }50+ press(t, m, "sal", "<enter>")51+ if m.overlay != nil || m.mode != modeReady {52+ t.Fatalf("after import: mode %v overlay %T err %q", m.mode, m.overlay, m.errMsg)53+ }54+ if v := m.sheet.Value(addr("B2")); v.Num != 1200 {55+ t.Errorf("B2 = %+v", v)56+ }57+ if l := line(m, contextLine); l != "Imported sales.csv (2 rows)" {58+ t.Errorf("context line %q", l)59+ }60+ if !strings.HasPrefix(line(m, m.height-1), "sales.csv") || m.changed {61+ t.Errorf("status %q changed %v", line(m, m.height-1), m.changed)62+ }63+64+ // A typed path imports a file that isn't listed.65+ m.runCommand("file.import")66+ press(t, m, "sub/deep.tsv", "<enter>")67+ if input(m, "B1") != "b" || m.xfer.source != "sub/deep.tsv" {68+ t.Errorf("typed path: B1 %q source %q err %q", input(m, "B1"), m.xfer.source, m.errMsg)69+ }70+ // Unknown types and broken files say what's wrong.71+ m.runCommand("file.import")72+ press(t, m, "notes.txt", "<enter>")73+ if m.mode != modeError || !strings.Contains(m.errMsg, "Can't import notes.txt: 012 imports .csv, .tsv") {74+ t.Errorf("txt: %v %q", m.mode, m.errMsg)75+ }76+ press(t, m, "<esc>")77+ m.runCommand("file.import")78+ press(t, m, "old", "<enter>")79+ if m.mode != modeError || m.errMsg != "Couldn't import Old.WK1: not a Lotus 1-2-3 worksheet: the file is empty" &&80+ !strings.Contains(m.errMsg, "Couldn't import Old.WK1: not a Lotus 1-2-3 worksheet") {81+ t.Errorf("wk1: %v %q", m.mode, m.errMsg)82+ }83+}84+85+func TestImportAsksBeforeReplacingChanges(t *testing.T) {86+ t.Chdir(t.TempDir())87+ writeFile(t, "a.csv", "1\n")88+ m := newModel()89+ press(t, m, "keep me", "<enter>")90+ m.runCommand("file.import")91+ press(t, m, "<enter>")92+ if _, ok := m.overlay.(*choiceBar); !ok || !strings.Contains(line(m, contextLine), "Importing replaces this sheet, which has unsaved changes.") {93+ t.Fatalf("no warning: %q", line(m, contextLine))94+ }95+ press(t, m, "<esc>")96+ if input(m, "A1") != "keep me" {97+ t.Errorf("Esc imported anyway")98+ }99+ m.runCommand("file.import")100+ press(t, m, "<enter>", "<enter>")101+ if input(m, "A1") != "1" {102+ t.Errorf("Enter didn't import: %q", input(m, "A1"))103+ }104+}105+106+func TestOpenImportsOtherFormats(t *testing.T) {107+ t.Chdir(t.TempDir())108+ writeFile(t, "data.tsv", "x\t2\n")109+ m := newModel()110+ press(t, m, "<ctrl+o>", "data.tsv", "<enter>")111+ if input(m, "B1") != "2" || m.filename != "" || m.xfer.source != "data.tsv" {112+ t.Errorf("open: B1 %q file %q source %q", input(m, "B1"), m.filename, m.xfer.source)113+ }114+ // Save offers a .012 file or exporting back.115+ press(t, m, "<ctrl+s>")116+ if l := line(m, contextLine); !strings.Contains(l, "data.tsv was imported.") || !strings.Contains(l, "Save as data.012") || !strings.Contains(l, "Download as TSV") {117+ t.Errorf("save choices %q", l)118+ }119+ press(t, m, "<enter>")120+ if m.mode != modePrompt || string(m.buf) != "data.012" {121+ t.Errorf("save as %v %q", m.mode, string(m.buf))122+ }123+ press(t, m, "<enter>")124+ if m.filename != "data.012" {125+ t.Errorf("saved as %q", m.filename)126+ }127+ press(t, m, "<ctrl+s>") // now a native file: saves directly128+ if m.overlay != nil || m.mode != modeReady {129+ t.Errorf("second save asked again")130+ }131+}132+133+func TestSQLiteTablePicker(t *testing.T) {134+ t.Chdir(t.TempDir())135+ db, err := sql.Open("sqlite", "shop.db")136+ if err != nil {137+ t.Fatal(err)138+ }139+ for _, s := range []string{140+ "CREATE TABLE orders (id INTEGER, total REAL)", "INSERT INTO orders VALUES (1, 9.5), (2, 3)",141+ "CREATE TABLE people (name TEXT)", "INSERT INTO people VALUES ('Ada')",142+ } {143+ if _, err := db.Exec(s); err != nil {144+ t.Fatal(err)145+ }146+ }147+ db.Close()148+ m := newModel()149+ m.runCommand("file.import")150+ press(t, m, "<enter>")151+ got := strings.Join(pickerTitles(t, m), "\n")152+ if got != "orders | 2 rows, 2 columns\npeople | 1 row, 1 column\nRun a query | SELECT …" {153+ t.Errorf("tables:\n%s", got)154+ }155+ if !strings.Contains(screen(m), "Import from shop.db") {156+ t.Errorf("title missing:\n%s", screen(m))157+ }158+ press(t, m, "<enter>")159+ if m.sheet.Value(addr("B2")).Num != 9.5 || line(m, contextLine) != "Imported orders from shop.db (3 rows)" {160+ t.Errorf("B2 %+v, context %q", m.sheet.Value(addr("B2")), line(m, contextLine))161+ }162+163+ m.runCommand("file.import")164+ press(t, m, "<enter>", "query", "<enter>")165+ if m.mode != modePrompt || string(m.buf) != "SELECT * FROM orders" {166+ t.Fatalf("query prompt %v %q", m.mode, string(m.buf))167+ }168+ press(t, m, "<ctrl+a>")169+ m.buf, m.bufPos = []rune("SELECT sum(total) AS s FROM orders"), len("SELECT sum(total) AS s FROM orders")170+ press(t, m, "<enter>")171+ if m.sheet.Value(addr("A2")).Num != 12.5 {172+ t.Errorf("query A2 %+v err %q", m.sheet.Value(addr("A2")), m.errMsg)173+ }174+}175+176+func TestDownload(t *testing.T) {177+ t.Chdir(t.TempDir())178+ m := newModel()179+ m.runCommand("file.download.csv")180+ if m.mode == modePrompt || line(m, contextLine) != "Nothing to download: the sheet is empty" {181+ t.Errorf("empty sheet: %q", line(m, contextLine))182+ }183+ press(t, m, "Item", "<tab>", "Cost", "<enter>", "Rent", "<tab>", "$1,450", "<enter>", "Total", "<tab>", "=B2*2", "<enter>")184+185+ m.runCommand("file.download.csv")186+ if l := line(m, contextLine); !strings.HasPrefix(l, "Download as CSV: SHEET1.csv") {187+ t.Errorf("prompt %q", l)188+ }189+ press(t, m, "bills", "<enter>")190+ data, err := os.ReadFile("bills.csv")191+ if err != nil || string(data) != "Item,Cost\nRent,\"$1,450\"\nTotal,\"$2,900\"\n" {192+ t.Errorf("bills.csv %q %v", data, err)193+ }194+ if l := line(m, contextLine); l != "Downloaded bills.csv (3 rows); 1 formula saved as values" {195+ t.Errorf("note %q", l)196+ }197+198+ // Downloading over a file asks first.199+ m.runCommand("file.download.csv")200+ press(t, m, "bills", "<enter>")201+ if l := line(m, contextLine); !strings.Contains(l, "bills.csv exists.") || !strings.Contains(l, "Replace") {202+ t.Fatalf("no replace question: %q", l)203+ }204+ press(t, m, "<esc>")205+206+ m.runCommand("file.download.xlsx")207+ press(t, m, "<enter>")208+ if _, err := os.Stat("SHEET1.xlsx"); err != nil {209+ t.Errorf("xlsx: %v %q", err, m.errMsg)210+ }211+212+ // SQLite asks for a table, and exports the selection when there is213+ // one.214+ press(t, m, "<ctrl+home>", "<shift+down>")215+ m.runCommand("file.download.sqlite")216+ if l := line(m, contextLine); !strings.HasPrefix(l, "Download A1:A2 as SQLite: SHEET1.sqlite") {217+ t.Errorf("sqlite prompt %q", l)218+ }219+ press(t, m, "<enter>")220+ if l := line(m, contextLine); !strings.HasPrefix(l, "Table in SHEET1.sqlite: SHEET1") {221+ t.Errorf("table prompt %q", l)222+ }223+ press(t, m, "<enter>")224+ if l := line(m, contextLine); l != "Downloaded SHEET1.sqlite (1 row)" {225+ t.Errorf("sqlite note %q err %q", l, m.errMsg)226+ }227+ m.runCommand("file.download.sqlite")228+ press(t, m, "<enter>", "<enter>")229+ if l := line(m, contextLine); !strings.Contains(l, "Table SHEET1 exists in SHEET1.sqlite.") {230+ t.Errorf("no table question: %q", l)231+ }232+ press(t, m, "<enter>")233+ ts, _ := fileio.Tables(context.Background(), "SHEET1.sqlite")234+ if len(ts) != 1 || ts[0].Rows != 1 {235+ t.Errorf("tables %+v", ts)236+ }237+}238+239+func TestImportProgress(t *testing.T) {240+ m := newModel()241+ m.Update(tea.WindowSizeMsg{Width: 80, Height: 16})242+ cancelled := false243+ prog := fileio.NewProgress()244+ m.xfer.job = &importJob{id: 7, name: "data/big.csv", prog: prog, cancel: func() { cancelled = true }}245+ prog.Report(1234, 0, 0)246+ if ind := m.indicator(); ind != "WAIT" {247+ t.Errorf("indicator %q", ind)248+ }249+ if l := line(m, contextLine); l != "Importing big.csv… Esc cancel" {250+ t.Errorf("context %q", l)251+ }252+ if l := line(m, m.height-1); !strings.HasPrefix(l, "Importing big.csv") || !strings.HasSuffix(l, "1,234 rows") {253+ t.Errorf("status %q", l)254+ }255+ if pb := m.View().ProgressBar; pb == nil || pb.State != tea.ProgressBarIndeterminate {256+ t.Errorf("terminal progress %+v", pb)257+ }258+ prog.Report(4096, 1, 2)259+ l := line(m, m.height-1)260+ if l != "Importing big.csv 4,096 rows ━━━━━━━━━━━━━━━─────────────── 50%" {261+ t.Errorf("status %q", l)262+ }263+ if pb := m.View().ProgressBar; pb == nil || pb.Value != 50 {264+ t.Errorf("terminal progress %+v", pb)265+ }266+ // Keys wait; Esc cancels.267+ press(t, m, "x", "<down>")268+ if m.cur != (sheet.Addr{}) || m.mode != modeReady {269+ t.Errorf("keys went through: %v %v", m.cur, m.mode)270+ }271+ press(t, m, "<esc>")272+ if !cancelled {273+ t.Error("Esc didn't cancel")274+ }275+ send(m, importedMsg{id: 7, name: "data/big.csv", err: context.Canceled})276+ if m.xfer.job != nil || line(m, contextLine) != "Import of big.csv cancelled" {277+ t.Errorf("after cancel: %q", line(m, contextLine))278+ }279+ // A stale result from an earlier import is ignored.280+ send(m, importedMsg{id: 3, name: "old.csv", res: &fileio.Result{Sheet: sheet.New()}})281+ if m.xfer.source != "" {282+ t.Errorf("stale import applied")283+ }284+}285+286+func TestImportOnStart(t *testing.T) {287+ t.Chdir(t.TempDir())288+ writeFile(t, "start.csv", "7\n")289+ m := New(sheet.New(), "")290+ m.Import("start.csv")291+ run(m, m.Init())292+ if input(m, "A1") != "7" || m.displayName() != "start.csv" {293+ t.Errorf("A1 %q name %q err %q", input(m, "A1"), m.displayName(), m.errMsg)294+ }295+}