1package diff23import (4 "encoding/json"5 "maps"6 "slices"7 "strconv"8 "strings"910 "src.rest/cat/012/internal/fileio"11 "src.rest/cat/012/internal/formula"12 "src.rest/cat/012/internal/functions"13 "src.rest/cat/012/internal/nuon"14 "src.rest/cat/012/internal/sheet"15)1617// 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.20type cellParts struct {21 input, note string22 format json.RawMessage // a compact object, or nil for none23}2425// splitCell reads a cell as the file stores it: its input alone, or an26// object with the input, the note and the formatting.27func 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}4546// joinCell is the inverse of splitCell; nil for a cell with nothing.47func 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}6667// formatText describes a cell's formatting in words from its fields:68// currency, decimals 2, bold.69func 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}9495// sheetDiff compares a sheet's cells, regions, tables and other fields; name is96// what the changes call the sheet.97func sheetDiff(a, b side, name string) []Change {98 out := cellChanges(a, b, name)99 out = append(out, notebookChanges(name, field(a.raw, "notebookCells"), field(b.raw, "notebookCells"))...)100 out = append(out, listChanges(KindRegion, name, field(a.raw, "regions"), field(b.raw, "regions"))...)101 out = append(out, listChanges(KindTable, name, field(a.raw, "tables"), field(b.raw, "tables"))...)102 keys := map[string]bool{}103 for _, s := range []*rawSheet{a.raw, b.raw} {104 for k := range s.fields {105 keys[k] = true106 }107 }108 for _, k := range slices.Sorted(maps.Keys(keys)) {109 if k != "regions" && k != "tables" && k != "notebookCells" {110 out = append(out, fieldChanges(KindLayout, name, k, a.raw.fields[k], b.raw.fields[k])...)111 }112 }113 return out114}115116func field(s *rawSheet, k string) json.RawMessage { return s.fields[k] }117118// cellChanges compares each cell either side has contents, formatting119// or a value in, row by row.120func cellChanges(a, b side, name string) []Change {121 var out []Change122 for _, at := range cellAddrs(a, b) {123 out = append(out, cellChange(a, b, name, at)...)124 }125 return out126}127128// cellAddrs are the cells either side has something in, in row order.129func cellAddrs(a, b side) []sheet.Addr {130 addrs := map[sheet.Addr]bool{}131 for _, sd := range []side{a, b} {132 for k := range sd.raw.cells {133 if at, ok := sheet.ParseAddr(k); ok {134 addrs[at] = true135 }136 }137 if sd.s != nil {138 for _, at := range sd.s.Addrs() {139 addrs[at] = true140 }141 }142 }143 return slices.SortedFunc(maps.Keys(addrs), byRow)144}145146func byRow(x, y sheet.Addr) int {147 if x.Row != y.Row {148 return x.Row - y.Row149 }150 return x.Col - y.Col151}152153// cellChange compares one cell's input, value, format and note.154func cellChange(a, b side, name string, at sheet.Addr) []Change {155 key := at.String()156 pa, pb := splitCell(a.raw.cells[key]), splitCell(b.raw.cells[key])157 item := func(field, old, new string) Change { return textChange(KindCell, name, key, field, old, new) }158 var out []Change159 if pa.input != pb.input {160 out = append(out, item("input", pa.input, pb.input))161 }162 if showsValue(pa, pb) && !a.vol[at] && !b.vol[at] {163 if c, ok := valueChange(a.s, b.s, at); ok {164 c.Sheet, c.Item = name, key165 out = append(out, c)166 }167 }168 if !equal(pa.format, pb.format) {169 c := item("format", formatText(pa.format), formatText(pb.format))170 c.Old, c.New = recordValue(pa.format), recordValue(pb.format)171 out = append(out, c)172 }173 if pa.note != pb.note {174 out = append(out, item("note", pa.note, pb.note))175 }176 return out177}178179// showsValue reports whether a cell's value says more than its input:180// it's a formula on either side, or it has no input on either (a value181// an array spilled or a region's table).182func showsValue(a, b cellParts) bool {183 return sheet.IsFormulaEntry(a.input) || sheet.IsFormulaEntry(b.input) || a.input == "" && b.input == ""184}185186// valueChange compares the values the two sheets compute at a.187func valueChange(a, b *sheet.Sheet, at sheet.Addr) (Change, bool) {188 oldV, oldT := cellValue(a, at)189 newV, newT := cellValue(b, at)190 if oldT == newT && nuon.Equal(oldV, newV) {191 return Change{}, false192 }193 return Change{Kind: KindCell, Field: "value", Old: oldV, New: newV, oldText: oldT, newText: newT}, true194}195196// cellValue is a cell's value typed by its format, and as it shows.197func 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 return fileio.CellValue(fileio.SnapCell{Value: s.Value(at), Format: s.DisplayFormat(at)}), s.LocalText(at)202}203204// recordValue is a JSON object as a NUON record, or null.205func recordValue(raw json.RawMessage) nuon.Value {206 if raw == nil {207 return nuon.NullValue()208 }209 v, err := nuon.Parse(raw)210 if err != nil {211 return nuon.StringValue(string(raw))212 }213 return v214}215216// volatiles are the cells of w whose values change on every217// recalculation, which comparing would report as changed each time:218// formulas calling a volatile function (NOW, RAND), what reads them,219// directly or not, on any sheet, and the cells their arrays spill into.220func volatiles(w *sheet.Workbook) map[*sheet.Sheet]map[sheet.Addr]bool {221 out := map[*sheet.Sheet]map[sheet.Addr]bool{}222 if w == nil {223 return out224 }225 var queue []sheet.Target226 mark := func(s *sheet.Sheet, at sheet.Addr) {227 if out[s] == nil {228 out[s] = map[sheet.Addr]bool{}229 }230 if !out[s][at] {231 out[s][at] = true232 queue = append(queue, sheet.Target{Sheet: s, Range: sheet.Rect{From: at, To: at}})233 }234 }235 for _, s := range w.Sheets() {236 for _, at := range s.Addrs() {237 if c := s.Cell(at); c != nil && c.IsFormula() && callsVolatile(c.Input) {238 mark(s, at)239 }240 }241 }242 for len(queue) > 0 {243 t := queue[0]244 queue = queue[1:]245 if area, ok := t.Sheet.SpillArea(t.Range.From); ok {246 for row := area.From.Row; row <= area.To.Row; row++ {247 for col := area.From.Col; col <= area.To.Col; col++ {248 mark(t.Sheet, sheet.Addr{Col: col, Row: row})249 }250 }251 }252 for _, d := range t.Sheet.Dependents(t.Range.From) {253 mark(d.Sheet, d.Range.From)254 }255 }256 return out257}258259// callsVolatile reports whether a formula calls a volatile function.260func callsVolatile(input string) bool {261 n, err := sheet.Parse(input)262 if err != nil {263 return false264 }265 found := false266 var walk func(formula.Node)267 walk = func(n formula.Node) {268 if call, ok := n.(formula.Call); ok {269 if f := functions.Of(call); f != nil && f.Volatile {270 found = true271 }272 }273 formula.EachChild(n, walk)274 }275 walk(n)276 return found277}278279// quoted writes text as the text output shows a side: as it is, or280// quoted when spaces at its ends or line breaks would hide.281func quoted(s string) string {282 if s != strings.TrimSpace(s) || strings.ContainsAny(s, "\n\t") {283 return strconv.Quote(s)284 }285 return s286}