cat / 012
clone

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

diff: compare and merge workbooks cell by cell

Dami committed 4c2e77f
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
10 files changed, +1965 -0
Ainternal/diff/cells.go289++++++++++++++++++++++++++++++
Ainternal/diff/diff.go202+++++++++++++++++++++
Ainternal/diff/diff_test.go194++++++++++++++++++++
Ainternal/diff/fields.go183+++++++++++++++++++
Ainternal/diff/match.go103+++++++++++
Ainternal/diff/merge.go286++++++++++++++++++++++++++++++
Ainternal/diff/merge_test.go155++++++++++++++++
Ainternal/diff/mergesheets.go149+++++++++++++++
Ainternal/diff/raw.go211++++++++++++++++++++++
Ainternal/diff/text.go193++++++++++++++++++++
A internal/diff/cells.go +289 -0
@@ -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+}
A internal/diff/diff.go +202 -0
@@ -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+}
A internal/diff/diff_test.go +194 -0
@@ -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+}
A internal/diff/fields.go +183 -0
@@ -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+}
A internal/diff/match.go +103 -0
@@ -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+}
A internal/diff/merge.go +286 -0
@@ -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+}
A internal/diff/merge_test.go +155 -0
@@ -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+}
A internal/diff/mergesheets.go +149 -0
@@ -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+}
A internal/diff/raw.go +211 -0
@@ -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+}
A internal/diff/text.go +193 -0
@@ -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+}