@particle-academy/holy-sheet 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -133,16 +133,16 @@ function allStringish(sample) {
133
133
 
134
134
  // src/helpers/array-builder.ts
135
135
  var ArrayBuilder = {
136
- build(rows, headers = null, sheetName = "Sheet 1", options = {}) {
136
+ build(rows, headers = null, sheetName2 = "Sheet 1", options = {}) {
137
137
  let hdrs = headers;
138
138
  let data = rows;
139
139
  if (hdrs === null) {
140
- if (rows.length === 0) return wrap([], [], sheetName, options);
140
+ if (rows.length === 0) return wrap([], [], sheetName2, options);
141
141
  hdrs = (rows[0] ?? []).map((v) => String(v));
142
142
  data = rows.slice(1);
143
143
  }
144
144
  const columns = inferColumns(data, hdrs, options);
145
- return wrap(columns, data, sheetName, options);
145
+ return wrap(columns, data, sheetName2, options);
146
146
  }
147
147
  };
148
148
  function inferColumns(rows, headers, options) {
@@ -151,8 +151,8 @@ function inferColumns(rows, headers, options) {
151
151
  return Inference.detect(columnValues, String(headerName), options);
152
152
  });
153
153
  }
154
- function wrap(columns, rows, sheetName, options) {
155
- const sheet = { name: sheetName, columns, rows };
154
+ function wrap(columns, rows, sheetName2, options) {
155
+ const sheet = { name: sheetName2, columns, rows };
156
156
  if (options.theme !== void 0) sheet.theme = options.theme;
157
157
  if (options.totals && typeof options.totals === "object") sheet.totals = options.totals;
158
158
  if (options.frozenRows !== void 0) sheet.frozenRows = Math.trunc(Number(options.frozenRows));
@@ -165,16 +165,16 @@ var CsvBuilder = {
165
165
  build(csv, options = {}) {
166
166
  const delimiter = options.delimiter ?? ",";
167
167
  const enclosure = options.enclosure ?? '"';
168
- const sheetName = options.sheetName ?? "Sheet 1";
168
+ const sheetName2 = options.sheetName ?? "Sheet 1";
169
169
  const rows = parseRows(csv, delimiter, enclosure);
170
170
  if (rows.length === 0) {
171
- return { sheets: [{ name: sheetName, columns: [], rows: [] }] };
171
+ return { sheets: [{ name: sheetName2, columns: [], rows: [] }] };
172
172
  }
173
173
  const headers = rows[0].map((v) => String(v));
174
174
  const dataRows = rows.slice(1).map(
175
175
  (row) => row.map((cell) => isNumericString(cell) ? numericStringToNumber(cell) : cell)
176
176
  );
177
- return ArrayBuilder.build(dataRows, headers, sheetName, options);
177
+ return ArrayBuilder.build(dataRows, headers, sheetName2, options);
178
178
  }
179
179
  };
180
180
  function parseRows(csv, delimiter, quote) {
@@ -721,6 +721,7 @@ var ERR_REF = "#REF!";
721
721
  var ERR_NAME = "#NAME?";
722
722
  var ERR_DIV0 = "#DIV/0!";
723
723
  var ERR_CIRC = "#CIRC!";
724
+ var SHEET_QUALIFIER = "('(?:[^']|'')+'|[A-Za-z_][A-Za-z0-9_.]*)!";
724
725
  var LinterError = class extends Error {
725
726
  constructor(errorCode) {
726
727
  super(errorCode);
@@ -731,9 +732,16 @@ var FormulaLinter = class {
731
732
  constructor() {
732
733
  this.index = /* @__PURE__ */ new Map();
733
734
  this.cache = /* @__PURE__ */ new Map();
735
+ /**
736
+ * Every sheet name, keyed by its lower-cased form. Excel matches sheet names
737
+ * without regard to case, and a reference to a sheet that is not here is
738
+ * `#REF!` rather than a range of blanks.
739
+ */
740
+ this.sheetNames = /* @__PURE__ */ new Map();
734
741
  }
735
742
  lint(schema) {
736
743
  const workbook = new Normalizer().normalize(schema);
744
+ this.sheetNames = new Map(workbook.sheets.map((sheet) => [sheet.name.toLowerCase(), sheet.name]));
737
745
  this.index = this.buildIndex(workbook);
738
746
  this.cache = /* @__PURE__ */ new Map();
739
747
  const issues = [];
@@ -809,6 +817,24 @@ var FormulaLinter = class {
809
817
  tokens.push({ type: "STRING", value });
810
818
  continue;
811
819
  }
820
+ if (ch === "'") {
821
+ const start = i;
822
+ i++;
823
+ for (; ; ) {
824
+ if (i >= len) throw new LinterError(ERR_NAME);
825
+ if (src[i] === "'") {
826
+ if (i + 1 < len && src[i + 1] === "'") {
827
+ i += 2;
828
+ continue;
829
+ }
830
+ i++;
831
+ break;
832
+ }
833
+ i++;
834
+ }
835
+ tokens.push({ type: "SHEET", value: src.slice(start, i) });
836
+ continue;
837
+ }
812
838
  if (isAlpha(ch) || ch === "_" || ch === "$") {
813
839
  const start = i;
814
840
  while (i < len && (isAlnum(src[i]) || src[i] === "_" || src[i] === "$" || src[i] === ".")) i++;
@@ -926,16 +952,18 @@ var FormulaLinter = class {
926
952
  c.pos++;
927
953
  return tok.value;
928
954
  }
929
- if (tok.type === "IDENT") {
955
+ if (tok.type === "SHEET" || tok.type === "IDENT") {
930
956
  const next = c.tokens[c.pos + 1];
931
957
  const next2 = c.tokens[c.pos + 2];
932
958
  if (next && next.type === "OP" && next.value === "!" && next2 && next2.type === "IDENT") {
933
- const sheetName = this.cleanSheetName(tok.value);
959
+ const sheetName2 = this.sheetNames.get(this.cleanSheetName(tok.value).toLowerCase());
960
+ if (sheetName2 === void 0) throw new LinterError(ERR_REF);
934
961
  c.pos += 2;
935
962
  const startTok = c.tokens[c.pos].value;
936
963
  c.pos++;
937
- return this.resolveRefOrRange(startTok, sheetName, c, stack);
964
+ return this.resolveRefOrRange(startTok, sheetName2, c, stack);
938
965
  }
966
+ if (tok.type === "SHEET") throw new LinterError(ERR_NAME);
939
967
  if (next && next.type === "OP" && next.value === "(") {
940
968
  const name = tok.value.toUpperCase();
941
969
  c.pos += 2;
@@ -980,9 +1008,13 @@ var FormulaLinter = class {
980
1008
  const r = ref.replace(/\$/g, "").toUpperCase();
981
1009
  return /^[A-Z]+\d+$/.test(r) ? r : null;
982
1010
  }
1011
+ /**
1012
+ * Unwrap the single quotes Excel puts around a sheet name that needs them,
1013
+ * and turn its doubled quote back into one: 'Q3 ''Final''' is Q3 'Final'.
1014
+ */
983
1015
  cleanSheetName(name) {
984
1016
  if (name.length >= 2 && name[0] === "'" && name[name.length - 1] === "'") {
985
- return name.slice(1, -1);
1017
+ return name.slice(1, -1).split("''").join("'");
986
1018
  }
987
1019
  return name;
988
1020
  }
@@ -1150,7 +1182,7 @@ var FormulaLinter = class {
1150
1182
  case ERR_VALUE:
1151
1183
  return this.hintValue(formula, sheet);
1152
1184
  case ERR_REF:
1153
- return "A cell reference points to a cell that doesn't exist in the workbook. Check column letters and row numbers.";
1185
+ return this.hintRef(formula);
1154
1186
  case ERR_NAME:
1155
1187
  return "The formula references an unknown function or has a syntax error. Holy Sheet supports: SUM, AVERAGE, COUNT, COUNTA, MIN, MAX, IF, ROUND, ABS, LEN, UPPER, LOWER, CONCAT.";
1156
1188
  case ERR_DIV0:
@@ -1161,20 +1193,29 @@ var FormulaLinter = class {
1161
1193
  return "Formula evaluation failed.";
1162
1194
  }
1163
1195
  }
1196
+ hintRef(formula) {
1197
+ for (const m of formula.matchAll(new RegExp(SHEET_QUALIFIER, "g"))) {
1198
+ const name = this.cleanSheetName(m[1]);
1199
+ if (!this.sheetNames.has(name.toLowerCase())) {
1200
+ return `The formula refers to a sheet named '${name}', and this workbook has no such sheet. Its sheets are: ${[...this.sheetNames.values()].join(", ")}. Quote a name that contains spaces or punctuation: 'My Sheet'!A1.`;
1201
+ }
1202
+ }
1203
+ return "A cell reference points to a cell that doesn't exist in the workbook. Check column letters and row numbers.";
1204
+ }
1164
1205
  hintValue(formula, sheet) {
1165
- const re = /(?:([A-Za-z][A-Za-z0-9_]*)!)?\$?([A-Z]+)\$?(\d+)/gi;
1206
+ const re = new RegExp(`(?:${SHEET_QUALIFIER})?\\$?([A-Z]+)\\$?(\\d+)`, "gi");
1166
1207
  const offenders = [];
1167
1208
  for (const m of formula.matchAll(re)) {
1168
- const sheetName = m[1] ? m[1] : sheet;
1209
+ const sheetName2 = m[1] ? this.sheetNames.get(this.cleanSheetName(m[1]).toLowerCase()) ?? this.cleanSheetName(m[1]) : sheet;
1169
1210
  const a1 = m[2].toUpperCase() + m[3];
1170
- const key = sheetName + "!" + a1;
1211
+ const key = sheetName2 + "!" + a1;
1171
1212
  const cell = this.index.get(key);
1172
1213
  if (!cell) continue;
1173
1214
  const value = this.cache.has(key) ? this.cache.get(key) : cell.formula !== null ? null : cell.value;
1174
1215
  if (typeof value === "string" && !isNumericString(value) && value !== "") {
1175
1216
  const row = parseInt(m[3], 10);
1176
1217
  const col = m[2];
1177
- const next = sheetName + "!" + col.toUpperCase() + (row + 1);
1218
+ const next = sheetName2 + "!" + col.toUpperCase() + (row + 1);
1178
1219
  const nextCell = this.index.get(next);
1179
1220
  let suggestion = "";
1180
1221
  if (nextCell && isNumericString(nextCell.value)) {
@@ -2390,7 +2431,7 @@ var OdsStyles = class {
2390
2431
  }
2391
2432
  }
2392
2433
  const parts = childrenIn(el2, NS.NUMBER);
2393
- const has = (local) => parts.some((p) => p.name === local);
2434
+ const has2 = (local) => parts.some((p) => p.name === local);
2394
2435
  const number = parts.find((p) => p.name === "number");
2395
2436
  let decimals = null;
2396
2437
  if (number) {
@@ -2399,7 +2440,7 @@ var OdsStyles = class {
2399
2440
  }
2400
2441
  switch (el2.name) {
2401
2442
  case "number-style": {
2402
- if (has("scientific-number") || has("fraction")) return null;
2443
+ if (has2("scientific-number") || has2("fraction")) return null;
2403
2444
  for (const text of parts.filter((p) => p.name === "text")) {
2404
2445
  const iso = currencyCode(ownText(text).replace(/[-()"\u00A0]/g, ""));
2405
2446
  if (iso !== null) return { displayFormat: "currency", decimals: decimals ?? 0, currency: iso };
@@ -2417,7 +2458,7 @@ var OdsStyles = class {
2417
2458
  return out;
2418
2459
  }
2419
2460
  case "date-style":
2420
- return ["hours", "minutes", "seconds", "am-pm"].some(has) ? { displayFormat: "datetime" } : { displayFormat: "date" };
2461
+ return ["hours", "minutes", "seconds", "am-pm"].some(has2) ? { displayFormat: "datetime" } : { displayFormat: "date" };
2421
2462
  case "time-style":
2422
2463
  return { displayFormat: "datetime" };
2423
2464
  case "text-style":
@@ -2484,11 +2525,11 @@ function currencyCode(symbol) {
2484
2525
  // src/reader/ods/ods-text.ts
2485
2526
  var SKIPPED = /* @__PURE__ */ new Set(["annotation", "note"]);
2486
2527
  function paragraphs(node) {
2487
- const lines = [];
2528
+ const lines2 = [];
2488
2529
  for (const c of node.children) {
2489
- if (c.ns === NS.TEXT && (c.name === "p" || c.name === "h")) lines.push(paragraph(c));
2530
+ if (c.ns === NS.TEXT && (c.name === "p" || c.name === "h")) lines2.push(paragraph(c));
2490
2531
  }
2491
- return lines.length === 0 ? null : lines.join("\n");
2532
+ return lines2.length === 0 ? null : lines2.join("\n");
2492
2533
  }
2493
2534
  function paragraph(p) {
2494
2535
  const state = { out: "", ignoreSpace: true };
@@ -3751,6 +3792,778 @@ function gmDateZ() {
3751
3792
  return (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
3752
3793
  }
3753
3794
 
3795
+ // src/ops/php.ts
3796
+ function isArr(value) {
3797
+ return typeof value === "object" && value !== null;
3798
+ }
3799
+ function has(value, key) {
3800
+ return isArr(value) && Object.prototype.hasOwnProperty.call(value, key) && value[key] !== void 0;
3801
+ }
3802
+ function get(value, key) {
3803
+ return has(value, key) ? value[key] ?? null : null;
3804
+ }
3805
+ function isEmptyArr(value) {
3806
+ return isArr(value) && Object.keys(value).every((k) => value[k] === void 0);
3807
+ }
3808
+ function valuesOf(value) {
3809
+ if (Array.isArray(value)) return value.slice();
3810
+ if (isArr(value)) return Object.keys(value).filter((k) => value[k] !== void 0).map((k) => value[k]);
3811
+ return [];
3812
+ }
3813
+ function entriesOf(value) {
3814
+ if (Array.isArray(value)) return value.map((item, i) => [String(i), item]);
3815
+ if (isArr(value)) return Object.keys(value).filter((k) => value[k] !== void 0).map((k) => [k, value[k]]);
3816
+ return [];
3817
+ }
3818
+ function phpInt2(value) {
3819
+ if (value === null || value === void 0) return 0;
3820
+ if (typeof value === "boolean") return value ? 1 : 0;
3821
+ if (typeof value === "number") return Number.isFinite(value) ? Math.trunc(value) || 0 : 0;
3822
+ if (typeof value === "string") {
3823
+ const m = /^[ \t\n\r\v\f]*[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/.exec(value);
3824
+ if (!m) return 0;
3825
+ const n = Number(m[0].trim());
3826
+ return Number.isFinite(n) ? Math.trunc(n) || 0 : 0;
3827
+ }
3828
+ if (isArr(value)) return isEmptyArr(value) ? 0 : 1;
3829
+ return 0;
3830
+ }
3831
+ function phpString(value) {
3832
+ if (value === null || value === void 0 || value === false) return "";
3833
+ if (value === true) return "1";
3834
+ if (typeof value === "string") return value;
3835
+ if (typeof value === "number") return String(value);
3836
+ if (isArr(value)) return "Array";
3837
+ return String(value);
3838
+ }
3839
+ function asciiUpper(value) {
3840
+ return value.replace(/[a-z]+/g, (s) => s.toUpperCase());
3841
+ }
3842
+ function parseAddress(address) {
3843
+ const m = /^([A-Z]+)(\d+)$/.exec(asciiUpper(address.replace(/^[ \t\n\r\v\0]+|[ \t\n\r\v\0]+$/g, "")));
3844
+ if (!m) return null;
3845
+ return [CellAddress.index(m[1]), parseInt(m[2], 10)];
3846
+ }
3847
+ var letter = CellAddress.letter;
3848
+
3849
+ // src/ops/sheet-reducer.ts
3850
+ var SheetReducer = {
3851
+ /** Sheet keys the granular ops address. A sheet with any other key is authored form. */
3852
+ CELL_FORM_KEYS: ["name", "cells", "mergedRegions", "columnWidths", "frozenRows", "frozenCols"],
3853
+ applyAll(schema, ops) {
3854
+ for (const op of ops) {
3855
+ schema = SheetReducer.apply(schema, op);
3856
+ }
3857
+ return schema;
3858
+ },
3859
+ apply(schema, op) {
3860
+ const type = get(op, "type");
3861
+ if (type === "set_workbook") {
3862
+ return isArr(get(op, "data")) ? op.data : schema;
3863
+ }
3864
+ if (type === "set_meta") {
3865
+ const out = { ...schema };
3866
+ if (get(op, "meta") === null) {
3867
+ delete out["meta"];
3868
+ } else {
3869
+ out["meta"] = op.meta;
3870
+ }
3871
+ return out;
3872
+ }
3873
+ const sheets = isArr(get(schema, "sheets")) ? valuesOf(schema.sheets) : [];
3874
+ if (type === "add_sheet") {
3875
+ if (!isArr(get(op, "sheet"))) {
3876
+ return schema;
3877
+ }
3878
+ const index = Math.max(0, Math.min(sheets.length, phpInt2(get(op, "index") ?? sheets.length)));
3879
+ sheets.splice(index, 0, op.sheet);
3880
+ return { ...schema, sheets };
3881
+ }
3882
+ const at2 = find(sheets, phpString(get(op, "sheet") ?? ""));
3883
+ if (at2 === null) {
3884
+ return schema;
3885
+ }
3886
+ switch (type) {
3887
+ case "remove_sheet":
3888
+ sheets.splice(at2, 1);
3889
+ break;
3890
+ case "rename_sheet":
3891
+ sheets[at2] = { ...sheets[at2], name: phpString(get(op, "name") ?? sheets[at2].name) };
3892
+ break;
3893
+ case "move_sheet": {
3894
+ const [moved] = sheets.splice(at2, 1);
3895
+ const to = Math.max(0, Math.min(sheets.length, phpInt2(get(op, "toIndex") ?? at2)));
3896
+ sheets.splice(to, 0, moved);
3897
+ break;
3898
+ }
3899
+ case "replace_sheet":
3900
+ if (isArr(get(op, "data"))) {
3901
+ sheets[at2] = op.data;
3902
+ }
3903
+ break;
3904
+ case "set_merged_regions":
3905
+ sheets[at2] = setOrUnset(sheets[at2], "mergedRegions", get(op, "mergedRegions") ?? [], EMPTY);
3906
+ break;
3907
+ case "set_column_widths":
3908
+ sheets[at2] = setOrUnset(sheets[at2], "columnWidths", get(op, "columnWidths") ?? [], EMPTY);
3909
+ break;
3910
+ case "set_frozen":
3911
+ sheets[at2] = setOrUnset(sheets[at2], "frozenRows", phpInt2(get(op, "rows") ?? 0), 0);
3912
+ sheets[at2] = setOrUnset(sheets[at2], "frozenCols", phpInt2(get(op, "cols") ?? 0), 0);
3913
+ break;
3914
+ case "set_cell":
3915
+ sheets[at2] = setCell(sheets[at2], op);
3916
+ break;
3917
+ case "set_range":
3918
+ sheets[at2] = setRange(sheets[at2], op);
3919
+ break;
3920
+ case "clear_cell": {
3921
+ const address = asciiUpper(phpString(get(op, "address") ?? ""));
3922
+ const cells = sheets[at2].cells;
3923
+ if (isArr(cells) && !Array.isArray(cells) && has(cells, address)) {
3924
+ const copy = { ...cells };
3925
+ delete copy[address];
3926
+ sheets[at2] = { ...sheets[at2], cells: copy };
3927
+ }
3928
+ break;
3929
+ }
3930
+ case "insert_rows":
3931
+ sheets[at2] = shiftRows(sheets[at2], phpInt2(get(op, "at") ?? 0), Math.max(0, phpInt2(get(op, "count") ?? 0)));
3932
+ break;
3933
+ case "delete_rows":
3934
+ sheets[at2] = shiftRows(sheets[at2], phpInt2(get(op, "at") ?? 0), -Math.max(0, phpInt2(get(op, "count") ?? 0)));
3935
+ break;
3936
+ case "insert_columns":
3937
+ sheets[at2] = shiftColumns(sheets[at2], phpInt2(get(op, "at") ?? 0), Math.max(0, phpInt2(get(op, "count") ?? 0)));
3938
+ break;
3939
+ case "delete_columns":
3940
+ sheets[at2] = shiftColumns(sheets[at2], phpInt2(get(op, "at") ?? 0), -Math.max(0, phpInt2(get(op, "count") ?? 0)));
3941
+ break;
3942
+ default:
3943
+ return schema;
3944
+ }
3945
+ return { ...schema, sheets };
3946
+ }
3947
+ };
3948
+ var EMPTY = /* @__PURE__ */ Symbol("empty");
3949
+ function find(sheets, name) {
3950
+ for (let i = 0; i < sheets.length; i++) {
3951
+ if (get(sheets[i], "name") === name) {
3952
+ return i;
3953
+ }
3954
+ }
3955
+ return null;
3956
+ }
3957
+ function setOrUnset(sheet, key, value, empty) {
3958
+ const out = { ...sheet };
3959
+ if (value === null || (empty === EMPTY ? isEmptyArr(value) : value === empty)) {
3960
+ delete out[key];
3961
+ } else {
3962
+ out[key] = value;
3963
+ }
3964
+ return out;
3965
+ }
3966
+ function setCell(sheet, op) {
3967
+ const address = asciiUpper(phpString(get(op, "address") ?? ""));
3968
+ if (parseAddress(address) === null) {
3969
+ return sheet;
3970
+ }
3971
+ const cells = isArr(get(sheet, "cells")) ? { ...sheet.cells } : {};
3972
+ const existing = get(cells, address);
3973
+ const value = get(op, "value");
3974
+ const carries = (key) => has(op, key) && op[key] !== null;
3975
+ if (existing === null && value === null && !carries("formula") && !carries("computedValue") && !carries("format") && !carries("comment")) {
3976
+ return sheet;
3977
+ }
3978
+ const cell = has(op, "value") ? { value } : {};
3979
+ for (const key of ["formula", "computedValue"]) {
3980
+ if (carries(key)) {
3981
+ cell[key] = op[key];
3982
+ }
3983
+ }
3984
+ for (const key of ["format", "comment"]) {
3985
+ if (has(op, key)) {
3986
+ if (op[key] !== null) {
3987
+ cell[key] = op[key];
3988
+ }
3989
+ } else if (isArr(existing) && has(existing, key)) {
3990
+ cell[key] = existing[key];
3991
+ }
3992
+ }
3993
+ if (Object.keys(cell).length === 0) {
3994
+ delete cells[address];
3995
+ } else {
3996
+ cells[address] = cell;
3997
+ }
3998
+ return { ...sheet, cells };
3999
+ }
4000
+ function setRange(sheet, op) {
4001
+ const start = parseAddress(phpString(get(op, "start") ?? ""));
4002
+ if (start === null || !isArr(get(op, "values"))) {
4003
+ return sheet;
4004
+ }
4005
+ const [col0, row0] = start;
4006
+ const rows = valuesOf(op.values);
4007
+ for (let r = 0; r < rows.length; r++) {
4008
+ const row = valuesOf(isArr(rows[r]) ? rows[r] : []);
4009
+ for (let c = 0; c < row.length; c++) {
4010
+ sheet = setCell(sheet, { address: letter(col0 + c) + String(row0 + r), value: row[c] === void 0 ? null : row[c] });
4011
+ }
4012
+ }
4013
+ return sheet;
4014
+ }
4015
+ function shiftRows(sheet, at2, delta) {
4016
+ if (at2 < 1 || delta === 0) {
4017
+ return sheet;
4018
+ }
4019
+ sheet = remapCells(sheet, (col, row) => {
4020
+ if (row < at2) {
4021
+ return [col, row];
4022
+ }
4023
+ if (delta < 0 && row < at2 - delta) {
4024
+ return null;
4025
+ }
4026
+ return [col, row + delta];
4027
+ });
4028
+ return remapMerges(sheet, "row", at2, delta);
4029
+ }
4030
+ function shiftColumns(sheet, at2, delta) {
4031
+ if (at2 < 1 || delta === 0) {
4032
+ return sheet;
4033
+ }
4034
+ sheet = remapCells(sheet, (col, row) => {
4035
+ const number = col + 1;
4036
+ if (number < at2) {
4037
+ return [col, row];
4038
+ }
4039
+ if (delta < 0 && number < at2 - delta) {
4040
+ return null;
4041
+ }
4042
+ return [col + delta, row];
4043
+ });
4044
+ if (isArr(get(sheet, "columnWidths"))) {
4045
+ const widths = /* @__PURE__ */ new Map();
4046
+ for (const [index, width] of entriesOf(sheet.columnWidths)) {
4047
+ const i = phpInt2(index);
4048
+ const number = i + 1;
4049
+ if (number < at2) {
4050
+ widths.set(i, width);
4051
+ } else if (delta > 0 || number >= at2 - delta) {
4052
+ widths.set(i + delta, width);
4053
+ }
4054
+ }
4055
+ const sorted = {};
4056
+ for (const [index, width] of [...widths.entries()].sort((x, y) => x[0] - y[0])) {
4057
+ sorted[index] = width;
4058
+ }
4059
+ sheet = setOrUnset(sheet, "columnWidths", sorted, EMPTY);
4060
+ }
4061
+ return remapMerges(sheet, "col", at2, delta);
4062
+ }
4063
+ function shiftSpan(start, end, at2, delta) {
4064
+ if (delta > 0) {
4065
+ return [start >= at2 ? start + delta : start, end >= at2 ? end + delta : end];
4066
+ }
4067
+ const last = at2 - delta - 1;
4068
+ const newStart = start < at2 ? start : start > last ? start + delta : at2;
4069
+ const newEnd = end < at2 ? end : end > last ? end + delta : at2 - 1;
4070
+ return newEnd < newStart ? null : [newStart, newEnd];
4071
+ }
4072
+ function remapCells(sheet, move) {
4073
+ if (!isArr(get(sheet, "cells"))) {
4074
+ return sheet;
4075
+ }
4076
+ const placed = [];
4077
+ for (const [address, cell] of entriesOf(sheet.cells)) {
4078
+ const parsed = parseAddress(address);
4079
+ if (parsed === null) {
4080
+ continue;
4081
+ }
4082
+ const to = move(parsed[0], parsed[1]);
4083
+ if (to !== null) {
4084
+ placed.push([to[1], to[0], letter(to[0]) + String(to[1]), cell]);
4085
+ }
4086
+ }
4087
+ placed.sort((x, y) => x[0] - y[0] || x[1] - y[1]);
4088
+ const cells = {};
4089
+ for (const [, , address, cell] of placed) {
4090
+ cells[address] = cell;
4091
+ }
4092
+ return { ...sheet, cells };
4093
+ }
4094
+ function remapMerges(sheet, axis, at2, delta) {
4095
+ if (!isArr(get(sheet, "mergedRegions"))) {
4096
+ return sheet;
4097
+ }
4098
+ const regions = [];
4099
+ for (const region of valuesOf(sheet.mergedRegions)) {
4100
+ const start = parseAddress(phpString(get(region, "start") ?? ""));
4101
+ const end = parseAddress(phpString(get(region, "end") ?? ""));
4102
+ if (start === null || end === null) {
4103
+ regions.push(region);
4104
+ continue;
4105
+ }
4106
+ if (axis === "row") {
4107
+ const moved = shiftSpan(start[1], end[1], at2, delta);
4108
+ if (moved === null) {
4109
+ continue;
4110
+ }
4111
+ regions.push({ start: letter(start[0]) + String(moved[0]), end: letter(end[0]) + String(moved[1]) });
4112
+ } else {
4113
+ const moved = shiftSpan(start[0] + 1, end[0] + 1, at2, delta);
4114
+ if (moved === null) {
4115
+ continue;
4116
+ }
4117
+ regions.push({ start: letter(moved[0] - 1) + String(start[1]), end: letter(moved[1] - 1) + String(end[1]) });
4118
+ }
4119
+ }
4120
+ return setOrUnset(sheet, "mergedRegions", regions, EMPTY);
4121
+ }
4122
+
4123
+ // src/ops/sheet-diff.ts
4124
+ var SheetDiff = {
4125
+ /** Above this many LCS cells (after trimming the common prefix and suffix), rows or columns are not aligned. */
4126
+ ALIGN_LIMIT: 25e4,
4127
+ diff(a, b) {
4128
+ if (SheetDiff.same(a, b) || SheetDiff.equivalent(a, b)) {
4129
+ return [];
4130
+ }
4131
+ const ops = granular(a, b);
4132
+ if (SheetDiff.same(SheetReducer.applyAll(a, ops), b)) {
4133
+ return ops;
4134
+ }
4135
+ return [{ type: "set_workbook", data: b }];
4136
+ },
4137
+ /**
4138
+ * Whether two schemas write the same workbook.
4139
+ *
4140
+ * Both are written and read back. `meta.created` is compared only when both
4141
+ * schemas name one: the writer stamps the current time on a schema that
4142
+ * does not, and that timestamp is not an edit.
4143
+ */
4144
+ equivalent(a, b) {
4145
+ let describedA = described(a);
4146
+ let describedB = described(b);
4147
+ if (!namesCreated(a) || !namesCreated(b)) {
4148
+ describedA = withoutCreated(describedA);
4149
+ describedB = withoutCreated(describedB);
4150
+ }
4151
+ return SheetDiff.same(describedA, describedB);
4152
+ },
4153
+ /**
4154
+ * Structural equality with map key order ignored and list order kept.
4155
+ *
4156
+ * Equality is on values, never on insertion order — a JS object cannot keep
4157
+ * the order of integer-like keys anyway (`columnWidths`). It follows PHP's
4158
+ * canonical form, where lists and maps are one array type: `{}` equals `[]`,
4159
+ * and an object keyed exactly "0".."n-1" equals the list of its values.
4160
+ *
4161
+ * Two divergences from the PHP `same()` cannot be removed, because the
4162
+ * distinctions do not exist in JS values:
4163
+ *
4164
+ * - PHP encodes the float `1.0` as `1.0` and the int `1` as `1`, so it calls
4165
+ * them different; JS has one number, so here they are the same. A PHP diff
4166
+ * can therefore emit a `set_cell` that rewrites `1` as `1.0` where this one
4167
+ * emits nothing. Parsed JSON gives PHP a float only for a literal written
4168
+ * with a fraction or exponent.
4169
+ * - PHP treats an int-keyed array whose keys are 0..n-1 but out of order (for
4170
+ * example `columnWidths` decoded from `{"1":140,"0":120}`) as a map, not a
4171
+ * list, and so as different from the same widths in order. A JS object has
4172
+ * no such order to compare.
4173
+ */
4174
+ same(a, b) {
4175
+ return canon(a) === canon(b);
4176
+ },
4177
+ /**
4178
+ * Hunks of a longest-common-subsequence alignment: [start in a, deleted, inserted].
4179
+ *
4180
+ * Ties break toward deleting first. Past `ALIGN_LIMIT` the changed middle is
4181
+ * one hunk.
4182
+ */
4183
+ hunks(a, b) {
4184
+ const n = a.length;
4185
+ const m = b.length;
4186
+ let prefix = 0;
4187
+ while (prefix < n && prefix < m && a[prefix] === b[prefix]) {
4188
+ prefix++;
4189
+ }
4190
+ let suffix = 0;
4191
+ while (suffix < n - prefix && suffix < m - prefix && a[n - 1 - suffix] === b[m - 1 - suffix]) {
4192
+ suffix++;
4193
+ }
4194
+ const midA = a.slice(prefix, n - suffix);
4195
+ const midB = b.slice(prefix, m - suffix);
4196
+ const rows = midA.length;
4197
+ const cols = midB.length;
4198
+ if (rows === 0 && cols === 0) {
4199
+ return [];
4200
+ }
4201
+ if (rows * cols > SheetDiff.ALIGN_LIMIT) {
4202
+ return [[prefix, rows, cols]];
4203
+ }
4204
+ const lengths = [];
4205
+ for (let i2 = 0; i2 <= rows; i2++) {
4206
+ lengths.push(new Int32Array(cols + 1));
4207
+ }
4208
+ for (let i2 = rows - 1; i2 >= 0; i2--) {
4209
+ for (let j2 = cols - 1; j2 >= 0; j2--) {
4210
+ lengths[i2][j2] = midA[i2] === midB[j2] ? lengths[i2 + 1][j2 + 1] + 1 : Math.max(lengths[i2 + 1][j2], lengths[i2][j2 + 1]);
4211
+ }
4212
+ }
4213
+ const hunks = [];
4214
+ let open = null;
4215
+ let i = 0;
4216
+ let j = 0;
4217
+ while (i < rows || j < cols) {
4218
+ if (i < rows && j < cols && midA[i] === midB[j]) {
4219
+ if (open !== null) {
4220
+ hunks.push(open);
4221
+ open = null;
4222
+ }
4223
+ i++;
4224
+ j++;
4225
+ continue;
4226
+ }
4227
+ open ?? (open = [prefix + i, 0, 0]);
4228
+ if (j >= cols || i < rows && lengths[i + 1][j] >= lengths[i][j + 1]) {
4229
+ open[1]++;
4230
+ i++;
4231
+ } else {
4232
+ open[2]++;
4233
+ j++;
4234
+ }
4235
+ }
4236
+ if (open !== null) {
4237
+ hunks.push(open);
4238
+ }
4239
+ return hunks;
4240
+ }
4241
+ };
4242
+ function namesCreated(schema) {
4243
+ return get(get(schema, "meta"), "created") !== null;
4244
+ }
4245
+ function withoutCreated(schema) {
4246
+ const out = { ...schema };
4247
+ if (isArr(out["meta"]) && !Array.isArray(out["meta"])) {
4248
+ const meta = { ...out["meta"] };
4249
+ delete meta["created"];
4250
+ out["meta"] = meta;
4251
+ }
4252
+ if (has(out, "meta") && isEmptyArr(out["meta"])) {
4253
+ delete out["meta"];
4254
+ }
4255
+ return out;
4256
+ }
4257
+ function described(schema) {
4258
+ return Agent.read(Agent.toBytes(schema));
4259
+ }
4260
+ function sheetName(sheet) {
4261
+ return phpString(get(sheet, "name") ?? "");
4262
+ }
4263
+ function granular(a, b) {
4264
+ const sheetsA = isArr(get(a, "sheets")) ? valuesOf(a.sheets) : [];
4265
+ const sheetsB = isArr(get(b, "sheets")) ? valuesOf(b.sheets) : [];
4266
+ const namesA = sheetsA.map(sheetName);
4267
+ const namesB = sheetsB.map(sheetName);
4268
+ if (new Set(namesA).size !== namesA.length || new Set(namesB).size !== namesB.length) {
4269
+ return [{ type: "set_workbook", data: b }];
4270
+ }
4271
+ const ops = [];
4272
+ if (!SheetDiff.same(get(a, "meta"), get(b, "meta"))) {
4273
+ ops.push({ type: "set_meta", meta: get(b, "meta") });
4274
+ }
4275
+ const removed = namesA.filter((name) => !namesB.includes(name));
4276
+ const added = namesB.filter((name) => !namesA.includes(name));
4277
+ const renames = /* @__PURE__ */ new Map();
4278
+ const renamedTo = (name) => [...renames.values()].includes(name);
4279
+ for (const old of removed) {
4280
+ for (const name of added) {
4281
+ if (renamedTo(name)) {
4282
+ continue;
4283
+ }
4284
+ if (SheetDiff.same(withoutName(sheetsA[namesA.indexOf(old)]), withoutName(sheetsB[namesB.indexOf(name)]))) {
4285
+ renames.set(old, name);
4286
+ break;
4287
+ }
4288
+ }
4289
+ }
4290
+ let unmatchedRemoved = removed.filter((name) => !renames.has(name));
4291
+ let unmatchedAdded = added.filter((name) => !renamedTo(name));
4292
+ if (unmatchedRemoved.length === 1 && unmatchedAdded.length === 1) {
4293
+ renames.set(unmatchedRemoved[0], unmatchedAdded[0]);
4294
+ unmatchedRemoved = [];
4295
+ unmatchedAdded = [];
4296
+ }
4297
+ for (const name of unmatchedRemoved) {
4298
+ ops.push({ type: "remove_sheet", sheet: name });
4299
+ }
4300
+ for (const name of namesA) {
4301
+ if (renames.has(name)) {
4302
+ ops.push({ type: "rename_sheet", sheet: name, name: renames.get(name) });
4303
+ }
4304
+ }
4305
+ namesB.forEach((name, index) => {
4306
+ if (unmatchedAdded.includes(name)) {
4307
+ ops.push({ type: "add_sheet", index, sheet: sheetsB[index] });
4308
+ }
4309
+ });
4310
+ let state = SheetReducer.applyAll(a, ops);
4311
+ namesB.forEach((name, index) => {
4312
+ const current = new Map(entriesOf(get(state, "sheets") ?? []).map(([key, sheet]) => [key, sheetName(sheet)]));
4313
+ if ((current.get(String(index)) ?? null) !== name) {
4314
+ const op = { type: "move_sheet", sheet: name, toIndex: index };
4315
+ ops.push(op);
4316
+ state = SheetReducer.apply(state, op);
4317
+ }
4318
+ });
4319
+ sheetsB.forEach((target, index) => {
4320
+ const current = state.sheets[index];
4321
+ if (SheetDiff.same(current, target)) {
4322
+ return;
4323
+ }
4324
+ for (const op of sheetOps(current, target)) {
4325
+ ops.push(op);
4326
+ }
4327
+ });
4328
+ return ops;
4329
+ }
4330
+ function sheetOps(sheet, target) {
4331
+ const name = phpString(target["name"]);
4332
+ const replace = [{ type: "replace_sheet", sheet: name, data: target }];
4333
+ if (!isCellForm(sheet) || !isCellForm(target)) {
4334
+ return replace;
4335
+ }
4336
+ const ops = [];
4337
+ let work = sheet;
4338
+ for (const axis of ["row", "col"]) {
4339
+ const structural = structuralOps(name, work, target, axis);
4340
+ for (const op of structural) {
4341
+ ops.push(op);
4342
+ work = applyToSheet(work, op);
4343
+ }
4344
+ }
4345
+ const cellsW = isArr(get(work, "cells")) ? work["cells"] : {};
4346
+ const cellsT = isArr(get(target, "cells")) ? target["cells"] : {};
4347
+ const addresses = [...new Set([...entriesOf(cellsW), ...entriesOf(cellsT)].map(([address]) => address))];
4348
+ for (const address of rowMajor(addresses)) {
4349
+ if (!has(cellsT, address)) {
4350
+ ops.push({ type: "clear_cell", sheet: name, address });
4351
+ continue;
4352
+ }
4353
+ const was = get(cellsW, address);
4354
+ if (was !== null && SheetDiff.same(was, cellsT[address])) {
4355
+ continue;
4356
+ }
4357
+ ops.push(setCellOp(name, address, isArr(was) ? was : null, isArr(cellsT[address]) ? cellsT[address] : {}));
4358
+ }
4359
+ if (!SheetDiff.same(get(work, "mergedRegions") ?? [], get(target, "mergedRegions") ?? [])) {
4360
+ ops.push({ type: "set_merged_regions", sheet: name, mergedRegions: get(target, "mergedRegions") ?? [] });
4361
+ }
4362
+ if (!SheetDiff.same(get(work, "columnWidths") ?? [], get(target, "columnWidths") ?? [])) {
4363
+ ops.push({ type: "set_column_widths", sheet: name, columnWidths: get(target, "columnWidths") ?? {} });
4364
+ }
4365
+ if (!identical(get(work, "frozenRows") ?? 0, get(target, "frozenRows") ?? 0) || !identical(get(work, "frozenCols") ?? 0, get(target, "frozenCols") ?? 0)) {
4366
+ ops.push({
4367
+ type: "set_frozen",
4368
+ sheet: name,
4369
+ rows: get(target, "frozenRows") ?? 0,
4370
+ cols: get(target, "frozenCols") ?? 0
4371
+ });
4372
+ }
4373
+ let result = sheet;
4374
+ for (const op of ops) {
4375
+ result = applyToSheet(result, op);
4376
+ }
4377
+ return SheetDiff.same(result, target) ? ops : replace;
4378
+ }
4379
+ function identical(x, y) {
4380
+ return isArr(x) || isArr(y) ? SheetDiff.same(x, y) : x === y;
4381
+ }
4382
+ function setCellOp(sheet, address, was, cell) {
4383
+ const op = { type: "set_cell", sheet, address };
4384
+ if (has(cell, "value")) {
4385
+ op["value"] = cell["value"];
4386
+ }
4387
+ for (const key of ["formula", "computedValue"]) {
4388
+ if (has(cell, key)) {
4389
+ op[key] = cell[key];
4390
+ }
4391
+ }
4392
+ for (const key of ["format", "comment"]) {
4393
+ if (!SheetDiff.same(get(was, key), get(cell, key))) {
4394
+ op[key] = get(cell, key);
4395
+ }
4396
+ }
4397
+ return op;
4398
+ }
4399
+ function structuralOps(name, sheet, target, axis) {
4400
+ const sheetLines = lines(sheet, axis);
4401
+ const targetLines = lines(target, axis);
4402
+ const ops = [];
4403
+ for (const [start, deleted, inserted] of SheetDiff.hunks(sheetLines, targetLines).reverse()) {
4404
+ const kept = Math.min(deleted, inserted);
4405
+ const at2 = start + kept + 1;
4406
+ if (deleted > inserted) {
4407
+ ops.push({ type: axis === "row" ? "delete_rows" : "delete_columns", sheet: name, at: at2, count: deleted - inserted });
4408
+ } else if (inserted > deleted && at2 <= sheetLines.length) {
4409
+ ops.push({ type: axis === "row" ? "insert_rows" : "insert_columns", sheet: name, at: at2, count: inserted - deleted });
4410
+ }
4411
+ }
4412
+ return ops;
4413
+ }
4414
+ function lines(sheet, axis) {
4415
+ const grouped = /* @__PURE__ */ new Map();
4416
+ let max = 0;
4417
+ for (const [address, cell] of entriesOf(isArr(get(sheet, "cells")) ? sheet["cells"] : {})) {
4418
+ const parsed = parseAddress(address);
4419
+ if (parsed === null) {
4420
+ continue;
4421
+ }
4422
+ const [col, row] = parsed;
4423
+ const line = axis === "row" ? row : col + 1;
4424
+ const position = axis === "row" ? col : row;
4425
+ let group = grouped.get(line);
4426
+ if (group === void 0) {
4427
+ group = /* @__PURE__ */ new Map();
4428
+ grouped.set(line, group);
4429
+ }
4430
+ group.set(position, cell);
4431
+ max = Math.max(max, line);
4432
+ }
4433
+ const out = [];
4434
+ for (let i = 1; i <= max; i++) {
4435
+ const positions = [...(grouped.get(i) ?? /* @__PURE__ */ new Map()).entries()].sort((x, y) => x[0] - y[0]);
4436
+ out.push(canonEntries(positions.map(([position, cell]) => [String(position), cell])));
4437
+ }
4438
+ return out;
4439
+ }
4440
+ function applyToSheet(sheet, op) {
4441
+ return SheetReducer.apply({ sheets: [sheet] }, op).sheets[0];
4442
+ }
4443
+ function isCellForm(sheet) {
4444
+ return entriesOf(sheet).every(([key]) => SheetReducer.CELL_FORM_KEYS.includes(key));
4445
+ }
4446
+ function withoutName(sheet) {
4447
+ const out = isArr(sheet) ? { ...sheet } : {};
4448
+ delete out["name"];
4449
+ return out;
4450
+ }
4451
+ function rowMajor(addresses) {
4452
+ const parsed = [];
4453
+ for (const address of addresses) {
4454
+ const cell = parseAddress(address);
4455
+ if (cell !== null) {
4456
+ parsed.push([cell[1], cell[0], address]);
4457
+ }
4458
+ }
4459
+ parsed.sort((x, y) => x[0] - y[0] || x[1] - y[1]);
4460
+ return parsed.map(([, , address]) => address);
4461
+ }
4462
+ function canon(value) {
4463
+ if (Array.isArray(value)) {
4464
+ return "[" + value.map(canon).join(",") + "]";
4465
+ }
4466
+ if (isArr(value)) {
4467
+ return canonEntries(entriesOf(value));
4468
+ }
4469
+ return JSON.stringify(value) ?? "null";
4470
+ }
4471
+ function canonEntries(entries) {
4472
+ if (entries.every(([key], i) => key === String(i))) {
4473
+ return "[" + entries.map(([, item]) => canon(item)).join(",") + "]";
4474
+ }
4475
+ const sorted = entries.slice().sort((x, y) => x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0);
4476
+ return "{" + sorted.map(([key, item]) => JSON.stringify(key) + ":" + canon(item)).join(",") + "}";
4477
+ }
4478
+
4479
+ // src/ops/sheet-op-schema.ts
4480
+ var SheetOpSchema = {
4481
+ /** Every op type, in the order the variants are listed. */
4482
+ TYPES: [
4483
+ "set_cell",
4484
+ "set_range",
4485
+ "set_workbook",
4486
+ "clear_cell",
4487
+ "insert_rows",
4488
+ "delete_rows",
4489
+ "insert_columns",
4490
+ "delete_columns",
4491
+ "add_sheet",
4492
+ "remove_sheet",
4493
+ "rename_sheet",
4494
+ "move_sheet",
4495
+ "replace_sheet",
4496
+ "set_merged_regions",
4497
+ "set_column_widths",
4498
+ "set_frozen",
4499
+ "set_meta"
4500
+ ],
4501
+ /** A fresh object on every call, so a caller editing it changes nothing here. */
4502
+ jsonSchema() {
4503
+ const address = () => ({ type: "string", pattern: "^[A-Za-z]+[0-9]+$" });
4504
+ const sheet = () => ({ type: "string", minLength: 1 });
4505
+ const count = () => ({ type: "integer", minimum: 1 });
4506
+ const position = () => ({ type: "integer", minimum: 1 });
4507
+ const object = () => ({ type: "object" });
4508
+ const nullableObject = () => ({ type: ["object", "null"] });
4509
+ const value = () => ({ type: ["string", "number", "boolean", "null"] });
4510
+ const variants = [
4511
+ variant(
4512
+ "set_cell",
4513
+ { sheet: sheet(), address: address(), value: value(), formula: { type: "string" }, computedValue: value(), format: nullableObject(), comment: nullableObject() },
4514
+ ["sheet", "address"],
4515
+ "Write one cell. Omitting formula or computedValue clears it; omitting format or comment keeps it; null clears it."
4516
+ ),
4517
+ variant(
4518
+ "set_range",
4519
+ { sheet: sheet(), start: address(), end: address(), values: { type: "array", items: { type: "array", items: value() } } },
4520
+ ["sheet", "start", "values"],
4521
+ "Write a block of values row-major from start, each as a set_cell with no formula."
4522
+ ),
4523
+ variant("set_workbook", { data: object() }, ["data"], "Replace the whole workbook schema."),
4524
+ variant("clear_cell", { sheet: sheet(), address: address() }, ["sheet", "address"], "Remove one cell."),
4525
+ variant("insert_rows", { sheet: sheet(), at: position(), count: count() }, ["sheet", "at", "count"], "Insert rows before 1-based row `at`; cells, merges below move down."),
4526
+ variant("delete_rows", { sheet: sheet(), at: position(), count: count() }, ["sheet", "at", "count"], "Delete rows starting at 1-based row `at`; cells, merges below move up."),
4527
+ variant("insert_columns", { sheet: sheet(), at: position(), count: count() }, ["sheet", "at", "count"], "Insert columns before 1-based column `at` (A = 1); cells, merges and widths move right."),
4528
+ variant("delete_columns", { sheet: sheet(), at: position(), count: count() }, ["sheet", "at", "count"], "Delete columns starting at 1-based column `at`; cells, merges and widths move left."),
4529
+ variant("add_sheet", { index: { type: "integer", minimum: 0 }, sheet: object() }, ["index", "sheet"], "Insert a sheet at a 0-based position."),
4530
+ variant("remove_sheet", { sheet: sheet() }, ["sheet"], "Remove a sheet by name."),
4531
+ variant("rename_sheet", { sheet: sheet(), name: sheet() }, ["sheet", "name"], "Rename a sheet."),
4532
+ variant("move_sheet", { sheet: sheet(), toIndex: { type: "integer", minimum: 0 } }, ["sheet", "toIndex"], "Move a sheet to a 0-based position."),
4533
+ variant("replace_sheet", { sheet: sheet(), data: object() }, ["sheet", "data"], "Replace one sheet whole."),
4534
+ variant(
4535
+ "set_merged_regions",
4536
+ { sheet: sheet(), mergedRegions: { type: "array", items: { type: "object", required: ["start", "end"], properties: { start: address(), end: address() } } } },
4537
+ ["sheet", "mergedRegions"],
4538
+ "Set every merged region of a sheet."
4539
+ ),
4540
+ variant("set_column_widths", { sheet: sheet(), columnWidths: object() }, ["sheet", "columnWidths"], "Set every column width of a sheet (0-based column index to pixels)."),
4541
+ variant(
4542
+ "set_frozen",
4543
+ { sheet: sheet(), rows: { type: "integer", minimum: 0 }, cols: { type: "integer", minimum: 0 } },
4544
+ ["sheet", "rows", "cols"],
4545
+ "Set frozen rows and columns."
4546
+ ),
4547
+ variant("set_meta", { meta: nullableObject() }, ["meta"], "Replace the workbook meta, or remove it with null.")
4548
+ ];
4549
+ return {
4550
+ $schema: "http://json-schema.org/draft-07/schema#",
4551
+ title: "Holy Sheet op",
4552
+ description: "One op from Agent::diff, applied by Agent::reduce.",
4553
+ oneOf: variants
4554
+ };
4555
+ }
4556
+ };
4557
+ function variant(type, properties, required, description) {
4558
+ return {
4559
+ type: "object",
4560
+ description,
4561
+ required: ["type", ...required],
4562
+ additionalProperties: false,
4563
+ properties: { type: { const: type }, ...properties }
4564
+ };
4565
+ }
4566
+
3754
4567
  // src/holy-sheet.schema.json
3755
4568
  var holy_sheet_schema_default = {
3756
4569
  $schema: "http://json-schema.org/draft-07/schema#",
@@ -4115,7 +4928,7 @@ var holy_sheet_schema_default = {
4115
4928
  };
4116
4929
 
4117
4930
  // src/agent.ts
4118
- var VERSION = "2.3.0";
4931
+ var VERSION = "2.4.0";
4119
4932
  function toU8(input) {
4120
4933
  return input instanceof Uint8Array ? input : new Uint8Array(input);
4121
4934
  }
@@ -4168,8 +4981,8 @@ var Agent = {
4168
4981
  return new Validator().validateAndRepair(schema);
4169
4982
  },
4170
4983
  /** Build a schema from a flat array of rows (+ optional headers). */
4171
- fromArray(rows, headers = null, sheetName = "Sheet 1", options = {}) {
4172
- return ArrayBuilder.build(rows, headers, sheetName, options);
4984
+ fromArray(rows, headers = null, sheetName2 = "Sheet 1", options = {}) {
4985
+ return ArrayBuilder.build(rows, headers, sheetName2, options);
4173
4986
  },
4174
4987
  /** Build a schema from CSV content (read files yourself in Node). */
4175
4988
  fromCsv(csv, options = {}) {
@@ -4179,10 +4992,55 @@ var Agent = {
4179
4992
  lint(schema) {
4180
4993
  return new FormulaLinter().lint(schema);
4181
4994
  },
4995
+ /**
4996
+ * The ops that turn schema `a` into schema `b`. Mirrors PHP `Agent::diff`, and
4997
+ * gives the same ops in the same order for the same inputs.
4998
+ *
4999
+ * - `reduce(a, diff(a, b))` equals `b` (key order aside).
5000
+ * - Schemas that write the same workbook diff to `[]`, so
5001
+ * `diff(s, read(toBytes(s)))` is `[]`: a save without a change records
5002
+ * nothing.
5003
+ * - One changed cell is one `set_cell`; an inserted row is one
5004
+ * `insert_rows` plus its cells.
5005
+ *
5006
+ * Store `diff(newer, older)` to keep a version as the ops that restore it.
5007
+ * Both schemas must be valid: the "same workbook" check writes them.
5008
+ */
5009
+ diff(a, b) {
5010
+ return SheetDiff.diff(a, b);
5011
+ },
5012
+ /**
5013
+ * Apply one op, or a list of them, to a schema; returns a new schema and never
5014
+ * modifies the input. An op naming a sheet that is not there is skipped.
5015
+ */
5016
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5017
+ reduce(schema, opOrOps) {
5018
+ return SheetReducer.applyAll(schema, isOpList(opOrOps) ? Object.values(opOrOps) : [opOrOps]);
5019
+ },
5020
+ /**
5021
+ * JSON Schema for one op. `set_cell`, `set_range` and `set_workbook` are
5022
+ * fancy-sheets' `SheetOp` shapes.
5023
+ */
5024
+ opSchema() {
5025
+ return SheetOpSchema.jsonSchema();
5026
+ },
5027
+ /**
5028
+ * Whether two schemas write the same workbook: a columns/rows sheet and the
5029
+ * cell map it becomes are equivalent, and so are a schema without
5030
+ * `meta.created` and its written copy.
5031
+ */
5032
+ equivalent(a, b) {
5033
+ return SheetDiff.equivalent(a, b);
5034
+ },
4182
5035
  version() {
4183
5036
  return VERSION;
4184
5037
  }
4185
5038
  };
5039
+ function isOpList(opOrOps) {
5040
+ if (Array.isArray(opOrOps)) return true;
5041
+ if (typeof opOrOps !== "object" || opOrOps === null) return false;
5042
+ return Object.keys(opOrOps).every((key, i) => key === String(i));
5043
+ }
4186
5044
 
4187
5045
  // src/holy-sheet.ts
4188
5046
  var HolySheet = class {
@@ -4207,8 +5065,8 @@ var HolySheet = class {
4207
5065
  validateAndRepair(schema) {
4208
5066
  return Agent.validateAndRepair(schema);
4209
5067
  }
4210
- fromArray(rows, headers = null, sheetName = "Sheet 1", options = {}) {
4211
- return Agent.fromArray(rows, headers, sheetName, options);
5068
+ fromArray(rows, headers = null, sheetName2 = "Sheet 1", options = {}) {
5069
+ return Agent.fromArray(rows, headers, sheetName2, options);
4212
5070
  }
4213
5071
  fromCsv(csv, options = {}) {
4214
5072
  return Agent.fromCsv(csv, options);
@@ -4237,6 +5095,9 @@ exports.Normalizer = Normalizer;
4237
5095
  exports.OdsReader = OdsReader;
4238
5096
  exports.Repairer = Repairer;
4239
5097
  exports.SchemaException = SchemaException;
5098
+ exports.SheetDiff = SheetDiff;
5099
+ exports.SheetOpSchema = SheetOpSchema;
5100
+ exports.SheetReducer = SheetReducer;
4240
5101
  exports.Theme = Theme;
4241
5102
  exports.UnsupportedFormatException = UnsupportedFormatException;
4242
5103
  exports.VERSION = VERSION;