cat / 012
clone

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

Keep macros with the workbook: undoable list, file field, and an undo step that stays open across calls

Dami committed 66366c6
commit
66366c6f963ccd13ed736a24b127bc9528ead85a
parent
9a70f51870c8a0b2c3fc005c4c3d9d0d9c341482
author
Dami <dami.etoile@gmail.com>
6 files changed, +445 -22
Minternal/sheet/file.go20++-
Minternal/sheet/history.go60+++++--
Minternal/sheet/history_test.go2+-
Ainternal/sheet/macros.go237++++++++++++++++++++++++++++++
Ainternal/sheet/macros_test.go145++++++++++++++++++
Minternal/sheet/workbook.go3+
M internal/sheet/file.go +17 -3
@@ -46,9 +46,13 @@ type fileFormat struct {
4646 // Arithmetic is "decimal" for decimal arithmetic, which needs no4747 // version bump: earlier builds ignore it and compute in binary, as4848 // Sheets would. It is for the whole workbook, so it stays at the top.49- Arithmetic string `json:"arithmetic,omitempty"`50- fileSheet // versions 1 to 3: the only sheet51- Sheets []fileSheet `json:"sheets,omitempty"` // version 449+ Arithmetic string `json:"arithmetic,omitempty"`50+ // Macros and where they were made need no version bump either:51+ // earlier builds ignore them, and the sheets read the same.52+ MacroOrigin string `json:"macroOrigin,omitempty"`53+ Macros []fileMacro `json:"macros,omitempty"`54+ fileSheet // versions 1 to 3: the only sheet55+ Sheets []fileSheet `json:"sheets,omitempty"` // version 45256 }5357 5458 // fileSheet is one sheet of a file.
@@ -233,6 +237,12 @@ func (w *Workbook) headLines() string {
233237 if w.decimal {234238 lines = append(lines, `"arithmetic": "decimal"`)235239 }240+ if len(w.macros) > 0 {241+ if w.macroOrigin != "" {242+ lines = append(lines, `"macroOrigin": `+jsonString(w.macroOrigin))243+ }244+ lines = append(lines, w.macrosLines())245+ }236246 return strings.Join(lines, ",\n ")237247 }238248
@@ -363,6 +373,10 @@ func ReadBook(r io.Reader) (*Workbook, error) {
363373 // Anything but "decimal" (say, a mode from a later build) computes in364374 // binary, as the file would in a build without the setting.365375 w.decimal = f.Arithmetic == "decimal"376+ if err := w.readMacros(f.Macros); err != nil {377+ return nil, err378+ }379+ w.macroOrigin = f.MacroOrigin366380 w.RecalcAll()367381 return w, nil368382 }
M internal/sheet/history.go +42 -18
@@ -48,6 +48,8 @@ type step struct {
4848 sheets *sheetList4949 // decimal is the arithmetic setting before the step, when it changed.5050 decimal *bool51+ // macros is the macro list before the step, when it changed.52+ macros *[]Macro5153 }5254 5355 // colKey is a column of a sheet.
@@ -73,7 +75,8 @@ func (st *step) empty() bool {
7375 7476 // widthOnly reports whether the step changed nothing but column widths.7577 func (st *step) widthOnly() bool {76- return len(st.cells) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && st.sheets == nil && st.decimal == nil78+ return len(st.cells) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && st.sheets == nil &&79+ st.decimal == nil && st.macros == nil7780 }7881 7982 func (c *Cell) clone() *Cell {
@@ -94,6 +97,9 @@ type Change struct {
9497 // Tabs is set when the step added, deleted, renamed or moved sheets;9598 // Focus means nothing then.9699 Tabs bool100+ // Macros is set when the step changed nothing but the macros; Focus101+ // means nothing then either.102+ Macros bool97103 }98104 99105 // Batch runs fn as a single undo step: everything it changes is undone
@@ -113,28 +119,38 @@ func (s *Sheet) change(label string, focus Rect, fn func()) {
113119 // change opens a step (unless one is open), runs fn, and on the way out114120 // of the outermost call recalculates what changed and pushes the step.115121 func (w *Workbook) change(s *Sheet, label string, focus Rect, fn func()) {122+ w.begin(s, label, focus)123+ defer w.finish()124+ fn()125+}126+127+// begin opens a step unless one is open, and nests into it.128+func (w *Workbook) begin(s *Sheet, label string, focus Rect) {116129 h := &w.hist117130 if h.depth == 0 {118131 h.open = newStep(label, s, focus)119132 w.structural = false120133 }121134 h.depth++122- defer func() {123- h.depth--124- if h.depth > 0 {125- return126- }127- st, dirty := h.open, h.dirty128- h.open, h.dirty = nil, nil129- if w.structural {130- w.structural = false131- w.recalcAll()132- } else {133- w.recalc(dirty)134- }135- w.push(st)136- }()137- fn()135+}136+137+// finish leaves a nesting level; the outermost recalculates what changed138+// and pushes the step.139+func (w *Workbook) finish() {140+ h := &w.hist141+ h.depth--142+ if h.depth > 0 {143+ return144+ }145+ st, dirty := h.open, h.dirty146+ h.open, h.dirty = nil, nil147+ if w.structural {148+ w.structural = false149+ w.recalcAll()150+ } else {151+ w.recalc(dirty)152+ }153+ w.push(st)138154 }139155 140156 // record saves the cell at a before its first change in the open step.
@@ -292,6 +308,9 @@ func (w *Workbook) dropUnchanged(st *step) {
292308 if st.decimal != nil && *st.decimal == w.decimal {293309 st.decimal = nil294310 }311+ if st.macros != nil && slices.Equal(*st.macros, w.macros) {312+ st.macros = nil313+ }295314 }296315 297316 // sameName reports whether two named ranges (nil for undefined) are the
@@ -408,6 +427,10 @@ func (w *Workbook) swap(from, to *[]*step) (Change, bool) {
408427 inv.decimal, w.decimal = &cur, *st.decimal409428 w.structural = true // every formula computes differently410429 }430+ if st.macros != nil {431+ cur := w.macros432+ inv.macros, w.macros = &cur, slices.Clone(*st.macros)433+ }411434 for s, v := range st.views {412435 cur := s.view413436 inv.views[s] = &cur
@@ -422,7 +445,8 @@ func (w *Workbook) swap(from, to *[]*step) (Change, bool) {
422445 }423446 *to = append(*to, inv)424447 w.hist.mergeWidths = false425- return Change{st.label, st.focus, st.sheet, st.sheets != nil}, true448+ macrosOnly := st.macros != nil && len(st.cells) == 0 && len(st.names) == 0 && len(st.charts) == 0 && st.sheets == nil449+ return Change{Label: st.label, Focus: st.focus, Sheet: st.sheet, Tabs: st.sheets != nil, Macros: macrosOnly}, true426450 }427451 428452 // colRect is the range covering whole columns from..to.
M internal/sheet/history_test.go +1 -1
A internal/sheet/macros.go +237 -0
@@ -0,0 +1,237 @@
1+package sheet2+3+import (4+ "bytes"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "slices"9+ "strings"10+ "unicode/utf8"11+)12+13+// Macros are kept with the workbook, as Google Sheets keeps a14+// spreadsheet's Apps Script: each is a named Starlark script with an15+// optional Ctrl+Alt+Shift+digit shortcut. The engine only stores them;16+// recording and running them is the UI's (internal/ui, internal/macro).17+// Changing the list is an undo step like any other edit, so the file's18+// modified flag follows it.19+20+// MacroAPI is the version of the scripting API macros are written for.21+// A file records it with each macro, so a later build that changes the22+// API can tell old scripts from new ones.23+const MacroAPI = 124+25+// Macro is a saved macro.26+type Macro struct {27+ Name string28+ Key string // the shortcut's digit, "0" to "9", or "" for none29+ Source string // the Starlark script30+ API int // the scripting API it was written for, MacroAPI or older31+}32+33+// maxMacroName keeps names short enough for menus and the palette.34+const maxMacroName = 6035+36+// ValidMacroName reports why name can't name a macro, or nil.37+func ValidMacroName(name string) error {38+ switch {39+ case strings.TrimSpace(name) == "":40+ return errors.New("a macro needs a name")41+ case utf8.RuneCountInString(name) > maxMacroName:42+ return fmt.Errorf("macro names are at most %d characters", maxMacroName)43+ case strings.ContainsAny(name, "\n\r\t"):44+ return errors.New("macro names are one line")45+ }46+ return nil47+}48+49+// Macros returns the saved macros in the order they were added.50+func (w *Workbook) Macros() []Macro { return slices.Clone(w.macros) }51+52+// Macro finds a macro by name, ignoring case.53+func (w *Workbook) Macro(name string) (Macro, bool) {54+ if i := w.macroIndex(name); i >= 0 {55+ return w.macros[i], true56+ }57+ return Macro{}, false58+}59+60+// MacroForKey finds the macro a shortcut digit runs.61+func (w *Workbook) MacroForKey(key string) (Macro, bool) {62+ for _, mc := range w.macros {63+ if key != "" && mc.Key == key {64+ return mc, true65+ }66+ }67+ return Macro{}, false68+}69+70+func (w *Workbook) macroIndex(name string) int {71+ return slices.IndexFunc(w.macros, func(mc Macro) bool { return strings.EqualFold(mc.Name, name) })72+}73+74+// SaveMacro stores mc in place of the macro named old, or adds it when75+// old is "", as one undo step labelled label. Names are unique ignoring76+// case, and so are shortcuts.77+func (w *Workbook) SaveMacro(old string, mc Macro, label string) error {78+ if err := ValidMacroName(mc.Name); err != nil {79+ return err80+ }81+ mc.Name = strings.TrimSpace(mc.Name)82+ if mc.API == 0 {83+ mc.API = MacroAPI84+ }85+ at := len(w.macros)86+ if old != "" {87+ if at = w.macroIndex(old); at < 0 {88+ return fmt.Errorf("there's no macro named %s", old)89+ }90+ }91+ if i := w.macroIndex(mc.Name); i >= 0 && i != at {92+ return fmt.Errorf("there's already a macro named %s", w.macros[i].Name)93+ }94+ if other, ok := w.MacroForKey(mc.Key); ok && !strings.EqualFold(other.Name, old) {95+ return fmt.Errorf("Ctrl+Alt+Shift+%s already runs %s", mc.Key, other.Name)96+ }97+ if mc.Key != "" && (len(mc.Key) != 1 || mc.Key[0] < '0' || mc.Key[0] > '9') {98+ return fmt.Errorf("a macro's shortcut is a digit, not %q", mc.Key)99+ }100+ w.changeMacros(label, func() {101+ if at == len(w.macros) {102+ w.macros = append(w.macros, mc)103+ } else {104+ w.macros[at] = mc105+ }106+ })107+ return nil108+}109+110+// DeleteMacro removes the named macro as an undo step, reporting whether111+// there was one.112+func (w *Workbook) DeleteMacro(name string) bool {113+ i := w.macroIndex(name)114+ if i < 0 {115+ return false116+ }117+ w.changeMacros("delete macro "+w.macros[i].Name, func() {118+ w.macros = slices.Delete(w.macros, i, i+1)119+ })120+ return true121+}122+123+// changeMacros runs fn, which changes the macro list, as an undo step.124+func (w *Workbook) changeMacros(label string, fn func()) {125+ s := w.sheets[w.Active()]126+ w.change(s, label, Rect{}, func() {127+ if st := w.hist.open; st != nil && st.macros == nil {128+ before := slices.Clone(w.macros)129+ st.macros = &before130+ }131+ w.macros = slices.Clone(w.macros) // steps keep the old slice132+ fn()133+ })134+}135+136+// MacroOrigin identifies the computer the workbook's macros were made or137+// trusted on, as saved in the file; "" when unknown. The UI compares it138+// with its own to decide whether to ask before running a macro from a139+// file made elsewhere.140+func (w *Workbook) MacroOrigin() string { return w.macroOrigin }141+142+// SetMacroOrigin records where the macros were made or trusted. It isn't143+// an edit: it changes nothing a user sees, and is saved with the next144+// save.145+func (w *Workbook) SetMacroOrigin(origin string) { w.macroOrigin = origin }146+147+// Begin opens an undo step that stays open until the returned function148+// is called, for changes made over several calls that undo together,149+// such as a macro run. Changes in between join it as in a Batch, and150+// Undo and Redo do nothing while it is open. Call Settle to see the151+// values of formulas changed so far.152+func (w *Workbook) Begin(c Change) (end func()) {153+ w.begin(c.Sheet, c.Label, c.Focus)154+ done := false155+ return func() {156+ if !done {157+ done = true158+ w.finish()159+ }160+ }161+}162+163+// Settle recalculates what the open step has changed so far, so formulas164+// read inside it (by a macro, or a command it runs) see current values.165+// Outside a step there is nothing pending.166+func (w *Workbook) Settle() {167+ h := &w.hist168+ if h.open == nil || len(h.dirty) == 0 && !w.structural {169+ return170+ }171+ dirty := h.dirty172+ h.dirty = nil173+ if w.structural {174+ w.structural = false175+ w.recalcAll()176+ } else {177+ w.recalc(dirty)178+ }179+}180+181+// fileMacro is a macro, one per line, its script a JSON string:182+//183+// {"name": "Totals", "key": "1", "api": 1, "source": "select(\"B9\")\nenter(\"=SUM(B2:B8)\")\n"}184+type fileMacro struct {185+ Name string `json:"name"`186+ Key string `json:"key,omitempty"`187+ API int `json:"api"`188+ Source string `json:"source"`189+}190+191+// macrosLines is the "macros" field: a list with one macro per line.192+func (w *Workbook) macrosLines() string {193+ var b strings.Builder194+ b.WriteString(`"macros": [`)195+ for i, mc := range w.macros {196+ if i > 0 {197+ b.WriteByte(',')198+ }199+ fmt.Fprintf(&b, "\n {\"name\": %s, ", plainJSON(mc.Name))200+ if mc.Key != "" {201+ fmt.Fprintf(&b, "\"key\": %s, ", plainJSON(mc.Key))202+ }203+ fmt.Fprintf(&b, "\"api\": %d, \"source\": %s}", mc.API, plainJSON(mc.Source))204+ }205+ b.WriteString("\n ]")206+ return b.String()207+}208+209+// plainJSON is s as a JSON string, leaving <, > and & as they are so210+// formulas in scripts stay readable.211+func plainJSON(s string) string {212+ var b bytes.Buffer213+ enc := json.NewEncoder(&b)214+ enc.SetEscapeHTML(false)215+ enc.Encode(s)216+ return strings.TrimSuffix(b.String(), "\n")217+}218+219+// readMacros keeps a file's macros. A macro whose name clashes or is220+// invalid is an error, as for named ranges; its script isn't checked221+// until it runs.222+func (w *Workbook) readMacros(macros []fileMacro) error {223+ for _, fm := range macros {224+ if err := ValidMacroName(fm.Name); err != nil {225+ return fmt.Errorf("macro %q: %w", fm.Name, err)226+ }227+ if w.macroIndex(fm.Name) >= 0 {228+ return fmt.Errorf("macro %q is defined twice", fm.Name)229+ }230+ key := fm.Key231+ if _, taken := w.MacroForKey(key); taken || len(key) != 1 || key[0] < '0' || key[0] > '9' {232+ key = "" // a clash or a key this build can't bind: keep the macro, drop the shortcut233+ }234+ w.macros = append(w.macros, Macro{Name: fm.Name, Key: key, Source: fm.Source, API: max(fm.API, 1)})235+ }236+ return nil237+}
A internal/sheet/macros_test.go +145 -0
@@ -0,0 +1,145 @@
1+package sheet2+3+import (4+ "bytes"5+ "strings"6+ "testing"7+)8+9+func TestSaveMacroIsUndoable(t *testing.T) {10+ w := NewBook()11+ if err := w.SaveMacro("", Macro{Name: "Totals", Key: "1", Source: "enter(\"x\")\n"}, "record macro Totals"); err != nil {12+ t.Fatal(err)13+ }14+ if err := w.SaveMacro("totals", Macro{Name: "Sums", Key: "1", Source: "enter(\"y\")\n"}, "edit macro Sums"); err != nil {15+ t.Fatal(err)16+ }17+ if ms := w.Macros(); len(ms) != 1 || ms[0].Name != "Sums" || ms[0].API != MacroAPI {18+ t.Fatalf("macros %+v", ms)19+ }20+ c, ok := w.Undo()21+ if !ok || c.Label != "edit macro Sums" || !c.Macros {22+ t.Fatalf("undo %+v %v", c, ok)23+ }24+ if mc, ok := w.Macro("TOTALS"); !ok || mc.Source != "enter(\"x\")\n" {25+ t.Fatalf("after undo %+v %v", mc, ok)26+ }27+ w.Undo()28+ if len(w.Macros()) != 0 {29+ t.Fatalf("macros after undoing the add: %+v", w.Macros())30+ }31+ w.Redo()32+ if _, ok := w.MacroForKey("1"); !ok {33+ t.Fatal("redo lost the shortcut")34+ }35+ if !w.DeleteMacro("Totals") || len(w.Macros()) != 0 {36+ t.Fatal("delete")37+ }38+}39+40+func TestSaveMacroChecksNamesAndKeys(t *testing.T) {41+ w := NewBook()42+ w.SaveMacro("", Macro{Name: "A", Key: "1"}, "add")43+ for _, tc := range []struct {44+ old string45+ mc Macro46+ want string47+ }{48+ {"", Macro{Name: " "}, "needs a name"},49+ {"", Macro{Name: "a"}, "already a macro named A"},50+ {"", Macro{Name: "B", Key: "1"}, "Ctrl+Alt+Shift+1 already runs A"},51+ {"", Macro{Name: "B", Key: "x"}, "is a digit"},52+ {"C", Macro{Name: "C"}, "no macro named C"},53+ } {54+ if err := w.SaveMacro(tc.old, tc.mc, "x"); err == nil || !strings.Contains(err.Error(), tc.want) {55+ t.Errorf("SaveMacro(%q, %+v) = %v, want %q", tc.old, tc.mc, err, tc.want)56+ }57+ }58+}59+60+func TestMacrosRoundTrip(t *testing.T) {61+ for _, sheets := range []int{1, 2} {62+ w := NewBook()63+ if sheets == 2 {64+ w.AddSheet("Two", 1)65+ }66+ src := "# a <script> & more\nselect(\"B2\")\nenter(\"=SUM(A1:A3)\")\n"67+ w.SaveMacro("", Macro{Name: "Totals", Key: "3", Source: src}, "add")68+ w.SaveMacro("", Macro{Name: "Plain", Source: "move(0, 1)\n"}, "add")69+ w.SetMacroOrigin("abc123")70+ var b bytes.Buffer71+ if err := w.Write(&b); err != nil {72+ t.Fatal(err)73+ }74+ if !strings.Contains(b.String(), `{"name": "Totals", "key": "3", "api": 1, "source": "# a <script> & more\nselect(\"B2\")`) {75+ t.Errorf("file:\n%s", b.String())76+ }77+ got, err := ReadBook(&b)78+ if err != nil {79+ t.Fatal(err)80+ }81+ ms := got.Macros()82+ if len(ms) != 2 || ms[0] != (Macro{Name: "Totals", Key: "3", Source: src, API: 1}) || ms[1].Name != "Plain" || ms[1].Key != "" {83+ t.Errorf("%d sheets: macros %+v", sheets, ms)84+ }85+ if got.MacroOrigin() != "abc123" {86+ t.Errorf("origin %q", got.MacroOrigin())87+ }88+ if got.CanUndo() {89+ t.Error("loading is undoable")90+ }91+ }92+}93+94+func TestFileWithoutMacrosHasNoMacroFields(t *testing.T) {95+ w := NewBook()96+ w.SetMacroOrigin("abc")97+ var b bytes.Buffer98+ w.Write(&b)99+ if strings.Contains(b.String(), "macro") {100+ t.Errorf("file:\n%s", b.String())101+ }102+}103+104+func TestReadMacrosDropsClashingKeys(t *testing.T) {105+ src := `{"version": 2, "macros": [{"name": "A", "key": "1", "api": 1, "source": ""}, {"name": "B", "key": "1", "api": 2, "source": ""}], "cells": {}}`106+ w, err := ReadBook(strings.NewReader(src))107+ if err != nil {108+ t.Fatal(err)109+ }110+ if ms := w.Macros(); ms[1].Key != "" || ms[1].API != 2 {111+ t.Errorf("macros %+v", ms)112+ }113+ dup := `{"version": 2, "macros": [{"name": "A", "api": 1, "source": ""}, {"name": "a", "api": 1, "source": ""}], "cells": {}}`114+ if _, err := ReadBook(strings.NewReader(dup)); err == nil {115+ t.Error("a name defined twice loads")116+ }117+}118+119+func TestBeginKeepsOneStepOpen(t *testing.T) {120+ s := New()121+ w := s.Book()122+ end := w.Begin(Change{Label: "run macro M", Sheet: s})123+ s.Set(Addr{}, "2")124+ s.Set(Addr{Row: 1}, "=A1*10")125+ w.Settle()126+ if v := s.Value(Addr{Row: 1}); v.Num != 20 {127+ t.Fatalf("A2 inside the step = %v", v)128+ }129+ s.Set(Addr{}, "3")130+ if _, ok := w.Undo(); ok {131+ t.Fatal("undo ran inside an open step")132+ }133+ end()134+ end() // a second call does nothing135+ if v := s.Value(Addr{Row: 1}); v.Num != 30 {136+ t.Fatalf("A2 after the step = %v", v)137+ }138+ c, ok := w.Undo()139+ if !ok || c.Label != "run macro M" || s.Cell(Addr{}) != nil || s.Cell(Addr{Row: 1}) != nil {140+ t.Fatalf("undo %+v %v: A1 %v", c, ok, s.Cell(Addr{}))141+ }142+ if w.CanUndo() {143+ t.Error("the step was split")144+ }145+}
M internal/sheet/workbook.go +3 -0