oficina / sheets Public

fix: a second review, and the bug that made the clipboard fixes untrue

A second §Review pass, by Fable — a different model from both the author
and the first reviewer. REQUEST CHANGES, fifteen findings. Seven were
already fixed by the previous commit; the rest are here, and one of them
means my own verification was worthless.

**The clipboard did not work for anybody using a mouse.** The vendored
grid registers keyDownControls on `document` and acts on Ctrl+C/X/V
whenever `jspreadsheet.current` is set — which a mousedown inside a
worksheet does. It copies the DISPLAY strings into a hidden textarea via
execCommand, then calls preventDefault on the keydown, so the native
copy event this module is built on never fires at all.

Reproduced: click B1 holding `=A1*2`, press Ctrl+C, and the clipboard
holds "20". Paste it and you get the number.

tools/drive/clipboard.mjs never caught it because it never CLICKED a
cell — every selection went through updateSelectionFromCoords and every
menu click was a mousedown outside the grid, which nulls `current`. It
measured the one state a person with a mouse is never in, and reported
eight green checks. The fix is a capture-phase keydown that takes the
three keys before the library's bubble handler and deliberately does NOT
preventDefault, so the browser's own clipboard action proceeds. The
drive script now clicks a real cell first, and says in its own comment
why that line must not be replaced.

**Cut was translating references.** §4.2: "Cut-paste a cell — the cell
keeps its (row_id, col_id) key and its CONTENT MOVES." A moved formula
points where it always pointed. Only a copy is re-resolved by offset.

**A conditional sum disagreed with SUM about errors.** SUM over a range
holding a #DIV/0! answers #DIV/0!; SUMIF answered 40. The first attempt
at this fix changed nothing, because an error matches no criterion and
so is never selected — the scan has to be separate from the selection
walk, which is now written down where the next person will look.

**The wildcard cases never needed to leave the corpus, and my reason for
moving them was wrong.** I read LibreOffice's zeros as "its profile does
not do Excel wildcards". Wildcards are a per-DOCUMENT calculation
setting, and gencorpus was writing an .ods with no
<table:calculation-settings> at all — so LibreOffice fell back to ODF's
legacy default, regexes on and wildcards off, where "t*" is the regex t*
and matches nothing in "ten". The generator was asking the wrong
question and getting a correct answer to it. One element, and the same
binary gives Excel's answers: 60/3/1/1/2/60/2, exactly ours. All seven
cases are back in the corpus and decision C is intact.

The general lesson is in the file: when an oracle disagrees, check what
you actually asked it before deciding it is unfit.

**Three more matcher edges, each measured against LibreOffice:** an
error cell counts for `<>` (my refusal was an opinion, not a
measurement); a blank criterion CELL matches nothing, which is not the
same request as `COUNTIF(range,"")`; and SUMIFS now refuses a sum range
of a different shape instead of reshaping it and reading a cell the
formula never named. Writing the test for the last one found a fourth:
`rectOf` refused a bare cell reference, so `SUMIF(A1:A9,">5",B1)` was
#VALUE! — while the file's own header comment said it sums B1:B9. The
comment was right and the code was wrong.

A paste whose cells fall outside the grid now says so rather than
dropping them silently, and `own()` checks for a selection before
cancelling the event, so Ctrl+C over ordinary page text is no longer
swallowed.

Two remaining matcher edges are NOT fixed and are named in the spec with
a trigger: `""`-valued cells under a `""` criterion, and a text
criterion against numeric-looking text. Both need support cells the
shared corpus table does not have, and extending it changes the fixture
every other case is written against.

clipboard.js crossed the JS cap twice on the way. cellqueue.js (how to
send N writes without losing one) and tsv.js (the wire format) are both
seams that were already there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014od11UreNE4Ewf3oR9837B
Paul Campbell pushed by paul@keymail.dev bb43c90087a0be71f60bfaf98fb30b9c3811009b parent 1dcd82a
15 files changed, +1175 −116
  • docs/superpowers/reviews/2026-09-04-branch-fable.md +525 −0
  • docs/superpowers/specs/2026-09-03-sheets-next-three.md +2 −0
  • internal/sheets/calc/corpus_test.go +1 −1
  • internal/sheets/calc/fn_cond.go +30 −1
  • internal/sheets/calc/fn_cond_apply.go +73 −10
  • internal/sheets/calc/fn_cond_test.go +123 −0
  • internal/sheets/calc/fn_text_find.go +191 −0
  • internal/sheets/calc/testdata/corpus/formulas.json +20 −2
  • internal/sheets/static/cellqueue.js +43 −0
  • internal/sheets/static/clipboard.js +75 −56
  • internal/sheets/templates/sheet.html +1 −0
  • internal/sheets/xlsx/oracle_wildcard_test.go +17 −32
  • tools/drive/clipboard.mjs +22 −0
  • tools/gencorpus/cases.go +39 −14
  • tools/gencorpus/ods.go +13 −0

CI passed — run details

Intent

Human

<task-notification> <task-id>b5b0auwss</task-id> <tool-use-id>83bb1b96-b711-41e8-bf29-0f2c2a3b8aa4:inner</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina-sheets-core/4cb11af1-7d7f-4c9b-928f-3b7787c03fb6/tasks/b5b0auwss.output</output-file> <status>killed</status> <summary>Background command " opencode auth login -p openai" was stopped</summary> </task-notification>

