Compare lists sheets added, removed, renamed (matched by their cells) or moved, each cell's input, value, format and note, regions, named ranges, macros and every other field of a sheet or workbook, as text, JSON or NUON records; values that change on every recalculation are left out. Dump writes a workbook as lines for git's textconv. Merge combines two sides from their base, part by part, keeping ours where both changed a part differently and noting theirs on the cell, and keeps the trust in commands only when the merged commands are ours'.
- commit
- 4c2e77f6da99956d47a7b843bffd6e7ba3e2f668
- parent
- 52f3f996b45b40a3bd9b16cbb62c123564484e28
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 0.9 of 3, Jev's estimate
| A | internal/ | 289 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 202 | +++++++++++++++++++++ |
| A | internal/ | 194 | ++++++++++++++++++++ |
| A | internal/ | 183 | +++++++++++++++++++ |
| A | internal/ | 103 | +++++++++++ |
| A | internal/ | 286 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 155 | ++++++++++++++++ |
| A | internal/ | 149 | +++++++++++++++ |
| A | internal/ | 211 | ++++++++++++++++++++++ |
| A | internal/ | 193 | ++++++++++++++++++++ |
@@ -0,0 +1,289 @@
1+package diff2+3+import (4+ "encoding/json"5+ "maps"6+ "slices"7+ "strconv"8+ "strings"9+10+ "github.com/FelineStateMachine/012/internal/fileio"11+ "github.com/FelineStateMachine/012/internal/formula"12+ "github.com/FelineStateMachine/012/internal/functions"13+ "github.com/FelineStateMachine/012/internal/nuon"14+ "github.com/FelineStateMachine/012/internal/sheet"15+)16+17+// cellParts are the parts of a cell that change separately: what was18+// typed, its note, and its formatting (every other field of the cell19+// in the file), which merging keeps apart too.20+type cellParts struct {21+ input, note string22+ format json.RawMessage // a compact object, or nil for none23+}24+25+// splitCell reads a cell as the file stores it: its input alone, or an26+// object with the input, the note and the formatting.27+func splitCell(raw json.RawMessage) cellParts {28+ var p cellParts29+ if raw == nil || json.Unmarshal(raw, &p.input) == nil {30+ return p31+ }32+ var fields map[string]json.RawMessage33+ if json.Unmarshal(raw, &fields) != nil {34+ return p35+ }36+ json.Unmarshal(fields["input"], &p.input)37+ json.Unmarshal(fields["note"], &p.note)38+ delete(fields, "input")39+ delete(fields, "note")40+ if len(fields) > 0 {41+ p.format, _ = json.Marshal(fields) // keys sorted42+ }43+ return p44+}45+46+// joinCell is the inverse of splitCell; nil for a cell with nothing.47+func joinCell(p cellParts) json.RawMessage {48+ if p.format == nil && p.note == "" {49+ if p.input == "" {50+ return nil51+ }52+ raw, _ := json.Marshal(p.input)53+ return raw54+ }55+ fields := map[string]json.RawMessage{}56+ json.Unmarshal(p.format, &fields)57+ if p.input != "" {58+ fields["input"], _ = json.Marshal(p.input)59+ }60+ if p.note != "" {61+ fields["note"], _ = json.Marshal(p.note)62+ }63+ raw, _ := json.Marshal(fields)64+ return raw65+}66+67+// formatText describes a cell's formatting in words from its fields:68+// currency, decimals 2, bold.69+func formatText(raw json.RawMessage) string {70+ if raw == nil {71+ return ""72+ }73+ var fields map[string]json.RawMessage74+ if json.Unmarshal(raw, &fields) != nil {75+ return string(raw)76+ }77+ var parts []string78+ for _, k := range slices.Sorted(maps.Keys(fields)) {79+ v := string(fields[k])80+ var s string81+ switch {82+ case v == "true":83+ parts = append(parts, k)84+ case k == "format" && json.Unmarshal(fields[k], &s) == nil:85+ parts = append([]string{s}, parts...)86+ case json.Unmarshal(fields[k], &s) == nil:87+ parts = append(parts, k+" "+s)88+ default:89+ parts = append(parts, k+" "+v)90+ }91+ }92+ return strings.Join(parts, ", ")93+}94+95+// sheetDiff compares a sheet's cells, regions and other fields; name is96+// what the changes call the sheet.97+func sheetDiff(a, b side, name string) []Change {98+ out := cellChanges(a, b, name)99+ out = append(out, listChanges(KindRegion, name, field(a.raw, "regions"), field(b.raw, "regions"))...)100+ keys := map[string]bool{}101+ for _, s := range []*rawSheet{a.raw, b.raw} {102+ for k := range s.fields {103+ keys[k] = true104+ }105+ }106+ for _, k := range slices.Sorted(maps.Keys(keys)) {107+ if k != "regions" {108+ out = append(out, fieldChanges(KindLayout, name, k, a.raw.fields[k], b.raw.fields[k])...)109+ }110+ }111+ return out112+}113+114+func field(s *rawSheet, k string) json.RawMessage { return s.fields[k] }115+116+// cellChanges compares each cell either side has contents, formatting117+// or a value in, row by row.118+func cellChanges(a, b side, name string) []Change {119+ var out []Change120+ for _, at := range cellAddrs(a, b) {121+ out = append(out, cellChange(a, b, name, at)...)122+ }123+ return out124+}125+126+// cellAddrs are the cells either side has something in, in row order.127+func cellAddrs(a, b side) []sheet.Addr {128+ addrs := map[sheet.Addr]bool{}129+ for _, sd := range []side{a, b} {130+ for k := range sd.raw.cells {131+ if at, ok := sheet.ParseAddr(k); ok {132+ addrs[at] = true133+ }134+ }135+ if sd.s != nil {136+ for _, at := range sd.s.Addrs() {137+ addrs[at] = true138+ }139+ }140+ }141+ return slices.SortedFunc(maps.Keys(addrs), byRow)142+}143+144+func byRow(x, y sheet.Addr) int {145+ if x.Row != y.Row {146+ return x.Row - y.Row147+ }148+ return x.Col - y.Col149+}150+151+// cellChange compares one cell's input, value, format and note.152+func cellChange(a, b side, name string, at sheet.Addr) []Change {153+ key := at.String()154+ pa, pb := splitCell(a.raw.cells[key]), splitCell(b.raw.cells[key])155+ item := func(field, old, new string) Change { return textChange(KindCell, name, key, field, old, new) }156+ var out []Change157+ if pa.input != pb.input {158+ out = append(out, item("input", pa.input, pb.input))159+ }160+ if showsValue(pa, pb) && !a.vol[at] && !b.vol[at] {161+ if c, ok := valueChange(a.s, b.s, at); ok {162+ c.Sheet, c.Item = name, key163+ out = append(out, c)164+ }165+ }166+ if !equal(pa.format, pb.format) {167+ c := item("format", formatText(pa.format), formatText(pb.format))168+ c.Old, c.New = recordValue(pa.format), recordValue(pb.format)169+ out = append(out, c)170+ }171+ if pa.note != pb.note {172+ out = append(out, item("note", pa.note, pb.note))173+ }174+ return out175+}176+177+// showsValue reports whether a cell's value says more than its input:178+// it's a formula on either side, or it has no input on either (a value179+// an array spilled or a region's table).180+func showsValue(a, b cellParts) bool {181+ return sheet.IsFormulaEntry(a.input) || sheet.IsFormulaEntry(b.input) || a.input == "" && b.input == ""182+}183+184+// valueChange compares the values the two sheets compute at a.185+func valueChange(a, b *sheet.Sheet, at sheet.Addr) (Change, bool) {186+ oldV, oldT := cellValue(a, at)187+ newV, newT := cellValue(b, at)188+ if oldT == newT && nuon.Equal(oldV, newV) {189+ return Change{}, false190+ }191+ return Change{Kind: KindCell, Field: "value", Old: oldV, New: newV, oldText: oldT, newText: newT}, true192+}193+194+// cellValue is a cell's value typed by its format, and as it shows. A195+// region's label line shows its name and command, which the region's196+// own changes say, so it has none.197+func cellValue(s *sheet.Sheet, at sheet.Addr) (nuon.Value, string) {198+ if s == nil || s.Value(at).Kind == sheet.Empty {199+ return nuon.NullValue(), ""200+ }201+ if _, label, ok := s.RegionAt(at); ok && label {202+ return nuon.NullValue(), ""203+ }204+ return fileio.CellValue(fileio.SnapCell{Value: s.Value(at), Format: s.DisplayFormat(at)}), s.LocalText(at)205+}206+207+// recordValue is a JSON object as a NUON record, or null.208+func recordValue(raw json.RawMessage) nuon.Value {209+ if raw == nil {210+ return nuon.NullValue()211+ }212+ v, err := nuon.Parse(raw)213+ if err != nil {214+ return nuon.StringValue(string(raw))215+ }216+ return v217+}218+219+// volatiles are the cells of w whose values change on every220+// recalculation, which comparing would report as changed each time:221+// formulas calling a volatile function (NOW, RAND), what reads them,222+// directly or not, on any sheet, and the cells their arrays spill into.223+func volatiles(w *sheet.Workbook) map[*sheet.Sheet]map[sheet.Addr]bool {224+ out := map[*sheet.Sheet]map[sheet.Addr]bool{}225+ if w == nil {226+ return out227+ }228+ var queue []sheet.Target229+ mark := func(s *sheet.Sheet, at sheet.Addr) {230+ if out[s] == nil {231+ out[s] = map[sheet.Addr]bool{}232+ }233+ if !out[s][at] {234+ out[s][at] = true235+ queue = append(queue, sheet.Target{Sheet: s, Range: sheet.Rect{From: at, To: at}})236+ }237+ }238+ for _, s := range w.Sheets() {239+ for _, at := range s.Addrs() {240+ if c := s.Cell(at); c != nil && c.IsFormula() && callsVolatile(c.Input) {241+ mark(s, at)242+ }243+ }244+ }245+ for len(queue) > 0 {246+ t := queue[0]247+ queue = queue[1:]248+ if area, ok := t.Sheet.SpillArea(t.Range.From); ok {249+ for row := area.From.Row; row <= area.To.Row; row++ {250+ for col := area.From.Col; col <= area.To.Col; col++ {251+ mark(t.Sheet, sheet.Addr{Col: col, Row: row})252+ }253+ }254+ }255+ for _, d := range t.Sheet.Dependents(t.Range.From) {256+ mark(d.Sheet, d.Range.From)257+ }258+ }259+ return out260+}261+262+// callsVolatile reports whether a formula calls a volatile function.263+func callsVolatile(input string) bool {264+ n, err := sheet.Parse(input)265+ if err != nil {266+ return false267+ }268+ found := false269+ var walk func(formula.Node)270+ walk = func(n formula.Node) {271+ if call, ok := n.(formula.Call); ok {272+ if f := functions.Of(call); f != nil && f.Volatile {273+ found = true274+ }275+ }276+ formula.EachChild(n, walk)277+ }278+ walk(n)279+ return found280+}281+282+// quoted writes text as the text output shows a side: as it is, or283+// quoted when spaces at its ends or line breaks would hide.284+func quoted(s string) string {285+ if s != strings.TrimSpace(s) || strings.ContainsAny(s, "\n\t") {286+ return strconv.Quote(s)287+ }288+ return s289+}@@ -0,0 +1,202 @@
1+// Package diff compares and merges .012 workbooks cell by cell, for2+// 012 diff and 012 merge-driver (see docs/files/git.md). Compare lists3+// what changed between two workbooks: sheets added, removed, renamed or4+// moved, each cell's input, value, format and note, regions, named5+// ranges, macros and the other fields of sheets and workbooks. Merge6+// combines two workbooks changed from a common one, and conflicts only7+// where both changed the same part of the same cell or field.8+//9+// Both read files as their JSON fields (raw.go), so nothing a file holds10+// goes unseen, and use the engine only to compute formulas' values,11+// which the file doesn't store. Nothing here runs a region's command or12+// asks JEV: values come from what the formulas compute alone.13+package diff14+15+import (16+ "bytes"17+ "fmt"18+19+ "github.com/FelineStateMachine/012/internal/nuon"20+ "github.com/FelineStateMachine/012/internal/sheet"21+)22+23+// Book is a workbook read for comparing: its file's fields and the24+// workbook they make.25+type Book struct {26+ raw *rawBook27+ wb *sheet.Workbook // nil for a workbook without sheets28+ vol map[*sheet.Sheet]map[sheet.Addr]bool29+}30+31+// Read reads a workbook file's contents. Empty contents are a workbook32+// without sheets, which is what git hands over for a file that doesn't33+// exist on one side.34+func Read(data []byte) (*Book, error) {35+ raw, err := parseRaw(data)36+ if err != nil {37+ return nil, err38+ }39+ b := &Book{raw: raw}40+ if len(raw.sheets) > 0 {41+ if b.wb, err = sheet.ReadBook(bytes.NewReader(data)); err != nil {42+ return nil, err43+ }44+ b.vol = volatiles(b.wb)45+ }46+ return b, nil47+}48+49+// Kinds of Change.50+const (51+ KindSheet = "sheet" // a sheet added, removed, renamed or moved52+ KindCell = "cell" // a cell's input, value, format or note53+ KindRegion = "region" // a notebook region or linked file54+ KindLayout = "layout" // another field of a sheet: widths, rules, charts55+ KindName = "name" // a named range56+ KindMacro = "macro" // a macro57+ KindWorkbook = "workbook" // a field of the workbook: locale, arithmetic58+)59+60+// Change is one difference. Old or New is null where the thing isn't on61+// that side.62+type Change struct {63+ Kind string64+ Sheet string // the sheet, by its name in the second workbook where it has one65+ Item string // the cell, region, name, macro or field that changed66+ // Field is what about it changed: for a cell input, value, format or67+ // note; for a sheet added, removed, renamed or moved; for a region,68+ // name or macro added, removed or its field's name; for a layout69+ // field, its key when it has keys (a column's width), else "".70+ Field string71+ Old, New nuon.Value72+ // oldText and newText are Old and New as the text output shows them.73+ oldText, newText string74+}75+76+// Where names the change's place as the text output does: Q3!B7,77+// sheet Q3, Q3 region r1, Q3 widths, name Sales.78+func (c Change) Where() string {79+ switch c.Kind {80+ case KindCell:81+ return sheet.QuoteSheet(c.Sheet) + "!" + c.Item82+ case KindSheet:83+ return "sheet " + c.Sheet84+ case KindRegion:85+ return c.Sheet + " region " + c.Item86+ case KindLayout:87+ return c.Sheet + " " + c.Item88+ }89+ return c.Kind + " " + c.Item90+}91+92+// Compare lists what changed from a to b: sheets first, then each93+// sheet's cells in row order, its regions and other fields, then the94+// workbook's names, macros and settings.95+func Compare(a, b *Book) []Change {96+ var out []Change97+ p := matchSheets(a.raw, b.raw)98+ out = append(out, sheetChanges(a.raw, b.raw, p)...)99+ empty := &rawSheet{fields: nil, cells: nil}100+ for _, i := range p.onlyA {101+ sa := a.raw.sheets[i]102+ out = append(out, sheetDiff(a.side(sa), side{raw: empty}, sa.name)...)103+ }104+ for _, pr := range p.pairs {105+ sa, sb := a.raw.sheets[pr[0]], b.raw.sheets[pr[1]]106+ out = append(out, sheetDiff(a.side(sa), b.side(sb), sb.name)...)107+ }108+ for _, j := range p.onlyB {109+ sb := b.raw.sheets[j]110+ out = append(out, sheetDiff(side{raw: empty}, b.side(sb), sb.name)...)111+ }112+ return append(out, bookChanges(a.raw, b.raw)...)113+}114+115+// side is one sheet of a comparison: its fields, and the sheet the116+// engine made of them, nil for a sheet that isn't there.117+type side struct {118+ raw *rawSheet119+ s *sheet.Sheet120+ vol map[sheet.Addr]bool // its cells whose values change on every recalculation121+}122+123+func (b *Book) side(s *rawSheet) side {124+ sd := side{raw: s}125+ if b.wb != nil {126+ sd.s = b.wb.Lookup(s.name)127+ sd.vol = b.vol[sd.s]128+ }129+ return sd130+}131+132+// sheetChanges are the sheets added, removed, renamed and moved.133+func sheetChanges(a, b *rawBook, p pairing) []Change {134+ var out []Change135+ for _, i := range p.onlyA {136+ name := a.sheets[i].name137+ out = append(out, textChange(KindSheet, name, "", "removed", name, ""))138+ }139+ for _, j := range p.onlyB {140+ name := b.sheets[j].name141+ out = append(out, textChange(KindSheet, name, "", "added", "", name))142+ }143+ for _, pr := range p.pairs {144+ na, nb := a.sheets[pr[0]].name, b.sheets[pr[1]].name145+ if na != nb {146+ out = append(out, textChange(KindSheet, nb, "", "renamed", na, nb))147+ }148+ }149+ // Moves: the fewest sheets that, taken out, leave the rest in the150+ // same order on both sides.151+ var orderB []int152+ for _, pr := range p.pairs {153+ orderB = append(orderB, pr[1])154+ }155+ stay := increasing(orderB)156+ for k, pr := range p.pairs {157+ if !stay[k] {158+ nb := b.sheets[pr[1]].name159+ c := textChange(KindSheet, nb, "", "moved", fmt.Sprint(pr[0]+1), fmt.Sprint(pr[1]+1))160+ c.Old, c.New = nuon.IntValue(int64(pr[0]+1)), nuon.IntValue(int64(pr[1]+1))161+ out = append(out, c)162+ }163+ }164+ return out165+}166+167+// increasing marks a longest increasing run (not necessarily adjacent)168+// of vs.169+func increasing(vs []int) []bool {170+ n := len(vs)171+ length, prev := make([]int, n), make([]int, n)172+ best := -1173+ for i := range vs {174+ length[i], prev[i] = 1, -1175+ for j := range i {176+ if vs[j] < vs[i] && length[j]+1 > length[i] {177+ length[i], prev[i] = length[j]+1, j178+ }179+ }180+ if best < 0 || length[i] > length[best] {181+ best = i182+ }183+ }184+ out := make([]bool, n)185+ for i := best; i >= 0; i = prev[i] {186+ out[i] = true187+ }188+ return out189+}190+191+// textChange is a change whose sides are text, "" for none.192+func textChange(kind, sheetName, item, field, old, new string) Change {193+ return Change{Kind: kind, Sheet: sheetName, Item: item, Field: field,194+ Old: textValue(old), New: textValue(new), oldText: old, newText: new}195+}196+197+func textValue(s string) nuon.Value {198+ if s == "" {199+ return nuon.NullValue()200+ }201+ return nuon.StringValue(s)202+}@@ -0,0 +1,194 @@
1+package diff2+3+import (4+ "bytes"5+ "strings"6+ "testing"7+8+ "github.com/FelineStateMachine/012/internal/nuon"9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+func addr(s string) sheet.Addr {13+ a, _ := sheet.ParseAddr(s)14+ return a15+}16+17+// file writes a workbook of sheets, each a map of cells to inputs, after18+// edit has its way with it.19+func file(t *testing.T, sheets []string, cells []map[string]string, edit func(w *sheet.Workbook)) []byte {20+ t.Helper()21+ w := sheet.NewBook()22+ for i, name := range sheets {23+ s := w.Sheet(0)24+ if i == 0 {25+ if err := w.RenameSheet(s, name); err != nil {26+ t.Fatal(err)27+ }28+ } else {29+ var err error30+ if s, err = w.AddSheet(name, i); err != nil {31+ t.Fatal(err)32+ }33+ }34+ for a, in := range cells[i] {35+ if err := s.Set(addr(a), in); err != nil {36+ t.Fatal(err)37+ }38+ }39+ }40+ if edit != nil {41+ edit(w)42+ }43+ var b bytes.Buffer44+ if err := w.Write(&b); err != nil {45+ t.Fatal(err)46+ }47+ return b.Bytes()48+}49+50+func read(t *testing.T, data []byte) *Book {51+ t.Helper()52+ b, err := Read(data)53+ if err != nil {54+ t.Fatal(err)55+ }56+ return b57+}58+59+// lines is the text output of comparing a with b.60+func lines(t *testing.T, a, b []byte) string {61+ t.Helper()62+ var out bytes.Buffer63+ if err := WriteChanges(&out, Compare(read(t, a), read(t, b)), "text", false); err != nil {64+ t.Fatal(err)65+ }66+ return out.String()67+}68+69+func TestCompareCells(t *testing.T) {70+ a := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "Item", "B1": "5", "B2": "=B1*2", "C1": "gone", "D1": "=NOW()", "D2": "=D1+1"}}, nil)71+ b := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "Item", "B1": "6", "B2": "=B1*2", "C2": "new", "D1": "=NOW()", "D2": "=D1+1"}}, func(w *sheet.Workbook) {72+ s := w.Sheet(0)73+ s.SetStyle(sheet.NewRect(addr("A1"), addr("A1")), func(st *sheet.Style) { st.Bold = true })74+ s.SetNote(addr("A1"), "the thing")75+ s.SetColWidth(2, 14)76+ })77+ want := `Sheet1!A1 format + bold78+Sheet1!A1 note + the thing79+Sheet1!B1 input 5 → 680+Sheet1!C1 input - gone81+Sheet1!B2 value 10 → 1282+Sheet1!C2 input + new83+Sheet1 widths C + 1484+`85+ if got := lines(t, a, b); got != want {86+ t.Errorf("got\n%s\nwant\n%s", got, want)87+ }88+ if got := lines(t, a, a); got != "" {89+ t.Errorf("a workbook against itself:\n%s", got)90+ }91+}92+93+func TestCompareSheets(t *testing.T) {94+ cells := map[string]string{"A1": "a", "A2": "b", "A3": "c", "A4": "d"}95+ edited := map[string]string{"A1": "a", "A2": "b", "A3": "c", "A4": "D"}96+ a := file(t, []string{"One", "Two", "Three", "Gone"}, []map[string]string{{"A1": "1"}, cells, {"A1": "3"}, {"A1": "x"}}, nil)97+ b := file(t, []string{"Three", "One", "Renamed", "Added"}, []map[string]string{{"A1": "3"}, {"A1": "1"}, edited, {"A1": "y"}}, nil)98+ got := lines(t, a, b)99+ for _, want := range []string{100+ "sheet Gone removed\n",101+ "sheet Added added\n",102+ "sheet Renamed renamed from Two\n",103+ "sheet Three moved from 3 to 1\n",104+ "Renamed!A4 input d → D\n",105+ "Gone!A1 input - x\n",106+ "Added!A1 input + y\n",107+ } {108+ if !strings.Contains(got, want) {109+ t.Errorf("lacks %q:\n%s", want, got)110+ }111+ }112+ if strings.Contains(got, "sheet One moved") {113+ t.Errorf("more moves than needed:\n%s", got)114+ }115+}116+117+func TestCompareShapes(t *testing.T) {118+ // One sheet written as a version 2 file and as a version 4 file is119+ // the same workbook.120+ v2 := []byte(`{"version": 2, "names": {"Sales": "A1:A2"}, "cells": {"A1": "1", "A2": "=A1+1"}}`)121+ v4 := []byte(`{"version": 4, "names": {"Sales": "Sheet1!A1:A2"}, "sheets": [{"name": "Sheet1", "cells": {"A1": "1", "A2": "=A1 + 1"}}]}`)122+ got := lines(t, v2, v4)123+ if got != "Sheet1!A2 input =A1+1 → =A1 + 1\n" {124+ t.Errorf("got\n%s", got)125+ }126+ empty := lines(t, nil, v2)127+ if !strings.HasPrefix(empty, "sheet Sheet1 added\n") || !strings.Contains(empty, "name Sales added + Sheet1!A1:A2") {128+ t.Errorf("from nothing:\n%s", empty)129+ }130+}131+132+func TestCompareRegionsAndBook(t *testing.T) {133+ a := []byte(`{"version": 4, "locale": "en-US", "macros": [{"name": "m1", "api": 1, "source": "x"}], "sheets": [{"name": "S", "notebook": true, "regions": [{"name": "r1", "command": "ls", "at": "A1"}, {"name": "r2", "command": "ps", "at": "A9"}], "cells": {}}]}`)134+ b := []byte(`{"version": 4, "locale": "de-DE", "active": 0, "sheets": [{"name": "S", "notebook": true, "regions": [{"name": "r1", "command": "ls -a", "at": "A1"}, {"name": "r3", "command": "date", "at": "A20"}], "cells": {}}]}`)135+ want := `S region r1 command ls → ls -a136+S region r2 removed - ps137+S region r3 added + date138+macro m1 removed - (no command)139+workbook locale en-US → de-DE140+`141+ if got := lines(t, a, b); got != want {142+ t.Errorf("got\n%s\nwant\n%s", got, want)143+ }144+}145+146+func TestWriteChanges(t *testing.T) {147+ a := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "=1+1", "B1": "x"}}, nil)148+ b := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "=1+2"}}, nil)149+ changes := Compare(read(t, a), read(t, b))150+ var out bytes.Buffer151+ WriteChanges(&out, changes, "nuon", false)152+ v, err := nuon.Parse(out.Bytes())153+ if err != nil || v.Kind != nuon.List || len(v.List) != 3 {154+ t.Fatalf("NUON %v: %s", err, out.String())155+ }156+ if got := v.List[1].String(); got != `{kind: cell, sheet: Sheet1, item: A1, field: value, old: 2, new: 3}` {157+ t.Errorf("a value's record: %s", got)158+ }159+ out.Reset()160+ WriteChanges(&out, changes, "json", false)161+ if !strings.Contains(out.String(), `{"kind":"cell","sheet":"Sheet1","item":"B1","field":"input","old":"x","new":null}`) {162+ t.Errorf("JSON:\n%s", out.String())163+ }164+ out.Reset()165+ WriteChanges(&out, changes[:1], "text", true)166+ if out.String() != "\x1b[1mSheet1!A1\x1b[0m \x1b[36minput\x1b[0m \x1b[31m=1+1\x1b[0m → \x1b[32m=1+2\x1b[0m\n" {167+ t.Errorf("colored: %q", out.String())168+ }169+ if err := WriteChanges(&out, changes, "xml", false); err == nil {170+ t.Error("xml")171+ }172+}173+174+func TestDump(t *testing.T) {175+ data := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "Rent", "B1": "1450", "B2": "=B1*12", "C1": "=RAND()"}}, func(w *sheet.Workbook) {176+ s := w.Sheet(0)177+ s.SetNote(addr("A1"), "monthly")178+ s.SetColWidth(0, 12)179+ })180+ var out bytes.Buffer181+ if err := Dump(&out, read(t, data)); err != nil {182+ t.Fatal(err)183+ }184+ want := `sheet Sheet1185+Sheet1 widths {"A":12}186+Sheet1!A1 Rent note: monthly187+Sheet1!B1 1450188+Sheet1!C1 =RAND()189+Sheet1!B2 =B1*12 = 17400190+`191+ if out.String() != want {192+ t.Errorf("got\n%s\nwant\n%s", out.String(), want)193+ }194+}@@ -0,0 +1,183 @@
1+package diff2+3+import (4+ "encoding/json"5+ "maps"6+ "slices"7+8+ "github.com/FelineStateMachine/012/internal/nuon"9+)10+11+// quietKeys are the workbook's fields that change without the workbook12+// changing: the file version, the sheet shown, the computer its macros13+// were trusted on and the notebook prompt's history.14+var quietKeys = map[string]bool{"version": true, "active": true, "macroOrigin": true, "shellHistory": true}15+16+// bookChanges compares the workbook's fields: named ranges, macros and17+// settings.18+func bookChanges(a, b *rawBook) []Change {19+ var out []Change20+ out = append(out, keyedChanges(KindName, "range", a.top["names"], b.top["names"])...)21+ out = append(out, listChanges(KindMacro, "", a.top["macros"], b.top["macros"])...)22+ keys := map[string]bool{}23+ for _, t := range []map[string]json.RawMessage{a.top, b.top} {24+ for k := range t {25+ keys[k] = true26+ }27+ }28+ for _, k := range slices.Sorted(maps.Keys(keys)) {29+ if !quietKeys[k] && k != "names" && k != "macros" {30+ out = append(out, fieldChanges(KindWorkbook, "", k, a.top[k], b.top[k])...)31+ }32+ }33+ return out34+}35+36+// fieldChanges compares a field: key by key when it's an object (a37+// column's width), else whole.38+func fieldChanges(kind, sheetName, item string, a, b json.RawMessage) []Change {39+ if equal(a, b) {40+ return nil41+ }42+ oa, okA := object(a)43+ ob, okB := object(b)44+ if okA && okB {45+ var out []Change46+ for _, k := range unionKeys(oa, ob) {47+ if !equal(oa[k], ob[k]) {48+ out = append(out, rawChange(kind, sheetName, item, k, oa[k], ob[k]))49+ }50+ }51+ return out52+ }53+ return []Change{rawChange(kind, sheetName, item, "", a, b)}54+}55+56+// keyedChanges compares an object whose keys are things (named ranges):57+// each added, removed, or its value changed, as field.58+func keyedChanges(kind, field string, a, b json.RawMessage) []Change {59+ oa, _ := object(a)60+ ob, _ := object(b)61+ var out []Change62+ for _, k := range unionKeys(oa, ob) {63+ switch va, vb := oa[k], ob[k]; {64+ case va == nil:65+ out = append(out, rawChange(kind, "", k, "added", nil, vb))66+ case vb == nil:67+ out = append(out, rawChange(kind, "", k, "removed", va, nil))68+ case !equal(va, vb):69+ out = append(out, rawChange(kind, "", k, field, va, vb))70+ }71+ }72+ return out73+}74+75+// listChanges compares lists of things with names (regions, macros):76+// each added, removed, or changed field by field.77+func listChanges(kind, sheetName string, a, b json.RawMessage) []Change {78+ la, lb := named(a), named(b)79+ var out []Change80+ for _, k := range unionKeys(la, lb) {81+ switch ea, eb := la[k], lb[k]; {82+ case ea == nil:83+ c := rawChange(kind, sheetName, k, "added", nil, eb)84+ c.newText = summary(eb)85+ out = append(out, c)86+ case eb == nil:87+ c := rawChange(kind, sheetName, k, "removed", ea, nil)88+ c.oldText = summary(ea)89+ out = append(out, c)90+ default:91+ fa, _ := object(ea)92+ fb, _ := object(eb)93+ for _, f := range unionKeys(fa, fb) {94+ if !equal(fa[f], fb[f]) {95+ out = append(out, rawChange(kind, sheetName, k, f, fa[f], fb[f]))96+ }97+ }98+ }99+ }100+ return out101+}102+103+// named reads a list of objects by their "name" fields, as compact104+// JSON.105+func named(raw json.RawMessage) map[string]json.RawMessage {106+ var list []json.RawMessage107+ json.Unmarshal(raw, &list)108+ out := map[string]json.RawMessage{}109+ for _, e := range list {110+ var n struct{ Name string }111+ json.Unmarshal(e, &n)112+ out[n.Name] = compact(e)113+ }114+ return out115+}116+117+// summary is what the text output shows of a region or macro added or118+// removed: its command, the file it follows, or its shortcut.119+func summary(raw json.RawMessage) string {120+ fields, _ := object(raw)121+ for _, k := range []string{"command", "path", "key"} {122+ var s string123+ if json.Unmarshal(fields[k], &s) == nil && s != "" {124+ return s125+ }126+ }127+ return "(no command)"128+}129+130+// rawChange is a change between two JSON values, nil for none.131+func rawChange(kind, sheetName, item, field string, a, b json.RawMessage) Change {132+ return Change{Kind: kind, Sheet: sheetName, Item: item, Field: field,133+ Old: jsonValue(a), New: jsonValue(b), oldText: rawText(a), newText: rawText(b)}134+}135+136+func jsonValue(raw json.RawMessage) nuon.Value {137+ if raw == nil {138+ return nuon.NullValue()139+ }140+ v, err := nuon.Parse(raw)141+ if err != nil {142+ return nuon.StringValue(string(raw))143+ }144+ return v145+}146+147+// rawText is a JSON value as the text output shows it: a string as it148+// is, anything else as compact JSON.149+func rawText(raw json.RawMessage) string {150+ var s string151+ if raw == nil {152+ return ""153+ }154+ if json.Unmarshal(raw, &s) == nil {155+ return s156+ }157+ return string(raw)158+}159+160+func object(raw json.RawMessage) (map[string]json.RawMessage, bool) {161+ var m map[string]json.RawMessage162+ if raw == nil {163+ return map[string]json.RawMessage{}, true164+ }165+ if json.Unmarshal(raw, &m) != nil || m == nil {166+ return nil, false167+ }168+ for k, v := range m {169+ m[k] = compact(v)170+ }171+ return m, true172+}173+174+func unionKeys(a, b map[string]json.RawMessage) []string {175+ keys := map[string]bool{}176+ for k := range a {177+ keys[k] = true178+ }179+ for k := range b {180+ keys[k] = true181+ }182+ return slices.Sorted(maps.Keys(keys))183+}@@ -0,0 +1,103 @@
1+package diff2+3+import "sort"4+5+// minSimilar is how alike two sheets' cells must be, as the share of6+// cells they hold the same, for a sheet gone under one name and a sheet7+// come under another to count as one sheet renamed.8+const minSimilar = 0.59+10+// pairing says which sheets of two workbooks are the same sheet: by11+// index in a and in b.12+type pairing struct {13+ pairs [][2]int // matched, in a's order14+ onlyA []int // a's sheets without a match: removed15+ onlyB []int // b's sheets without a match: added16+}17+18+// matchSheets pairs a's sheets with b's: first by name, ignoring case,19+// then each sheet left in a with the most alike left in b, when they20+// share at least half their cells (a rename, perhaps with edits).21+func matchSheets(a, b *rawBook) pairing {22+ var p pairing23+ usedB := make([]bool, len(b.sheets))24+ var leftA []int25+ for i, s := range a.sheets {26+ j := b.sheetIndex(s.name)27+ if j < 0 || usedB[j] {28+ leftA = append(leftA, i)29+ continue30+ }31+ usedB[j] = true32+ p.pairs = append(p.pairs, [2]int{i, j})33+ }34+ type candidate struct {35+ i, j int36+ score float6437+ }38+ var cands []candidate39+ for _, i := range leftA {40+ for j := range b.sheets {41+ if !usedB[j] {42+ if sc := similarity(a.sheets[i], b.sheets[j]); sc >= minSimilar {43+ cands = append(cands, candidate{i, j, sc})44+ }45+ }46+ }47+ }48+ sort.SliceStable(cands, func(x, y int) bool { return cands[x].score > cands[y].score })49+ usedA := map[int]bool{}50+ for _, c := range cands {51+ if !usedA[c.i] && !usedB[c.j] {52+ usedA[c.i], usedB[c.j] = true, true53+ p.pairs = append(p.pairs, [2]int{c.i, c.j})54+ }55+ }56+ sort.Slice(p.pairs, func(x, y int) bool { return p.pairs[x][0] < p.pairs[y][0] })57+ for _, i := range leftA {58+ if !usedA[i] {59+ p.onlyA = append(p.onlyA, i)60+ }61+ }62+ for j := range b.sheets {63+ if !usedB[j] {64+ p.onlyB = append(p.onlyB, j)65+ }66+ }67+ return p68+}69+70+// similarity is the share of cells two sheets hold the same, 1 for two71+// empty sheets.72+func similarity(a, b *rawSheet) float64 {73+ n := max(len(a.cells), len(b.cells))74+ if n == 0 {75+ return 176+ }77+ same := 078+ for k, v := range a.cells {79+ if equal(v, b.cells[k]) {80+ same++81+ }82+ }83+ return float64(same) / float64(n)84+}85+86+// sameSheet reports whether two sheets are alike in every field, their87+// names included.88+func sameSheet(a, b *rawSheet) bool {89+ if a.name != b.name || len(a.cells) != len(b.cells) || len(a.fields) != len(b.fields) {90+ return false91+ }92+ for k, v := range a.cells {93+ if !equal(v, b.cells[k]) {94+ return false95+ }96+ }97+ for k, v := range a.fields {98+ if !equal(v, b.fields[k]) {99+ return false100+ }101+ }102+ return true103+}@@ -0,0 +1,286 @@
1+package diff2+3+import (4+ "encoding/json"5+ "maps"6+ "slices"7+ "strconv"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// Conflict is a part of a workbook both sides changed differently. The13+// merge keeps ours; the report says what theirs had.14+type Conflict struct {15+ Where string // as a Change's: Q3!B7, sheet Q3, Q3 widths, name Sales16+ Field string // what about it: input, format, note, a field's key, or ""17+ // Base, Ours and Theirs are the three versions as text, "" where18+ // the thing isn't there; What says what happened instead, when the19+ // conflict is about a whole sheet.20+ Base, Ours, Theirs string21+ What string22+}23+24+// String is the conflict as the merge report lists it.25+func (c Conflict) String() string {26+ s := c.Where27+ if c.Field != "" {28+ s += " " + c.Field29+ }30+ if c.What != "" {31+ return s + ": " + c.What32+ }33+ return s + ": ours " + side3(c.Ours) + ", theirs " + side3(c.Theirs) + ", base " + side3(c.Base)34+}35+36+func side3(s string) string {37+ if s == "" {38+ return "(none)"39+ }40+ return quoted(s)41+}42+43+// Merge combines ours and theirs, two workbook files changed from base,44+// and returns the merged file as 012 saves it. Each cell's input, format45+// and note, each field of a sheet or the workbook (a column's width, a46+// region, a named range, a macro) and each sheet's name and presence47+// merge on their own: a part changed on one side only takes that side's48+// version, and a part both changed alike takes it too. A part both49+// changed differently is a conflict: the merge keeps ours, and a cell's50+// note says what theirs had (on top of its own note), so the conflicts51+// can be found in the grid. Empty contents stand for a missing file.52+//53+// Sheets are matched by name or, when renamed, by their cells (see54+// matchSheets). Rows or columns inserted on one side shift the cells55+// below or right of them, which a cell-by-cell merge can't tell from56+// edits: merge such changes in 012 rather than here.57+func Merge(base, ours, theirs []byte) ([]byte, []Conflict, error) {58+ var books [3]*rawBook59+ for i, data := range [][]byte{base, ours, theirs} {60+ b, err := parseRaw(data)61+ if err != nil {62+ return nil, nil, err63+ }64+ books[i] = b65+ }66+ m := &merger{}67+ merged := m.books(books[0], books[1], books[2])68+ if len(merged.sheets) == 0 {69+ return nil, m.conflicts, nil70+ }71+ out, err := normalize(merged)72+ return out, m.conflicts, err73+}74+75+// merger collects the conflicts of a merge.76+type merger struct {77+ conflicts []Conflict78+}79+80+func (m *merger) conflict(where, field string, b, o, t json.RawMessage) {81+ m.conflicts = append(m.conflicts, Conflict{Where: where, Field: field, Base: rawText(b), Ours: rawText(o), Theirs: rawText(t)})82+}83+84+// said records a conflict that isn't between three values, in words.85+func (m *merger) said(where, field, what string) {86+ m.conflicts = append(m.conflicts, Conflict{Where: where, Field: field, What: what})87+}88+89+// merge3 merges one value: what changed on one side, or both alike.90+func merge3(b, o, t json.RawMessage) (json.RawMessage, bool) {91+ switch {92+ case equal(o, t), equal(b, t):93+ return o, false94+ case equal(b, o):95+ return t, false96+ }97+ return o, true98+}99+100+// value merges one value, reporting a conflict at where.101+func (m *merger) value(where, field string, b, o, t json.RawMessage) json.RawMessage {102+ v, clash := merge3(b, o, t)103+ if clash {104+ m.conflict(where, field, b, o, t)105+ }106+ return v107+}108+109+// books merges the workbooks' fields, then their sheets.110+func (m *merger) books(b, o, t *rawBook) *rawBook {111+ out := &rawBook{top: map[string]json.RawMessage{}}112+ for _, k := range unionKeys3(b.top, o.top, t.top) {113+ var v json.RawMessage114+ switch k {115+ case "version":116+ v = maxVersion(b.top[k], o.top[k], t.top[k])117+ case "names":118+ v = m.object(b.top[k], o.top[k], t.top[k], func(key string) (string, string) { return "name " + key, "" })119+ case "macros":120+ v = m.list(b.top[k], o.top[k], t.top[k], func(key string) string { return "macro " + key })121+ case "active", "shellHistory", "macroOrigin":122+ v, _ = merge3(b.top[k], o.top[k], t.top[k]) // what's shown or was typed: ours if both differ123+ default:124+ v = m.value("workbook "+k, "", b.top[k], o.top[k], t.top[k])125+ }126+ if v != nil {127+ out.top[k] = v128+ }129+ }130+ out.sheets = m.sheets(b, o, t)131+ if n, err := strconv.Atoi(string(out.top["active"])); err == nil && n >= len(out.sheets) {132+ delete(out.top, "active") // the sheet shown went: show the first133+ }134+ trust(out, o, t)135+ return out136+}137+138+// trust keeps the computer ours trusted its commands on only when the139+// merge's commands (macros, regions' commands and files) are ours', or140+// theirs were trusted on the same computer, so a merge never makes141+// someone else's commands run without asking.142+func trust(out, o, t *rawBook) {143+ if equal(o.top["macroOrigin"], t.top["macroOrigin"]) || code(out) == code(o) {144+ if origin := o.top["macroOrigin"]; origin != nil {145+ out.top["macroOrigin"] = origin146+ }147+ return148+ }149+ delete(out.top, "macroOrigin")150+}151+152+// code is what a workbook can make 012 run or read: its macros and its153+// regions' commands and files, in one string to compare.154+func code(b *rawBook) string {155+ var parts []string156+ parts = append(parts, string(b.top["macros"]))157+ for _, s := range b.sheets {158+ for _, r := range named(s.fields["regions"]) {159+ f, _ := object(r)160+ parts = append(parts, string(f["command"])+string(f["path"])+string(f["input"])+string(f["query"]))161+ }162+ }163+ slices.Sort(parts)164+ raw, _ := json.Marshal(parts)165+ return string(raw)166+}167+168+func maxVersion(vs ...json.RawMessage) json.RawMessage {169+ best, out := 0, json.RawMessage(nil)170+ for _, v := range vs {171+ if n, err := strconv.Atoi(string(v)); err == nil && n > best {172+ best, out = n, v173+ }174+ }175+ return out176+}177+178+// object merges an object key by key (named ranges, column widths);179+// where names each key's place and field in conflicts.180+func (m *merger) object(b, o, t json.RawMessage, where func(key string) (string, string)) json.RawMessage {181+ ob, okB := object(b)182+ oo, okO := object(o)183+ ot, okT := object(t)184+ if !okB || !okO || !okT {185+ at, _ := where("")186+ return m.value(at, "", b, o, t)187+ }188+ out := map[string]json.RawMessage{}189+ for _, k := range unionKeys3(ob, oo, ot) {190+ at, field := where(k)191+ if v := m.value(at, field, ob[k], oo[k], ot[k]); v != nil {192+ out[k] = v193+ }194+ }195+ if len(out) == 0 && o == nil && t == nil {196+ return nil197+ }198+ raw, _ := json.Marshal(out)199+ return raw200+}201+202+// list merges a list of things with names (regions, macros) thing by203+// thing, in ours' order with theirs' additions after.204+func (m *merger) list(b, o, t json.RawMessage, where func(string) string) json.RawMessage {205+ lb, lo, lt := named(b), named(o), named(t)206+ var order []string207+ for _, raw := range []json.RawMessage{o, t} {208+ var items []struct{ Name string }209+ json.Unmarshal(raw, &items)210+ for _, it := range items {211+ if !slices.Contains(order, it.Name) {212+ order = append(order, it.Name)213+ }214+ }215+ }216+ var out []json.RawMessage217+ for _, k := range order {218+ if v := m.value(where(k), "", lb[k], lo[k], lt[k]); v != nil {219+ out = append(out, v)220+ }221+ }222+ if len(out) == 0 {223+ return nil224+ }225+ raw, _ := json.Marshal(out)226+ return raw227+}228+229+// cell merges a cell's input, format and note apart. Where they230+// conflict, ours stays and the note says what theirs had.231+func (m *merger) cell(where string, b, o, t json.RawMessage) json.RawMessage {232+ pb, po, pt := splitCell(b), splitCell(o), splitCell(t)233+ str := func(s string) json.RawMessage {234+ if s == "" {235+ return nil236+ }237+ raw, _ := json.Marshal(s)238+ return raw239+ }240+ out := cellParts{}241+ var said []string242+ part := func(field string, b, o, t json.RawMessage) json.RawMessage {243+ v, clash := merge3(b, o, t)244+ if clash {245+ text := rawText246+ if field == "format" {247+ text = formatText248+ }249+ m.conflicts = append(m.conflicts, Conflict{Where: where, Field: field, Base: text(b), Ours: text(o), Theirs: text(t)})250+ said = append(said, field+" "+side3(text(t)))251+ }252+ return v253+ }254+ json.Unmarshal(part("input", str(pb.input), str(po.input), str(pt.input)), &out.input)255+ out.format = part("format", pb.format, po.format, pt.format)256+ json.Unmarshal(part("note", str(pb.note), str(po.note), str(pt.note)), &out.note)257+ if len(said) > 0 {258+ out.note = conflictNote(out.note, said)259+ }260+ return joinCell(out)261+}262+263+// conflictNote is a cell's note with what theirs had added below it.264+func conflictNote(note string, said []string) string {265+ text := "Merge conflict, kept ours; theirs had "266+ for i, s := range said {267+ if i > 0 {268+ text += "; "269+ }270+ text += s271+ }272+ if note != "" {273+ text = note + "\n\n" + text274+ }275+ return sheet.CleanNote(text)276+}277+278+func unionKeys3(a, b, c map[string]json.RawMessage) []string {279+ keys := map[string]bool{}280+ for _, m := range []map[string]json.RawMessage{a, b, c} {281+ for k := range m {282+ keys[k] = true283+ }284+ }285+ return slices.Sorted(maps.Keys(keys))286+}@@ -0,0 +1,155 @@
1+package diff2+3+import (4+ "bytes"5+ "strings"6+ "testing"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+func merge(t *testing.T, base, ours, theirs []byte) (*sheet.Workbook, []string) {12+ t.Helper()13+ out, conflicts, err := Merge(base, ours, theirs)14+ if err != nil {15+ t.Fatal(err)16+ }17+ w, err := sheet.ReadBook(bytes.NewReader(out))18+ if err != nil {19+ t.Fatalf("the merge doesn't read: %v\n%s", err, out)20+ }21+ var said []string22+ for _, c := range conflicts {23+ said = append(said, c.String())24+ }25+ return w, said26+}27+28+func input(w *sheet.Workbook, ref string) string {29+ name, rest := sheet.SplitSheet(ref)30+ s := w.Lookup(name)31+ if s == nil {32+ return "(no sheet " + name + ")"33+ }34+ c := s.Cell(addr(rest))35+ if c == nil {36+ return ""37+ }38+ return c.Input39+}40+41+func TestMergeCells(t *testing.T) {42+ base := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "Item", "B1": "1", "B2": "2", "B3": "3", "B4": "=SUM(B1:B3)"}}, nil)43+ ours := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "Item", "B1": "10", "B2": "2", "B3": "30", "B4": "=SUM(B1:B3)", "C1": "ours"}}, nil)44+ theirs := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "Item", "B1": "1", "B2": "20", "B3": "31", "B4": "=SUM(B1:B3)", "D1": "theirs"}}, func(w *sheet.Workbook) {45+ s := w.Sheet(0)46+ s.SetStyle(sheet.NewRect(addr("B1"), addr("B1")), func(st *sheet.Style) { st.Bold = true })47+ s.SetColWidth(3, 20)48+ })49+ w, said := merge(t, base, ours, theirs)50+ for ref, want := range map[string]string{"Sheet1!B1": "10", "Sheet1!B2": "20", "Sheet1!B3": "30", "Sheet1!C1": "ours", "Sheet1!D1": "theirs"} {51+ if got := input(w, ref); got != want {52+ t.Errorf("%s = %q, want %q", ref, got, want)53+ }54+ }55+ s := w.Sheet(0)56+ if !s.CellStyle(addr("B1")).Bold || s.ColWidth(3) != 20 {57+ t.Error("theirs' formatting and width were lost")58+ }59+ if v := s.Value(addr("B4")); v.Num != 60 {60+ t.Errorf("B4 = %v", v)61+ }62+ if len(said) != 1 || said[0] != "Sheet1!B3 input: ours 30, theirs 31, base 3" {63+ t.Errorf("conflicts: %v", said)64+ }65+ if note := s.Note(addr("B3")); note != "Merge conflict, kept ours; theirs had input 31" {66+ t.Errorf("B3's note: %q", note)67+ }68+}69+70+func TestMergeSheets(t *testing.T) {71+ cells := map[string]string{"A1": "a", "A2": "b", "A3": "c"}72+ base := file(t, []string{"Plan", "Old", "Keep"}, []map[string]string{cells, {"A1": "old"}, {"A1": "k"}}, nil)73+ // Ours renames Plan and adds a sheet; theirs edits Plan, removes Old74+ // and adds a sheet of the same name as ours'.75+ ours := file(t, []string{"Q3 plan", "Old", "Keep", "Notes"}, []map[string]string{cells, {"A1": "old"}, {"A1": "k"}, {"A1": "ours"}}, nil)76+ edited := map[string]string{"A1": "a", "A2": "B", "A3": "c"}77+ theirs := file(t, []string{"Plan", "Keep", "Notes"}, []map[string]string{edited, {"A1": "k"}, {"B1": "theirs"}}, nil)78+ w, said := merge(t, base, ours, theirs)79+ var names []string80+ for _, s := range w.Sheets() {81+ names = append(names, s.Name())82+ }83+ if strings.Join(names, ",") != "Q3 plan,Keep,Notes" {84+ t.Errorf("sheets: %v", names)85+ }86+ if input(w, "'Q3 plan'!A2") != "B" || input(w, "Notes!A1") != "ours" || input(w, "Notes!B1") != "theirs" {87+ t.Errorf("cells: %q %q %q", input(w, "'Q3 plan'!A2"), input(w, "Notes!A1"), input(w, "Notes!B1"))88+ }89+ if len(said) != 0 {90+ t.Errorf("conflicts: %v", said)91+ }92+93+ // A sheet removed on one side and changed on the other conflicts;94+ // ours stays.95+ oursEdit := file(t, []string{"Plan", "Old", "Keep"}, []map[string]string{cells, {"A1": "older"}, {"A1": "k"}}, nil)96+ w, said = merge(t, base, oursEdit, theirs)97+ if w.Lookup("Old") == nil || len(said) != 1 || said[0] != "sheet Old: changed in ours, removed in theirs" {98+ t.Errorf("removed in theirs: %v %v", w.Lookup("Old"), said)99+ }100+ theirsEdit := file(t, []string{"Plan", "Old", "Keep"}, []map[string]string{cells, {"A1": "older"}, {"A1": "k"}}, nil)101+ oursGone := file(t, []string{"Plan", "Keep"}, []map[string]string{cells, {"A1": "k"}}, nil)102+ w, said = merge(t, base, oursGone, theirsEdit)103+ if w.Lookup("Old") != nil || len(said) != 1 || said[0] != "sheet Old: removed in ours, changed in theirs" {104+ t.Errorf("removed in ours: %v %v", w.Lookup("Old"), said)105+ }106+107+ // Both renaming one sheet differently conflicts.108+ theirsRenamed := file(t, []string{"Plan B", "Old", "Keep"}, []map[string]string{cells, {"A1": "old"}, {"A1": "k"}}, nil)109+ _, said = merge(t, base, ours, theirsRenamed)110+ if len(said) != 1 || said[0] != "sheet Q3 plan name: ours Q3 plan, theirs Plan B, base Plan" {111+ t.Errorf("renamed twice: %v", said)112+ }113+}114+115+func TestMergeTrust(t *testing.T) {116+ book := func(origin, command string) []byte {117+ return []byte(`{"version": 4, "macroOrigin": "` + origin + `", "sheets": [{"name": "S", "notebook": true, "regions": [{"name": "r1", "command": "` + command + `", "at": "A1"}], "cells": {}}]}`)118+ }119+ base := book("here", "ls")120+ cases := []struct {121+ ours, theirs []byte122+ want string123+ }{124+ {book("here", "ls -a"), book("here", "ls"), "here"}, // theirs brought no commands125+ {book("here", "ls"), book("there", "ls"), "here"}, // only theirs' origin differs126+ {book("here", "echo"), book("there", "echo"), "here"}, // both made the same change127+ {book("here", "ls -a"), book("there", "ls -b"), "here"}, // a conflict keeps ours' command128+ {book("here", "ls"), book("here", "ls -l"), "here"}, // theirs trusted on this computer too129+ {book("there", "ls"), book("there", "ls -l"), "there"}, // both from elsewhere, as ours was130+ {book("here", "ls"), book("there", "rm -rf ~"), ""}, // theirs' command from elsewhere131+ {book("here", "ls"), book("", "ls -l"), ""}, // theirs' command, never trusted132+ {book("here", "ls"), book("here2", "ls -l"), ""}, // another computer's id133+ {book("", "ls"), book("here", "ls -l"), ""}, // ours stays untrusted134+ }135+ for i, c := range cases {136+ w, _ := merge(t, base, c.ours, c.theirs)137+ if got := w.MacroOrigin(); got != c.want {138+ t.Errorf("case %d: origin %q, want %q", i, got, c.want)139+ }140+ }141+}142+143+func TestMergeBroken(t *testing.T) {144+ if _, _, err := Merge([]byte("{"), nil, nil); err == nil {145+ t.Error("a broken base merged")146+ }147+ // Names that end up pointing at a sheet no longer there make a148+ // workbook the engine refuses, which is an error, not a merge.149+ base := []byte(`{"version": 4, "sheets": [{"name": "A", "cells": {}}, {"name": "B", "cells": {}}]}`)150+ ours := []byte(`{"version": 4, "sheets": [{"name": "A", "cells": {}}]}`)151+ theirs := []byte(`{"version": 4, "names": {"N": "B!A1"}, "sheets": [{"name": "A", "cells": {}}, {"name": "B", "cells": {}}]}`)152+ if _, _, err := Merge(base, ours, theirs); err == nil {153+ t.Error("a merge naming a sheet removed")154+ }155+}@@ -0,0 +1,149 @@
1+package diff2+3+import (4+ "encoding/json"5+ "strings"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// sheets merges the workbooks' sheets. A sheet is matched across the11+// three by name or, renamed, by its cells; one removed on one side goes12+// when the other left it as it was, and stays (a conflict) when ours13+// changed it, or goes (a conflict) when only theirs did. Sheets added14+// on both sides under one name merge cell by cell. The order is ours',15+// with theirs' additions after the sheet they follow in theirs.16+func (m *merger) sheets(b, o, t *rawBook) []*rawSheet {17+ oOf, oBase := matchIndex(matchSheets(b, o))18+ tOf, tBase := matchIndex(matchSheets(b, t))19+ tDone := make([]bool, len(t.sheets))20+ var out []*rawSheet21+ for j, so := range o.sheets {22+ i, inBase := oBase[j]23+ switch {24+ case !inBase:25+ out = append(out, m.added(so, t, tBase, tDone))26+ case hasKey(tOf, i):27+ tDone[tOf[i]] = true28+ out = append(out, m.sheet(b.sheets[i], so, t.sheets[tOf[i]]))29+ case sameSheet(b.sheets[i], so):30+ // removed in theirs, unchanged in ours: removed31+ default:32+ m.said("sheet "+so.name, "", "changed in ours, removed in theirs")33+ out = append(out, so)34+ }35+ }36+ for k, st := range t.sheets {37+ if tDone[k] {38+ continue39+ }40+ i, inBase := tBase[k]41+ switch {42+ case !inBase:43+ out = insertAfter(out, st, t, k)44+ case !hasKey(oOf, i) && !sameSheet(b.sheets[i], st):45+ m.said("sheet "+st.name, "", "removed in ours, changed in theirs")46+ }47+ }48+ return m.unique(out)49+}50+51+// matchIndex is a pairing as maps: a's index to b's, and back.52+func matchIndex(p pairing) (toB, toA map[int]int) {53+ toB, toA = map[int]int{}, map[int]int{}54+ for _, pr := range p.pairs {55+ toB[pr[0]], toA[pr[1]] = pr[1], pr[0]56+ }57+ return toB, toA58+}59+60+func hasKey(m map[int]int, k int) bool { _, ok := m[k]; return ok }61+62+// added is a sheet ours added, merged with one theirs added under the63+// same name, if any, as if both started empty.64+func (m *merger) added(so *rawSheet, t *rawBook, tBase map[int]int, tDone []bool) *rawSheet {65+ k := t.sheetIndex(so.name)66+ if _, inBase := tBase[k]; k < 0 || inBase || tDone[k] {67+ return so68+ }69+ tDone[k] = true70+ return m.sheet(&rawSheet{name: so.name, fields: map[string]json.RawMessage{}, cells: map[string]json.RawMessage{}}, so, t.sheets[k])71+}72+73+// insertAfter puts s, theirs' k-th sheet, after the sheet before it in74+// theirs, or first.75+func insertAfter(out []*rawSheet, s *rawSheet, t *rawBook, k int) []*rawSheet {76+ at := 077+ for p := k - 1; p >= 0; p-- {78+ if i := indexNamed(out, t.sheets[p].name); i >= 0 {79+ at = i + 180+ break81+ }82+ }83+ out = append(out, nil)84+ copy(out[at+1:], out[at:])85+ out[at] = s86+ return out87+}88+89+func indexNamed(list []*rawSheet, name string) int {90+ for i, s := range list {91+ if strings.EqualFold(s.name, name) {92+ return i93+ }94+ }95+ return -196+}97+98+// unique renames sheets that came out with one name, ignoring case, as99+// when ours renamed a sheet to the name of one theirs added.100+func (m *merger) unique(list []*rawSheet) []*rawSheet {101+ for i, s := range list {102+ if indexNamed(list[:i], s.name) < 0 {103+ continue104+ }105+ name := s.name + " (theirs)"106+ for n := 2; indexNamed(list, name) >= 0; n++ {107+ name = s.name + " (theirs " + itoa(n) + ")"108+ }109+ m.said("sheet "+s.name, "name", "a second sheet of this name, from theirs, is named "+name)110+ s.name = name111+ }112+ return list113+}114+115+func itoa(n int) string {116+ raw, _ := json.Marshal(n)117+ return string(raw)118+}119+120+func str(s string) json.RawMessage {121+ raw, _ := json.Marshal(s)122+ return raw123+}124+125+// sheet merges one sheet: its name, each cell and each field.126+func (m *merger) sheet(b, o, t *rawSheet) *rawSheet {127+ out := &rawSheet{fields: map[string]json.RawMessage{}, cells: map[string]json.RawMessage{}}128+ json.Unmarshal(m.value("sheet "+o.name, "name", str(b.name), str(o.name), str(t.name)), &out.name)129+ for _, k := range unionKeys3(b.cells, o.cells, t.cells) {130+ if v := m.cell(sheet.QuoteSheet(out.name)+"!"+k, b.cells[k], o.cells[k], t.cells[k]); v != nil {131+ out.cells[k] = v132+ }133+ }134+ for _, k := range unionKeys3(b.fields, o.fields, t.fields) {135+ var v json.RawMessage136+ switch k {137+ case "regions":138+ v = m.list(b.fields[k], o.fields[k], t.fields[k], func(key string) string { return out.name + " region " + key })139+ case "widths", "heights", "lines":140+ v = m.object(b.fields[k], o.fields[k], t.fields[k], func(key string) (string, string) { return out.name + " " + k, key })141+ default:142+ v = m.value(out.name+" "+k, "", b.fields[k], o.fields[k], t.fields[k])143+ }144+ if v != nil {145+ out.fields[k] = v146+ }147+ }148+ return out149+}@@ -0,0 +1,211 @@
1+package diff2+3+import (4+ "bytes"5+ "encoding/json"6+ "fmt"7+ "maps"8+ "slices"9+ "strconv"10+ "strings"11+12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// A .012 file is JSON whose sheets hold their cells as a map by address16+// and their other fields (widths, rules, regions) beside them; see17+// docs/files/format.md. Diffing and merging read it as that JSON, field18+// by field, so every field is compared and kept, including ones this19+// package knows nothing about, and the workbook the fields make is read20+// with the engine only for the values formulas compute.21+22+// rawBook is a file's fields: the workbook's, and each sheet's.23+type rawBook struct {24+ top map[string]json.RawMessage // the workbook's fields, compact, without "sheets"25+ sheets []*rawSheet26+}27+28+// rawSheet is one sheet's fields, compact.29+type rawSheet struct {30+ name string31+ fields map[string]json.RawMessage // everything but name and cells32+ cells map[string]json.RawMessage // by address33+}34+35+// bookKeys are the workbook's fields; in a file of one sheet (versions36+// 1 to 3) every other top-level field is the sheet's.37+var bookKeys = map[string]bool{"version": true, "names": true, "active": true, "arithmetic": true,38+ "locale": true, "macroOrigin": true, "shellHistory": true, "macros": true, "sheets": true}39+40+// parseRaw reads a file's fields. An empty file (git's stand-in for a41+// file that isn't there) is a workbook without sheets.42+func parseRaw(data []byte) (*rawBook, error) {43+ b := &rawBook{top: map[string]json.RawMessage{}}44+ if len(bytes.TrimSpace(data)) == 0 {45+ return b, nil46+ }47+ var top map[string]json.RawMessage48+ if err := json.Unmarshal(data, &top); err != nil {49+ return nil, err50+ }51+ if raw, ok := top["sheets"]; ok {52+ var sheets []map[string]json.RawMessage53+ if err := json.Unmarshal(raw, &sheets); err != nil {54+ return nil, fmt.Errorf("sheets: %w", err)55+ }56+ for _, fields := range sheets {57+ s, err := parseSheet(fields)58+ if err != nil {59+ return nil, err60+ }61+ b.sheets = append(b.sheets, s)62+ }63+ } else {64+ fields := map[string]json.RawMessage{}65+ for k, v := range top {66+ if !bookKeys[k] {67+ fields[k] = v68+ }69+ }70+ s, err := parseSheet(fields)71+ if err != nil {72+ return nil, err73+ }74+ if s.name == "" {75+ s.name = "Sheet1"76+ }77+ b.sheets = []*rawSheet{s}78+ if err := qualifyNames(top, s.name); err != nil {79+ return nil, err80+ }81+ }82+ for k, v := range top {83+ if bookKeys[k] && k != "sheets" {84+ b.top[k] = compact(v)85+ }86+ }87+ return b, nil88+}89+90+// parseSheet splits a sheet's fields into its name, cells and the rest.91+func parseSheet(fields map[string]json.RawMessage) (*rawSheet, error) {92+ s := &rawSheet{fields: map[string]json.RawMessage{}, cells: map[string]json.RawMessage{}}93+ if raw, ok := fields["name"]; ok {94+ if err := json.Unmarshal(raw, &s.name); err != nil {95+ return nil, fmt.Errorf("sheet name: %w", err)96+ }97+ }98+ if raw, ok := fields["cells"]; ok && string(raw) != "null" {99+ if err := json.Unmarshal(raw, &s.cells); err != nil {100+ return nil, fmt.Errorf("sheet %s: cells: %w", s.name, err)101+ }102+ }103+ for k, v := range s.cells {104+ s.cells[k] = compact(v)105+ }106+ for k, v := range fields {107+ if k != "name" && k != "cells" {108+ s.fields[k] = compact(v)109+ }110+ }111+ return s, nil112+}113+114+// qualifyNames writes a one-sheet file's named ranges with their sheet,115+// as files of several sheets have them, so files of both shapes compare.116+func qualifyNames(top map[string]json.RawMessage, sheetName string) error {117+ raw, ok := top["names"]118+ if !ok {119+ return nil120+ }121+ var names map[string]string122+ if err := json.Unmarshal(raw, &names); err != nil {123+ return fmt.Errorf("names: %w", err)124+ }125+ for k, ref := range names {126+ if ref != "#REF!" && !strings.Contains(ref, "!") {127+ names[k] = sheet.QuoteSheet(sheetName) + "!" + ref128+ }129+ }130+ top["names"], _ = json.Marshal(names)131+ return nil132+}133+134+// compact is raw without insignificant space, so equal values compare135+// equal byte for byte.136+func compact(raw json.RawMessage) json.RawMessage {137+ var b bytes.Buffer138+ if json.Compact(&b, raw) != nil {139+ return raw140+ }141+ return b.Bytes()142+}143+144+// marshal writes the fields as a file of several sheets, which the145+// engine reads whatever the count; the engine's own writer then gives146+// the file its usual shape.147+func (b *rawBook) marshal() []byte {148+ var out bytes.Buffer149+ out.WriteString("{\n")150+ version := json.RawMessage("4")151+ if n, err := strconv.Atoi(string(b.top["version"])); err == nil && n > 4 {152+ version = b.top["version"]153+ }154+ fmt.Fprintf(&out, "%q: %s", "version", version)155+ for _, k := range slices.Sorted(maps.Keys(b.top)) {156+ if k != "version" {157+ fmt.Fprintf(&out, ",\n%q: %s", k, b.top[k])158+ }159+ }160+ out.WriteString(",\n\"sheets\": [")161+ for i, s := range b.sheets {162+ if i > 0 {163+ out.WriteByte(',')164+ }165+ name, _ := json.Marshal(s.name)166+ fmt.Fprintf(&out, "\n{\"name\": %s", name)167+ for _, k := range slices.Sorted(maps.Keys(s.fields)) {168+ fmt.Fprintf(&out, ",\n%q: %s", k, s.fields[k])169+ }170+ out.WriteString(",\n\"cells\": {")171+ for j, k := range slices.Sorted(maps.Keys(s.cells)) {172+ if j > 0 {173+ out.WriteByte(',')174+ }175+ fmt.Fprintf(&out, "\n%q: %s", k, s.cells[k])176+ }177+ out.WriteString("}}")178+ }179+ out.WriteString("]\n}\n")180+ return out.Bytes()181+}182+183+// normalize reads merged fields with the engine and writes them as 012184+// saves a workbook, checking that they make one.185+func normalize(b *rawBook) ([]byte, error) {186+ w, err := sheet.ReadBook(bytes.NewReader(b.marshal()))187+ if err != nil {188+ return nil, err189+ }190+ var out bytes.Buffer191+ if err := w.Write(&out); err != nil {192+ return nil, err193+ }194+ return out.Bytes(), nil195+}196+197+// equal reports whether two compact values are the same; nil is a198+// field or cell that isn't there.199+func equal(a, b json.RawMessage) bool {200+ return (a == nil) == (b == nil) && bytes.Equal(a, b)201+}202+203+// sheetIndex finds a sheet by name, ignoring case as the engine does.204+func (b *rawBook) sheetIndex(name string) int {205+ for i, s := range b.sheets {206+ if strings.EqualFold(s.name, name) {207+ return i208+ }209+ }210+ return -1211+}@@ -0,0 +1,193 @@
1+package diff2+3+import (4+ "bufio"5+ "encoding/json"6+ "fmt"7+ "io"8+ "maps"9+ "slices"10+ "strings"11+12+ "github.com/FelineStateMachine/012/internal/nuon"13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+// SGR codes of the text output's colors, from the 16 ANSI colors so the17+// terminal's palette applies; the words carry the meaning without them.18+const (19+ sgrWhere = "\x1b[1m" // bold: the place20+ sgrField = "\x1b[36m" // cyan: what about it21+ sgrOld = "\x1b[31m" // red: what it was22+ sgrNew = "\x1b[32m" // green: what it is23+ sgrReset = "\x1b[0m"24+)25+26+// Formats are what WriteChanges writes.27+var Formats = []string{"text", "json", "nuon"}28+29+// WriteChanges writes changes in format: text, a line each (colored with30+// color), or a table of records with the columns kind, sheet, item,31+// field, old and new as JSON or NUON.32+func WriteChanges(w io.Writer, changes []Change, format string, color bool) error {33+ switch format {34+ case "", "text":35+ return writeText(w, changes, color)36+ case "json":37+ return writeJSON(w, changes)38+ case "nuon":39+ return writeNUON(w, changes)40+ }41+ return fmt.Errorf("--format %s: 012 diff writes %s", format, strings.Join(Formats, ", "))42+}43+44+// writeText writes a line a change: its place, what about it changed,45+// and "old → new", "+ new" for something added or "- old" for46+// something removed.47+func writeText(w io.Writer, changes []Change, color bool) error {48+ bw := bufio.NewWriter(w)49+ paint := func(sgr, s string) string {50+ if !color || s == "" {51+ return s52+ }53+ return sgr + s + sgrReset54+ }55+ for _, c := range changes {56+ line := paint(sgrWhere, c.Where())57+ if c.Field != "" {58+ line += " " + paint(sgrField, c.Field)59+ }60+ switch {61+ case c.Kind == KindSheet:62+ line += sheetText(c)63+ case c.Old.Kind == nuon.Null:64+ line += " " + paint(sgrNew, "+ "+quoted(c.newText))65+ case c.New.Kind == nuon.Null:66+ line += " " + paint(sgrOld, "- "+quoted(c.oldText))67+ default:68+ line += " " + paint(sgrOld, quoted(c.oldText)) + " → " + paint(sgrNew, quoted(c.newText))69+ }70+ bw.WriteString(line + "\n")71+ }72+ return bw.Flush()73+}74+75+// sheetText is the end of a sheet's line: where it was renamed or moved76+// from.77+func sheetText(c Change) string {78+ switch c.Field {79+ case "renamed":80+ return " from " + c.oldText81+ case "moved":82+ return " from " + c.oldText + " to " + c.newText83+ }84+ return ""85+}86+87+var recordCols = []string{"kind", "sheet", "item", "field", "old", "new"}88+89+func (c Change) values() []nuon.Value {90+ str := func(s string) nuon.Value { return nuon.StringValue(s) }91+ return []nuon.Value{str(c.Kind), str(c.Sheet), str(c.Item), str(c.Field), c.Old, c.New}92+}93+94+func writeNUON(w io.Writer, changes []Change) error {95+ tw := nuon.NewTableWriter(w, recordCols)96+ for _, c := range changes {97+ if err := tw.Write(c.values()); err != nil {98+ return err99+ }100+ }101+ return tw.Close()102+}103+104+func writeJSON(w io.Writer, changes []Change) error {105+ bw := bufio.NewWriter(w)106+ bw.WriteString("[")107+ fields := make([]nuon.Field, len(recordCols))108+ for i, c := range changes {109+ for j, v := range c.values() {110+ fields[j] = nuon.Field{Key: recordCols[j], Value: v}111+ }112+ if i > 0 {113+ bw.WriteString(",")114+ }115+ bw.WriteString("\n")116+ bw.Write(nuon.AppendJSONRecord(nil, fields))117+ }118+ if len(changes) > 0 {119+ bw.WriteString("\n")120+ }121+ bw.WriteString("]\n")122+ return bw.Flush()123+}124+125+// Dump writes a workbook as lines of text, one for each cell and each126+// field, each naming its sheet: what git diffs when 012 diff --textconv127+// is a .012 file's textconv. A formula's line ends with its value,128+// unless it changes on every recalculation (NOW, RAND and what reads129+// them), which would show as a change each time.130+func Dump(w io.Writer, b *Book) error {131+ bw := bufio.NewWriter(w)132+ for _, s := range b.raw.sheets {133+ sd := b.side(s)134+ q := sheet.QuoteSheet(s.name)135+ fmt.Fprintf(bw, "sheet %s\n", s.name)136+ for _, k := range slices.Sorted(maps.Keys(s.fields)) {137+ if k == "regions" {138+ for _, name := range slices.Sorted(maps.Keys(named(s.fields[k]))) {139+ fmt.Fprintf(bw, "%s region %s %s\n", s.name, name, named(s.fields[k])[name])140+ }141+ continue142+ }143+ fmt.Fprintf(bw, "%s %s %s\n", s.name, k, s.fields[k])144+ }145+ for _, at := range sortedAddrs(s.cells) {146+ bw.WriteString(q + "!" + cellLine(sd, at, s.cells[at.String()]) + "\n")147+ }148+ }149+ names, _ := object(b.raw.top["names"])150+ for _, k := range slices.Sorted(maps.Keys(names)) {151+ fmt.Fprintf(bw, "name %s %s\n", k, rawText(names[k]))152+ }153+ macros := named(b.raw.top["macros"])154+ for _, k := range slices.Sorted(maps.Keys(macros)) {155+ fmt.Fprintf(bw, "macro %s %s\n", k, macros[k])156+ }157+ for _, k := range slices.Sorted(maps.Keys(b.raw.top)) {158+ if !quietKeys[k] && k != "names" && k != "macros" {159+ fmt.Fprintf(bw, "workbook %s %s\n", k, b.raw.top[k])160+ }161+ }162+ return bw.Flush()163+}164+165+// cellLine is a cell as Dump writes it after its sheet: B7 =SUM(A1:A6)166+// = 21 [currency, decimals 2] note: …167+func cellLine(sd side, at sheet.Addr, raw json.RawMessage) string {168+ p := splitCell(raw)169+ line := at.String() + " " + quoted(p.input)170+ if sheet.IsFormulaEntry(p.input) && sd.s != nil && !sd.vol[at] {171+ _, shown := cellValue(sd.s, at)172+ line += " = " + quoted(shown)173+ }174+ if p.format != nil {175+ line += " [" + formatText(p.format) + "]"176+ }177+ if p.note != "" {178+ line += " note: " + quoted(p.note)179+ }180+ return line181+}182+183+// sortedAddrs are the cells' addresses in row order.184+func sortedAddrs(cells map[string]json.RawMessage) []sheet.Addr {185+ var out []sheet.Addr186+ for k := range cells {187+ if at, ok := sheet.ParseAddr(k); ok {188+ out = append(out, at)189+ }190+ }191+ slices.SortFunc(out, byRow)192+ return out193+}