1package diff23import (4 "encoding/json"5 "maps"6 "slices"78 "src.rest/cat/012/internal/nuon"9)1011// 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.14var quietKeys = map[string]bool{"version": true, "active": true, "macroOrigin": true, "shellHistory": true}1516// bookChanges compares the workbook's fields: named ranges, macros and17// settings.18func 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}3536// fieldChanges compares a field: key by key when it's an object (a37// column's width), else whole.38func 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}5556// keyedChanges compares an object whose keys are things (named ranges):57// each added, removed, or its value changed, as field.58func 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}7475// listChanges compares lists of things with names (regions, macros):76// each added, removed, or changed field by field.77func 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}102103// named reads a list of objects by their "name" fields, as compact104// JSON.105func 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}116117// summary is what the text output shows of a region, table or macro118// added or removed: the file a region follows or that it's a notebook's119// output; a table's range; a macro's script's first line.120func summary(raw json.RawMessage) string {121 fields, _ := object(raw)122 var s string123 switch {124 case json.Unmarshal(fields["path"], &s) == nil && s != "":125 return s126 case string(fields["output"]) == "true":127 return "a notebook cell's output"128 case json.Unmarshal(fields["range"], &s) == nil && s != "":129 return s130 case json.Unmarshal(fields["source"], &s) == nil && s != "":131 return firstLine(s)132 }133 return string(raw)134}135136// rawChange is a change between two JSON values, nil for none.137func rawChange(kind, sheetName, item, field string, a, b json.RawMessage) Change {138 return Change{Kind: kind, Sheet: sheetName, Item: item, Field: field,139 Old: jsonValue(a), New: jsonValue(b), oldText: rawText(a), newText: rawText(b)}140}141142func jsonValue(raw json.RawMessage) nuon.Value {143 if raw == nil {144 return nuon.NullValue()145 }146 v, err := nuon.Parse(raw)147 if err != nil {148 return nuon.StringValue(string(raw))149 }150 return v151}152153// rawText is a JSON value as the text output shows it: a string as it154// is, anything else as compact JSON.155func rawText(raw json.RawMessage) string {156 var s string157 if raw == nil {158 return ""159 }160 if json.Unmarshal(raw, &s) == nil {161 return s162 }163 return string(raw)164}165166func object(raw json.RawMessage) (map[string]json.RawMessage, bool) {167 var m map[string]json.RawMessage168 if raw == nil {169 return map[string]json.RawMessage{}, true170 }171 if json.Unmarshal(raw, &m) != nil || m == nil {172 return nil, false173 }174 for k, v := range m {175 m[k] = compact(v)176 }177 return m, true178}179180func unionKeys(a, b map[string]json.RawMessage) []string {181 keys := map[string]bool{}182 for k := range a {183 keys[k] = true184 }185 for k := range b {186 keys[k] = true187 }188 return slices.Sorted(maps.Keys(keys))189}