cat / 012
clone

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

Give the import flow its own methods and split out downloads

Dami committed a9bf9c2
transfer.go was 531 lines. File > Download, with its commands, moves to
download.go. Starting and cancelling an import and drawing its context
line, status line and terminal progress bar become methods of transfer,
which owns the import's state; Model keeps the flow that opens pickers
and prompts.
commit
a9bf9c233ffd678a1054682ceed7fadb2df115a0
parent
9ee03491c3e32598b19f41e356ee0fcf83216d40
author
Dami <dami.etoile@gmail.com>
3 files changed, +213 -184
Ainternal/ui/download.go168+++++++++++++++++++++++
Minternal/ui/panel.go6+-
Minternal/ui/transfer.go223++++++------------------------
A internal/ui/download.go +168 -0
@@ -0,0 +1,168 @@
1+package ui2+3+import (4+ "context"5+ "fmt"6+ "os"7+ "path/filepath"8+ "strings"9+10+ tea "charm.land/bubbletea/v2"11+12+ "github.com/FelineStateMachine/012/internal/fileio"13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+// File > Download: the sheet, or for SQLite the selection, written in17+// the background as CSV, TSV, Excel or a SQLite table, asking before18+// replacing a file or table.19+20+func init() {21+ for _, k := range fileio.Kinds() {22+ if !k.CanExport() {23+ continue24+ }25+ register(&command{26+ id: "file.download." + strings.ToLower(k.String()),27+ title: "Download as " + k.String(),28+ desc: downloadDesc(k),29+ run: func(m *Model) tea.Cmd { return m.openDownload(k) },30+ })31+ }32+}33+34+type exportedMsg struct {35+ name string36+ kind fileio.Kind37+ res *fileio.ExportResult38+ err error39+}40+41+func downloadDesc(k fileio.Kind) string {42+ switch k {43+ case fileio.CSV, fileio.TSV:44+ return "Save the values as shown, as " + strings.ToLower(k.Label()) + " (" + k.Ext() + ")"45+ case fileio.XLSX:46+ return "Save as an Excel workbook (.xlsx) with formulas, formats and widths"47+ case fileio.SQLite:48+ return "Save the sheet, or the selection, as a table in a SQLite database"49+ }50+ return ""51+}52+53+// displayBase is the name downloads and saves start from: the sheet's54+// file or the imported file, without its extension.55+func (m *Model) displayBase() string {56+ name := m.filename57+ if name == "" {58+ name = m.xfer.source59+ }60+ if name == "" {61+ return "SHEET1"62+ }63+ return strings.TrimSuffix(name, filepath.Ext(name))64+}65+66+// openDownload asks where to download the sheet as k.67+func (m *Model) openDownload(k fileio.Kind) tea.Cmd {68+ if _, ok := m.sheet.UsedRange(); !ok {69+ m.note = "Nothing to download: the sheet is empty"70+ return nil71+ }72+ r := sheet.Rect{}73+ label := "Download as " + k.String() + ":"74+ if k == fileio.SQLite && m.hasRange() {75+ r = m.selection()76+ label = "Download " + r.String() + " as SQLite:"77+ }78+ m.openText(label, m.displayBase()+k.Ext(), func(m *Model, text string) tea.Cmd {79+ if text == "" {80+ return nil81+ }82+ name := text83+ if filepath.Ext(name) == "" {84+ name += k.Ext()85+ }86+ if k == fileio.SQLite {87+ m.openTableName(name, r)88+ return nil89+ }90+ return m.confirmReplace(filepath.Base(name)+" exists.", func(m *Model) tea.Cmd {91+ return m.download(name, k, r, "")92+ }, exists(name))93+ })94+ return nil95+}96+97+func exists(name string) bool {98+ _, err := os.Stat(name)99+ return err == nil100+}101+102+// openTableName asks for the table to write in a SQLite database.103+func (m *Model) openTableName(name string, r sheet.Rect) {104+ m.openText("Table in "+filepath.Base(name)+":", fileio.TableName(filepath.Base(m.displayBase())), func(m *Model, table string) tea.Cmd {105+ if table == "" {106+ return nil107+ }108+ has := false109+ if exists(name) {110+ ts, err := fileio.Tables(context.Background(), name)111+ if err != nil {112+ m.fail(fmt.Sprintf("Couldn't open %s: %v", filepath.Base(name), err))113+ return nil114+ }115+ for _, t := range ts {116+ has = has || strings.EqualFold(t.Name, table)117+ }118+ }119+ return m.confirmReplace("Table "+table+" exists in "+filepath.Base(name)+".", func(m *Model) tea.Cmd {120+ return m.download(name, fileio.SQLite, r, table)121+ }, has)122+ })123+}124+125+// confirmReplace runs do, first asking on the context line when it126+// would replace something.127+func (m *Model) confirmReplace(msg string, do func(*Model) tea.Cmd, replaces bool) tea.Cmd {128+ if !replaces {129+ return do(m)130+ }131+ m.openOverlay(&choiceBar{132+ msg: msg,133+ warn: true,134+ choices: []choice{135+ {key: "enter", label: "Replace", run: do},136+ {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},137+ },138+ })139+ return nil140+}141+142+// download snapshots the sheet and writes it in the background.143+func (m *Model) download(name string, k fileio.Kind, r sheet.Rect, table string) tea.Cmd {144+ snap := fileio.Snap(m.sheet, r, filepath.Base(m.displayBase()))145+ if k == fileio.XLSX && r == (sheet.Rect{}) {146+ snap = fileio.SnapBook(m.sheet) // every sheet, as Sheets' .xlsx download147+ }148+ return func() tea.Msg {149+ res, err := fileio.Export(context.Background(), name, k, snap, fileio.ExportOptions{Table: table})150+ return exportedMsg{name: name, kind: k, res: res, err: err}151+ }152+}153+154+// saveImported is Save for a sheet imported from another format: save it155+// as a .012 file (keeping formulas and formatting), or export it back.156+func (m *Model) saveImported() tea.Cmd {157+ k := m.xfer.kind158+ choices := []choice{{key: "enter", label: "Save as " + filepath.Base(m.displayBase()) + sheet.FileExt, run: (*Model).openSave}}159+ if k.CanExport() {160+ choices = append(choices, choice{key: "e", label: "Download as " + k.String(), run: func(m *Model) tea.Cmd { return m.openDownload(k) }})161+ }162+ choices = append(choices, choice{key: "esc", label: "Cancel", run: func(m *Model) tea.Cmd {163+ m.quitAfterSave = false164+ return nil165+ }})166+ m.openOverlay(&choiceBar{msg: filepath.Base(m.xfer.source) + " was imported.", choices: choices})167+ return nil168+}
M internal/ui/panel.go +3 -3
M internal/ui/transfer.go +42 -181
@@ -14,7 +14,7 @@ import (
1414 "github.com/charmbracelet/x/ansi"1515 1616 "github.com/FelineStateMachine/012/internal/fileio"17- "github.com/FelineStateMachine/012/internal/sheet"17+ "github.com/FelineStateMachine/012/internal/ui/theme"1818 )1919 2020 // Data comes in through File > Import (a picker of the files around),
@@ -51,13 +51,6 @@ type importedMsg struct {
5151 5252 type importTickMsg struct{ id int }5353 54-type exportedMsg struct {55- name string56- kind fileio.Kind57- res *fileio.ExportResult58- err error59-}60-6154 // importTick is how often the progress display refreshes.6255 const importTick = 100 * time.Millisecond6356
@@ -65,29 +58,6 @@ func init() {
6558 register(&command{id: "file.import", title: "Import",6659 desc: "Import a CSV, TSV, Excel, SQLite, Parquet or 1-2-3 file, replacing this sheet",6760 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 ""9161 }9262 9363 // Import sets a file to import when the program starts, e.g. from
@@ -203,15 +173,20 @@ func (m *Model) confirmImport(name string, opt fileio.Options) tea.Cmd {
203173 204174 // startImport reads name in the background.205175 func (m *Model) startImport(name string, opt fileio.Options) tea.Cmd {206- if m.xfer.job != nil {207- m.xfer.job.cancel()176+ m.note = ""177+ return m.xfer.start(name, opt)178+}179+180+// start reads name in the background, replacing any import running.181+func (x *transfer) start(name string, opt fileio.Options) tea.Cmd {182+ if x.job != nil {183+ x.job.cancel()208184 }209185 ctx, cancel := context.WithCancel(context.Background())210- m.xfer.lastID++211- job := &importJob{id: m.xfer.lastID, name: name, prog: fileio.NewProgress(), cancel: cancel}186+ x.lastID++187+ job := &importJob{id: x.lastID, name: name, prog: fileio.NewProgress(), cancel: cancel}212188 opt.Progress = job.prog213- m.xfer.job = job214- m.note = ""189+ x.job = job215190 return tea.Batch(216191 func() tea.Msg {217192 res, err := fileio.Import(ctx, name, opt)
@@ -251,10 +226,15 @@ func (m *Model) importing(msg tea.Msg) (tea.Cmd, bool) {
251226 // cancelImport stops the import at once; its result, if it still252227 // arrives, is ignored.253228 func (m *Model) cancelImport() {254- job := m.xfer.job229+ m.note = "Import of " + filepath.Base(m.xfer.cancel()) + " cancelled"230+}231+232+// cancel stops the import and returns the name of its file.233+func (x *transfer) cancel() string {234+ job := x.job255235 job.cancel()256- m.xfer.job = nil257- m.note = "Import of " + filepath.Base(job.name) + " cancelled"236+ x.job = nil237+ return job.name258238 }259239 260240 // handleTransfer handles import and export messages.
@@ -375,157 +355,38 @@ func quoteSQL(s string) string {
375355 return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`376356 }377357 378-// importLine is the context line during an import.379-func (m *Model) importLine() string {380- return "Importing " + filepath.Base(m.xfer.job.name) + "… " + m.th.KeyHints("Esc", "cancel")358+// line is the context line during an import.359+func (x *transfer) line(th *theme.Theme) string {360+ return "Importing " + filepath.Base(x.job.name) + "… " + th.KeyHints("Esc", "cancel")381361 }382362 383-// importStatus is the status line during an import: the file, the rows384-// read, and a bar when the total is known.385-func (m *Model) importStatus() string {386- job := m.xfer.job387- rows, frac := job.prog.Get()388- left := m.th.Key.Render("Importing " + filepath.Base(job.name))389- right := countRows(rows) + " read"390- if frac >= 0 {391- pct := fmt.Sprintf(" %3d%%", int(frac*100))392- w := clamp(m.width-ansi.StringWidth(left)-ansi.StringWidth(right)-len(pct)-6, 0, 30)393- if w >= 8 {394- done := int(frac * float64(w))395- right += " " + m.th.Progress.Render(strings.Repeat("━", done)) +396- m.th.ProgressTodo.Render(strings.Repeat("─", w-done)) + pct397- } else {398- right += pct399- }363+// status is the status line during an import, width wide: the file, the364+// rows read, and a bar when the total is known.365+func (x *transfer) status(th *theme.Theme, width int) (left, right string) {366+ rows, frac := x.job.prog.Get()367+ left = th.Key.Render("Importing " + filepath.Base(x.job.name))368+ right = countRows(rows) + " read"369+ if frac < 0 {370+ return left, right371+ }372+ pct := fmt.Sprintf(" %3d%%", int(frac*100))373+ w := clamp(width-ansi.StringWidth(left)-ansi.StringWidth(right)-len(pct)-6, 0, 30)374+ if w < 8 {375+ return left, right + pct400376 }401- return m.spread(left, right)377+ done := int(frac * float64(w))378+ return left, right + " " + th.Progress.Render(strings.Repeat("━", done)) +379+ th.ProgressTodo.Render(strings.Repeat("─", w-done)) + pct402380 }403381 404382 // progressBar is the terminal's own progress indicator (OSC 9;4), shown405383 // in the tab or title bar where supported.406-func (m *Model) progressBar() *tea.ProgressBar {407- if m.xfer.job == nil {384+func (x *transfer) progressBar() *tea.ProgressBar {385+ if x.job == nil {408386 return nil409387 }410- if _, frac := m.xfer.job.prog.Get(); frac >= 0 {388+ if _, frac := x.job.prog.Get(); frac >= 0 {411389 return tea.NewProgressBar(tea.ProgressBarDefault, int(frac*100))412390 }413391 return tea.NewProgressBar(tea.ProgressBarIndeterminate, 0)414392 }415-416-// displayBase is the name downloads and saves start from: the sheet's417-// file or the imported file, without its extension.418-func (m *Model) displayBase() string {419- name := m.filename420- if name == "" {421- name = m.xfer.source422- }423- if name == "" {424- return "SHEET1"425- }426- return strings.TrimSuffix(name, filepath.Ext(name))427-}428-429-// openDownload asks where to download the sheet as k.430-func (m *Model) openDownload(k fileio.Kind) tea.Cmd {431- if _, ok := m.sheet.UsedRange(); !ok {432- m.note = "Nothing to download: the sheet is empty"433- return nil434- }435- r := sheet.Rect{}436- label := "Download as " + k.String() + ":"437- if k == fileio.SQLite && m.hasRange() {438- r = m.selection()439- label = "Download " + r.String() + " as SQLite:"440- }441- m.openText(label, m.displayBase()+k.Ext(), func(m *Model, text string) tea.Cmd {442- if text == "" {443- return nil444- }445- name := text446- if filepath.Ext(name) == "" {447- name += k.Ext()448- }449- if k == fileio.SQLite {450- m.openTableName(name, r)451- return nil452- }453- return m.confirmReplace(filepath.Base(name)+" exists.", func(m *Model) tea.Cmd {454- return m.download(name, k, r, "")455- }, exists(name))456- })457- return nil458-}459-460-func exists(name string) bool {461- _, err := os.Stat(name)462- return err == nil463-}464-465-// openTableName asks for the table to write in a SQLite database.466-func (m *Model) openTableName(name string, r sheet.Rect) {467- m.openText("Table in "+filepath.Base(name)+":", fileio.TableName(filepath.Base(m.displayBase())), func(m *Model, table string) tea.Cmd {468- if table == "" {469- return nil470- }471- has := false472- if exists(name) {473- ts, err := fileio.Tables(context.Background(), name)474- if err != nil {475- m.fail(fmt.Sprintf("Couldn't open %s: %v", filepath.Base(name), err))476- return nil477- }478- for _, t := range ts {479- has = has || strings.EqualFold(t.Name, table)480- }481- }482- return m.confirmReplace("Table "+table+" exists in "+filepath.Base(name)+".", func(m *Model) tea.Cmd {483- return m.download(name, fileio.SQLite, r, table)484- }, has)485- })486-}487-488-// confirmReplace runs do, first asking on the context line when it489-// would replace something.490-func (m *Model) confirmReplace(msg string, do func(*Model) tea.Cmd, replaces bool) tea.Cmd {491- if !replaces {492- return do(m)493- }494- m.openOverlay(&choiceBar{495- msg: msg,496- warn: true,497- choices: []choice{498- {key: "enter", label: "Replace", run: do},499- {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},500- },501- })502- return nil503-}504-505-// download snapshots the sheet and writes it in the background.506-func (m *Model) download(name string, k fileio.Kind, r sheet.Rect, table string) tea.Cmd {507- snap := fileio.Snap(m.sheet, r, filepath.Base(m.displayBase()))508- if k == fileio.XLSX && r == (sheet.Rect{}) {509- snap = fileio.SnapBook(m.sheet) // every sheet, as Sheets' .xlsx download510- }511- return func() tea.Msg {512- res, err := fileio.Export(context.Background(), name, k, snap, fileio.ExportOptions{Table: table})513- return exportedMsg{name: name, kind: k, res: res, err: err}514- }515-}516-517-// saveImported is Save for a sheet imported from another format: save it518-// as a .012 file (keeping formulas and formatting), or export it back.519-func (m *Model) saveImported() tea.Cmd {520- k := m.xfer.kind521- choices := []choice{{key: "enter", label: "Save as " + filepath.Base(m.displayBase()) + sheet.FileExt, run: (*Model).openSave}}522- if k.CanExport() {523- choices = append(choices, choice{key: "e", label: "Download as " + k.String(), run: func(m *Model) tea.Cmd { return m.openDownload(k) }})524- }525- choices = append(choices, choice{key: "esc", label: "Cancel", run: func(m *Model) tea.Cmd {526- m.quitAfterSave = false527- return nil528- }})529- m.openOverlay(&choiceBar{msg: filepath.Base(m.xfer.source) + " was imported.", choices: choices})530- return nil531-}