cat / 012
clone

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

Find and replace: macros record and replay replacements

Dami committed 67f9f15
The find bar runs a replacement through its host, which records
run("edit.replace", answer={...}) with the query, the replacement, the
options on, where it searched and, for one replacement, its cell, after
the selection it started from; scripts answer the command the same way.
A recording compares the selection without the far corner of a range
that isn't selected, so moving the active cell alone doesn't read as a
new selection.
commit
67f9f1535644c9f722655106798ea7eb4dd462a7
parent
0c96387a2b14f4b98b8c002f086628e491a68387
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.8 of 3, Jev's estimate
7 files changed, +200 -13
Minternal/ui/find.go5++
Ainternal/ui/findbar/answer.go92++++++++++++++++++++++++++++++
Minternal/ui/findbar/find.go30+++++++---
Minternal/ui/findbar/find_test.go45++++++++++++++-
Minternal/ui/hosts.go12++++-
Minternal/ui/macrodialog_test.go21+++++++
Minternal/ui/macrorec.go8++-
M internal/ui/find.go +5 -0
A internal/ui/findbar/answer.go +92 -0
@@ -0,0 +1,92 @@
1+package findbar2+3+import (4+ "encoding/json"5+ "errors"6+ "fmt"7+ "slices"8+ "strings"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// answerJSON is a replacement as a macro answers Find and replace:14+//15+// {"find": "Rent", "replace": "Lease", "matchCase": true, "within": "all"}16+//17+// the query and the replacement, the options that are on, where to18+// search (left out for the sheet shown, "all" for every sheet, or a19+// range of the sheet shown), and "cell" to replace only the match in20+// that cell rather than all of them.21+type answerJSON struct {22+ Find string `json:"find"`23+ Replace string `json:"replace"`24+ MatchCase bool `json:"matchCase,omitempty"`25+ WholeCell bool `json:"wholeCell,omitempty"`26+ Regex bool `json:"regex,omitempty"`27+ InFormulas bool `json:"inFormulas,omitempty"`28+ Within string `json:"within,omitempty"`29+ Cell string `json:"cell,omitempty"`30+}31+32+// answer is the bar's search and replacement as a macro records them,33+// replacing only in cell when it isn't "".34+func (f *Bar) answer(cell string) string {35+ a := answerJSON{Find: f.fields[0], Replace: f.fields[1], MatchCase: f.opts.MatchCase, WholeCell: f.opts.WholeCell,36+ Regex: f.opts.Regex, InFormulas: f.opts.InFormulas, Cell: cell}37+ switch f.where {38+ case inAll:39+ a.Within = "all"40+ case inRange:41+ a.Within = f.scope.String()42+ }43+ raw, _ := json.Marshal(a)44+ return string(raw)45+}46+47+// Answer makes the replacement a macro recorded (see answerJSON), as if48+// typed and chosen in the bar.49+func (f *Bar) Answer(text string) error {50+ var a answerJSON51+ if err := json.Unmarshal([]byte(text), &a); err != nil {52+ return fmt.Errorf("the answer: %w", err)53+ }54+ if a.Find == "" {55+ return errors.New(`the answer has nothing to find: give "find"`)56+ }57+ f.fields, f.replace, f.field = [2]string{a.Find, a.Replace}, true, 158+ f.h.Line().Set(a.Replace)59+ f.opts = sheet.FindOptions{MatchCase: a.MatchCase, WholeCell: a.WholeCell, Regex: a.Regex, InFormulas: a.InFormulas}60+ s, at := f.h.At()61+ switch a.Within {62+ case "":63+ f.where = inSheet64+ case "all":65+ f.where = inAll66+ default:67+ r, ok := sheet.ParseRange(strings.ToUpper(a.Within))68+ if !ok {69+ return fmt.Errorf(`"within" is %q: "all", or a range such as B2:D9`, a.Within)70+ }71+ f.scope, f.home, f.where = &r, s, inRange72+ }73+ f.from = Match{s, at}74+ if f.search(); f.err != "" {75+ return errors.New(f.err)76+ }77+ if a.Cell == "" {78+ f.replaceAll()79+ return nil80+ }81+ c, ok := sheet.ParseAddr(strings.ToUpper(a.Cell))82+ if !ok {83+ return fmt.Errorf("%q isn't a cell", a.Cell)84+ }85+ i := slices.Index(f.matches, Match{s, c})86+ if i < 0 {87+ return fmt.Errorf("%s doesn't match %q", c, a.Find)88+ }89+ f.cur = i90+ f.replaceOne()91+ return nil92+}
M internal/ui/findbar/find.go +21 -9
@@ -35,8 +35,11 @@ type Host interface {
3535 Show(s *sheet.Sheet, a sheet.Addr)3636 // Note says how the last action went, on the context line.3737 Note(msg string)38- // Edited marks the file modified.39- Edited()38+ // Replace runs a replacement, do, which returns what it did as a39+ // macro answers Find and replace (see Answer), or "" when it changed40+ // nothing: the file is then modified, and a macro being recorded41+ // records it after the selection it started from.42+ Replace(do func() string)4043 // Leave closes the bar, keeping its search for Ctrl+F and find next.4144 Leave()4245 // Press handles a mouse press outside the bar, as on the grid.
@@ -343,26 +346,34 @@ func (f *Bar) replaceOne() {
343346 if f.cur < 0 {344347 return345348 }349+ f.h.Replace(f.doReplaceOne)350+}351+352+func (f *Bar) doReplaceOne() string {346353 c := f.matches[f.cur]347354 a := c.At348355 changed, err := c.Sheet.Replace(a, f.fields[0], f.fields[1], f.options())349356 switch {350357 case err != nil:351358 f.h.Note("Can't replace in " + a.String() + ": " + err.Error())352- return359+ return ""353360 case !changed:354361 f.h.Note("Formulas in " + a.String() + " change only when searching within formulas")355- default:356- f.h.Edited()357362 }358363 f.from = c359364 f.search()360365 if changed && f.cur >= 0 && f.matches[f.cur] == c {361366 f.step(1) // the cell still matches; move past it362367 }368+ if !changed {369+ return ""370+ }371+ return f.answer(a.String())363372 }364373 365-func (f *Bar) replaceAll() {374+func (f *Bar) replaceAll() { f.h.Replace(f.doReplaceAll) }375+376+func (f *Bar) doReplaceAll() string {366377 // Across sheets, still one undo step.367378 span := f.h.Trace().Start("replace")368379 n := 0
@@ -378,9 +389,6 @@ func (f *Bar) replaceAll() {
378389 return nil379390 })380391 span.End(slog.Int("replaced", n))381- if n > 0 {382- f.h.Edited()383- }384392 switch {385393 case err != nil:386394 f.h.Note("Replaced " + cellCount(n) + ", then stopped: " + err.Error())
@@ -390,6 +398,10 @@ func (f *Bar) replaceAll() {
390398 f.h.Note("Replaced " + cellCount(n))391399 }392400 f.search()401+ if n == 0 {402+ return ""403+ }404+ return f.answer("")393405 }394406 395407 // cellCount is n cells in words: "1 cell", "3 cells".
M internal/ui/findbar/find_test.go +43 -2
@@ -23,6 +23,7 @@ type fakeHost struct {
2323 cur sheet.Addr2424 note string2525 edited bool26+ answers []string2627 left int2728 pressed []int2829 }
@@ -51,8 +52,13 @@ func (f *fakeHost) Trace() *telemetry.Trace { return nil }
5152 func (f *fakeHost) At() (*sheet.Sheet, sheet.Addr) { return f.s, f.cur }5253 func (f *fakeHost) Show(s *sheet.Sheet, a sheet.Addr) { f.s, f.cur = s, a }5354 func (f *fakeHost) Note(msg string) { f.note = msg }54-func (f *fakeHost) Edited() { f.edited = true }55-func (f *fakeHost) Leave() { f.left++ }55+func (f *fakeHost) Replace(do func() string) {56+ if a := do(); a != "" {57+ f.edited = true58+ f.answers = append(f.answers, a)59+ }60+}61+func (f *fakeHost) Leave() { f.left++ }5662 5763 func (f *fakeHost) Press(x, y int, _ tea.MouseButton) tea.Cmd {5864 f.pressed = []int{x, y}
@@ -144,6 +150,41 @@ func TestReplace(t *testing.T) {
144150 if got := h.s.Cell(addr("A3")).Input; got != "total lease" || h.note != "Replaced 2 cells" {145151 t.Errorf("replace all: A3 %q, note %q", got, h.note)146152 }153+ // What a macro records of each.154+ want := `{"find":"rent","replace":"lease","cell":"A1"}` + "\n" + `{"find":"rent","replace":"lease"}`155+ if got := strings.Join(h.answers, "\n"); got != want {156+ t.Errorf("answers:\n%s\nwant:\n%s", got, want)157+ }158+}159+160+func TestAnswerReplaces(t *testing.T) {161+ h := newHost(t, "A1", "rent", "A2", "Rent", "B2", "rent")162+ b := open(h, true)163+ if err := b.Answer(`{"find":"rent","replace":"lease","matchCase":true,"cell":"B2"}`); err != nil {164+ t.Fatal(err)165+ }166+ if h.s.Cell(addr("B2")).Input != "lease" || h.s.Cell(addr("A1")).Input != "rent" {167+ t.Fatalf("one replacement: A1 %q B2 %q", h.s.Cell(addr("A1")).Input, h.s.Cell(addr("B2")).Input)168+ }169+ if err := b.Answer(`{"find":"rent","replace":"hire","within":"A1:A2"}`); err != nil {170+ t.Fatal(err)171+ }172+ if h.s.Cell(addr("A1")).Input != "hire" || h.s.Cell(addr("A2")).Input != "hire" {173+ t.Fatalf("within a range: A1 %q A2 %q", h.s.Cell(addr("A1")).Input, h.s.Cell(addr("A2")).Input)174+ }175+ if last := h.answers[len(h.answers)-1]; last != `{"find":"rent","replace":"hire","within":"A1:A2"}` {176+ t.Errorf("recorded %s", last)177+ }178+ for text, want := range map[string]string{179+ `{"replace":"x"}`: "nothing to find",180+ `{"find":"x","within":"here"}`: `"within" is "here"`,181+ `{"find":"(","regex":true}`: "Invalid regular expression",182+ `{"find":"zzz","replace":"y","cell":"A1"}`: `A1 doesn't match "zzz"`,183+ } {184+ if err := b.Answer(text); err == nil || !strings.Contains(err.Error(), want) {185+ t.Errorf("Answer(%s) = %v, want %q", text, err, want)186+ }187+ }147188 }148189 149190 func TestMouse(t *testing.T) {
M internal/ui/hosts.go +11 -1
M internal/ui/macrodialog_test.go +21 -0
M internal/ui/macrorec.go +7 -1