cat / 012
clone

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

Engine: proposals, a change made on a copy and applied whole or by cell

Dami committed 6f104fb
An agent's suggestion in live mode is a proposal: Propose makes the
change on a copy and lists the cells it sets and what else it does;
Apply makes it on the workbook as one step of the current author, the
cells picked or, for a change beyond cells, the whole change run again.
Adopt gives a person the steps made before the history was shared.
TestRandomSharedEdits has an agent among the authors, its proposals
accepted whole, in part or not at all.
commit
6f104fba135597a813c12aa7213e878c21227410
parent
e34a85f0dda96ae15f0397b53f13abee49669c16
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.3 of 3, Jev's estimate
4 files changed, +512 -8
Minternal/sheet/authors.go16++
Ainternal/sheet/proposal.go279++++++++++++++++++++++++++++++
Ainternal/sheet/proposal_test.go165++++++++++++++++++
Minternal/sheet/randedit_shared_test.go60++++++-
M internal/sheet/authors.go +16 -0
A internal/sheet/proposal.go +279 -0
@@ -0,0 +1,279 @@
1+package sheet2+3+import (4+ "bytes"5+ "cmp"6+ "errors"7+ "slices"8+9+ "github.com/FelineStateMachine/012/internal/formula"10+)11+12+// Proposals: a change made on a copy of a workbook, to be made on the13+// workbook itself later, whole or cell by cell, or not at all. An14+// agent's suggestion in live mode is one (docs/agents/live.md): the15+// person sees the cells it would change and accepts some or all of16+// them, and what's accepted is one step, its author the workbook's17+// author when it's applied, so undo and the others' marks follow as18+// for any step.19+//20+// A change that does more than set cells (inserting rows, adding a21+// sheet or a chart, a filter) is made whole only, by running it again22+// on the workbook; a change of cells alone is applied as the cells it23+// made, so what's accepted is what was shown.24+25+// Proposal is a change made on a copy of a workbook.26+type Proposal struct {27+ Label string28+ // Cells are the cells whose contents, format or note the change29+ // sets, by sheet in the copy's order, then row and column.30+ Cells []Proposed31+ // Other says what the change does besides setting cells, one32+ // phrase each ("inserts or deletes rows or columns on Sheet1"); a33+ // proposal with any is made whole only.34+ Other []string35+ // Sheets are the sheets, by name, whose lines, charts, rules,36+ // filters or pivot the change changes; Book is set when it changes37+ // the sheet list, the settings, the names or the macros.38+ Sheets []string39+ Book bool40+ // Remote is set when a formula it enters asks a hosted model (JEV),41+ // which reaches the network once the change is made.42+ Remote bool43+44+ copy *Workbook45+ fn func(*Workbook) error46+}47+48+// Proposed is a cell a proposal sets: its sheet, where, its input49+// before and after ("" for blank), and whether its format, style or50+// note change too.51+type Proposed struct {52+ Sheet string53+ At Addr54+ Was, Now string55+ Look bool56+}57+58+// ErrWhole is returned when part of a proposal that must be made whole59+// is picked.60+var ErrWhole = errors.New("this change does more than set cells: it can only be accepted whole")61+62+// Propose makes fn, labelled label, on a copy of w and says what it63+// changed. w isn't changed; fn may be run again on it when the proposal64+// is applied whole.65+func Propose(w *Workbook, label string, fn func(*Workbook) error) (*Proposal, error) {66+ var data bytes.Buffer67+ if err := w.Write(&data); err != nil {68+ return nil, err69+ }70+ s, err := Read(&data)71+ if err != nil {72+ return nil, err73+ }74+ c := s.Book()75+ before := c.StateID()76+ if err := c.Batch(Change{Label: label, Sheet: c.Sheet(c.Active())}, func() error { return fn(c) }); err != nil {77+ return nil, err78+ }79+ p := &Proposal{Label: label, copy: c, fn: fn}80+ if st := c.hist.top(); st != nil && st.id != before {81+ p.collect(w, st)82+ }83+ return p, nil84+}85+86+// Empty reports whether the proposal changes nothing.87+func (p *Proposal) Empty() bool { return len(p.Cells) == 0 && len(p.Other) == 0 }88+89+// Whole reports whether the proposal can only be made whole.90+func (p *Proposal) Whole() bool { return len(p.Other) > 0 }91+92+// Result is the copy with the change made, to read what it would look93+// like. It must not be changed.94+func (p *Proposal) Result() *Workbook { return p.copy }95+96+// collect notes what step st made on the copy, against w.97+func (p *Proposal) collect(w *Workbook, st *step) {98+ order := map[string]int{}99+ for i, s := range p.copy.sheets {100+ order[s.name] = i101+ }102+ for s, img := range st.cells {103+ orig := w.Lookup(s.name)104+ img.addrs(func(a Addr) bool {105+ p.cell(orig, s, a)106+ return true107+ })108+ }109+ slices.SortFunc(p.Cells, func(x, y Proposed) int {110+ return cmp.Or(cmp.Compare(order[x.Sheet], order[y.Sheet]), cmp.Compare(x.At.Row, y.At.Row), cmp.Compare(x.At.Col, y.At.Col))111+ })112+ p.others(st)113+}114+115+// cell notes a's change on the copy's sheet s, when it changed from116+// what the workbook's sheet orig holds (nil: the sheet is new).117+func (p *Proposal) cell(orig, s *Sheet, a Addr) {118+ now := s.cells.get(a)119+ if now.Spilled() || now != nil && now.derived {120+ return // an array's or a pivot's: its formula is the change121+ }122+ var was *Cell123+ if orig != nil {124+ was = orig.cells.get(a)125+ }126+ in := func(c *Cell) string {127+ if c == nil {128+ return ""129+ }130+ return c.Input131+ }132+ look := func(c *Cell) (Format, Style, string) {133+ if c == nil {134+ return Format{}, Style{}, ""135+ }136+ return c.Format, c.Style, c.Note137+ }138+ wf, ws, wn := look(was)139+ nf, ns, nn := look(now)140+ lookChanged := wf != nf || ws != ns || wn != nn141+ if in(was) == in(now) && !lookChanged {142+ return143+ }144+ p.Cells = append(p.Cells, Proposed{Sheet: s.name, At: a, Was: in(was), Now: in(now), Look: lookChanged})145+ if now != nil && now.expr != nil && callsRemote(now.expr) {146+ p.Remote = true147+ }148+}149+150+// others notes what the step changed besides cells.151+func (p *Proposal) others(st *step) {152+ sheets := map[*Sheet]bool{}153+ add := func(s *Sheet) {154+ if !sheets[s] {155+ sheets[s] = true156+ p.Sheets = append(p.Sheets, s.name)157+ }158+ }159+ for s := range st.shifts {160+ add(s)161+ p.Other = append(p.Other, "inserts or deletes rows or columns on "+s.name)162+ }163+ for _, m := range []struct {164+ what string165+ on map[*Sheet]bool166+ }{{"charts", keysOf(st.charts)}, {"a pivot table", keysOf(st.pivots)}, {"rules", keysOf(st.rules)}, {"regions", keysOf(st.regions)}, {"the view", keysOf(st.views)}} {167+ for s := range m.on {168+ add(s)169+ p.Other = append(p.Other, "changes "+m.what+" on "+s.name)170+ }171+ }172+ lines := map[*Sheet]bool{}173+ for k := range st.widths {174+ lines[k.s] = true175+ }176+ for k := range st.heights {177+ lines[k.s] = true178+ }179+ for k := range st.lines {180+ lines[k.s] = true181+ }182+ for s := range lines {183+ add(s)184+ p.Other = append(p.Other, "changes columns' or rows' sizes or formats on "+s.name)185+ }186+ for _, b := range []struct {187+ what string188+ on bool189+ }{{"adds, deletes, renames or moves sheets", st.sheets != nil}, {"changes named ranges", len(st.names) > 0},190+ {"changes the settings", st.settings != nil}, {"changes the macros", st.macros != nil}} {191+ if b.on {192+ p.Book = true193+ p.Other = append(p.Other, b.what)194+ }195+ }196+ slices.Sort(p.Other)197+}198+199+// callsRemote reports whether a formula calls a function a hosted200+// model answers.201+func callsRemote(n Node) bool {202+ found := false203+ var walk func(Node)204+ walk = func(n Node) {205+ if call, ok := n.(formula.Call); ok && !found {206+ if f := funcOf(call); f != nil && f.Remote() {207+ found = true208+ }209+ }210+ formula.EachChild(n, walk)211+ }212+ walk(n)213+ return found214+}215+216+// Apply makes the proposal on w as one step, in the name of w's author:217+// the cells pick keeps (nil keeps them all), or the whole change run218+// again on w when it does more than set cells.219+func (p *Proposal) Apply(w *Workbook, pick func(i int) bool) error {220+ if p.Whole() {221+ for i := range p.Cells {222+ if pick != nil && !pick(i) {223+ return ErrWhole224+ }225+ }226+ return w.Batch(Change{Label: p.Label, Sheet: w.Sheet(w.Active())}, func() error { return p.fn(w) })227+ }228+ var picked []Proposed229+ for i, c := range p.Cells {230+ if pick == nil || pick(i) {231+ picked = append(picked, c)232+ }233+ }234+ if len(picked) == 0 {235+ return nil236+ }237+ first := w.Lookup(picked[0].Sheet)238+ if first == nil {239+ return errors.New(picked[0].Sheet + " is gone")240+ }241+ focus := Rect{From: picked[0].At, To: picked[0].At}242+ for _, c := range picked[1:] {243+ if c.Sheet == picked[0].Sheet {244+ focus = union(focus, Rect{From: c.At, To: c.At})245+ }246+ }247+ return w.Batch(Change{Label: p.Label, Sheet: first, Focus: focus}, func() error {248+ for _, c := range picked {249+ if err := p.place(w, c); err != nil {250+ return err251+ }252+ }253+ return nil254+ })255+}256+257+// place sets c's cell on w as the copy holds it.258+func (p *Proposal) place(w *Workbook, c Proposed) error {259+ s := w.Lookup(c.Sheet)260+ if s == nil {261+ return errors.New(c.Sheet + " is gone")262+ }263+ r := Rect{From: c.At, To: c.At}264+ if s.InPivot(r) {265+ return ErrPivotEdit266+ }267+ if _, ok := s.SpillAnchor(c.At); ok && c.Now != "" {268+ return ErrSpillEdit269+ }270+ src := p.copy.Lookup(c.Sheet).cells.get(c.At)271+ if src == nil {272+ if s.cells.has(c.At) {273+ s.place(c.At, nil)274+ }275+ return nil276+ }277+ s.place(c.At, src.clone())278+ return nil279+}
A internal/sheet/proposal_test.go +165 -0
@@ -0,0 +1,165 @@
1+package sheet2+3+import (4+ "errors"5+ "testing"6+)7+8+// proposalBook is a workbook with 1, 2, 3 in A1:A3 and their sum in A4,9+// its history shared by two authors, 1 the person and 2 the agent.10+func proposalBook(t *testing.T) *Workbook {11+ t.Helper()12+ w := New().Book()13+ s := w.Sheet(0)14+ for i, in := range []string{"1", "2", "3", "=SUM(A1:A3)"} {15+ if err := s.Set(Addr{Row: i}, in); err != nil {16+ t.Fatal(err)17+ }18+ }19+ w.ShareHistory()20+ return w21+}22+23+func setCells(entries map[Addr]string) func(*Workbook) error {24+ return func(w *Workbook) error {25+ for a, in := range entries {26+ if err := w.Sheet(0).Set(a, in); err != nil {27+ return err28+ }29+ }30+ return nil31+ }32+}33+34+func TestProposeLeavesTheWorkbook(t *testing.T) {35+ w := proposalBook(t)36+ state := w.StateID()37+ p, err := Propose(w, "set", setCells(map[Addr]string{{Row: 0}: "10", {Row: 1, Col: 1}: "x"}))38+ if err != nil {39+ t.Fatal(err)40+ }41+ if w.StateID() != state || w.Sheet(0).Value(Addr{Row: 3}).Num != 6 {42+ t.Fatal("proposing changed the workbook")43+ }44+ if len(p.Cells) != 2 || p.Whole() || p.Remote {45+ t.Fatalf("cells %+v, other %v", p.Cells, p.Other)46+ }47+ if c := p.Cells[0]; c.At != (Addr{Row: 0}) || c.Was != "1" || c.Now != "10" {48+ t.Fatalf("first cell %+v", c)49+ }50+ if got := p.Result().Sheet(0).Value(Addr{Row: 3}).Num; got != 15 {51+ t.Fatalf("the copy's sum is %v", got)52+ }53+}54+55+func TestProposalAcceptedWholeIsOneStepOfItsAuthor(t *testing.T) {56+ w := proposalBook(t)57+ p, _ := Propose(w, "fix", setCells(map[Addr]string{{Row: 0}: "10", {Row: 1}: "20"}))58+ w.SetAuthor(1)59+ w.Sheet(0).Set(Addr{Col: 2}, "mine")60+ w.SetAuthor(2)61+ if err := p.Apply(w, nil); err != nil {62+ t.Fatal(err)63+ }64+ if got := w.Sheet(0).Value(Addr{Row: 3}).Num; got != 33 {65+ t.Fatalf("sum %v after accepting", got)66+ }67+ w.SetAuthor(2)68+ if w.UndoLabel() != "fix" {69+ t.Fatalf("the agent's undo is %q", w.UndoLabel())70+ }71+ w.Undo()72+ if got := w.Sheet(0).Value(Addr{Row: 3}).Num; got != 6 || w.Sheet(0).Cell(Addr{Col: 2}).Input != "mine" {73+ t.Fatal("the agent's undo took back more than its step")74+ }75+}76+77+func TestProposalAcceptedByCell(t *testing.T) {78+ w := proposalBook(t)79+ p, _ := Propose(w, "fix", setCells(map[Addr]string{{Row: 0}: "10", {Row: 1}: "20", {Row: 2}: ""}))80+ if len(p.Cells) != 3 {81+ t.Fatalf("cells %+v", p.Cells)82+ }83+ if err := p.Apply(w, func(i int) bool { return i != 1 }); err != nil {84+ t.Fatal(err)85+ }86+ s := w.Sheet(0)87+ if inputOf(s, Addr{Row: 0}) != "10" || inputOf(s, Addr{Row: 1}) != "2" || inputOf(s, Addr{Row: 2}) != "" {88+ t.Fatal("the picked cells weren't the ones set")89+ }90+ if got := s.Value(Addr{Row: 3}).Num; got != 12 {91+ t.Fatalf("sum %v", got)92+ }93+}94+95+func TestProposalRejectedChangesNothing(t *testing.T) {96+ w := proposalBook(t)97+ state := w.StateID()98+ p, _ := Propose(w, "fix", setCells(map[Addr]string{{Row: 0}: "10"}))99+ if err := p.Apply(w, func(int) bool { return false }); err != nil || w.StateID() != state {100+ t.Fatal("picking no cell made a step")101+ }102+}103+104+func TestProposalKeepsFormats(t *testing.T) {105+ w := proposalBook(t)106+ p, _ := Propose(w, "bold", func(w *Workbook) error {107+ w.Sheet(0).SetStyle(Rect{From: Addr{Row: 0}, To: Addr{Row: 0}}, func(st *Style) { st.Bold = true })108+ return nil109+ })110+ if len(p.Cells) != 1 || !p.Cells[0].Look || p.Cells[0].Was != p.Cells[0].Now {111+ t.Fatalf("cells %+v", p.Cells)112+ }113+ p.Apply(w, nil)114+ if !w.Sheet(0).Cell(Addr{Row: 0}).Style.Bold {115+ t.Fatal("the style wasn't applied")116+ }117+}118+119+func TestProposalOfRowsIsWhole(t *testing.T) {120+ w := proposalBook(t)121+ p, _ := Propose(w, "insert", func(w *Workbook) error { return w.Sheet(0).InsertRows(0, 1) })122+ if !p.Whole() || len(p.Sheets) != 1 || p.Sheets[0] != "Sheet1" {123+ t.Fatalf("other %v, sheets %v", p.Other, p.Sheets)124+ }125+ if err := p.Apply(w, func(int) bool { return false }); !errors.Is(err, ErrWhole) && len(p.Cells) > 0 {126+ t.Fatalf("part of a whole change: %v", err)127+ }128+ if err := p.Apply(w, nil); err != nil {129+ t.Fatal(err)130+ }131+ if w.Sheet(0).Cell(Addr{Row: 1}).Input != "1" {132+ t.Fatal("the row wasn't inserted")133+ }134+}135+136+func TestProposalNotesRemoteFormulas(t *testing.T) {137+ w := proposalBook(t)138+ p, _ := Propose(w, "ask", setCells(map[Addr]string{{Col: 1}: `=JEV.CLASSIFY(A1, "parity", "odd, even")`}))139+ if !p.Remote {140+ t.Fatalf("a JEV formula isn't remote: %+v", p.Cells)141+ }142+}143+144+func TestAdoptKeepsEarlierStepsTheirs(t *testing.T) {145+ w := New().Book()146+ w.Sheet(0).Set(Addr{}, "1")147+ w.ShareHistory()148+ w.Adopt(1)149+ w.SetAuthor(1)150+ if !w.CanUndo() {151+ t.Fatal("the adopted step isn't the author's")152+ }153+ w.SetAuthor(2)154+ if w.CanUndo() {155+ t.Fatal("another author can undo it")156+ }157+}158+159+// inputOf is the input at a, "" for a blank cell.160+func inputOf(s *Sheet, a Addr) string {161+ if c := s.Cell(a); c != nil {162+ return c.Input163+ }164+ return ""165+}
M internal/sheet/randedit_shared_test.go +52 -8
@@ -11,7 +11,9 @@ import (
1111 // Random edits by several participants: the edits of TestRandomEdits,1212 // each made by one of three authors drawn from the seed, interleaved in1313 // one workbook whose history is shared, as a room of 012 serve orders14-// them. Then the authors undo, in turns drawn from the seed, each only14+// them, and among them an agent's (live mode): its edits are proposals15+// (Propose), accepted whole, cell by cell or not at all, as drawn from16+// the seed. Then the authors, the agent among them, undo, in turns drawn from the seed, each only1517 // their own steps, from under the others' where nothing later overlaps1618 // them. What must hold:1719 //
@@ -41,8 +43,46 @@ func TestRandomSharedEdits(t *testing.T) {
4143 }4244 }4345 44-// randAuthors is how many participants edit.45-const randAuthors = 346+// randAuthors is how many people edit; the agent is one more.47+const (48+ randAuthors = 349+ randAgent = randAuthors + 150+)51+52+// suggest has the agent propose an edit, made on a copy, and accepts it53+// whole, some of its cells or none, as rng draws.54+func (r *randBook) suggest(e *edits, rng *rand.Rand) string {55+ from := *e56+ var label string57+ edit := func(w *Workbook) error {58+ b := r59+ if w != r.wb {60+ b = &randBook{wb: w, nb: w.Lookup(r.nb.Name()), feeds: maps.Clone(r.feeds)}61+ }62+ d := from63+ label = b.edit(&d)64+ *e = d65+ return nil66+ }67+ p, err := Propose(r.wb, "suggestion", edit)68+ if err != nil {69+ return fmt.Sprintf("%d suggests %q: %v", randAgent, label, err)70+ }71+ var pick func(int) bool72+ what := "accepted"73+ switch rng.IntN(3) {74+ case 0:75+ return fmt.Sprintf("%d suggests %q: rejected", randAgent, label)76+ case 1:77+ if !p.Whole() {78+ pick, what = func(int) bool { return rng.IntN(2) == 0 }, "accepted in part"79+ }80+ }81+ if err := p.Apply(r.wb, pick); err != nil {82+ what = err.Error()83+ }84+ return fmt.Sprintf("%d suggests %q: %s", randAgent, label, what)85+}4686 4787 func randomSharedEdits(data []byte, steps int, rng *rand.Rand) (string, []string) {4888 e := &edits{data: data}
@@ -54,9 +94,13 @@ func randomSharedEdits(data []byte, steps int, rng *rand.Rand) (string, []string
5494 if e.done() {5595 break5696 }57- a := 1 + e.n(randAuthors)97+ a := 1 + e.n(randAuthors+1)5898 r.wb.SetAuthor(a)59- log = append(log, fmt.Sprintf("%d: %s", a, r.edit(e)))99+ if a == randAgent {100+ log = append(log, r.suggest(e, rng))101+ } else {102+ log = append(log, fmt.Sprintf("%d: %s", a, r.edit(e)))103+ }60104 feedStale(r.wb, r.feeds)61105 }62106 for undone := 0; ; undone++ {
@@ -100,10 +144,10 @@ func randomSharedEdits(data []byte, steps int, rng *rand.Rand) (string, []string
100144 // from rng, or reports false when nobody has a step left. It fails the101145 // run when steps are left but nobody can undo one.102146 func (r *randBook) nextUndo(rng *rand.Rand) (int, bool) {103- first := rng.IntN(randAuthors)147+ first := rng.IntN(randAgent)104148 left := false105- for i := range randAuthors {106- a := 1 + (first+i)%randAuthors149+ for i := range randAgent {150+ a := 1 + (first+i)%randAgent107151 r.wb.SetAuthor(a)108152 if !r.wb.CanUndo() {109153 continue