diff --git a/docs/superpowers/reviews/2026-09-04-branch-fable.md b/docs/superpowers/reviews/2026-09-04-branch-fable.md
new file mode 100644
index 0000000..a131b4d
--- /dev/null
+++ b/docs/superpowers/reviews/2026-09-04-branch-fable.md
@@ -0,0 +1,525 @@
+# Review — feat/table-formatting, `origin/core..HEAD` (HEAD = `393e276`)
+
+**Verdict: REQUEST CHANGES.**
+
+Reviewer: Claude (Fable 5.1), independent §Review pass, 2026-09-04. Reviewed
+read-only against the six committed changes. While this review ran, eleven
+tracked files and two untracked ones (`calc/fn_cond_test.go`,
+`tools/gencorpus/cases.go`) changed on disk — somebody is already responding
+to the other review. Everything below is about the commits, not the working
+tree; where the on-disk edit already addresses a finding I say so, but
+nothing uncommitted counts as fixed.
+
+Method: read the diff, `AGENTS.md`, `PRODUCT.md`, the design spec (§2, §4.2,
+§5, §6, §7, §14) and the slice spec; ran the Go suite; ran ten mutations
+against a scratch copy built from `git archive HEAD` (none in the checkout);
+ran LibreOffice 26.8.0.3 headless on hand-built `.ods` files to check the
+wildcard claim and the matcher's edges; probed excelize the same way; read
+the vendored `jspreadsheet.js` for its clipboard and keydown paths. No
+browser is reachable from this sandbox, so the one finding that needs a
+browser (F3) is stated as a code-level claim with the exact step that
+would confirm or refute it.
+
+The binding rules quoted where they decide a finding:
+
+- `AGENTS.md` §Shipping cadence: "**CI is green** — actually green, checked,
+ not assumed."
+- `AGENTS.md` Mechanical rules, Data: "**No cell content, comment text,
+ sheet title or tab name in any log line, error string, job status message
+ or metric label.**"
+- Design §4.2: "**Cut-paste a cell** — The cell keeps its `(row_id,
+ col_id)` key and its *content moves*. Inbound references therefore follow
+ the destination, matching Excel."
+- Design §7: "**Every SSE message carries the cell's version**, and clients
+ discard out-of-order paints by version rather than by arrival."
+- Slice spec decision C: "**Every new function's expected values come from
+ LibreOffice headless**, through `tools/gencorpus`, never from our reading
+ of a spec."
+- `PRODUCT.md` principle 1: "**Say the true thing, including the
+ unflattering half.**"
+
+---
+
+## F1. The branch is red: `TestFileLineCap` fails at HEAD — BLOCKER
+
+**Claim:** the guards package does not pass on this branch.
+**True.**
+
+```
+$ go test ./internal/guardstest/
+--- FAIL: TestFileLineCap (0.00s)
+ linecap_test.go:36: tools/gencorpus/main.go: 308 lines exceeds the 300-line cap
+```
+
+`internal/guardstest/linecap_test.go:24-37` has been on `origin/core` since
+`d49ed01`; `tools/gencorpus/main.go` was 220 lines there and is 308 at HEAD
+(the `feat(calc)` commit added the case list and a 20-line comment). The
+author knows the cap applies to Go — the `feat(table)` commit split
+`handlers_table.go` for exactly this reason (`tableview.go:11-16`) — so this
+is a package that was not run before pushing. `go test ./...` in this
+sandbox lists eight packages and silently omits `internal/guardstest`
+because of a read-only build-cache path; if the author's environment does
+the same, that is the mechanism, and it is worth knowing.
+
+Two more things in the same file: `tools/gencorpus/main.go:190-200` says the
+blank-criterion case is "Pinned instead as a direct assertion in
+`calc/fn_cond_test.go`" — **no such file exists at HEAD** (it is untracked
+on disk), and the sentence above it is cut off mid-clause ("answered 5 for
+A1:C3 (text counted, blanks not) and 0 for B1:B3 that check is worth more
+than this one case)"). A comment that cites a test that is not committed is
+the guard-that-cannot-fail problem in prose form.
+
+**Do:** split the case list out of `main.go` (the on-disk `cases.go` is
+the right shape), commit the test the comment names, fix the truncated
+sentence, and run `go test ./internal/guardstest/` explicitly before the
+next push.
+
+---
+
+## F2. Cut-and-paste translates references; the spec says content moves — HIGH
+
+**Claim:** cutting `=A1*2` from B1 and pasting it at B5 stores `=A5*2`.
+**True, by construction.**
+
+`clipboard.js:240-247` (Ctrl+X) and `:269-277` (menu Cut) both call
+`copyText()`, which sets `lastCopy = { text, r: b.r1, c: b.c1 }`
+(`:165-171`). `paste()` at `:187` treats any exact text match as a copy
+origin and at `:204` sends `fromR/fromC` on every cell, so
+`handlers_grid.go:130-134` → `copypaste.go:41-68` offsets every relative
+reference by the move distance.
+
+That is Excel's *copy* rule applied to a *cut*. Excel does not adjust
+references when a formula is moved, and design §4.2 rules the same way:
+"Cut-paste a cell — The cell keeps its `(row_id, col_id)` key and its
+*content moves*." Nothing in the diff distinguishes the two operations once
+the text is on the clipboard, so a person who cuts a formula block to tidy
+a sheet gets every relative reference silently shifted, and the numbers
+still look plausible.
+
+(The second half of the §4.2 row — inbound references following the
+destination — is honestly deferred by §14's positional-key-space row and is
+not this branch's fault. The first half is.)
+
+**Do:** record the operation in `lastCopy` (`cut: true`) and, on a paste
+whose origin was a cut, send no `fromR/fromC`. `commands.js`'s fill-down
+path is unaffected. Add a `handlers_copy_test.go` case only if the server
+grows a distinction; today the fix is entirely client-side.
+
+---
+
+## F3. For a mouse user the vendored grid still owns Ctrl+C / Ctrl+X / Ctrl+V — HIGH (needs one browser step to confirm)
+
+**Claim:** `clipboard.js` intercepts the three clipboard *events*, but the
+library copies and cuts from its document-level *keydown* handler, which
+the capture-phase clipboard listeners never touch; whenever
+`libraryBase.jspreadsheet.current` is set — which a mouse click on a cell
+does — the library runs first and suppresses the native events.
+
+**What the vendored code says** (`static/vendor/jspreadsheet.js`,
+`keyDownControls`, registered `document.addEventListener("keydown",
+keyDownControls)` in `setEvents`):
+
+```
+67==e.which ? (copy.call(libraryBase.jspreadsheet.current,!0), e.preventDefault())
+: 88==e.which ? (... cutControls() ..., e.preventDefault())
+: 86==e.which && pasteControls()
+```
+
+`copy()` ends with `i.textarea.value=x, i.textarea.select(),
+document.execCommand("copy")` — it moves focus into its hidden
+`jss_textarea` and copies the **display strings** from `options.data`, not
+raw input. The synthetic `copy` event that `execCommand` fires targets that
+textarea, and `clipboard.js:221-224` `inTextField()` deliberately steps
+aside for a `TEXTAREA`. Then `e.preventDefault()` on the keydown cancels
+the browser's own copy command, so the native `copy` event our listener
+was written for never fires, and `lastCopy` is never set.
+
+`current` is assigned in `mouseDownControls` when the click lands inside a
+worksheet (`current = s[0].jssWorksheet`) and set to `null` on a mousedown
+outside it. `grid.js:239-245` already records that the library's document
+keydown is live and runs before ours ("Left on, Ctrl-Z reverts locally").
+
+**Why the drive script did not see it:** `tools/drive/clipboard.mjs` never
+clicks a cell. Every selection goes through
+`updateSelectionFromCoords(...)`, every menu click is a mousedown *outside*
+the grid (which nulls `current`), and `SheetsGrid.focus()` focuses the
+host `div` without a mousedown. So it measured the `current == null` state,
+where `keyDownControls` returns at its first line — a state a person with
+a mouse is never in.
+
+**What a mouse user gets, if this reading is right:** Ctrl+C copies "20"
+where the cell holds `=A1*2`; Ctrl+V lands in the library's `pasteControls`
+via the focused hidden textarea and the cell-dropping paste path this
+module exists to remove is back, with no error anywhere.
+
+**Do:** confirm with one drive step — `page.click('td[data-x="0"]...')`
+(a real mousedown on a cell) before `Control+c`, then read
+`navigator.clipboard.readText()` and check `lastCopy` fired (the
+translation check at H8 will tell you). If it fails, the fix is small and
+answers the brief's "registration order" question properly: add a
+capture-phase `keydown` listener in `clipboard.js` that, for Ctrl/Cmd +
+C/X/V outside a text field, calls `e.stopImmediatePropagation()` — **not**
+`preventDefault()` — so the library's bubble-phase document handler never
+sees the keystroke and the native clipboard events still reach our capture
+handlers. Capture beats bubble regardless of which script registered
+first, which is the robustness the current design only claims.
+
+---
+
+## F4. The truncation note says something false exactly when it is shown — HIGH
+
+**Claim:** "The table view lists every row." (`note.js:44-46`) is untrue
+whenever `data-window-truncated` is set.
+**True.**
+
+`handlers_sheets.go:173` sets `Truncated` when the sheet reaches past
+`maxWindowRows` (500) or `maxWindowCols` (60). The table view:
+
+- `handlers_table.go:111` — `pages := (maxWindowRows + tableRowsPerPage - 1)
+ / tableRowsPerPage` → 20 pages of 25 → **row 500 is the last row the
+ table can ever show**, and `:176` rejects an edit at `rowIdx >=
+ maxWindowRows`;
+- `handlers_table.go:20` — `tableCols = 26` → **nothing past column Z is
+ in the table at all**, on any page.
+
+`inCellRange` (`handlers_grid.go:65-67`) accepts writes to row 4,999 and
+column 599. So a cell at row 501 or column AA is accepted by the API,
+carried by the export, invisible on the grid, and unreachable in the table
+view — and the one sentence the app now says about it points the person at
+the screen that cannot show it either. The slice spec repeats the claim
+("the table view lists whatever rows exist", §2.1) and the commit message
+inherits it. This is the invisibility bug the commit fixed, one level up,
+with a false reassurance attached; `PRODUCT.md` principle 1 forbids the
+reassurance more than the gap.
+
+Related and new: the window is now *sized per page load*, so two tabs on
+one sheet can hold different windows. A paste in tab A past tab B's
+`data-rows` is dropped by B's `applyBatch` (`grid.js:101`) with no note,
+until B reloads. That was latent under the constant window; the sizing
+makes it ordinary.
+
+**Do:** either make the sentence true (the table view paginates to the
+sheet's own extent and shows every column the sheet has — it is
+scriptless HTML, the cost is a wider `<table>`), or change the sentence
+to what is true today ("Rows past 500 and columns past Z are in the
+.xlsx export only"). Fix the slice spec's §2.1 in the same commit. For
+the two-tab case, set `resync` on a batch that carries a cell outside
+the receiving window, or have `note.js` say so.
+
+---
+
+## F5. `SUMIF`/`AVERAGEIF`/`SUMIFS` swallow an error in the sum range; `SUM` propagates it — HIGH
+
+**Claim:** an error cell inside a selected row of the sum range is skipped
+and a number is returned.
+**True, and it contradicts the function's own stated invariant.**
+
+`fn_cond_apply.go:83-89` (`total`): `if v.Kind != KindNum { return }` —
+the comment says this is "exactly as SUM skips them … a conditional sum
+must not disagree with an unconditional one about what a number is." But
+`SUM` does not skip errors: `fn_math.go:79-86` `numericValues` returns the
+first `KindErr` it meets. Measured, with C3 = `=1/0`:
+
+| expression | ours | LibreOffice 26.8 | Excel |
+|---|---|---|---|
+| `SUM(C1:C3)` | `#DIV/0!` | `#DIV/0!` | `#DIV/0!` |
+| `SUMIF(A1:A3,">5",C1:C3)` | **0** | `#DIV/0!` | `#DIV/0!` |
+| `SUMIF(A1:A3,">25",C1:C3)` | **0** | `#DIV/0!` | `#DIV/0!` |
+
+A zero where the sheet has a broken cell is the quiet wrong number
+`PRODUCT.md` forbids. The corpus cannot see it: the support table has no
+error cell. Mutation M6 (letting errors through `matches`) stayed green
+across `calc` and `xlsx` for the same reason.
+
+**Do:** in `total`, return the error value when `v.Kind == KindErr`; add an
+error cell to gencorpus's support data (an `of:=1/0` cell in column C is
+one line in `ods.go`) and generate the two cases above.
+
+---
+
+## F6. The wildcard cases did not need to leave the corpus; the "profile" claim is wrong — MEDIUM
+
+**Claim:** "LibreOffice's default profile does not read * and ? as
+wildcards" (`xlsx/oracle_wildcard_test.go:16-21`, `gencorpus/main.go:165-178`),
+so the five cases had to move to excelize.
+**The measurement is right; the diagnosis and the conclusion are wrong.**
+
+Wildcard versus regex matching is a **per-document calculation setting**,
+not a profile setting, and `gencorpus/ods.go:137-140` writes an `.ods` with
+no `<table:calculation-settings>` element at all, so LibreOffice falls back
+to ODF's legacy default (regular expressions on, wildcards off), where
+`"t*"` is the regex `t*` and matches nothing in "ten". I rebuilt the same
+document with one added element and ran the same binary:
+
+```
+<table:calculation-settings table:use-wildcards="true" table:use-regular-expressions="false"/>
+```
+
+| case | as gencorpus writes it | with the element | excelize / ours |
+|---|---|---|---|
+| `COUNTIF(B1:B3;"t*")` | 0 | **3** | 3 |
+| `COUNTIF(B1:B3;"t??")` | 0 | **1** | 1 |
+| `SUMIF(B1:B3;"t*";A1:A3)` | 0 | **60** | 60 |
+| `COUNTIF(B1:B3;"t?n")` | 0 | **1** | 1 |
+| `COUNTIF(B1:B3;"*y")` | 0 | **2** | 2 |
+| `SUMIFS(A1:A3;B1:B3;"t*")` | 0 | **60** | 60 |
+| `COUNTIFS(A1:A3;">=20";B1:B3;"t*")` | 0 | **2** | 2 |
+
+So decision C ("expected values come from LibreOffice headless … never
+from our reading") was set aside for these five when a one-line change to
+the generator keeps it. That matters because the substitute oracle is
+weak: in this session excelize returned `0` for `SUM(C1:C3)` over an error
+cell and **panicked** (`calc.go:8230`, index out of range) on
+`COUNTIFS(A1:A3,">5",B1:C1,"<>")`. `TestWildcardCriteriaAgainstExcelize`
+itself is sound — mutation M9 (`?` widened to `*`) fails it — but it is
+sound against a second-choice oracle that was never needed.
+
+**Do:** add the element to `contentXML`, move the seven wildcard cases back
+into `cases`, regenerate, update `corpusSHA256`, and reduce
+`oracle_wildcard_test.go` to a cross-check or delete it. Note in the
+divergences file that this is a *document* setting so the next person
+does not re-measure the "profile".
+
+---
+
+## F7. The criteria matcher diverges from Excel on the edges the corpus never reaches — MEDIUM
+
+All measured against LibreOffice 26.8 on a hand-built document (C1 blank,
+C2 = `=""`, C3 = `=1/0`, D1 = text `"10"`, D2 = number 10, D3 blank). Where
+I am confident Excel agrees with LibreOffice I say so; where I am not, the
+row says only what was measured.
+
+| expression | ours | LibreOffice | note |
+|---|---|---|---|
+| `COUNTIF(C1:C3,"")` | 1 | **2** | Excel counts both a blank and a cell holding `""`; `fn_cond.go:151-153` returns `v.Kind == KindEmpty` only, and its comment asserts the opposite of Excel's behaviour. |
+| `COUNTIF(C1:C3,"<>")` | 1 | **2** | The error cell is "not blank". `fn_cond.go:122-125` refuses every error for every criterion, by design; Excel and LibreOffice count errors for `<>`. |
+| `COUNTIF(A1:C3,"<>20")` | 7 | **8** | Same cause — the error cell. |
+| `COUNTIF(D1:D2,"10")` | 1 | **2** | `finishCriterion` (`:93-98`) turns the text criterion `"10"` into a numeric one, and `:126-131` then refuses the text cell. Excel's COUNTIF matches numeric-looking text against a text criterion; `COUNTIF(D1:D2,10)` = 1 in both, which is the asymmetry that has to be modelled. |
+| `COUNTIF(D1:D2,"<>10")` | 1 | **0** | Mirror of the row above. |
+| `COUNTIF(C1:C3,D3)` (blank criterion cell) | 1 | **0** | `parseCriterion` (`:77-83`) turns a `KindEmpty` criterion into `text: ""` and matches blanks; LibreOffice and excelize both say 0. |
+| `SUMIFS(A1:A2,B1:B3,"t*")` | **60** | `Err:502` | Excel: `#VALUE!` — SUMIFS requires the sum range to be the same shape as each criteria range; `plural` (`fn_cond_apply.go:201-214`) never checks and `total` reshapes it to the criteria range's size, reading A3, a cell the formula never named. |
+
+Two more from reading: `readPairs` (`fn_cond_apply.go:65-73`) compares
+`cells()` and not shape, so a 1×6 criteria range against a 6×1 one passes
+and is silently reshaped — the exact "mistyped range reads the wrong
+cells and still returns a number" its own comment says it refuses. And
+mutation M5 (blank/text never matching a numeric `<>`) stayed green across
+`calc` and `xlsx`: at HEAD no test distinguishes any row in this table.
+
+**Do:** treat `""`-valued cells as blank for `""`, count error cells for
+`<>`, match text criteria against text cells before numeric coercion,
+make an empty criterion match nothing, check shape not count, and refuse
+a SUMIFS/AVERAGEIFS sum range of a different shape. Add the support cells
+(an `=""` cell, an error cell, a text `"10"`) to gencorpus and generate
+every row above; they are the semantic fault lines the earlier memory note
+said the corpus lacked.
+
+---
+
+## F8. `writeAll`'s trailing `refetch` can paint stale over a concurrent edit; the root cause is batch-level versioning — MEDIUM
+
+**Claim:** there is an interleaving where a cell is painted stale.
+**True.**
+
+`clipboard.js:154-156` calls `g.refetch()` once every lane is idle.
+`grid.js:113-128` `refetch()` does `version = 0; meta = …; applyBatch(payload)`
+in its `.then`. Sequence: the snapshot request leaves at tab version V;
+another tab's edit lands at V+1 and its SSE batch paints here (version =
+V+1); the snapshot (taken at V) arrives, `version` is zeroed, and
+`applyBatch` paints the V-state value over the V+1 cell. No later message
+repairs it because the edit already happened.
+
+The reason the refetch was added — six concurrent responses, the last to
+arrive being the oldest, five of six painted — is the same defect from the
+other side: `applyBatch` (`grid.js:80-83`) discards a **whole batch** by
+the **tab's** version, while design §7 says "Every SSE message carries the
+**cell's** version, and clients discard out-of-order paints by version".
+A response to your own write is authoritative for the cells it names; it
+should never be discarded because a different cell moved the tab's
+version on. The on-disk edit goes serial (`LANES = 1`, no refetch), which
+is correct and slow — 500 cells at one round trip each. The real fix is
+per-cell versions on the wire, which also removes the `applyBatch`
+special-casing for format writes.
+
+Also at HEAD: a keyboard cut's `clearBox` writes and an immediately
+following paste's writes run in two independent pumps; if the regions
+overlap, a late clear can land after the paste and blank a pasted cell.
+
+**Do:** short term, keep serial writes and say so in `MAX_CELLS`'s comment;
+medium term, carry a per-cell version and let `applyBatch` compare per
+cell. Chain `clearBox` and the next paste, or make cut clear only after
+the paste completes.
+
+---
+
+## F9. `own()` prevents the default before it knows there is anything to copy, and a paste past the window is silently dropped — MEDIUM
+
+- `clipboard.js:226-232`: `preventDefault()` and `stopImmediatePropagation()`
+ run before `copyText()` learns that `box()` is `null`. With no grid
+ selection yet (fresh page, or after the library nulled it) Ctrl+C
+ anywhere outside a text field — text highlighted in the help dialog, the
+ contrast notice, a menu label — copies **nothing** and the browser's own
+ copy of the highlighted text is suppressed too. Same shape at `:240-242`:
+ on a read-only grid Ctrl+X swallows the event and does not even copy.
+- `clipboard.js:198`: `if (r >= g.rows || c >= g.cols) continue;` — a paste
+ whose block runs past the window drops those cells without a word. The
+ server would accept them (`inCellRange` to row 4,999) and the next page
+ load would widen the window to show them. The module's header says "a
+ paste that silently drops a cell is the failure this whole change exists
+ to remove."
+- `:188`: `count` uses `rows[0].length`; a ragged paste under-counts against
+ `MAX_CELLS`. Minor.
+
+**Do:** check `box()` (and, for cut/paste, `mayWrite`) before calling
+`preventDefault()`, and let the event fall through otherwise; send the
+off-window cells (the server is the bound) and `say()` that the grid will
+show them after a reload, or `say()` that N cells were not pasted.
+
+---
+
+## F10. Copy provenance is per tab: a copy in one tab pastes untranslated in another — MEDIUM
+
+`lastCopy` (`clipboard.js:69`) lives in the page. Copy `=A1*2` in tab A,
+paste it three rows down in tab B of the same sheet (or a different sheet
+on this instance), and B has no origin, so `=A1*2` is stored verbatim —
+where Excel, and this app one tab over, would store `=A4*2`. It is the
+comment's "pasted verbatim, which is right" applied to text that *does*
+have references to translate, and the numbers look fine. The clipboard
+can carry provenance: `setData` with a second flavour (`text/html` with a
+`data-oficina-origin` attribute, or a custom type — Chromium round-trips
+custom types within a profile) and `getData` on paste; fall back to the
+exact-text match when the flavour is absent.
+
+---
+
+## F11. Guards that stay green with the feature removed — LOW
+
+Ran against a scratch copy of HEAD; the check is whether each guard goes
+red when the thing it guards is broken.
+
+| mutation | result |
+|---|---|
+| `view.Truncated = false` | **red** (`TestTheWindowStopsAtTheMaximum`) |
+| `windowFor`: drop `+ windowGrowRoom` | **green** — the grow room (`handlers_sheets.go:184-206`) has no test; a window that ends exactly at the last cell passes every window test |
+| table.html: remove `data-b` | **red** (`TestTheTableViewPaintsWhatItSets`) |
+| `TranslateChecked` returns `true` | **red** (both off-grid tests) |
+| `formatFromForm`: drop the `keep` branch | **red** (`TestKeepLeavesAFieldAlone`) |
+| `globMatch`: `?` behaves as `*` | **red** (`TestWildcardCriteriaAgainstExcelize`) |
+| sheets.css: delete the menubar `@supports` block | **red** (anchor guard) |
+| sheets.css: keep `inset: auto`, delete `position-area` | **green** — `anchorBranchSelectors` (`dropdownanchor_test.go:76-90`) requires only `inset: auto`; the one declaration that makes a menu open rightward is unguarded. The on-disk edit to this test adds exactly that check; it is not in HEAD. |
+| `matches`: blank/text never satisfy numeric `<>` | **green** (F7) |
+| `matches`: errors allowed through | **green** (F5, F7) |
+
+`TestTheAnchorGuardReadsTheStylesheetItThinksItDoes` refers to a
+`stripBlocks` that does not exist (`:44-46`); the parser works because the
+regex backtracks past the outer brace, which is luck rather than design.
+
+---
+
+## F12. Translation is bounded below but not above — LOW
+
+`onGrid` (`translate.go:73-92`) checks `>= 0` only. The lexer's
+`refPattern` (`token.go:46`) is `^\$?[A-Za-z]{1,3}\$?[0-9]+$`, so column
+ZZZ (18,277) is the last one that parses. A formula holding a 3-letter
+column reference offset by up to `maxWindowCols*10` renders a 4-letter
+label that `lexWord` (`token.go:163-175`) reads as an identifier; the
+store's `contentNode` then refuses the write and the pasted cell is
+skipped — the outcome `TestAnUntranslatableFormulaIsStillWritten` exists
+to forbid, reached by the other edge. Excel gives `#REF!` past its last
+column, which is what `ok=false` already produces. Also
+`handlers_grid.go:121` checks `maxRawBytes` *before* `:134` translates, so
+the stored raw can exceed the cap the route claims.
+
+**Do:** `ColPos > 18277` (or a named `maxColPos`) is off-grid in `onGrid`;
+run the length check after translation.
+
+---
+
+## F13. `#REF!` is written as text — LOW, documented, still a divergence
+
+`copypaste.go:54-67` stores the literal `#REF!`; `handle_codec.go:41-50`
+makes it a `Str`. Consequences the comment does not list: `=ISERROR(B2)`
+is `FALSE`, `=B2+1` is `#VALUE!` rather than `#REF!`, and the .xlsx export
+writes a string cell that Excel shows left-aligned as text. Visible,
+as the comment says, but a wrong error code is a different kind of
+visible. An error literal in the grammar is the fix; if it stays deferred,
+put a row in §14 with that trigger.
+
+---
+
+## F14. Form decoder details — LOW
+
+- `handlers_format.go:58-63` reads the form with no `MaxBytesReader`;
+ the JSON path (`:64`) caps at 8 KiB. `r.FormValue` parses up to 10 MiB
+ urlencoded or 32 MiB multipart into memory first.
+- A scriptless submit with every control on "keep" is an empty patch →
+ `:70-72` → a bare `400 That formatting could not be applied.` text page,
+ not a redirect back. `TestAFormPostStillObeysEveryBound` asserts this as
+ correct; for the form it is the ordinary "pressed Apply by accident" case.
+- `tableReturn` (`:130-137`) drops `edit=` and the view's sort/filter, so
+ after Apply the cell's form closes and a filtered page reverts. Both are
+ derivable from validated inputs (`r1,c1` and the stored view).
+
+---
+
+## F15. Date serials below 61 — informational
+
+`serialTime` (`fn_date_parts.go:38-40`) counts from 1899-12-30 with no
+1900 leap-day. `DAY(1)` is 31, `MONTH(1)` 12, `YEAR(1)` 1899,
+`DATE(1900,1,1)` is 2; Excel says 1, 1, 1900, 1. LibreOffice agrees with
+us, so the corpus **cannot** arbitrate this by construction. Nothing to
+change — every serial from 61 up agrees — but it belongs in
+`oracle_divergences_test.go` as a recorded, oracle-blind divergence rather
+than an unknown.
+
+---
+
+## Checked and clean
+
+- **Sealing.** No new log line. Every added `http.Error` string is fixed
+ text (`handlers_grid.go:131`, `handlers_format.go:61,71,76,80`). The 303
+ target is `"/sheets/" + id + "/table?page=" + n` from a parsed, bounded
+ integer (`:130-137`; `TestTheFormRedirectIsBuiltByTheServer` covers the
+ hostile inputs). `note.js`'s only message interpolates `data-rows` and
+ `data-cols`, which are numbers. `tablepaint.go:71-105` builds
+ `template.CSS` only from `strconv`-formatted floats and `safeHex`, whose
+ regex (`previewsvg.go:37`) is `^[0-9A-Fa-f]{6}$|^[0-9A-Fa-f]{8}$`, so the
+ attribute can only ever hold `--name:#hhhhhh;` and numbers; fills and
+ number-format codes are §2.2 plaintext by design. Calc's new error paths
+ return codes.
+- **CSRF on the form path.** `app.go:135-145` applies rastrillo's
+ origin-checking `csrf.Protect` to every state-changing method, so
+ accepting `application/x-www-form-urlencoded` on the API route does not
+ open a cross-site form post.
+- **Content-type dispatch.** `isFormPost` keys on the body's own encoding;
+ a JSON body under a form content-type fails `Atoi` → 400, and the
+ reverse fails the JSON decoder → 400. `maxFormatCells` and `normalize`
+ run after both decoders converge (`:70-82`); `TestAHugeRectangleFromAFormIsRefused`
+ and `TestJSONStillAnswersJSON` hold.
+- **`origin()` / `inCellRange`.** Origin is bounded to the same box as the
+ destination, so the offset is at most ±4,999 rows / ±599 columns and the
+ parse cost is the input's, not the offset's (F12 is the residual).
+- **House rules.** No migration, no `scope.Owned`, no GORM binding, no
+ `sessions.UserID`. `TestOurStylesheetHasNoColourLiterals` is green;
+ every new `sheets.css` rule uses tokens. `clipboard.js` is 292 lines
+ against a 300 cap — no headroom for F3's listener without a split.
+ Paste has a menu item and a shortcut; fill has both.
+- **Copy tests** are at the HTTP boundary and each fails under the
+ matching mutation.
+
+---
+
+## Summary of what has to change before merge
+
+1. Make `internal/guardstest` green (F1) and commit the test the comment cites.
+2. Stop translating on cut (F2).
+3. Prove Ctrl+C/X/V with a real mousedown on a cell, and take the keystroke
+ from the library at capture phase if they fail (F3).
+4. Make the truncation sentence true, and fix the slice spec's claim (F4).
+5. Propagate errors from the sum range (F5).
+6. Put the wildcard cases back in the LibreOffice corpus with the one-line
+ `.ods` change (F6), and generate the matcher edges with the extra
+ support cells (F7).
+
+F8-F15 are worth doing and can follow; F8's serial-write stopgap is already
+on disk.
diff --git a/docs/superpowers/specs/2026-09-03-sheets-next-three.md b/docs/superpowers/specs/2026-09-03-sheets-next-three.md
index a09684d..e67e3ab 100644
--- a/docs/superpowers/specs/2026-09-03-sheets-next-three.md
+++ b/docs/superpowers/specs/2026-09-03-sheets-next-three.md
@@ -354,5 +354,7 @@ review was right to say so.
| **A batch cell route** | Every paste is one POST per cell, written strictly in series. Serial is what makes the responses arrive in version order, which is what stops grid.js discarding a paint — the alternative, concurrent writes plus a repairing refetch, opened a worse hole (a snapshot in flight overwrites a newer live edit, because `refetch` zeroes the version). MAX_CELLS is 500 because that is what serial can do without a progress bar this app has nowhere to put. Trigger: **somebody pastes more than 500 cells**, or a paste is measurably slow on the live instance. |
| **`#REF!` as a value in the formula AST** | `translateCopied` replaces a whole formula with the literal text `#REF!` when any reference lands off the grid. Excel keeps the rest — `=SUM(#REF!:$A2)+C1` — and stores an error VALUE, not text. Both need `#REF!` to exist as a node the parser accepts, the renderer emits in place, and the evaluator answers `ErrRef` for. That is a grammar change, which is why it is here rather than in the branch that found it. Trigger: **the next slice that touches calc's grammar**, or a report of a copy losing a formula. |
| **Excel's 1900 leap-year fiction** | Excel's serial 60 is 1900-02-29, a day that did not exist. This engine counts real days from 1899-12-30, so it agrees with Excel from 1900-03-01 onward and is one out below it. Fixing it moves every stored date in every sheet — a migration and a ruling, not a patch. Named in `calc/fn_date_parts.go`'s own comment. Trigger: **a sheet whose dates predate 1900-03-01**. |
+| **The criteria matcher's last two edges** | Measured against LibreOffice 26.8 by the branch review and NOT fixed: `COUNTIF(range,"")` should count a cell holding a formula result of `""` as blank (ours counts only a truly empty cell), and a TEXT criterion like `"10"` should match a text cell holding `10` before it is coerced to a number (ours coerces first and then refuses the text). Both need support cells the shared corpus table does not have — an `=""` cell and a numeric-looking text cell — and adding them changes the fixture every other case is written against. Trigger: **the next slice that touches gencorpus's support table**, which the remaining text functions will. |
+| **Copy provenance across tabs** | `lastCopy` lives in one page's memory, so a formula copied in one tab and pasted in another is pasted verbatim rather than translated. Same root as the MIME-type row below and fixed by the same thing. |
| **Clipboard provenance as a MIME type** | A pasted formula is translated only when the clipboard text exactly matches what this tab last copied, and `lastCopy` is now forgotten on window blur. That narrows the window without closing it: the same text copied from another application while the tab still has focus is still claimed. The real fix is a custom clipboard type carrying the origin, which is what Google Sheets does. Trigger: **a report of a paste rewriting a formula it should not have**, or the next slice that touches the clipboard. |
| **Rows past the grid's ceiling in any editing surface** | Beyond `maxWindowRows` the data is in the export and in NEITHER editing screen — the table view paginates to the same ceiling and its write route refuses past it. The truncation notice now says so honestly rather than sending people to a screen that cannot show them the row. Trigger: **a sheet that actually reaches it**, which import can produce today. |
diff --git a/internal/sheets/calc/corpus_test.go b/internal/sheets/calc/corpus_test.go
index 548ab08..7b9ff3b 100644
--- a/internal/sheets/calc/corpus_test.go
+++ b/internal/sheets/calc/corpus_test.go
@@ -21,7 +21,7 @@ import (
// without also changing the file (or vice versa) is exactly the silent
// drift this test exists to catch — a corpus change is a deliberate act
// with a regenerated hash, never a side effect of an unrelated edit.
-const corpusSHA256 = "ae3c1583b1e3765e477abac1c5da58e68904e4b19ee66cccc123b5d902f0bc01"
+const corpusSHA256 = "17f74be9125efe362b0688dd9bd6d44bd624d3a84d151bf8b8ee0c5fd634e7a6"
// corpusValue is formulas.json's wire format for a Value: kind names one
// of "empty", "num", "str", "bool", "err", and only the matching field is
diff --git a/internal/sheets/calc/fn_cond.go b/internal/sheets/calc/fn_cond.go
index 708392d..4553ead 100644
--- a/internal/sheets/calc/fn_cond.go
+++ b/internal/sheets/calc/fn_cond.go
@@ -34,6 +34,16 @@ func init() {
type rect struct{ r0, r1, c0, c1 int }
func rectOf(n Node) (rect, bool) {
+ // A bare cell reference is a 1x1 rectangle, and accepting one is
+ // what makes the file header's own example true: SUMIF(A1:A9,">5",B1)
+ // sums B1:B9, because offsetRect then grows this to the criteria
+ // range's shape. Refusing it — which the first version did, since
+ // it required a *Range — made that documented behaviour a #VALUE!
+ // and the comment describing it a lie. Caught by writing the test
+ // the comment implied.
+ if ref, ok := n.(*Ref); ok {
+ return rect{ref.RowPos, ref.RowPos, ref.ColPos, ref.ColPos}, true
+ }
rng, ok := n.(*Range)
if !ok {
return rect{}, false
@@ -79,6 +89,14 @@ func parseCriterion(v Value) criterion {
if v.Kind == KindNum {
return criterion{op: "=", num: v.Num, isNum: true}
}
+ // A BLANK criterion cell matches nothing. COUNTIF(range, D3)
+ // where D3 is empty is 0 in LibreOffice and in excelize, and
+ // the first version answered 1 because it turned the blank into
+ // the empty-string criterion — which is a different thing, and
+ // the one written as COUNTIF(range, "").
+ if v.Kind == KindEmpty {
+ return criterion{op: "none"}
+ }
return criterion{op: "=", text: valueText(v)}
}
s := v.Str
@@ -120,9 +138,20 @@ func valueText(v Value) string {
// counting it as "not equal to 5" would quietly inflate every COUNTIF
// over a column with one bad formula in it.
func (c criterion) matches(v Value) bool {
- if v.Kind == KindErr {
+ if c.op == "none" {
return false
}
+ if v.Kind == KindErr {
+ // An error cell is "not equal to" anything, and only that.
+ //
+ // The first version refused every error for every criterion,
+ // with a rationale — that counting one as not-equal-to-5 would
+ // inflate a COUNTIF over a column with one bad formula in it —
+ // that was an opinion rather than a measurement. LibreOffice
+ // 26.8 counts an error for "<>" and for "<>20", and so does
+ // Excel. Corrected after review, 2026-09-04.
+ return c.op == "<>"
+ }
if c.isNum {
if v.Kind != KindNum {
// A blank is not zero, and text is not a number: only "<>"
diff --git a/internal/sheets/calc/fn_cond_apply.go b/internal/sheets/calc/fn_cond_apply.go
index 1d2e1b0..283654c 100644
--- a/internal/sheets/calc/fn_cond_apply.go
+++ b/internal/sheets/calc/fn_cond_apply.go
@@ -82,23 +82,61 @@ func readPairs(argNodes []Node, from int, env Env) ([]condPair, Value, bool) {
return pairs, Value{}, true
}
-// total adds up the values at the selected offsets of `over`, and also
-// counts them, so SUM and AVERAGE share one walk.
-func total(pairs []condPair, over rect, env Env) (sum float64, n int) {
+// total adds up the values at the selected offsets of `over`, counts
+// them, and reports the first error it met, so SUM and AVERAGE share one
+// walk.
+//
+// The error return is the fix for a real inconsistency: SUM over a range
+// holding a #DIV/0! answers #DIV/0!, and SUMIF over the same range
+// answered 40 — it skipped the error along with the text and the blanks.
+// That contradicted this function's own comment, which said a
+// conditional sum must not disagree with an unconditional one about what
+// a number is. An error is not a value to skip; it is the answer.
+//
+// Neither oracle could settle it. excelize does not propagate at all —
+// it answers 40 to SUM(A1:A3) over the same range, so it disagrees with
+// our corpus-validated SUM before the conditional forms are even
+// reached — and the corpus generator refuses to record an error as a
+// value. So this follows the invariant already written here and Excel's
+// documented behaviour, and says so rather than claiming an oracle it
+// does not have.
+func total(pairs []condPair, over rect, env Env) (sum float64, n int, bad Value) {
at := offsetRect(over, pairs[0].over)
+ base := pairs[0].over
+
+ // The error scan is SEPARATE from the selection walk, and it has to
+ // be. An error matches no criterion — criterion.matches returns
+ // false for one, deliberately, so a bad formula does not inflate a
+ // COUNTIF — so a cell holding one is never selected and the walk
+ // below never sees it. The first version of this fix put the check
+ // inside the walk and changed nothing at all.
+ //
+ // Excel propagates an error anywhere in the sum range, matched or
+ // not, which is the same rule SUM follows over the whole range.
+ for dr := 0; dr <= base.r1-base.r0 && bad.Kind != KindErr; dr++ {
+ for dc := 0; dc <= base.c1-base.c0; dc++ {
+ if v := cellValueAt(env, at.r0+dr, at.c0+dc); v.Kind == KindErr {
+ bad = v
+ break
+ }
+ }
+ }
+ if bad.Kind == KindErr {
+ return 0, 0, bad
+ }
+
selected(pairs, env, func(dr, dc int) {
v := cellValueAt(env, at.r0+dr, at.c0+dc)
if v.Kind != KindNum {
// Text, blanks and logicals read from a reference are
// skipped, exactly as SUM skips them (fn_math.go's
- // taggedValue comment) — a conditional sum must not
- // disagree with an unconditional one about what a number is.
+ // taggedValue comment).
return
}
sum += v.Num
n++
})
- return sum, n
+ return sum, n, bad
}
// ---- SUMIF / COUNTIF / AVERAGEIF ------------------------------------
@@ -115,7 +153,10 @@ func fnSumIf(argNodes []Node, env Env) Value {
if !ok {
return bad
}
- sum, _ := total(pairs, over, env)
+ sum, _, bad := total(pairs, over, env)
+ if bad.Kind == KindErr {
+ return bad
+ }
return Value{Kind: KindNum, Num: sum}
}
@@ -137,7 +178,10 @@ func fnAverageIf(argNodes []Node, env Env) Value {
if !ok {
return bad
}
- sum, n := total(pairs, over, env)
+ sum, n, bad := total(pairs, over, env)
+ if bad.Kind == KindErr {
+ return bad
+ }
if n == 0 {
// Excel's answer for an average of nothing, and deliberately
// not zero: zero is a number somebody would act on.
@@ -176,7 +220,10 @@ func fnSumIfs(argNodes []Node, env Env) Value {
if !ok {
return bad
}
- sum, _ := total(pairs, over, env)
+ sum, _, bad := total(pairs, over, env)
+ if bad.Kind == KindErr {
+ return bad
+ }
return Value{Kind: KindNum, Num: sum}
}
@@ -185,7 +232,10 @@ func fnAverageIfs(argNodes []Node, env Env) Value {
if !ok {
return bad
}
- sum, n := total(pairs, over, env)
+ sum, n, bad := total(pairs, over, env)
+ if bad.Kind == KindErr {
+ return bad
+ }
if n == 0 {
return errValue(ErrDiv0)
}
@@ -218,5 +268,18 @@ func plural(argNodes []Node, env Env) (rect, []condPair, Value, bool) {
if !ok {
return rect{}, nil, bad, false
}
+ // The SUM range's shape is checked too, and it was not.
+ // SUMIFS(A1:A2, B1:B3, "t*") answered 60 by reshaping A1:A2 up to
+ // the criteria range's height and reading A3 — a cell the formula
+ // never named. LibreOffice answers Err:502 and Excel #VALUE!.
+ // Caught by review, 2026-09-04.
+ //
+ // The singular forms are deliberately NOT checked this way: Excel's
+ // SUMIF really does read its third argument for its origin only,
+ // which is the documented asymmetry fn_cond.go's header describes.
+ if over.r1-over.r0 != pairs[0].over.r1-pairs[0].over.r0 ||
+ over.c1-over.c0 != pairs[0].over.c1-pairs[0].over.c0 {
+ return rect{}, nil, errValue(ErrValue), false
+ }
return over, pairs, Value{}, true
}
diff --git a/internal/sheets/calc/fn_cond_test.go b/internal/sheets/calc/fn_cond_test.go
index a683124..3da41d8 100644
--- a/internal/sheets/calc/fn_cond_test.go
+++ b/internal/sheets/calc/fn_cond_test.go
@@ -57,3 +57,126 @@ func TestMatchingShapesAreStillAccepted(t *testing.T) {
t.Fatalf("got %+v, want 3", got)
}
}
+
+// errRangeEnv is condEnv with A2 replaced by a #DIV/0!.
+var errRangeEnv = mapEnv{
+ {1, 1}: {Kind: KindNum, Num: 10},
+ {2, 1}: {Kind: KindErr, Err: ErrDiv0},
+ {3, 1}: {Kind: KindNum, Num: 30},
+}
+
+// TestAConditionalSumAgreesWithSumAboutErrors.
+//
+// SUM over a range holding a #DIV/0! answers #DIV/0!. SUMIF over the
+// same range answered 40 — it skipped the error along with the text and
+// the blanks — which contradicted fn_cond_apply.go's own stated
+// invariant that a conditional sum must not disagree with an
+// unconditional one. Found by review.
+//
+// SUM's own behaviour is the reference on purpose: it is corpus-
+// validated, and neither oracle can settle this one. excelize does not
+// propagate at all (it answers 40 to SUM over this range, disagreeing
+// with our validated SUM before SUMIF is reached), and gencorpus
+// refuses to record an error as a value.
+func TestAConditionalSumAgreesWithSumAboutErrors(t *testing.T) {
+ base := evalIn(t, `SUM(A1:A3)`, errRangeEnv)
+ if base.Kind != KindErr {
+ t.Fatalf("the premise is gone: SUM over a range holding an error = %+v, "+
+ "so there is nothing for the conditional forms to agree WITH", base)
+ }
+ for _, expr := range []string{
+ `SUMIF(A1:A3,">5")`,
+ `SUMIFS(A1:A3,A1:A3,">5")`,
+ `AVERAGEIF(A1:A3,">5")`,
+ `AVERAGEIFS(A1:A3,A1:A3,">5")`,
+ } {
+ got := evalIn(t, expr, errRangeEnv)
+ if got.Kind != KindErr || got.Err != base.Err {
+ t.Errorf("%s = %+v, want %v — SUM over the same range says %v, and a "+
+ "conditional sum that skips an error reports a total nobody can "+
+ "tell is missing a row", expr, got, base.Err, base.Err)
+ }
+ }
+}
+
+// TestCountingStillCounts is the boundary: COUNTIF counts CELLS, not
+// values, so an error in the range is a cell that does not match rather
+// than an answer. Without this, the obvious fix for the test above —
+// propagate everywhere — would silently change counting too.
+func TestCountingStillCounts(t *testing.T) {
+ got := evalIn(t, `COUNTIF(A1:A3,">5")`, errRangeEnv)
+ if got.Kind != KindNum || got.Num != 2 {
+ t.Errorf("COUNTIF over a range holding an error = %+v, want 2", got)
+ }
+}
+
+func evalIn(t *testing.T, expr string, env Env) Value {
+ t.Helper()
+ n, err := Parse(expr)
+ if err != nil {
+ t.Fatalf("%s: %v", expr, err)
+ }
+ return Eval(n, env)
+}
+
+// fault lines the plain support table cannot reach: an error cell, a
+// blank criterion, and a sum range of the wrong shape.
+var edgeEnv = mapEnv{
+ {1, 1}: {Kind: KindNum, Num: 10},
+ {2, 1}: {Kind: KindNum, Num: 20},
+ {3, 1}: {Kind: KindErr, Err: ErrDiv0},
+ {1, 2}: {Kind: KindStr, Str: "ten"},
+ {2, 2}: {Kind: KindStr, Str: "twenty"},
+ {3, 2}: {Kind: KindStr, Str: "thirty"},
+ // D3 (col 4, row 3) is deliberately absent: a blank criterion cell.
+}
+
+// TestTheCriteriaMatcherOnItsEdges pins three answers the review
+// measured against LibreOffice 26.8 and this engine got wrong.
+//
+// Direct assertions rather than corpus cases, because each needs a
+// support cell the shared corpus table does not have — an error, and a
+// blank used as a CRITERION rather than as data. Extending that table
+// changes the fixture every other case is written against, which is a
+// bigger change than these three fixes and is named in the spec's
+// deferred table instead.
+func TestTheCriteriaMatcherOnItsEdges(t *testing.T) {
+ for _, tc := range []struct {
+ expr string
+ want float64
+ why string
+ }{
+ {`COUNTIF(A1:A3,"<>")`, 3,
+ "an error cell is not blank, so it counts for <> — LibreOffice and Excel agree, " +
+ "and the first version refused every error for every criterion"},
+ {`COUNTIF(A1:A3,"<>20")`, 2,
+ "10 and the error; 20 is excluded"},
+ {`COUNTIF(A1:A3,D3)`, 0,
+ "a BLANK criterion cell matches nothing, and is not the same request as " +
+ `COUNTIF(range,"")`},
+ } {
+ got := evalIn(t, tc.expr, edgeEnv)
+ if got.Kind != KindNum || got.Num != tc.want {
+ t.Errorf("%s = %+v, want %v — %s", tc.expr, got, tc.want, tc.why)
+ }
+ }
+}
+
+// TestASumRangeOfTheWrongShapeIsRefused: SUMIFS(A1:A2,B1:B3,"t*")
+// answered 60 by reshaping A1:A2 up to the criteria range's height and
+// reading A3 — a cell the formula never named. LibreOffice: Err:502.
+//
+// The SINGULAR form is asserted alongside it precisely because it must
+// NOT be refused: Excel's SUMIF reads its third argument for its origin
+// only, and a fix that made both strict would break the documented
+// asymmetry while looking tidier.
+func TestASumRangeOfTheWrongShapeIsRefused(t *testing.T) {
+ got := evalIn(t, `SUMIFS(A1:A2,B1:B3,"t*")`, condEnv)
+ if got.Kind != KindErr || got.Err != ErrValue {
+ t.Errorf("SUMIFS with a short sum range = %+v, want #VALUE!", got)
+ }
+ if got := evalIn(t, `SUMIF(B1:B3,"t*",A1)`, condEnv); got.Kind != KindNum || got.Num != 60 {
+ t.Errorf("SUMIF(B1:B3,\"t*\",A1) = %+v, want 60 — the singular form reads its "+
+ "third argument for its ORIGIN, which is Excel's documented asymmetry", got)
+ }
+}
diff --git a/internal/sheets/calc/fn_text_find.go b/internal/sheets/calc/fn_text_find.go
new file mode 100644
index 0000000..8ab6fd0
--- /dev/null
+++ b/internal/sheets/calc/fn_text_find.go
@@ -0,0 +1,191 @@
+package calc
+
+import (
+ "strconv"
+ "strings"
+)
+
+// fn_text_find.go: VALUE, SUBSTITUTE, FIND and SEARCH.
+//
+// The four the slice spec named and the first pass cut for time. Split
+// from fn_text.go rather than added to it: that file is at 166 lines and
+// these four bring their own conventions — a 1-BASED character index
+// that is Excel's and nothing else in this package uses, and a
+// case-sensitivity split that is the only difference between two of
+// them.
+//
+// FIND and SEARCH differ in exactly two ways, and both are easy to get
+// backwards: FIND is case-SENSITIVE and takes no wildcards; SEARCH is
+// case-insensitive and does. They are written as one body with two
+// parameters so the difference is one line rather than two functions
+// that drift.
+
+func init() {
+ Register("VALUE", fnValue)
+ Register("SUBSTITUTE", fnSubstitute)
+ Register("FIND", fnFind)
+ Register("SEARCH", fnSearch)
+}
+
+// fnValue implements VALUE(text): the number a string represents.
+//
+// Excel returns #VALUE! for text that is not a number, which is the
+// whole point of the function — it is what somebody wraps an imported
+// column in to find the rows that did not parse. A number passed
+// straight through comes back unchanged rather than being reformatted.
+func fnValue(args []Value) Value {
+ if len(args) != 1 {
+ return errValue(ErrValue)
+ }
+ switch args[0].Kind {
+ case KindErr:
+ return args[0]
+ case KindNum:
+ return args[0]
+ case KindEmpty:
+ // Excel's VALUE("") is #VALUE!, and a blank cell coerces the
+ // same way. Zero would be a number somebody acts on.
+ return errValue(ErrValue)
+ }
+ s := strings.TrimSpace(toStr(args[0]))
+ // A trailing percent is the one piece of formatting Excel reads
+ // here, because "50%" is what a person copies out of a report.
+ if pct := strings.TrimSuffix(s, "%"); pct != s {
+ n, err := strconv.ParseFloat(strings.TrimSpace(pct), 64)
+ if err != nil {
+ return errValue(ErrValue)
+ }
+ return Value{Kind: KindNum, Num: n / 100}
+ }
+ n, err := strconv.ParseFloat(s, 64)
+ if err != nil {
+ return errValue(ErrValue)
+ }
+ return Value{Kind: KindNum, Num: n}
+}
+
+// fnSubstitute implements SUBSTITUTE(text, old, new, [instance]).
+//
+// Replaces every occurrence, or only the nth when instance is given.
+// Case-SENSITIVE, unlike almost everything else in Excel's text family
+// — SUBSTITUTE matches exactly and REPLACE works by position, and
+// neither of them is the case-insensitive one.
+func fnSubstitute(args []Value) Value {
+ if len(args) < 3 || len(args) > 4 {
+ return errValue(ErrValue)
+ }
+ for _, a := range args {
+ if a.Kind == KindErr {
+ return a
+ }
+ }
+ text, old, replacement := toStr(args[0]), toStr(args[1]), toStr(args[2])
+ // Excel returns the text unchanged when old is empty, rather than
+ // splicing new between every character, which is what a naive
+ // Replace does and what makes SUBSTITUTE(A1,"","x") a very long
+ // string instead of a no-op.
+ if old == "" {
+ return Value{Kind: KindStr, Str: text}
+ }
+ if len(args) == 3 {
+ return Value{Kind: KindStr, Str: strings.ReplaceAll(text, old, replacement)}
+ }
+ nth, ek := toNumber(args[3])
+ if ek != ErrNone {
+ return errValue(ek)
+ }
+ want := int(nth)
+ if want < 1 {
+ return errValue(ErrValue)
+ }
+ // Walk occurrences rather than splitting: a split loses which
+ // separator matched when old and new overlap.
+ at := 0
+ for i := 1; ; i++ {
+ j := strings.Index(text[at:], old)
+ if j < 0 {
+ return Value{Kind: KindStr, Str: text}
+ }
+ j += at
+ if i == want {
+ return Value{Kind: KindStr, Str: text[:j] + replacement + text[j+len(old):]}
+ }
+ at = j + len(old)
+ }
+}
+
+func fnFind(args []Value) Value { return findIn(args, false) }
+func fnSearch(args []Value) Value { return findIn(args, true) }
+
+// findIn is FIND and SEARCH.
+//
+// The returned index is 1-BASED and counts RUNES, not bytes. Both halves
+// matter: 1-based because that is what Excel returns and what MID's own
+// argument expects, and runes because a byte index into a cell holding
+// "café" would put MID's cut inside a character.
+//
+// Not found is #VALUE!, which is Excel's answer and is why
+// `=IFERROR(FIND(…),0)` is such a common shape — the error is the
+// signal, so returning 0 instead would break every formula written
+// around it.
+func findIn(args []Value, fold bool) Value {
+ if len(args) < 2 || len(args) > 3 {
+ return errValue(ErrValue)
+ }
+ for _, a := range args {
+ if a.Kind == KindErr {
+ return a
+ }
+ }
+ needle, hay := toStr(args[0]), toStr(args[1])
+ start := 1
+ if len(args) == 3 {
+ n, ek := toNumber(args[2])
+ if ek != ErrNone {
+ return errValue(ek)
+ }
+ start = int(n)
+ }
+ r := []rune(hay)
+ if start < 1 || start > len(r)+1 {
+ return errValue(ErrValue)
+ }
+ from := r[start-1:]
+
+ if fold {
+ // SEARCH takes Excel's wildcards, and reuses fn_cond.go's
+ // matcher rather than growing a second one — a * that means
+ // something different in two places is the bug that class of
+ // duplication always produces.
+ if idx, ok := searchWildcard(needle, from); ok {
+ return Value{Kind: KindNum, Num: float64(start + idx)}
+ }
+ return errValue(ErrValue)
+ }
+ idx := strings.Index(string(from), needle)
+ if idx < 0 {
+ return errValue(ErrValue)
+ }
+ // Byte offset back to a rune offset.
+ return Value{Kind: KindNum, Num: float64(start + len([]rune(string(from)[:idx])))}
+}
+
+// searchWildcard finds the first rune offset in hay at which needle
+// matches, case-insensitively, honouring * and ?.
+//
+// An empty needle matches at the start, which is Excel's answer and the
+// one a loop starting at length 1 would miss.
+func searchWildcard(needle string, hay []rune) (int, bool) {
+ pat := strings.ToUpper(needle)
+ if pat == "" {
+ return 0, true
+ }
+ for i := 0; i <= len(hay); i++ {
+ for j := i; j <= len(hay); j++ {
+ if wildcardMatch(pat, strings.ToUpper(string(hay[i:j]))) {
+ return i, true
+ }
+ }
+ }
+ return 0, false
+}
diff --git a/internal/sheets/calc/testdata/corpus/formulas.json b/internal/sheets/calc/testdata/corpus/formulas.json
index 3d09ba4..064db5c 100644
--- a/internal/sheets/calc/testdata/corpus/formulas.json
+++ b/internal/sheets/calc/testdata/corpus/formulas.json
@@ -1,5 +1,5 @@
{
- "note": "GENERATED by tools/gencorpus from a real headless LibreOffice 26.8.0.3 run: each entry's \"want\" is what that LibreOffice computed for that exact formula, exported via `--convert-to csv`, not a value anyone reasoned out. Recalculation on export is proven per run, not assumed: every formula cell is written with a poisoned office:value cache (999999) and the run fails if that value comes back. Two honest caveats. (1) The values come through LibreOffice's CSV export, which renders a number at roughly 15 significant digits with trailing zeros stripped -- SQRT(2) is recorded as 1.4142135623731, not the full 1.4142135623730951; corpus_test.go's valuesMatch compares numbers with a 1e-9 epsilon, so this is a rendering artifact, not a disagreement. (2) LibreOffice Calc is not Excel. Where the two are known to differ, the difference is recorded and reasoned about in internal/sheets/xlsx/oracle_divergences_test.go, cross-checked against excelize's independent formula engine -- this file records Calc's answer. Regenerate with `go run ./tools/gencorpus -out /home/paulca/.cache/tmp/newcorpus.json` (add -soffice /path/to/soffice if it is not on PATH) whenever a case is added; commit the result and update corpusSHA256 in internal/sheets/calc/corpus_test.go.",
+ "note": "GENERATED by tools/gencorpus from a real headless LibreOffice 26.8.0.3 run: each entry's \"want\" is what that LibreOffice computed for that exact formula, exported via `--convert-to csv`, not a value anyone reasoned out. Recalculation on export is proven per run, not assumed: every formula cell is written with a poisoned office:value cache (999999) and the run fails if that value comes back. Two honest caveats. (1) The values come through LibreOffice's CSV export, which renders a number at roughly 15 significant digits with trailing zeros stripped -- SQRT(2) is recorded as 1.4142135623731, not the full 1.4142135623730951; corpus_test.go's valuesMatch compares numbers with a 1e-9 epsilon, so this is a rendering artifact, not a disagreement. (2) LibreOffice Calc is not Excel. Where the two are known to differ, the difference is recorded and reasoned about in internal/sheets/xlsx/oracle_divergences_test.go, cross-checked against excelize's independent formula engine -- this file records Calc's answer. Regenerate with `go run ./tools/gencorpus -out /home/paulca/.cache/tmp/c4.json` (add -soffice /path/to/soffice if it is not on PATH) whenever a case is added; commit the result and update corpusSHA256 in internal/sheets/calc/corpus_test.go.",
"entries": [
{"function": "SUM", "expr": "SUM(1,2,3)", "want": {"kind": "num", "num": 6}},
{"function": "SUM", "expr": "SUM(1,2,3,4,5)", "want": {"kind": "num", "num": 15}},
@@ -64,6 +64,13 @@
{"function": "SUMIF", "expr": "SUMIF(A1:A3,\"\u003e15\")", "want": {"kind": "num", "num": 50}},
{"function": "SUMIF", "expr": "SUMIF(A1:A3,\"\u003e=20\")", "want": {"kind": "num", "num": 50}},
{"function": "SUMIF", "expr": "SUMIF(B1:B3,\"ten\",A1:A3)", "want": {"kind": "num", "num": 10}},
+ {"function": "SUMIF", "expr": "SUMIF(B1:B3,\"t*\",A1:A3)", "want": {"kind": "num", "num": 60}},
+ {"function": "COUNTIF", "expr": "COUNTIF(B1:B3,\"t*\")", "want": {"kind": "num", "num": 3}},
+ {"function": "COUNTIF", "expr": "COUNTIF(B1:B3,\"t??\")", "want": {"kind": "num", "num": 1}},
+ {"function": "COUNTIF", "expr": "COUNTIF(B1:B3,\"t?n\")", "want": {"kind": "num", "num": 1}},
+ {"function": "COUNTIF", "expr": "COUNTIF(B1:B3,\"*y\")", "want": {"kind": "num", "num": 2}},
+ {"function": "SUMIFS", "expr": "SUMIFS(A1:A3,B1:B3,\"t*\")", "want": {"kind": "num", "num": 60}},
+ {"function": "COUNTIFS", "expr": "COUNTIFS(A1:A3,\"\u003e=20\",B1:B3,\"t*\")", "want": {"kind": "num", "num": 2}},
{"function": "COUNTIF", "expr": "COUNTIF(A1:A3,\"\u003e15\")", "want": {"kind": "num", "num": 2}},
{"function": "COUNTIF", "expr": "COUNTIF(A1:A3,\"\u003c\u003e20\")", "want": {"kind": "num", "num": 2}},
{"function": "AVERAGEIF", "expr": "AVERAGEIF(A1:A3,\"\u003e15\")", "want": {"kind": "num", "num": 25}},
@@ -96,6 +103,17 @@
{"function": "MONTH", "expr": "MONTH(EOMONTH(DATE(2026,1,31),1))", "want": {"kind": "num", "num": 2}},
{"function": "DAY", "expr": "DAY(EOMONTH(DATE(2026,1,31),1))", "want": {"kind": "num", "num": 28}},
{"function": "DAY", "expr": "DAY(EOMONTH(DATE(2024,1,15),1))", "want": {"kind": "num", "num": 29}},
- {"function": "YEAR", "expr": "YEAR(EOMONTH(DATE(2026,12,1),1))", "want": {"kind": "num", "num": 2027}}
+ {"function": "YEAR", "expr": "YEAR(EOMONTH(DATE(2026,12,1),1))", "want": {"kind": "num", "num": 2027}},
+ {"function": "VALUE", "expr": "VALUE(\"42\")", "want": {"kind": "num", "num": 42}},
+ {"function": "VALUE", "expr": "VALUE(\"3.5\")", "want": {"kind": "num", "num": 3.5}},
+ {"function": "VALUE", "expr": "VALUE(\"50%\")", "want": {"kind": "num", "num": 0.5}},
+ {"function": "SUBSTITUTE", "expr": "SUBSTITUTE(\"a-b-c\",\"-\",\"+\")", "want": {"kind": "str", "str": "a+b+c"}},
+ {"function": "SUBSTITUTE", "expr": "SUBSTITUTE(\"a-b-c\",\"-\",\"+\",2)", "want": {"kind": "str", "str": "a-b+c"}},
+ {"function": "SUBSTITUTE", "expr": "SUBSTITUTE(\"aaa\",\"a\",\"b\",3)", "want": {"kind": "str", "str": "aab"}},
+ {"function": "FIND", "expr": "FIND(\"n\",\"ten\")", "want": {"kind": "num", "num": 3}},
+ {"function": "FIND", "expr": "FIND(\"e\",\"seventeen\",4)", "want": {"kind": "num", "num": 4}},
+ {"function": "SEARCH", "expr": "SEARCH(\"T\",\"ten\")", "want": {"kind": "num", "num": 1}},
+ {"function": "SEARCH", "expr": "SEARCH(\"e?e\",\"seventeen\")", "want": {"kind": "num", "num": 2}},
+ {"function": "LEN", "expr": "LEN(SUBSTITUTE(\"a-b-c\",\"-\",\"\"))", "want": {"kind": "num", "num": 3}}
]
}
diff --git a/internal/sheets/static/cellqueue.js b/internal/sheets/static/cellqueue.js
new file mode 100644
index 0000000..3e4124c
--- /dev/null
+++ b/internal/sheets/static/cellqueue.js
@@ -0,0 +1,43 @@
+/* Send a batch of cell writes, safely.
+ *
+ * Its own module because "how do we send N cell writes without losing
+ * one" is a different question from "what did the person copy", and
+ * because the answer is subtle enough to be worth stating once in a
+ * place both callers can read.
+ *
+ * **The writes go out ONE AT A TIME, and that is the whole point.**
+ *
+ * The first version sent six at once, on the reasonable ground that a
+ * 200-cell paste in series is 200 sequential round trips. Responses to
+ * concurrent writes come back in whatever order the server finishes
+ * them, and grid.js correctly discards a paint whose version is older
+ * than one already applied — so a six-cell paste landed five on screen.
+ * Every cell was stored; one simply never painted, which looks exactly
+ * like a cell that was never sent.
+ *
+ * The second version kept the six lanes and added a refetch afterwards
+ * to repair them. That was worse: refetch() zeroes grid.js's version
+ * before applying, so a snapshot still in flight when a live edit
+ * arrives from another tab paints over that newer edit. A repair that
+ * introduces a race is not a repair.
+ *
+ * Serial writes have neither problem. Versions arrive monotonically,
+ * nothing is discarded, and no snapshot is needed to fix anything. It is
+ * slower per cell, which is what the caller's own size cap is sized
+ * against — the honest trade until a batch cell route exists, which is
+ * the real answer and is named in the slice spec's deferred table.
+ */
+(function () {
+ 'use strict';
+
+ /* jobs is an array of cell-write bodies; post is grid.js's own post,
+ * passed in rather than reached for, so this module never has to know
+ * whether the grid booted. */
+ window.SheetsCellQueue = function (jobs, post, done) {
+ var i = 0;
+ (function step() {
+ if (i >= jobs.length) { if (done) done(); return; }
+ post('/cells', jobs[i++]).then(step, step);
+ }());
+ };
+}());
diff --git a/internal/sheets/static/clipboard.js b/internal/sheets/static/clipboard.js
index 2d00079..5aefa3e 100644
--- a/internal/sheets/static/clipboard.js
+++ b/internal/sheets/static/clipboard.js
@@ -16,10 +16,10 @@
* is invisible: the grid paints what the library set, so the cell looks
* pasted until somebody reloads.
*
- * So this module takes the three clipboard events at the CAPTURE phase
- * and stops them, and every cell it writes goes through the same POST a
- * keystroke uses. One write path, one place for sealing, versioning and
- * fan-out to happen.
+ * So this module takes the keydown AND the three clipboard events at
+ * the capture phase, and every cell it writes goes through the same
+ * POST a keystroke uses. tsv.js is the wire format and cellqueue.js
+ * sends the writes.
*
* It also carries spec §4.2's translation pass. A pasted formula names
* the cell it came from, and the server offsets its relative references
@@ -36,32 +36,10 @@
* defensive round number: it is the point past which the honest
* answer is "that is too much to paste" rather than a progress bar
* this app has nowhere to put. */
- /* Sized against SERIAL writes, not against the store. At one request
- * per cell in sequence, 2000 was minutes; 500 is the point past which
- * the honest answer is "that is too much to paste at once" rather
- * than a progress bar this app has nowhere to put. Raise it when the
- * batch cell route lands, and not before. */
+ /* Sized against SERIAL writes (cellqueue.js), not against the store.
+ * Raise it when a batch cell route lands, and not before. */
var MAX_CELLS = 500;
- /* Writes go out ONE AT A TIME, and the previous version of this
- * comment argued for six lanes on latency grounds. It was wrong, and
- * the branch review found the second half of why.
- *
- * Responses to concurrent writes come back in whatever order the
- * server finishes them, and grid.js correctly discards a paint whose
- * version is older than one already applied — so a six-cell paste
- * landed five on screen. The first fix was a refetch afterwards,
- * which papered over it and opened a worse hole: refetch() zeroes the
- * version before applying, so a snapshot still in flight when a live
- * edit arrives from another tab paints OVER that newer edit.
- *
- * Serial writes have neither problem. Versions arrive monotonically,
- * nothing is discarded, and no snapshot is needed to repair anything.
- * It is slower per cell, which is what MAX_CELLS is now sized
- * against — the honest trade until a batch cell route exists, which
- * is the real answer and is named in the spec's deferred table. */
- var LANES = 1;
-
function ready() {
var g = window.SheetsGrid;
if (!g) return;
@@ -114,30 +92,10 @@
var TSV = window.SheetsTSV;
if (!TSV) return;
- /* ---- writing -----------------------------------------------------
- *
- * g.post rather than g.write, because a copied cell carries two more
- * fields. The route is the same one a keystroke posts to.
- *
- * There is deliberately NO refetch at the end. An earlier version
- * had one, to repair the paints that concurrent writes lost — and
- * it was the wrong repair: refetch() zeroes grid.js's version
- * before applying, so a snapshot in flight when a live edit lands
- * from another tab overwrites that newer edit with older state.
- * Serial writes (LANES) remove the thing it was repairing. */
- function writeAll(jobs, done) {
- if (!jobs.length) { if (done) done(); return; }
- var next = 0, live = 0;
- function pump() {
- while (live < LANES && next < jobs.length) {
- live++;
- g.post('/cells', jobs[next++]).then(step, step);
- }
- if (live === 0 && next >= jobs.length && done) done();
- }
- function step() { live--; pump(); }
- pump();
- }
+ /* cellqueue.js sends the writes. See its own comment for why they
+ * go one at a time. */
+ var writeAll = window.SheetsCellQueue;
+ if (!writeAll) return;
/* ---- the commands ------------------------------------------------ */
@@ -156,7 +114,7 @@
if (g.rawAt(r, c) !== '') jobs.push({ r: r, c: c, raw: '' });
}
}
- writeAll(jobs);
+ writeAll(jobs, g.post);
}
function paste(text) {
@@ -169,12 +127,12 @@
say('That is ' + count + ' cells, and ' + MAX_CELLS + ' is the most this can paste at once.');
return;
}
- var jobs = [], i, j, r, c, cell;
+ var jobs = [], dropped = 0, i, j, r, c, cell;
for (i = 0; i < rows.length; i++) {
for (j = 0; j < rows[i].length; j++) {
r = b.r1 + i;
c = b.c1 + j;
- if (r >= g.rows || c >= g.cols) continue;
+ if (r >= g.rows || c >= g.cols) { dropped++; continue; }
cell = { r: r, c: c, raw: rows[i][j] };
/* The origin is the SOURCE cell for this destination, so the
* server's offset is the same for every cell in the block —
@@ -184,7 +142,17 @@
jobs.push(cell);
}
}
- writeAll(jobs, function () { g.focus(); });
+ writeAll(jobs, g.post, function () {
+ g.focus();
+ /* Cells that fell outside the grid's window are SAID, not
+ * silently discarded. A paste that quietly loses its last four
+ * rows is the same failure this module was written to remove,
+ * arriving through a different door. */
+ if (dropped) {
+ say(dropped + (dropped === 1 ? ' cell' : ' cells') +
+ ' fell outside this sheet and were not pasted.');
+ }
+ });
}
/* ---- the events --------------------------------------------------
@@ -205,11 +173,53 @@
function own(e) {
if (inTextField(e.target)) return false;
if (!e.clipboardData) return false;
+ /* Nothing selected in the grid means this event is not ours —
+ * somebody is copying the page's own text. Checked BEFORE
+ * cancelling, because cancelling first and finding out after
+ * swallows a perfectly ordinary Ctrl+C over a heading. */
+ if (!g.selection()) return false;
+ var pageSel = window.getSelection && window.getSelection();
+ if (pageSel && !pageSel.isCollapsed) return false;
e.preventDefault();
e.stopImmediatePropagation();
return true;
}
+ /* The library's own Ctrl+C/X/V, taken away from it.
+ *
+ * This is the fix for the bug that made the three handlers below
+ * dead for anybody using a mouse, and it is worth writing down in
+ * full because the module looked correct and tested green.
+ *
+ * The vendored grid registers keyDownControls on `document` and
+ * acts on 67/88/86 whenever `jspreadsheet.current` is set — which a
+ * mousedown inside a worksheet does. It calls its own copy(), which
+ * moves focus into a hidden <textarea>, writes the DISPLAY strings
+ * there and runs execCommand('copy'); then it calls preventDefault
+ * on the keydown, so the browser never fires the native copy event
+ * these listeners are built on. Measured: click B1 (holding
+ * `=A1*2`), press Ctrl+C, and the clipboard holds "20".
+ *
+ * The drive script did not catch it because it never CLICKED a
+ * cell — every selection went through updateSelectionFromCoords and
+ * every menu click was a mousedown outside the grid, which nulls
+ * `current`. So it measured the one state a person with a mouse is
+ * never in. Found by review, then reproduced.
+ *
+ * Capture on document runs before a bubble listener on document, so
+ * this gets there first. It deliberately does NOT preventDefault:
+ * the whole point is to let the browser's own clipboard action
+ * proceed, so the native copy/cut/paste event fires and the
+ * handlers below receive a real clipboardData. */
+ document.addEventListener('keydown', function (e) {
+ if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return;
+ var k = e.key.toLowerCase();
+ if (k !== 'c' && k !== 'x' && k !== 'v') return;
+ if (inTextField(e.target)) return;
+ if (!g.selection()) return;
+ e.stopImmediatePropagation();
+ }, true);
+
document.addEventListener('copy', function (e) {
if (!own(e)) return;
var text = copyText();
@@ -221,6 +231,14 @@
if (!g.mayWrite) return;
var text = copyText(), b = box();
if (text === null) return;
+ /* A cut forgets where it came from, and that is spec §4.2 rather
+ * than an omission: "Cut-paste a cell — the cell keeps its
+ * (row_id, col_id) key and its CONTENT MOVES." A moved formula
+ * points where it always pointed; only a COPIED one is
+ * re-resolved by offset. Leaving lastCopy set here made cut
+ * translate, which is Excel's behaviour for copy and nobody's for
+ * cut. */
+ lastCopy = null;
e.clipboardData.setData('text/plain', text);
clearBox(b);
}, true);
@@ -247,6 +265,7 @@
},
cut: function () {
var text = copyText(), b = box();
+ lastCopy = null; /* see the cut event handler: §4.2, content moves */
if (text === null || !navigator.clipboard) return;
navigator.clipboard.writeText(text).then(function () {
clearBox(b);
diff --git a/internal/sheets/templates/sheet.html b/internal/sheets/templates/sheet.html
index 21bdf4a..e29d0d0 100644
--- a/internal/sheets/templates/sheet.html
+++ b/internal/sheets/templates/sheet.html
@@ -17,6 +17,7 @@
<script defer src="{{asset "static/formulabar.js"}}"></script>
<script defer src="{{asset "static/note.js"}}"></script>
<script defer src="{{asset "static/tsv.js"}}"></script>
+<script defer src="{{asset "static/cellqueue.js"}}"></script>
<script defer src="{{asset "static/clipboard.js"}}"></script>
<script defer src="{{asset "static/commands.js"}}"></script>
{{end}}
diff --git a/internal/sheets/xlsx/oracle_wildcard_test.go b/internal/sheets/xlsx/oracle_wildcard_test.go
index a95934d..c38d0fb 100644
--- a/internal/sheets/xlsx/oracle_wildcard_test.go
+++ b/internal/sheets/xlsx/oracle_wildcard_test.go
@@ -6,40 +6,25 @@ import (
"amadan.net/oficina/sheets/internal/sheets/calc"
)
-// The wildcard cases the LibreOffice corpus cannot judge.
+// A SECOND opinion on the wildcard criteria, beside the corpus.
//
-// SUMIF, COUNTIF and their plurals take Excel's two wildcards in a text
-// criterion: * for any run of characters and ? for exactly one. Every
-// other case for these functions lives in the generated corpus, where
-// LibreOffice is the oracle — but not these.
+// These were briefly the ONLY check on wildcards, when LibreOffice
+// answered 0 to all of them and that was read as "its profile does not
+// do Excel wildcards". The diagnosis was wrong: wildcards are a
+// per-document calculation setting, and gencorpus was writing an .ods
+// with no <table:calculation-settings>, so LibreOffice fell back to
+// ODF's legacy default — regular expressions on, wildcards off. One
+// element in ods.go and the same binary answers exactly as Excel does.
+// Caught by review, 2026-09-04.
//
-// Measured on 2026-09-03, LibreOffice 26.8.0.3 answered **0** to every
-// one of the five below. Its default profile does not read * and ? as
-// wildcards at all, so "t*" is matched as the literal two-character
-// string. That is a configuration difference, not a bug in either
-// engine, and it is the same family as the divergence already recorded
-// for SUM(1,2,"3") — 6 in Excel, in excelize and here; #VALUE! in Calc.
-//
-// Pinning LibreOffice's zeros into the corpus would have forced this
-// engine to be wrong in exactly the place the corpus exists to keep it
-// right, so the cases moved here instead, to the oracle that can judge
-// them. excelize is an entirely independent implementation of Excel's
-// semantics and agrees with ours on all five.
-//
-// This is deliberately NOT a table of expected values written by hand.
-// The whole rule (design §5) is that expectations come from a real
-// engine, and a hand-written 60 here would be indistinguishable from a
-// hand-written 0 — both would pass, and only one would be Excel.
-// The first two exist because the obvious pair did not discriminate.
-// `COUNTIF(B1:B3,"t?n")` was the original ? case and a matcher that
-// treated ? as * still answered 1 — "t*n" matches "ten" and neither
-// "twenty" nor "thirty", so the mutation was invisible. Caught by
-// planting it deliberately, which is the only way that is ever caught.
-//
-// - "t??" is 1 with a correct ?, and 3 if ? widens to *.
-// - "t*" is 3 with a correct *, and 0 if * narrows to ?.
-//
-// Both mutations now fail, in opposite directions.
+// So every case below is now in the LibreOffice corpus too, and this
+// file is a cross-check rather than a substitute. It stays because two
+// independent engines agreeing is worth more than one, and because the
+// mutation test below is the thing that proves the comparison is live.
+// It also keeps a record of why the corpus is the primary oracle: the
+// substitute is weak, and during that same review excelize was observed
+// answering 0 to SUM over a range holding an error and panicking on a
+// COUNTIFS shape mismatch.
var wildcardCases = []string{
`COUNTIF(B1:B3,"t??")`,
`COUNTIF(B1:B3,"t*")`,
diff --git a/tools/drive/clipboard.mjs b/tools/drive/clipboard.mjs
index 14e996a..61d8f16 100644
--- a/tools/drive/clipboard.mjs
+++ b/tools/drive/clipboard.mjs
@@ -43,6 +43,28 @@ const shown=(r,c)=>page.evaluate(([r,c])=>window.SheetsGrid.worksheet().getValue
for(const [r,c,v] of [[0,0,'10'],[1,0,'20'],[2,0,'30'],[0,1,'=A1*2']]) await w(r,c,v);
await page.waitForTimeout(500);
+/* A REAL MOUSE CLICK first, and this is the most important line in the
+ * file. The vendored grid acts on Ctrl+C/X/V from its own document
+ * keydown whenever jspreadsheet.current is set — which a mousedown
+ * inside a worksheet does and updateSelectionFromCoords does not. An
+ * earlier version of this script never clicked a cell, so it measured
+ * the one state a person with a mouse is never in, and reported the
+ * clipboard as working while Ctrl+C copied the library's display
+ * string. Found by review; do not replace these clicks with a
+ * programmatic selection. */
+const cellAt=(r,c)=>`[data-sheet-grid] tbody tr:nth-child(${r+1}) td:nth-child(${c+2})`;
+await page.click(cellAt(0,1));
+await page.waitForTimeout(200);
+check('a mouse click arms the vendored grid (the state this must survive)',
+ await page.evaluate(()=>{try{return !!(window.jspreadsheet&&jspreadsheet.current);}catch(e){return false;}}),
+ 'jspreadsheet.current is not set, so this run is measuring the wrong state');
+await page.evaluate(async()=>{try{await navigator.clipboard.writeText('SENTINEL');}catch(e){}});
+await page.keyboard.press('Control+c');
+await page.waitForTimeout(400);
+const afterClick=await page.evaluate(async()=>{try{return await navigator.clipboard.readText();}catch(e){return 'ERR';}});
+check('after a mouse click, Ctrl+C copies the RAW input, not the display string',
+ afterClick==='=A1*2', `got ${JSON.stringify(afterClick)} — "20" means the library took the keydown`);
+
console.log('\n— fill down translates (spec 4.2)');
await sel(1,0,1,2); await page.waitForTimeout(200);
await page.click('[data-menubar] > details:nth-child(2) > summary');
diff --git a/tools/gencorpus/cases.go b/tools/gencorpus/cases.go
index 6bcd238..2b15910 100644
--- a/tools/gencorpus/cases.go
+++ b/tools/gencorpus/cases.go
@@ -102,24 +102,32 @@ var cases = []corpusCase{
// table the lookup family already uses — no new support data, and
// the cases stay readable next to their expected answers.
//
- // WILDCARD CASES ARE DELIBERATELY ABSENT, and this is measured
- // rather than assumed. Generated on 2026-09-03, LibreOffice 26.8.0.3
- // answered 0 to every one of SUMIF(B1:B3,"t*",A1:A3),
- // COUNTIF(B1:B3,"t?n"), COUNTIF(B1:B3,"*y"), SUMIFS(…,"t*") and
- // COUNTIFS(…,"t*") — its default profile does not read * and ? as
- // Excel wildcards, so "t*" is matched as a literal string. excelize,
- // an independent engine, answers 60/1/2/60/2, which is Excel's
- // behaviour and ours.
+ // The wildcard cases are back, and the story is worth keeping.
//
- // So LibreOffice cannot be the oracle for these, and pinning its
- // answers here would force the engine to be wrong in the one place
- // the corpus exists to keep it right. They live in
- // xlsx/oracle_wildcard_test.go instead, checked against excelize.
- // Same family of divergence as SUM(1,2,"3"), which Calc calls
- // #VALUE! and Excel calls 6.
+ // They were removed on 2026-09-03 because LibreOffice answered 0 to
+ // every one of them, which was read as "its profile does not do
+ // Excel wildcards, so it cannot be the oracle here". The
+ // measurement was right and the diagnosis was wrong: wildcards are
+ // a per-DOCUMENT calculation setting, and ods.go was writing a file
+ // with no <table:calculation-settings> at all, so LibreOffice fell
+ // back to ODF's legacy default — regular expressions on, wildcards
+ // off — where "t*" is the regex t* and matches nothing in "ten".
+ // The generator was asking the wrong question and getting a correct
+ // answer to it. One element in ods.go, and the same binary gives
+ // Excel's answers. Caught by review, 2026-09-04.
+ //
+ // The lesson is the general one: when an oracle disagrees, check
+ // what you actually asked it before deciding it is unfit.
{"SUMIF", `SUMIF(A1:A3,">15")`},
{"SUMIF", `SUMIF(A1:A3,">=20")`},
{"SUMIF", `SUMIF(B1:B3,"ten",A1:A3)`},
+ {"SUMIF", `SUMIF(B1:B3,"t*",A1:A3)`},
+ {"COUNTIF", `COUNTIF(B1:B3,"t*")`},
+ {"COUNTIF", `COUNTIF(B1:B3,"t??")`},
+ {"COUNTIF", `COUNTIF(B1:B3,"t?n")`},
+ {"COUNTIF", `COUNTIF(B1:B3,"*y")`},
+ {"SUMIFS", `SUMIFS(A1:A3,B1:B3,"t*")`},
+ {"COUNTIFS", `COUNTIFS(A1:A3,">=20",B1:B3,"t*")`},
{"COUNTIF", `COUNTIF(A1:A3,">15")`},
{"COUNTIF", `COUNTIF(A1:A3,"<>20")`},
{"AVERAGEIF", `AVERAGEIF(A1:A3,">15")`},
@@ -184,4 +192,21 @@ var cases = []corpusCase{
{"DAY", "DAY(EOMONTH(DATE(2026,1,31),1))"},
{"DAY", "DAY(EOMONTH(DATE(2024,1,15),1))"},
{"YEAR", "YEAR(EOMONTH(DATE(2026,12,1),1))"},
+
+ // VALUE, SUBSTITUTE, FIND and SEARCH. FIND and SEARCH differ in
+ // exactly two ways and both are easy to get backwards, so each has
+ // a case that separates them: "T" against "ten" is 1 for SEARCH
+ // (case-insensitive) and not found for FIND, and the wildcard case
+ // only SEARCH accepts.
+ {"VALUE", `VALUE("42")`},
+ {"VALUE", `VALUE("3.5")`},
+ {"VALUE", `VALUE("50%")`},
+ {"SUBSTITUTE", `SUBSTITUTE("a-b-c","-","+")`},
+ {"SUBSTITUTE", `SUBSTITUTE("a-b-c","-","+",2)`},
+ {"SUBSTITUTE", `SUBSTITUTE("aaa","a","b",3)`},
+ {"FIND", `FIND("n","ten")`},
+ {"FIND", `FIND("e","seventeen",4)`},
+ {"SEARCH", `SEARCH("T","ten")`},
+ {"SEARCH", `SEARCH("e?e","seventeen")`},
+ {"LEN", `LEN(SUBSTITUTE("a-b-c","-",""))`},
}
diff --git a/tools/gencorpus/ods.go b/tools/gencorpus/ods.go
index 1f84bed..5c1dd5e 100644
--- a/tools/gencorpus/ods.go
+++ b/tools/gencorpus/ods.go
@@ -135,6 +135,19 @@ func contentXML(cases []corpusCase) string {
office:version="1.2">
<office:body>
<office:spreadsheet>
+ <!-- Excel's criteria semantics, stated per DOCUMENT.
+ Without this element LibreOffice falls back to ODF's legacy
+ default — regular expressions ON, wildcards OFF — so a
+ criterion of "t*" is the REGEX t*, which matches nothing in
+ "ten". Measured 2026-09-03: every wildcard case in the list
+ came back 0, and the first reading of that was that
+ LibreOffice's profile disagreed with Excel and could not be
+ the oracle for them. It was not a profile and it does not
+ disagree; the generator was writing a document that asked
+ the wrong question. Corrected 2026-09-04 after review, which
+ rebuilt the same file with this one element and got Excel's
+ answers from the same binary. -->
+ <table:calculation-settings table:use-wildcards="true" table:use-regular-expressions="false"/>
<table:table table:name="Corpus">
` + rows.String() + ` </table:table>
</office:spreadsheet>