- commit
- c867e165713d45fc163ac9e965200efa2fec52a3
- parent
- 625fe586e7dc87dadad36db5dc705c5bcd1b576f
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 9 | ++ |
| A | internal/ | 117 | +++++++++++++++++++++++++++++ |
| A | internal/ | 112 | ++++++++++++++++++++++++++++ |
| M | internal/ | 15 | +--- |
| A | internal/ | 121 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 117 | +++++++++++++++++++++++++++++ |
| M | internal/ | 2 | +- |
| M | internal/ | 51 | +++++++------ |
| M | internal/ | 12 | +- |
@@ -7,6 +7,15 @@ through File > Download. Imports run in the background with a progress
77 bar; Esc cancels. Saving an imported sheet asks whether to save it as a88 `.012` file or download it back in its format.99 10+File > Import asks where the data goes, as Sheets' Import location does11+(a new, empty spreadsheet is simply replaced):12+13+| Location | Does |14+|---|---|15+| Insert new sheet(s) | Adds the file's sheets after the others: every sheet of an `.xlsx`, named after the file for other formats. A name already taken gets a number (`Sales 2`) and the file's formulas follow it; named ranges come along unless their name is taken. One undo step, and the spreadsheet stays the file you're editing |16+| Replace current sheet | Puts the data in place of the sheet shown, keeping its name and position, so formulas and named ranges that read it read the new data. One undo step. Not offered for `.xlsx`, which holds several sheets |17+| Replace spreadsheet | Opens the file instead, as File > Open and the command line do, asking first when there are unsaved changes |18+1019 | Format | Import | Download |1120 |---|---|---|1221 | 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 |@@ -0,0 +1,117 @@
1+package sheet2+3+import (4+ "errors"5+ "fmt"6+ "slices"7+)8+9+// Importing into a workbook, as Sheets' Import location: "Insert new10+// sheet(s)" moves every sheet of an imported workbook into this one, and11+// "Replace current sheet" puts the imported sheet in the place of one.12+// The imported workbook is built on its own (fileio) and given up to13+// these; either is one undo step.14+15+// ImportResult says what an import into a workbook did.16+type ImportResult struct {17+ Sheets []*Sheet // the sheets added, in tab order18+ Renamed map[string]string // imported names changed to fit, old to new19+ Names int // named ranges left out, their names taken20+}21+22+// InsertBook moves the sheets of src, an imported workbook, into w after23+// its last sheet, with src's named ranges, as one undo step labelled24+// label. A sheet whose name w already has gets a number ("Sales 2"), and25+// src's formulas follow the new name; a named range whose name w already26+// has is left out. src is used up.27+func (w *Workbook) InsertBook(src *Workbook, label string) (ImportResult, error) {28+ var res ImportResult29+ if src == w || len(src.sheets) == 0 {30+ return res, errors.New("Nothing to import")31+ }32+ sheets := slices.Clone(src.sheets)33+ for _, s := range sheets {34+ if w.Lookup(s.name) == nil {35+ continue36+ }37+ name := freeIn(s.name, w, src)38+ if res.Renamed == nil {39+ res.Renamed = map[string]string{}40+ }41+ res.Renamed[s.name] = name42+ if err := src.RenameSheet(s, name); err != nil {43+ return res, err44+ }45+ }46+ names := src.Names()47+ for _, s := range sheets {48+ src.detach(s)49+ s.wb = w50+ }51+ src.sheets = nil52+ w.change(sheets[0], label, Rect{}, func() {53+ w.recordSheets()54+ for _, s := range sheets {55+ w.insert(s, len(w.sheets))56+ }57+ for _, n := range names {58+ if _, taken := w.LookupName(n.Name); taken {59+ res.Names++60+ continue61+ }62+ w.putName(nameKey(n.Name), &n)63+ }64+ })65+ res.Sheets = sheets66+ return res, nil67+}68+69+// ReplaceSheet puts s, the sheet of an imported workbook, in the place of70+// dst, as one undo step labelled label: it takes dst's name and position,71+// so formulas reading dst read it, and named ranges on dst move to it.72+// dst's cells, charts and the rest go with it; undo brings them back.73+func (w *Workbook) ReplaceSheet(dst, s *Sheet, label string) error {74+ switch {75+ case !dst.live || dst.wb != w:76+ return errors.New("That sheet was deleted")77+ case s.wb == w:78+ return errors.New("That sheet is already in the spreadsheet")79+ }80+ s.wb.detach(s)81+ s.wb.sheets = slices.DeleteFunc(s.wb.sheets, func(t *Sheet) bool { return t == s })82+ s.wb, s.tabHidden = w, dst.tabHidden83+ w.change(dst, label, Rect{}, func() {84+ w.recordSheets()85+ i := w.Index(dst)86+ w.remove(dst)87+ s.name = dst.name88+ w.insert(s, i)89+ for _, n := range w.Names() {90+ if n.Sheet == dst {91+ n.Sheet = s92+ w.putName(nameKey(n.Name), &n)93+ }94+ }95+ })96+ return nil97+}98+99+// freeIn returns base, or base with a number added ("Sales 2"), so that100+// none of books has a sheet of that name, within the length limit.101+func freeIn(base string, books ...*Workbook) string {102+ trim := func(s string, n int) string {103+ if r := []rune(s); len(r) > n {104+ return string(r[:n])105+ }106+ return s107+ }108+ taken := func(name string) bool {109+ return slices.ContainsFunc(books, func(w *Workbook) bool { return w.Lookup(name) != nil })110+ }111+ name := trim(base, maxSheetName)112+ for n := 2; taken(name); n++ {113+ suffix := fmt.Sprintf(" %d", n)114+ name = trim(base, maxSheetName-len(suffix)) + suffix115+ }116+ return name117+}@@ -0,0 +1,112 @@
1+package sheet2+3+import (4+ "testing"5+)6+7+// Inserting an imported workbook adds its sheets after the others,8+// renaming the ones whose names are taken (and the formulas that use9+// them), brings its free named ranges, and undoes as one step.10+func TestInsertBook(t *testing.T) {11+ w := bookOf(t,12+ page{"Sales", map[string]string{"A1": "1"}},13+ page{"Notes", map[string]string{"A1": "=Sales!A1+Data!A1"}},14+ )15+ w.DefineName("Taken", w.Sheet(0), NewRect(at("A1"), at("A1")))16+ w.ClearHistory()17+ src := bookOf(t,18+ page{"Sales", map[string]string{"A1": "10", "A2": "=Data!A1*2"}},19+ page{"Data", map[string]string{"A1": "5", "A2": "=Sales!A1+1"}},20+ )21+ src.DefineName("Taken", src.Sheet(1), NewRect(at("A1"), at("A1")))22+ src.DefineName("Fresh", src.Sheet(1), NewRect(at("A1"), at("A1")))23+ src.hidePlain(src.Sheet(1))24+25+ res, err := w.InsertBook(src, "import book.xlsx")26+ if err != nil {27+ t.Fatal(err)28+ }29+ if got := namesOf(w); got != "Sales,Notes,Sales 2,Data" {30+ t.Fatalf("sheets %s", got)31+ }32+ if len(res.Sheets) != 2 || res.Renamed["Sales"] != "Sales 2" || res.Names != 1 {33+ t.Errorf("result %+v", res)34+ }35+ for ref, want := range map[string]string{36+ "Sales 2!A2": "10", // Data!A1*2 on the imported sheets37+ "Data!A2": "11", // Sales!A1+1 now reads Sales 238+ "Data!input:A2": "='Sales 2'!A1+1",39+ "Notes!A1": "6", // Data exists now40+ } {41+ if got := show(t, w, ref); got != want {42+ t.Errorf("%s = %s, want %s", ref, got, want)43+ }44+ }45+ if !w.Lookup("Data").Hidden() {46+ t.Error("Data lost its hidden flag")47+ }48+ if n, ok := w.LookupName("Fresh"); !ok || n.Sheet != w.Lookup("Data") {49+ t.Errorf("Fresh: %v %+v", ok, n)50+ }51+ if n, _ := w.LookupName("Taken"); n.Sheet != w.Sheet(0) {52+ t.Error("an imported name replaced Taken")53+ }54+55+ c, ok := w.Undo()56+ if !ok || c.Label != "import book.xlsx" || namesOf(w) != "Sales,Notes" {57+ t.Fatalf("undo: %+v, sheets %s", c, namesOf(w))58+ }59+ if _, ok := w.LookupName("Fresh"); ok || show(t, w, "Notes!A1") != "#REF!" {60+ t.Errorf("undo left Fresh %v, Notes!A1 %s", ok, show(t, w, "Notes!A1"))61+ }62+ w.Redo()63+ if namesOf(w) != "Sales,Notes,Sales 2,Data" || show(t, w, "Notes!A1") != "6" {64+ t.Errorf("redo: %s", namesOf(w))65+ }66+}67+68+// Replacing a sheet puts the imported one in its place, with its name,69+// so formulas and named ranges reading it read the new data.70+func TestReplaceSheet(t *testing.T) {71+ w := bookOf(t,72+ page{"Sales", map[string]string{"A1": "1", "B1": "old"}},73+ page{"Notes", map[string]string{"A1": "=Sales!A1*2"}},74+ )75+ w.DefineName("First", w.Sheet(0), NewRect(at("A1"), at("A1")))76+ w.Sheet(1).Set(at("A2"), "=First")77+ w.ClearHistory()78+ src := bookOf(t, page{"sales", map[string]string{"A1": "21"}})79+80+ old := w.Sheet(0)81+ if err := w.ReplaceSheet(old, src.Sheet(0), "import sales.csv"); err != nil {82+ t.Fatal(err)83+ }84+ if namesOf(w) != "Sales,Notes" || old.Live() || show(t, w, "Sales!B1") != "" {85+ t.Fatalf("sheets %s", namesOf(w))86+ }87+ if show(t, w, "Notes!A1") != "42" || show(t, w, "Notes!A2") != "21" {88+ t.Errorf("Notes!A1 %s, A2 %s", show(t, w, "Notes!A1"), show(t, w, "Notes!A2"))89+ }90+ c, _ := w.Undo()91+ if c.Label != "import sales.csv" || w.Sheet(0) != old || show(t, w, "Notes!A1") != "2" || show(t, w, "Notes!A2") != "1" {92+ t.Errorf("undo: %+v, Notes!A1 %s", c, show(t, w, "Notes!A1"))93+ }94+ w.Redo()95+ if show(t, w, "Notes!A1") != "42" {96+ t.Errorf("redo: %s", show(t, w, "Notes!A1"))97+ }98+}99+100+func namesOf(w *Workbook) string {101+ var out string102+ for i, s := range w.sheets {103+ if i > 0 {104+ out += ","105+ }106+ out += s.name107+ }108+ return out109+}110+111+// hidePlain hides s outside the history, as a loader would.112+func (w *Workbook) hidePlain(s *Sheet) { s.tabHidden = true }@@ -163,20 +163,7 @@ func (w *Workbook) nextSheetName() string {
163163 164164 // freeName returns base, or base with a number added ("Copy of Sheet1 2")165165 // until no sheet has the name, within the length limit.166-func (w *Workbook) freeName(base string) string {167- trim := func(s string, n int) string {168- if r := []rune(s); len(r) > n {169- return string(r[:n])170- }171- return s172- }173- name := trim(base, maxSheetName)174- for n := 2; w.Lookup(name) != nil; n++ {175- suffix := fmt.Sprintf(" %d", n)176- name = trim(base, maxSheetName-len(suffix)) + suffix177- }178- return name179-}166+func (w *Workbook) freeName(base string) string { return freeIn(base, w) }180167 181168 // checkName validates a new name for s (nil for a new sheet).182169 func (w *Workbook) checkName(s *Sheet, name string) error {@@ -0,0 +1,121 @@
1+package ui2+3+import (4+ "path/filepath"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/fileio"10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// Where File > Import puts what it reads, as Sheets' Import location:14+// into new sheets after the others, in place of the sheet shown, or in15+// place of the whole spreadsheet (what File > Open and the command line16+// do). A new, empty spreadsheet is just replaced. Inserting or replacing17+// a sheet is one undo step, and the file stays the one being edited.18+19+// importPlace is where an import goes.20+type importPlace int21+22+const (23+ placeBook importPlace = iota // replace the spreadsheet24+ placeNewSheets // insert new sheets25+ placeSheet // replace the sheet shown26+)27+28+// pristine reports whether the spreadsheet is new and untouched, so an29+// import may simply replace it.30+func (m *Model) pristine() bool {31+ return !m.changed && m.filename == "" && m.xfer.source == "" && m.book().Len() == 1 &&32+ m.sheet.Len() == 0 && len(m.sheet.Charts()) == 033+}34+35+// askImportPlace imports name, asking first where it goes unless the36+// spreadsheet is new and empty.37+func (m *Model) askImportPlace(name string) tea.Cmd {38+ if m.pristine() {39+ return m.startImport(name, fileio.Options{}, placeBook)40+ }41+ k, _ := fileio.KindOf(name)42+ base := filepath.Base(name)43+ insert := "Insert new sheet"44+ if k.HoldsSheets() {45+ insert = "Insert new sheets"46+ }47+ item := func(title, detail, desc string, run func(m *Model) tea.Cmd) pickItem {48+ return pickItem{title: title, name: len(title), detail: detail, desc: desc, pick: func(m *Model) tea.Cmd {49+ m.closeOverlay()50+ return run(m)51+ }}52+ }53+ items := []pickItem{item(insert, "after the others", "Add "+base+" as new sheets",54+ func(m *Model) tea.Cmd { return m.startImport(name, fileio.Options{}, placeNewSheets) })}55+ if !k.HoldsSheets() {56+ items = append(items, item("Replace current sheet", m.sheet.Name(), "Put "+base+" in place of "+m.sheet.Name()+", keeping its name",57+ func(m *Model) tea.Cmd { return m.startImport(name, fileio.Options{}, placeSheet) }))58+ }59+ detail := "open it instead"60+ if m.changed {61+ detail = "unsaved changes"62+ }63+ items = append(items, item("Replace spreadsheet", detail, "Open "+base+" instead, as File > Open does",64+ func(m *Model) tea.Cmd { return m.confirmImport(name, fileio.Options{}) }))65+ p := newPicker(m, "Import "+base, "Import location", 60, items)66+ p.action = "import"67+ m.openOverlay(p)68+ return nil69+}70+71+// placeImport puts an imported workbook where the import was asked to go72+// and says what it did, or reports false to replace the spreadsheet.73+func (m *Model) placeImport(msg importedMsg, what string) bool {74+ res := msg.res75+ label := "import " + filepath.Base(msg.name)76+ switch msg.place {77+ case placeNewSheets:78+ ins, err := m.book().InsertBook(res.Sheet.Book(), label)79+ if err != nil {80+ m.fail(err.Error())81+ return true82+ }83+ show := res.Sheet84+ if show.Hidden() {85+ show = ins.Sheets[0]86+ }87+ m.afterSheetsChange(show, m.book().Index(m.sheet))88+ m.note = "Imported " + what + " as " + sheetList(ins.Sheets) + " (" + countRows(res.Rows) + ")"89+ if ins.Names > 0 {90+ m.note += "; " + plural(ins.Names, "1 named range", thousands(ins.Names)+" named ranges") + " left out, their names taken"91+ }92+ case placeSheet:93+ i := m.book().Index(m.sheet)94+ if err := m.book().ReplaceSheet(m.sheet, res.Sheet, label); err != nil {95+ m.fail(err.Error())96+ return true97+ }98+ m.afterSheetsChange(res.Sheet, i)99+ m.note = "Imported " + what + " into " + res.Sheet.Name() + " (" + countRows(res.Rows) + ")"100+ default:101+ return false102+ }103+ m.changed = true104+ if len(res.Notes) > 0 {105+ m.note += "; " + strings.Join(res.Notes, "; ")106+ }107+ return true108+}109+110+// sheetList names sheets for a note: "Sales", "Sales and Q3", or "3111+// sheets, Sales to Q3".112+func sheetList(sheets []*sheet.Sheet) string {113+ switch n := len(sheets); n {114+ case 1:115+ return sheets[0].Name()116+ case 2:117+ return sheets[0].Name() + " and " + sheets[1].Name()118+ default:119+ return thousands(n) + " sheets, " + sheets[0].Name() + " to " + sheets[n-1].Name()120+ }121+}@@ -0,0 +1,117 @@
1+package ui2+3+import (4+ "context"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/fileio"10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// importInto runs File > Import on name and picks place in the location14+// picker.15+func importInto(t *testing.T, m *Model, name, place string) {16+ t.Helper()17+ m.runCommand("file.import")18+ press(t, m, name, "<enter>")19+ p, ok := m.overlay.(*picker)20+ if !ok || !strings.HasPrefix(p.title, "Import ") {21+ t.Fatalf("no location picker: %T", m.overlay)22+ }23+ press(t, m, place, "<enter>")24+}25+26+func TestImportLocation(t *testing.T) {27+ t.Chdir(t.TempDir())28+ writeFile(t, "sales.csv", "Region,Total\nNorth,12\n")29+ m := newModel()30+ press(t, m, "=sales!B2*2", "<enter>")31+ m.changed = false32+33+ // The picker offers Sheets' choices, what the current sheet is, and34+ // says what each does.35+ m.runCommand("file.import")36+ press(t, m, "sales.csv", "<enter>")37+ got := strings.Join(pickerTitles(t, m), "\n")38+ want := "Insert new sheet | after the others\nReplace current sheet | Sheet1\nReplace spreadsheet | open it instead"39+ if got != want {40+ t.Errorf("locations:\n%s\nwant\n%s", got, want)41+ }42+ if st := status(m); !strings.Contains(st, "Add sales.csv as new sheets") {43+ t.Errorf("status %q", st)44+ }45+ press(t, m, "<esc>")46+ if m.overlay != nil || m.book().Len() != 1 {47+ t.Fatalf("Esc imported: %d sheets", m.book().Len())48+ }49+50+ // Insert new sheet: one undo step, the file stays this one.51+ importInto(t, m, "sales.csv", "Insert new sheet")52+ if sheetNames(m) != "Sheet1,sales" || m.sheet.Name() != "sales" || input(m, "A2") != "North" {53+ t.Fatalf("inserted: %s on %s", sheetNames(m), m.sheet.Name())54+ }55+ if line(m, contextLine) != "Imported sales.csv as sales (2 rows)" || !m.changed || m.xfer.source != "" {56+ t.Errorf("context %q changed %v source %q", line(m, contextLine), m.changed, m.xfer.source)57+ }58+ if v := m.book().Sheet(0).Value(addr("A1")); v.Num != 24 {59+ t.Errorf("Sheet1!A1 reading the import = %v", v)60+ }61+ // Again: the name is taken, so the new sheet gets a number.62+ importInto(t, m, "sales.csv", "Insert new sheet")63+ if sheetNames(m) != "Sheet1,sales,sales 2" || m.sheet.Name() != "sales 2" {64+ t.Errorf("second insert: %s", sheetNames(m))65+ }66+ press(t, m, "<ctrl+z>")67+ if sheetNames(m) != "Sheet1,sales" || !strings.Contains(line(m, contextLine), "Undid: import sales.csv") {68+ t.Errorf("undo: %s, %q", sheetNames(m), line(m, contextLine))69+ }70+71+ // Replace current sheet keeps the sheet's name and place.72+ press(t, m, "<ctrl+pgup>")73+ writeFile(t, "sales.csv", "Region,Total\nSouth,50\n")74+ importInto(t, m, "sales.csv", "Replace current sheet")75+ if sheetNames(m) != "Sheet1,sales" || m.sheet.Name() != "Sheet1" || input(m, "A2") != "South" || input(m, "A1") != "Region" {76+ t.Fatalf("replaced: %s on %s, A2 %q", sheetNames(m), m.sheet.Name(), input(m, "A2"))77+ }78+ if line(m, contextLine) != "Imported sales.csv into Sheet1 (2 rows)" {79+ t.Errorf("context %q", line(m, contextLine))80+ }81+ press(t, m, "<ctrl+z>")82+ if m.sheet.Name() != "Sheet1" || input(m, "A1") != "=sales!B2*2" {83+ t.Errorf("undo replace: on %s, A1 %q", m.sheet.Name(), input(m, "A1"))84+ }85+}86+87+// An XLSX workbook inserts every sheet, names made unique, and offers no88+// Replace current sheet.89+func TestImportLocationWorkbook(t *testing.T) {90+ t.Chdir(t.TempDir())91+ src := sheet.New()92+ book := src.Book()93+ book.RenameSheet(src, "Sheet1")94+ src.Set(addr("A1"), "1")95+ q3, _ := book.AddSheet("Q3", 1)96+ q3.Set(addr("A1"), "=Sheet1!A1+1")97+ if _, err := fileio.Export(context.Background(), filepath.Join(".", "book.xlsx"), fileio.XLSX, fileio.SnapBook(src), fileio.ExportOptions{}); err != nil {98+ t.Fatal(err)99+ }100+ m := newModel()101+ press(t, m, "x", "<enter>")102+ m.runCommand("file.import")103+ press(t, m, "book", "<enter>")104+ if got := strings.Join(pickerTitles(t, m), "\n"); got != "Insert new sheets | after the others\nReplace spreadsheet | unsaved changes" {105+ t.Errorf("locations:\n%s", got)106+ }107+ press(t, m, "insert", "<enter>")108+ if sheetNames(m) != "Sheet1,Sheet1 2,Q3" || m.sheet.Name() != "Sheet1 2" {109+ t.Fatalf("sheets %s on %s (%q)", sheetNames(m), m.sheet.Name(), m.errMsg)110+ }111+ if got := m.book().Lookup("Q3"); got.Cell(addr("A1")).Input != "='Sheet1 2'!A1+1" || got.Value(addr("A1")).Num != 2 {112+ t.Errorf("Q3!A1 %q = %v", got.Cell(addr("A1")).Input, got.Value(addr("A1")))113+ }114+ if l := line(m, contextLine); l != "Imported book.xlsx as Sheet1 2 and Q3 (2 rows)" {115+ t.Errorf("context %q", l)116+ }117+}@@ -100,7 +100,7 @@ func TestServedFilesStayInside(t *testing.T) {
100100 if !slices.Equal(names, []string{"data.csv"}) {101101 t.Errorf("importable = %v", names)102102 }103- run(m, m.startImport("../elsewhere/other.csv", fileio.Options{}))103+ run(m, m.startImport("../elsewhere/other.csv", fileio.Options{}, placeBook))104104 if m.mode != modeError || m.xfer.job != nil {105105 t.Errorf("import outside: mode %v job %v", m.mode, m.xfer.job)106106 }@@ -40,14 +40,16 @@ type importJob struct {
4040 name string4141 prog *fileio.Progress4242 cancel context.CancelFunc43+ place importPlace // where the result goes; see importplace.go4344 }4445 4546 type importedMsg struct {46- id int47- name string48- opt fileio.Options49- res *fileio.Result50- err error47+ id int48+ name string49+ opt fileio.Options50+ place importPlace51+ res *fileio.Result52+ err error5153 }5254 5355 type importTickMsg struct{ id int }@@ -57,7 +59,7 @@ const importTick = 100 * time.Millisecond
5759 5860 func init() {5961 register(&command{id: "file.import", macro: macroNever, title: "Import",60- desc: "Import a " + importNouns() + " file, replacing this sheet",62+ desc: "Import a " + importNouns() + " file as new sheets, or in place of this sheet or spreadsheet",6163 run: (*Model).openImport})6264 }6365 @@ -83,7 +85,7 @@ func (m *Model) startupCmd() tea.Cmd {
8385 return nil8486 }8587 m.xfer.startup = ""86- return m.startImport(name, fileio.Options{})88+ return m.startImport(name, fileio.Options{}, placeBook)8789 }8890 8991 // importable lists the files in the directory names are relative to@@ -130,7 +132,7 @@ func (m *Model) openImport() tea.Cmd {
130132 items = append(items, pickItem{title: name, name: len(name), detail: detail, desc: desc,131133 pick: func(m *Model) tea.Cmd {132134 m.closeOverlay()133- return m.confirmImport(name, fileio.Options{})135+ return m.askImportPlace(name)134136 }})135137 }136138 p := newPicker(m, "Import", "Type to filter, or a path", 72, items)@@ -145,7 +147,7 @@ func (m *Model) openImport() tea.Cmd {
145147 m.fail(fmt.Sprintf("Can't import %s: 012 imports %s", query, importExts()))146148 return nil, true147149 }148- return m.confirmImport(query, fileio.Options{}), true150+ return m.askImportPlace(query), true149151 }150152 m.openOverlay(p)151153 return nil@@ -177,13 +179,13 @@ func fileSize(n int64) string {
177179 // unsaved changes.178180 func (m *Model) confirmImport(name string, opt fileio.Options) tea.Cmd {179181 if !m.changed {180- return m.startImport(name, opt)182+ return m.startImport(name, opt, placeBook)181183 }182184 m.openOverlay(&choiceBar{183- msg: "Importing replaces this sheet, which has unsaved changes.",185+ msg: "Importing replaces this spreadsheet, which has unsaved changes.",184186 warn: true,185187 choices: []choice{186- {key: "enter", label: "Import", run: func(m *Model) tea.Cmd { return m.startImport(name, opt) }},188+ {key: "enter", label: "Import", run: func(m *Model) tea.Cmd { return m.startImport(name, opt, placeBook) }},187189 {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},188190 },189191 })@@ -191,30 +193,30 @@ func (m *Model) confirmImport(name string, opt fileio.Options) tea.Cmd {
191193 }192194 193195 // startImport reads name in the background.194-func (m *Model) startImport(name string, opt fileio.Options) tea.Cmd {196+func (m *Model) startImport(name string, opt fileio.Options, place importPlace) tea.Cmd {195197 m.note = ""196198 path, ok := m.path("import", name)197199 if !ok {198200 return nil199201 }200- return m.xfer.start(name, path, opt)202+ return m.xfer.start(name, path, opt, place)201203 }202204 203205 // start reads name, at path on disk, in the background, replacing any204206 // import running.205-func (x *transfer) start(name, path string, opt fileio.Options) tea.Cmd {207+func (x *transfer) start(name, path string, opt fileio.Options, place importPlace) tea.Cmd {206208 if x.job != nil {207209 x.job.cancel()208210 }209211 ctx, cancel := context.WithCancel(context.Background())210212 x.lastID++211- job := &importJob{id: x.lastID, name: name, prog: fileio.NewProgress(), cancel: cancel}213+ job := &importJob{id: x.lastID, name: name, prog: fileio.NewProgress(), cancel: cancel, place: place}212214 opt.Progress = job.prog213215 x.job = job214216 return tea.Batch(215217 func() tea.Msg {216218 res, err := fileio.Import(ctx, path, opt)217- return importedMsg{id: job.id, name: name, opt: opt, res: res, err: err}219+ return importedMsg{id: job.id, name: name, opt: opt, place: place, res: res, err: err}218220 },219221 tickImport(job.id),220222 )@@ -310,14 +312,12 @@ func (m *Model) handleImported(msg importedMsg) tea.Cmd {
310312 m.note = "Import of " + filepath.Base(msg.name) + " cancelled"311313 return nil312314 case errors.As(msg.err, &need):313- m.openTablePicker(msg.name, need.Tables)315+ m.openTablePicker(msg.name, need.Tables, msg.place)314316 return nil315317 case msg.err != nil:316318 m.fail(fmt.Sprintf("Couldn't import %s: %v", filepath.Base(msg.name), msg.err))317319 return nil318320 }319- m.reset(msg.res.Sheet, "")320- m.xfer.source, m.xfer.kind = msg.name, msg.res.Kind321321 what := filepath.Base(msg.name)322322 switch {323323 case msg.opt.Table != "":@@ -325,6 +325,11 @@ func (m *Model) handleImported(msg importedMsg) tea.Cmd {
325325 case msg.opt.Query != "":326326 what = "the query from " + what327327 }328+ if m.placeImport(msg, what) {329+ return m.term.notify("Imported " + what)330+ }331+ m.reset(msg.res.Sheet, "")332+ m.xfer.source, m.xfer.kind = msg.name, msg.res.Kind328333 m.note = "Imported " + what + " (" + countRows(msg.res.Rows) + ")"329334 if len(msg.res.Notes) > 0 {330335 m.note += "; " + strings.Join(msg.res.Notes, "; ")@@ -335,7 +340,7 @@ func (m *Model) handleImported(msg importedMsg) tea.Cmd {
335340 336341 // openTablePicker asks which table of a SQLite database to import, or337342 // for a query.338-func (m *Model) openTablePicker(name string, tables []fileio.TableInfo) {343+func (m *Model) openTablePicker(name string, tables []fileio.TableInfo, place importPlace) {339344 var items []pickItem340345 for _, t := range tables {341346 detail := countRows(t.Rows) + ", " + plural(len(t.Cols), "1 column", fmt.Sprintf("%d columns", len(t.Cols)))@@ -346,7 +351,7 @@ func (m *Model) openTablePicker(name string, tables []fileio.TableInfo) {
346351 items = append(items, pickItem{title: t.Name, name: len(t.Name), detail: detail, desc: desc,347352 pick: func(m *Model) tea.Cmd {348353 m.closeOverlay()349- return m.startImport(name, fileio.Options{Table: t.Name})354+ return m.startImport(name, fileio.Options{Table: t.Name}, place)350355 }})351356 }352357 first := "table"@@ -361,7 +366,7 @@ func (m *Model) openTablePicker(name string, tables []fileio.TableInfo) {
361366 if text == "" {362367 return nil363368 }364- return m.startImport(name, fileio.Options{Query: text})369+ return m.startImport(name, fileio.Options{Query: text}, place)365370 })366371 m.prompt.indicator = "SQL"367372 return nil@@ -64,7 +64,7 @@ func TestImportPicker(t *testing.T) {
6464 6565 // A typed path imports a file that isn't listed.6666 m.runCommand("file.import")67- press(t, m, "sub/deep.tsv", "<enter>")67+ press(t, m, "sub/deep.tsv", "<enter>", "Replace spreadsheet", "<enter>")6868 if input(m, "B1") != "b" || m.xfer.source != "sub/deep.tsv" {6969 t.Errorf("typed path: B1 %q source %q err %q", input(m, "B1"), m.xfer.source, m.errMsg)7070 }@@ -76,7 +76,7 @@ func TestImportPicker(t *testing.T) {
7676 }7777 press(t, m, "<esc>")7878 m.runCommand("file.import")79- press(t, m, "old", "<enter>")79+ press(t, m, "old", "<enter>", "Replace spreadsheet", "<enter>")8080 if m.mode != modeError || m.errMsg != "Couldn't import Old.WK1: not a Lotus 1-2-3 worksheet: the file is empty" &&8181 !strings.Contains(m.errMsg, "Couldn't import Old.WK1: not a Lotus 1-2-3 worksheet") {8282 t.Errorf("wk1: %v %q", m.mode, m.errMsg)@@ -89,8 +89,8 @@ func TestImportAsksBeforeReplacingChanges(t *testing.T) {
8989 m := newModel()9090 press(t, m, "keep me", "<enter>")9191 m.runCommand("file.import")92- press(t, m, "<enter>")93- if _, ok := m.overlay.(*choiceBar); !ok || !strings.Contains(line(m, contextLine), "Importing replaces this sheet, which has unsaved changes.") {92+ press(t, m, "<enter>", "Replace spreadsheet", "<enter>")93+ if _, ok := m.overlay.(*choiceBar); !ok || !strings.Contains(line(m, contextLine), "Importing replaces this spreadsheet, which has unsaved changes.") {9494 t.Fatalf("no warning: %q", line(m, contextLine))9595 }9696 press(t, m, "<esc>")@@ -98,7 +98,7 @@ func TestImportAsksBeforeReplacingChanges(t *testing.T) {
9898 t.Errorf("Esc imported anyway")9999 }100100 m.runCommand("file.import")101- press(t, m, "<enter>", "<enter>")101+ press(t, m, "<enter>", "Replace spreadsheet", "<enter>", "<enter>")102102 if input(m, "A1") != "1" {103103 t.Errorf("Enter didn't import: %q", input(m, "A1"))104104 }@@ -162,7 +162,7 @@ func TestSQLiteTablePicker(t *testing.T) {
162162 }163163 164164 m.runCommand("file.import")165- press(t, m, "<enter>", "query", "<enter>")165+ press(t, m, "<enter>", "Replace spreadsheet", "<enter>", "query", "<enter>")166166 if m.mode != modePrompt || m.line.text() != "SELECT * FROM orders" {167167 t.Fatalf("query prompt %v %q", m.mode, m.line.text())168168 }