@particle-academy/holy-sheet 2.4.3 → 2.6.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/CHANGELOG.md +550 -0
- package/README.md +3 -3
- package/dist/index.cjs +145 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -2
- package/dist/index.d.ts +18 -2
- package/dist/index.js +145 -62
- package/dist/index.js.map +1 -1
- package/package.json +67 -66
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@particle-academy/holy-sheet` are documented here.
|
|
4
|
+
(The repository is `holy-sheet-js`; the published package has never carried the
|
|
5
|
+
`-js` suffix, and this line used to say it did — a name a consumer would have
|
|
6
|
+
typed into `npm install` and got a 404 for.)
|
|
7
|
+
|
|
8
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
9
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
10
|
+
|
|
11
|
+
## [Unreleased]
|
|
12
|
+
|
|
13
|
+
## [2.6.0] — 2026-10-11
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **A cell value that cannot be represented is REFUSED and names the cell,
|
|
18
|
+
instead of being silently stringified. In this runtime the old behaviour wrote
|
|
19
|
+
`"x"` for the cell `["x"]` — the corruption looked correct.** Mirrors the PHP
|
|
20
|
+
reference fix in `particle-academy/holy-sheet` 2.5.0 ([holy-sheet#9], reported
|
|
21
|
+
from MOIC production); tracked here as [#2].
|
|
22
|
+
|
|
23
|
+
`coerceValue()` ended with "not a primitive, so it must be safe to stringify":
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") {
|
|
27
|
+
return String(v); // an ARRAY reaches this line
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
An array gets there whenever a plain `rows` form is nested one level too
|
|
32
|
+
deep — `rows: [[["x"]]]` instead of `rows: [["x"]]`. No object-cell syntax is
|
|
33
|
+
needed, which is exactly the shape an AI-generated edit produces.
|
|
34
|
+
|
|
35
|
+
**Why this port's version was worse than PHP's.** `Array.prototype.toString`
|
|
36
|
+
joins, so a one-level nesting produced *exactly what the author meant*:
|
|
37
|
+
|
|
38
|
+
| cell | before | PHP before its fix |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `["x"]` | `"x"` | `"Array"` |
|
|
41
|
+
| `["x","y"]` | `"x,y"` | `"Array"` |
|
|
42
|
+
|
|
43
|
+
PHP wrote visible garbage, which someone files a bug about. This wrote a
|
|
44
|
+
workbook that was silently, undetectably correct-looking — and for a
|
|
45
|
+
two-element row it merged two cells into one with the data intact enough to
|
|
46
|
+
look deliberate. A wrong answer that looks right is the expensive kind.
|
|
47
|
+
|
|
48
|
+
The guard now accepts an object that defines its own `toString` (PHP's
|
|
49
|
+
`Stringable`) and refuses everything else, with **two exclusions that are both
|
|
50
|
+
load-bearing**: a function, because `Function.prototype.toString` returns the
|
|
51
|
+
function's SOURCE TEXT — the old code wrote a code listing into the cell — and
|
|
52
|
+
an array, because `Array.prototype.toString` is *also* "its own", so "has a
|
|
53
|
+
custom toString" is true of the one value that must never pass. A symbol is
|
|
54
|
+
refused too, where it previously wrote `"Symbol(x)"`.
|
|
55
|
+
|
|
56
|
+
The refusal is a `SchemaException` whose structured error names the cell as
|
|
57
|
+
`Sheet!A1`, with a hint explaining the nesting mistake. That differs from the
|
|
58
|
+
`sheets[0].rows[1]` schema path used elsewhere, deliberately: this fault is
|
|
59
|
+
always one cell, and the A1 location is what an operator can act on.
|
|
60
|
+
|
|
61
|
+
- **A `Date`, or any object with a `toString`, in a plain `rows` row no longer
|
|
62
|
+
writes an EMPTY CELL.** Found while fixing the above, and it is the more
|
|
63
|
+
ordinary bug of the two.
|
|
64
|
+
|
|
65
|
+
The cell-spec dispatcher tested `isPlainObject(value)` — `typeof === "object"
|
|
66
|
+
&& !null && !Array.isArray` — which is true for **every** non-array object. So
|
|
67
|
+
a `Date`, a class instance and a `Map` were all read as the `{value, format}`
|
|
68
|
+
cell-spec form, `value.value` was `undefined`, and the cell came out blank: no
|
|
69
|
+
error, no validation failure, just a hole where the date should be. PHP's
|
|
70
|
+
equivalent guard is `is_array && !array_is_list`, and a PHP
|
|
71
|
+
`DateTimeInterface` is an object rather than an array, so it fell straight
|
|
72
|
+
through to the date branch and wrote the serial. **The pair's contract is
|
|
73
|
+
"same bytes either backend", and the byte-parity suite never saw this because
|
|
74
|
+
its fixtures carry ISO date *strings* rather than `Date` objects.**
|
|
75
|
+
|
|
76
|
+
The dispatcher now claims only a plain object literal — which is also what
|
|
77
|
+
`JSON.parse` produces for every schema arriving as JSON — and anything with
|
|
78
|
+
its own prototype is treated as a value.
|
|
79
|
+
|
|
80
|
+
- **`validate()` no longer passes a schema that `normalize()` will throw on.**
|
|
81
|
+
The same input returned **zero** validation errors, so `Agent.validate()` told
|
|
82
|
+
a caller the workbook was fine and the write then failed — moving the failure
|
|
83
|
+
to where it is hardest to attribute, in the exact validate-then-write order an
|
|
84
|
+
agent uses. `Validator` now reports `sheets[0].rows[0][0]` for an array cell.
|
|
85
|
+
|
|
86
|
+
The check is `Array.isArray`, **not** "is an object": the object cell form
|
|
87
|
+
`{value, format}` has always been legal, and rejecting any object would have
|
|
88
|
+
traded this bug for a far worse one — every formatted cell in every workbook.
|
|
89
|
+
There is a test holding that line.
|
|
90
|
+
|
|
91
|
+
**What you must do:** nothing, if your cells were already scalars, dates or
|
|
92
|
+
cell objects. If any schema you build can nest a row one level too deep, you
|
|
93
|
+
now get a `SchemaException` naming the cell where you previously got a
|
|
94
|
+
workbook with the joined value in it — read `getErrors()[0].path` for the
|
|
95
|
+
address. Released as a MINOR rather than a patch because that throw is
|
|
96
|
+
observable to a caller whose input the validator previously accepted.
|
|
97
|
+
|
|
98
|
+
- **`CHANGELOG.md` is now in the published tarball.** `files` did not whitelist it, so npm never shipped it: a consumer who followed a link to the changelog — from the README, from npm, or from an upgrade guide — found nothing. Nothing for you to do; the file simply arrives from this release on.
|
|
99
|
+
- Dropped `docs` from `files`: it was declared and never existed, so the package claimed to ship it and did not.
|
|
100
|
+
|
|
101
|
+
### Security
|
|
102
|
+
|
|
103
|
+
- `source-map-js` is pinned forward to `^1.2.2` via `overrides`. Versions up to
|
|
104
|
+
1.2.1 allow an event-loop denial of service through indexed source-map section
|
|
105
|
+
offsets, and it arrives here transitively through the build toolchain.
|
|
106
|
+
**Nothing for a consumer to do, and no runtime change**: an npm package does
|
|
107
|
+
not ship a lockfile, so this governs builds OF this repo, not anything
|
|
108
|
+
installed FROM it. Recorded rather than left silent because the override it
|
|
109
|
+
sits beside — `shell-quote` `^1.9.0`, added for an earlier advisory — was
|
|
110
|
+
carried with no note of why, and had drifted back inside the vulnerable range
|
|
111
|
+
before anyone looked.
|
|
112
|
+
|
|
113
|
+
[#2]: https://github.com/Particle-Academy/holy-sheet-js/issues/2
|
|
114
|
+
[holy-sheet#9]: https://github.com/Particle-Academy/holy-sheet/issues/9
|
|
115
|
+
|
|
116
|
+
## [2.5.0] — 2026-10-02
|
|
117
|
+
|
|
118
|
+
### Fixed
|
|
119
|
+
|
|
120
|
+
- **A column's own `width` is honoured. It was documented and silently ignored.**
|
|
121
|
+
|
|
122
|
+
`skills/holy-sheet.schema.json` — the tool definition handed to an LLM, shared
|
|
123
|
+
byte-identically with the PHP and Python ports under a checksum test —
|
|
124
|
+
describes `columns[].width` as *"Column width in pixels. Same as columnWidths
|
|
125
|
+
but per-column."* Only the sheet-level `columnWidths` map was ever read, so a
|
|
126
|
+
width written the way the schema documents it emitted **no `<cols>` element at
|
|
127
|
+
all**: no exception, no validation error, and nothing from `validateAndRepair`.
|
|
128
|
+
Silent at every layer.
|
|
129
|
+
|
|
130
|
+
Reported against the PHP package as **holy-sheet#8** by the MOIC team, whose
|
|
131
|
+
owner's complaint was "can't style spreadsheets at all". Measured, that was
|
|
132
|
+
mostly this: the theme, the header fill, the banded rows and the currency
|
|
133
|
+
formats all landed, but a real account name truncated and a correctly-formatted
|
|
134
|
+
currency rendered as `#####` — which is what a reader actually sees. Fixed in
|
|
135
|
+
PHP 2.4.0; this is the matching twin.
|
|
136
|
+
|
|
137
|
+
**Precedence, identical to PHP: the sheet-level `columnWidths` map is applied
|
|
138
|
+
LAST and wins.** It is the mechanism that already worked, so a consumer who
|
|
139
|
+
moved to it to route around this bug must not then find a leftover `width`
|
|
140
|
+
quietly overriding them. The two sources merge rather than replace, and `<col>`
|
|
141
|
+
elements are emitted in ascending column order — merging two sources means
|
|
142
|
+
insertion order is no longer column order.
|
|
143
|
+
|
|
144
|
+
A `width` now goes through the same `ColumnWidths` rule the map already obeyed,
|
|
145
|
+
so `"abc"` or `-5` is rejected rather than written, and **`validate()` reports
|
|
146
|
+
it at `sheets[0].columns[0].width`**. That part matters as much as the fix: the
|
|
147
|
+
original defect's real cost was that an agent composes against the schema doc,
|
|
148
|
+
gets no error, and never learns the field was dropped.
|
|
149
|
+
|
|
150
|
+
**What you must do: nothing, and do not migrate off `columnWidths`.** If you
|
|
151
|
+
set `width` and saw no effect, it now works. If you moved to `columnWidths`, it
|
|
152
|
+
still wins. Output bytes change for anyone who had `width` set — which is why
|
|
153
|
+
this is a minor rather than a patch.
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
## [2.4.3] — 2026-09-14
|
|
157
|
+
|
|
158
|
+
### Fixed
|
|
159
|
+
|
|
160
|
+
- **A `columnWidths` key that is not a column index wrote a `NaN` column.** The
|
|
161
|
+
normalizer read keys with `parseInt`, so `{"abc": 999}` became a width for
|
|
162
|
+
column `NaN` in the written file. PHP overwrote column A with it and Python
|
|
163
|
+
raised; all three follow one rule now, mirroring PHP holy-sheet 2.3.4
|
|
164
|
+
(`src/schema/column-widths.ts`):
|
|
165
|
+
- a **key** is a 0-based column index from 0 to 16383, as a string of digits;
|
|
166
|
+
- a **width** is a non-negative finite number, or a string of digits (`"80.5"`).
|
|
167
|
+
- `validate()` reports each other entry by path (`sheets[0].columnWidths.abc`),
|
|
168
|
+
so `write()` and `toBytes()` refuse it.
|
|
169
|
+
- `validateAndRepair()` turns a one- or two-letter key into its index (`"B"` is
|
|
170
|
+
1) and drops an entry it cannot repair, and lists both.
|
|
171
|
+
- The normalizer skips such an entry.
|
|
172
|
+
|
|
173
|
+
**What you must do:** nothing, unless a schema carried a width keyed by a letter
|
|
174
|
+
or by junk. `write()` now refuses it; run it through `validateAndRepair()`, or
|
|
175
|
+
key widths by index.
|
|
176
|
+
|
|
177
|
+
## [2.4.2] — 2026-09-14
|
|
178
|
+
|
|
179
|
+
### Fixed
|
|
180
|
+
|
|
181
|
+
Six defects in the op code, mirroring `particle-academy/holy-sheet` 2.3.2 (five,
|
|
182
|
+
found by the Python port) and 2.3.3 (one), which are the reference. Each has a
|
|
183
|
+
test ported from PHP's `SheetOpsTest.php`, and each fails against 2.4.1 except
|
|
184
|
+
the second, which this port never had.
|
|
185
|
+
|
|
186
|
+
- **`Agent.opSchema()` rejected a `set_column_widths` op carrying a list.** 2.4.1
|
|
187
|
+
allowed only an empty array, but PHP encodes widths keyed 0..n-1 as a list
|
|
188
|
+
(`[120, 80, 140]`), and a schema read from PHP's JSON carries that list through
|
|
189
|
+
this port's `diff()` into its op. `columnWidths` is now an object or an array
|
|
190
|
+
of non-negative numbers, a list indexed by position. The schema matches PHP
|
|
191
|
+
2.3.2's byte for byte, description included.
|
|
192
|
+
- **A `type` that is not an op type is skipped before anything else.** PHP's
|
|
193
|
+
loose `switch` let `type: true` remove a sheet. This port's `switch` was
|
|
194
|
+
always strict, so it never had that defect; `reduce()` now checks the type
|
|
195
|
+
against `SheetOpSchema.TYPES` first, as PHP does.
|
|
196
|
+
- **A padded address wrote a key of its own.** `set_cell` stored `" a1 "` under
|
|
197
|
+
`" A1 "`, and `clear_cell` could not reach A1 with it. Both now trim with PHP's
|
|
198
|
+
`trim()` set (space, tab, LF, CR, NUL, vertical tab; not NBSP) before
|
|
199
|
+
upper-casing.
|
|
200
|
+
- **A column-width key that is not a column index was read as column A** by
|
|
201
|
+
`insert_columns` and `delete_columns` (`"abc"` became column 0), and could
|
|
202
|
+
overwrite column A's width. It is dropped. A digit string (`"007"`) and a
|
|
203
|
+
negative integer key are still indexes, as they are in PHP.
|
|
204
|
+
- **Two values JSON cannot hold compared as the same.** `SheetDiff.same()`, and
|
|
205
|
+
so `diff()`, wrote `NaN`, `Infinity`, functions and symbols as `null`, so a
|
|
206
|
+
cell going from `NaN` to `Infinity` recorded no change. It now throws a
|
|
207
|
+
`TypeError` for a non-finite number, a lone UTF-16 surrogate in a string or a
|
|
208
|
+
key (a JS string cannot hold invalid UTF-8, and this is its equivalent), a
|
|
209
|
+
function or a symbol. Nesting deeper than PHP's `json_encode` depth of 4096
|
|
210
|
+
throws too, at the same level PHP does (every object and array counts, an
|
|
211
|
+
empty one included). The comparison is now a loop: the recursion it replaces
|
|
212
|
+
overflowed the call stack near that depth, at a level that varied from run to
|
|
213
|
+
run.
|
|
214
|
+
- **An op with a position or count that is not a number moved or unfroze
|
|
215
|
+
things** (PHP 2.3.3). `add_sheet.index`, `move_sheet.toIndex`,
|
|
216
|
+
`set_frozen.rows`/`cols` and the row and column ops' `at`/`count` were cast as
|
|
217
|
+
PHP's `(int)` does, so `toIndex: "last"` moved a sheet to the front,
|
|
218
|
+
`index: "end"` inserted one there, and `rows: "one"` unfroze the panes. A
|
|
219
|
+
present value that is not an integer or a string of digits, `null` included,
|
|
220
|
+
now skips the op; absent keys keep their defaults (`add_sheet` appends,
|
|
221
|
+
`move_sheet` stays, `set_frozen` uses 0). As in PHP, the check covers every op
|
|
222
|
+
that reaches a sheet, so a junk `count` on a `set_cell` skips it too. One
|
|
223
|
+
divergence cannot be removed: `JSON.parse` gives `2` for `2.0`, so a position
|
|
224
|
+
written `2.0` applies here, where PHP decodes a float and skips the op.
|
|
225
|
+
|
|
226
|
+
`tests/sheet-ops-parity.test.ts` gains PHP-checked reduce cases for each fix,
|
|
227
|
+
diff cases for a list of widths, a padded cell key and a width key that is not
|
|
228
|
+
an index, and `SheetDiff.same` at 4095 to 4097 levels, which the PHP helper
|
|
229
|
+
builds itself so the nesting never crosses JSON.
|
|
230
|
+
|
|
231
|
+
**What you must do:** nothing, unless you relied on one of the above. `diff()`
|
|
232
|
+
output changes only for a schema holding a padded cell key or a width key that
|
|
233
|
+
is not an index, where it matches PHP 2.3.3's, and `diff()` now throws on a
|
|
234
|
+
schema holding a value JSON cannot, which JSON input never does.
|
|
235
|
+
|
|
236
|
+
## [2.4.1] — 2026-09-14
|
|
237
|
+
|
|
238
|
+
### Fixed
|
|
239
|
+
|
|
240
|
+
- **`Agent.opSchema()` accepts a `set_column_widths` op with an empty array**, as
|
|
241
|
+
PHP 2.3.1 now declares. PHP's diff emits `columnWidths: []` when every width is
|
|
242
|
+
removed (its JSON for an empty map), which the schema rejected; an op stream
|
|
243
|
+
written by the PHP package failed validation here. `columnWidths` may be an
|
|
244
|
+
object or an empty array. The parity suite compares the schema with PHP's
|
|
245
|
+
byte for byte, so the two stay one schema.
|
|
246
|
+
|
|
247
|
+
**What you must do:** nothing.
|
|
248
|
+
|
|
249
|
+
## [2.4.0] — 2026-09-14
|
|
250
|
+
|
|
251
|
+
### Added
|
|
252
|
+
|
|
253
|
+
- **`Agent.diff()`, `Agent.reduce()`, `Agent.opSchema()` and `Agent.equivalent()`:
|
|
254
|
+
a workbook's versions stored as ops** (holy-sheet [#7](https://github.com/Particle-Academy/holy-sheet/issues/7)).
|
|
255
|
+
Ported from `particle-academy/holy-sheet` 2.3.0, which is the reference: the
|
|
256
|
+
same algorithm, so the same two schemas give the same ops in the same order
|
|
257
|
+
in both runtimes, and an op history written by one replays in the other.
|
|
258
|
+
Hashing xlsx bytes cannot keep a one-cell edit small, because a zip changes
|
|
259
|
+
nearly every byte, so a version history had to store a whole file per edit.
|
|
260
|
+
`diff(newer, older)` is the op list that restores `older` from `newer`.
|
|
261
|
+
- `reduce(a, diff(a, b))` equals `b`, key order aside. The ops are verified
|
|
262
|
+
by replaying them: a sheet the granular ops cannot reproduce is replaced
|
|
263
|
+
whole, and so, as a last resort, is the workbook.
|
|
264
|
+
- One changed cell is one `set_cell`. Rows and columns are aligned by content
|
|
265
|
+
first, so an inserted row is one `insert_rows` plus its cells rather than
|
|
266
|
+
every cell below it rewritten.
|
|
267
|
+
- Schemas that write the same workbook diff to `[]`, so a save without a change
|
|
268
|
+
records nothing. A columns/rows sheet and the cells it becomes are the same,
|
|
269
|
+
and so is the creation time the writer stamps on a schema that names none.
|
|
270
|
+
- `set_cell`, `set_range` and `set_workbook` are fancy-sheets' `SheetOp` shapes
|
|
271
|
+
and behave as its reducer does (a `set_cell` without a formula clears it and
|
|
272
|
+
keeps the format). The rest are holy-sheet's: `clear_cell`,
|
|
273
|
+
`insert_rows`/`delete_rows`, `insert_columns`/`delete_columns`,
|
|
274
|
+
`add_sheet`/`remove_sheet`/`rename_sheet`/`move_sheet`/`replace_sheet`,
|
|
275
|
+
`set_merged_regions`, `set_column_widths`, `set_frozen` and `set_meta`.
|
|
276
|
+
- Row and column ops move cells, merged regions and column widths. They do not
|
|
277
|
+
rewrite formula text; a formula that changes with an insert is its own
|
|
278
|
+
`set_cell`.
|
|
279
|
+
- **`SheetOp`**, a discriminated union on `type` (and one interface per op),
|
|
280
|
+
plus **`SheetDiff`**, **`SheetReducer`** and **`SheetOpSchema`**, exported
|
|
281
|
+
beside the other building blocks.
|
|
282
|
+
|
|
283
|
+
`tests/sheet-ops-parity.test.ts` runs the PHP package's `Agent::diff` and
|
|
284
|
+
`Agent::reduce` on every case and requires the same ops, field for field and in
|
|
285
|
+
order: each edit PHP's own suite pins, both ways; seeded random cell edits;
|
|
286
|
+
seeded row and column inserts and deletes; moved rows and raw alignments, where
|
|
287
|
+
the delete-first tie-break decides the result; and the op JSON Schema, byte for
|
|
288
|
+
byte. Two differences cannot be removed, because JS values do not carry the
|
|
289
|
+
distinction:
|
|
290
|
+
|
|
291
|
+
- PHP counts `1` and `1.0` as different values, so its diff rewrites a `1` as
|
|
292
|
+
`1.0` with a `set_cell` where this one records nothing.
|
|
293
|
+
- An emptied column-width map is emitted as `{}`, where PHP's JSON writes its
|
|
294
|
+
empty array as `[]`. Both reducers read either as empty.
|
|
295
|
+
|
|
296
|
+
**What you must do:** nothing. This only adds methods.
|
|
297
|
+
|
|
298
|
+
## [2.3.1] — 2026-09-14
|
|
299
|
+
|
|
300
|
+
### Fixed
|
|
301
|
+
|
|
302
|
+
- **`Agent.lint()` accepts quoted sheet names** (holy-sheet [#6](https://github.com/Particle-Academy/holy-sheet/issues/6)).
|
|
303
|
+
`=SUM('My Earnings Projection'!A2:A3)` linted as `#NAME?` because the
|
|
304
|
+
tokenizer had no case for `'`, so any cross-sheet formula pointing at a sheet
|
|
305
|
+
whose name contains a space failed. Excel's doubled-quote escape works too:
|
|
306
|
+
`'Q3 ''Final'''!B2` is the sheet `Q3 'Final'`.
|
|
307
|
+
- **A reference to a sheet that does not exist is `#REF!`**, quoted or not, and
|
|
308
|
+
the hint names the sheet and lists the ones that exist. It used to lint clean,
|
|
309
|
+
because the missing sheet's cells read as blanks.
|
|
310
|
+
- **Sheet names match case-insensitively**, as in Excel.
|
|
311
|
+
|
|
312
|
+
The same fix as `particle-academy/holy-sheet` 2.2.1, with the hint text
|
|
313
|
+
identical in all three runtimes and pinned by a test in each.
|
|
314
|
+
|
|
315
|
+
**What you must do:** nothing, unless you relied on a formula that names a
|
|
316
|
+
missing sheet linting clean. It now reports `#REF!`.
|
|
317
|
+
|
|
318
|
+
## [2.3.0] — 2026-09-13
|
|
319
|
+
|
|
320
|
+
### Added
|
|
321
|
+
|
|
322
|
+
- **`Agent.read()` and `Agent.describe()` read OpenDocument spreadsheets
|
|
323
|
+
(`.ods`) into the same schema as `.xlsx`.** Ported from
|
|
324
|
+
`particle-academy/holy-sheet`'s new reader, which has the full list of what
|
|
325
|
+
maps (values, formulas translated to A1, repeats, merges, comments, styles,
|
|
326
|
+
data styles, metadata) and what does not (frozen panes, column widths,
|
|
327
|
+
fonts, function-name translation). The format is sniffed from the bytes, so
|
|
328
|
+
the caller's branch on the file type can go.
|
|
329
|
+
|
|
330
|
+
Diffed against the PHP reader on the PHP repo's own fixtures, key order
|
|
331
|
+
included, in `tests/ods-reader-parity.test.ts`: a LibreOffice-converted
|
|
332
|
+
workbook, a hand-written native file, and a hand-built package of what
|
|
333
|
+
LibreOffice rewrites on save. `tests/ported-php/OdsReaderTest.test.ts` ports
|
|
334
|
+
the PHP assertions. Both load the fixtures from the PHP checkout
|
|
335
|
+
(`HOLY_SHEET_PHP_SRC`, or the sibling directory), as the parity suites
|
|
336
|
+
already did.
|
|
337
|
+
|
|
338
|
+
- **`UnsupportedFormatException`**, exported, thrown for bytes that are
|
|
339
|
+
neither format, with the declared `mimetype` when there is one.
|
|
340
|
+
- **`OdsReader`** and **`FormatSniffer`**, exported beside `XlsxReader`, and
|
|
341
|
+
`XlsxReader.readFiles()` for an already-unzipped package.
|
|
342
|
+
- **`parseXml(src, { namespaces: true })`** resolves namespaces and keeps mixed
|
|
343
|
+
content in order. OpenDocument needs both; without the option the parser
|
|
344
|
+
behaves exactly as before.
|
|
345
|
+
|
|
346
|
+
### Changed
|
|
347
|
+
|
|
348
|
+
- **Unreadable bytes now throw `UnsupportedFormatException` instead of a bare
|
|
349
|
+
`Error`.** It extends `Error`, so **an existing `catch` keeps working: do
|
|
350
|
+
nothing.** The message for bytes that are not a zip still says "not a zip
|
|
351
|
+
archive"; only code matching the rest of the old text (`(no EOCD record)`,
|
|
352
|
+
`missing xl/workbook.xml`) sees different wording.
|
|
353
|
+
|
|
354
|
+
### Fixed
|
|
355
|
+
|
|
356
|
+
- **The tool schema announced its own shipped features as unreleased.**
|
|
357
|
+
`src/holy-sheet.schema.json` is a byte-identical copy of the PHP twin's, and
|
|
358
|
+
carried the same defect: every formatting field described in the future tense
|
|
359
|
+
("Lands in 0.3.", "Lands in 0.5.") under a heading declaring a v0.2.0 that
|
|
360
|
+
could not style anything, while the package shipped at 2.x with all of it
|
|
361
|
+
working.
|
|
362
|
+
|
|
363
|
+
Updated in lockstep with `particle-academy/holy-sheet`; both checksum pins
|
|
364
|
+
moved together. See that package's entry for the measurement.
|
|
365
|
+
|
|
366
|
+
- **An empty `cells: []` validates.** PHP's `describe()` reports a sheet with no cells as `cells: []`, and this validator rejected any array, so a schema read in PHP and written here failed on an empty sheet. The PHP validator had the same defect and is fixed in holy-sheet 2.2.0. A non-empty list is still an error.
|
|
367
|
+
|
|
368
|
+
## 2.2.1 — 2026-09-10
|
|
369
|
+
|
|
370
|
+
### Fixed
|
|
371
|
+
|
|
372
|
+
- **`version()` reports the version this package actually ships as.** It
|
|
373
|
+
returned `1.0.0` from a 2.2.x release. The constant had drifted because
|
|
374
|
+
nothing compared it to the packaging metadata — the same shape as every other
|
|
375
|
+
two-copies-of-one-number failure in this estate.
|
|
376
|
+
|
|
377
|
+
`VersionIsSingleSourcedTest` / `version.test.ts` now pins it, so the class is
|
|
378
|
+
closed rather than the instance fixed. `dark-slide-py` already had that
|
|
379
|
+
assertion and was the only engine in the family to catch itself.
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
## 2.2.0 — 2026-09-10
|
|
383
|
+
|
|
384
|
+
### Added
|
|
385
|
+
|
|
386
|
+
- **A checksum test pinning `holy-sheet.schema.json` to its PHP twin.** The
|
|
387
|
+
schema is a byte-identical copy of `holy-sheet/skills/holy-sheet.schema.json`,
|
|
388
|
+
kept in sync by remembering to edit both. Nothing checked that — and this is
|
|
389
|
+
the file handed to an LLM as the tool definition, so a one-sided edit did not
|
|
390
|
+
fail a build, it changed what an agent was told the API is on one backend and
|
|
391
|
+
not the other.
|
|
392
|
+
|
|
393
|
+
To change the schema: edit both copies, run either suite, paste the new hash
|
|
394
|
+
into both tests. The mild annoyance is the mechanism.
|
|
395
|
+
|
|
396
|
+
The hash is taken over **newline-normalised** content. Neither repo has a
|
|
397
|
+
`.gitattributes`, so the file is stored LF and lands CRLF on a Windows
|
|
398
|
+
checkout — a raw-bytes checksum would have passed on CI and failed on the
|
|
399
|
+
maintainer's own machine, which is a worse failure than the one being
|
|
400
|
+
prevented.
|
|
401
|
+
|
|
402
|
+
### Fixed
|
|
403
|
+
|
|
404
|
+
- **A sheet with BOTH a table and explicit cells no longer discards the table.**
|
|
405
|
+
`normalizeSheet` returned the moment `cells` was set, silently throwing away
|
|
406
|
+
`columns`, `rows`, `totals` and `theme`. A four-row report with one styled
|
|
407
|
+
title cell wrote a **one-cell workbook**, and `validate()` reported no errors,
|
|
408
|
+
because the validator explicitly permits both shapes together.
|
|
409
|
+
|
|
410
|
+
The PHP twin had the identical bug in the identical place. That is why no
|
|
411
|
+
parity test caught it: **parity detects disagreement, and the two runtimes
|
|
412
|
+
agreed.**
|
|
413
|
+
|
|
414
|
+
**What you must do: nothing.** A sheet using only one of the two shapes
|
|
415
|
+
behaves exactly as before. Explicit cells win at their address, and their
|
|
416
|
+
format is merged over the table's, so a cell asking only for `bold` keeps the
|
|
417
|
+
column's currency format instead of dropping to bare.
|
|
418
|
+
|
|
419
|
+
- **Bare scalar cells are written instead of silently emptied.** `{A1: 42}` read
|
|
420
|
+
`.value` off a number, got `undefined`, and wrote a blank cell — no error,
|
|
421
|
+
just a hole where the number should be. **This one was Node-only:** PHP has
|
|
422
|
+
always had the branch, and the port dropped it.
|
|
423
|
+
|
|
424
|
+
- **A bare `"=SUM(...)"` string is promoted to a real formula**, in both cell
|
|
425
|
+
maps and rows. Also Node-only: the same schema produced a working formula in
|
|
426
|
+
PHP and the literal text `=SUM(...)` in Node.
|
|
427
|
+
|
|
428
|
+
- **A `comment` given as a plain string is no longer dropped** — only the object
|
|
429
|
+
form was read, and the string form was accepted by the validator and lost.
|
|
430
|
+
|
|
431
|
+
## 2.1.0 — 2026-08-09
|
|
432
|
+
|
|
433
|
+
### Fixed
|
|
434
|
+
|
|
435
|
+
- **`1e300` was written to the sheet as `1e+3`.** The worst bug in this package's
|
|
436
|
+
history, and it never threw. `toFixed` switches to exponential notation at
|
|
437
|
+
1e21, and the trailing-zero strip then chewed the EXPONENT rather than a
|
|
438
|
+
fraction:
|
|
439
|
+
|
|
440
|
+
```
|
|
441
|
+
(1e300).toFixed(14) === "1e+300"
|
|
442
|
+
"1e+300".replace(/0+$/, "") === "1e+3"
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
So a cell holding 1e300 was written holding 1000. Not a crash, not invalid
|
|
446
|
+
XML — a different number, in a file someone opens later and believes.
|
|
447
|
+
|
|
448
|
+
- **`1e21` and above were written as `<v>1e+21</v>`**, which is not valid cell
|
|
449
|
+
content. This had a second cause the first fix did not reach: the writer only
|
|
450
|
+
called `formatFloat` for NON-integers, and `Number.isInteger(1e21)` is `true`,
|
|
451
|
+
so it took a `String(v)` branch. Mirroring PHP's `is_float(...)` test leaks —
|
|
452
|
+
a PHP int never stringifies exponentially, an integral JS double does.
|
|
453
|
+
|
|
454
|
+
- **Numeric strings lost their exponent.** `numericStringToNumber` used
|
|
455
|
+
`parseInt` when the string had no `.`, and `parseInt` stops at the `e`:
|
|
456
|
+
|
|
457
|
+
| input | was | now |
|
|
458
|
+
|---|---|---|
|
|
459
|
+
| `"1e5"` | `1` | `100000` |
|
|
460
|
+
| `"1E5"` | `1` | `100000` |
|
|
461
|
+
| `"2e-3"` | `2` | `0.002` |
|
|
462
|
+
| `"1e21"` | `1` | `1e21` |
|
|
463
|
+
|
|
464
|
+
It is now `Number(v)` — which is what `FormulaLinter` already used for the
|
|
465
|
+
same job, so the two disagreed with each other as well as with PHP.
|
|
466
|
+
|
|
467
|
+
- **`NaN` and `Infinity` were written into cells.** Both now write `0`, matching
|
|
468
|
+
the PHP twin.
|
|
469
|
+
|
|
470
|
+
### Added
|
|
471
|
+
|
|
472
|
+
- A `numericHazards` case in the cross-engine parity fixtures. The unit tables on
|
|
473
|
+
both sides were green while the engines still disagreed on `1e21`; only the
|
|
474
|
+
byte-for-byte diff caught it. Numbers are now a parity *case*, not a
|
|
475
|
+
convention.
|
|
476
|
+
|
|
477
|
+
### Changed
|
|
478
|
+
|
|
479
|
+
- **BREAKING in effect, though the API is identical:** values listed above now
|
|
480
|
+
serialize differently, because they were wrong. If you have a golden xlsx
|
|
481
|
+
containing an exponent-notation string, a value ≥ 1e21, or `NaN`, regenerate
|
|
482
|
+
it. Anything below 1e21 with no exponent string is byte-identical to 2.0.2.
|
|
483
|
+
|
|
484
|
+
## 2.0.2 — 2026-08-09
|
|
485
|
+
|
|
486
|
+
### Fixed
|
|
487
|
+
|
|
488
|
+
- **`scripts/php-describe.php` was missed by 2.0.1**, so the reader-parity suite
|
|
489
|
+
still could not find the PHP classes outside the `.agi` envelope and CI failed
|
|
490
|
+
on the 2.0.1 commit. It now honours `HOLY_SHEET_PHP_SRC` like its sibling.
|
|
491
|
+
|
|
492
|
+
Worth saying plainly: CI caught this the very first time the parity suites
|
|
493
|
+
actually ran. Under the old `skipIf` the incomplete fix would have gone green.
|
|
494
|
+
|
|
495
|
+
## 2.0.1 — 2026-08-09
|
|
496
|
+
|
|
497
|
+
### Fixed
|
|
498
|
+
|
|
499
|
+
- **The cross-engine parity suites now actually run in CI.** They are the
|
|
500
|
+
strongest guarantee this pair has — every OOXML part diffed byte-for-byte
|
|
501
|
+
against the PHP `holy-sheet` — and they had never executed on a runner.
|
|
502
|
+
|
|
503
|
+
Two things combined to hide it. `describe.skipIf(!HAS_PHP)` skipped silently,
|
|
504
|
+
and `ci.yml` installed Node only, so the build went green with **zero
|
|
505
|
+
cross-engine coverage**. Separately, the parity helper autoloaded the PHP
|
|
506
|
+
sources from a hard-coded `../../holy-sheet/src`, which only resolves inside
|
|
507
|
+
the `.agi` envelope — so even with PHP present, a different layout found no
|
|
508
|
+
classes.
|
|
509
|
+
|
|
510
|
+
Now: CI installs PHP 8.4 and checks out the PHP repo; the helper takes
|
|
511
|
+
`HOLY_SHEET_PHP_SRC` (falling back to the sibling path); and a missing PHP
|
|
512
|
+
**throws in CI** instead of skipping. A green build that asserts nothing is
|
|
513
|
+
worse than a red one, because nobody investigates green.
|
|
514
|
+
|
|
515
|
+
**What you must do:** nothing, unless you run these suites outside the
|
|
516
|
+
envelope — then set `HOLY_SHEET_PHP_SRC` to the PHP package's `src`.
|
|
517
|
+
|
|
518
|
+
## 2.0.0 — 2026-08-07
|
|
519
|
+
|
|
520
|
+
### Changed
|
|
521
|
+
|
|
522
|
+
- **BREAKING — Node 18 is no longer supported.** `engines.node` moves from `>=18` to `>=22`.
|
|
523
|
+
|
|
524
|
+
**What you must do:** on Node 22 or newer, nothing. Note npm only *warns* on an `engines` mismatch while **pnpm fails the install**, so this surfaces differently depending on your package manager. Node 18 is end-of-life and 20 is maintenance-only.
|
|
525
|
+
|
|
526
|
+
### Why
|
|
527
|
+
|
|
528
|
+
These are the kit 0.5 platform floors, applied across every package at once so a consumer never has to resolve a mix. **No API changed, nothing was removed, nothing was renamed** — only what the package requires.
|
|
529
|
+
|
|
530
|
+
This package is past 1.0, so a floor raise takes a **major**. Most of the suite is pre-1.0 and lands the identical change in a minor — that is semver, not a difference in how much changed.
|
|
531
|
+
|
|
532
|
+
## 1.0.0 — 2026-05-30
|
|
533
|
+
|
|
534
|
+
Initial Node/TypeScript port of `particle-academy/holy-sheet` (PHP), at
|
|
535
|
+
**feature-parity with PHP 1.2.0**. Zero-dependency, isomorphic (browser + Node).
|
|
536
|
+
|
|
537
|
+
- Full `Agent` surface: `validate`, `toBytes`, `write` (Node), `validateAndRepair`,
|
|
538
|
+
`lint`, `fromArray`, `fromCsv`, `read`/`describe`, `toolDefinition`, `version`,
|
|
539
|
+
plus the `HolySheet` instance class.
|
|
540
|
+
- xlsx **writer** — inline-string/number/bool/date/formula cells, deduped
|
|
541
|
+
styles (fonts/fills/borders/numFmts/xfs), currency/percent/date number
|
|
542
|
+
formats, 4 themes, symbolic totals, merged cells, frozen panes, column
|
|
543
|
+
widths, comments (+ VML), multi-sheet.
|
|
544
|
+
- xlsx **reader** — round-trips the above back to a schema (own + Excel-authored
|
|
545
|
+
files), via a hand-rolled isomorphic ZIP (STORE write / inflate read) and a
|
|
546
|
+
tiny XML parser.
|
|
547
|
+
- **Formula linter** — `#VALUE!/#REF!/#DIV/0!/#NAME?/#CIRC!` with off-by-one
|
|
548
|
+
hints; 15+ functions.
|
|
549
|
+
- **Verified byte-identical** to the PHP engine across sparse, themed/totaled,
|
|
550
|
+
decorated, and multi-sheet workbooks (cross-engine parity suite).
|
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @particle-academy/holy-sheet
|
|
2
2
|
|
|
3
|
-
[](https://particle.academy)
|
|
4
4
|
|
|
5
5
|
Zero-dependency, **isomorphic** (browser + Node) `.xlsx` writer + reader +
|
|
6
6
|
formula linter for agentic document creation. The Node/TypeScript mirror of the
|
|
7
7
|
PHP [`particle-academy/holy-sheet`](https://github.com/Particle-Academy/holy-sheet)
|
|
8
|
-
— same schema in, same `.xlsx` out (
|
|
9
|
-
|
|
8
|
+
— same schema in, same `.xlsx` out (the PHP package's `dumpJson` and `Toolkit`
|
|
9
|
+
are not mirrored here; `CHANGELOG.md` records which PHP releases each fix mirrors).
|
|
10
10
|
|
|
11
11
|
```ts
|
|
12
12
|
import { Agent } from "@particle-academy/holy-sheet";
|