fix(sync): countTextCharsBefore multiplied the document by node depth
Release / build-and-push (push) Successful in 2m57s

countTextCharsBefore started its sibling walk at the target node itself
and recursed after counting the current node's subtree, so every
ancestor level re-counted everything accumulated so far — a node at
depth 4 in the single-document 1984 epub reported 2.38M chars before it
in a 589k-char document, producing healed percentages of 4.04 (>1) and
a 500 on the reading_progress percentage check constraint. Every
healed progress save from a fresh client failed; only exact-context
matches (no heal) stored.

The walk now starts at target.PrevSibling: strictly the characters
before the node, per the contract all three callers already assume
(healed book offsets, CRE convert percentage, kepub offsets).

Found by the real-phone validation pass: the phone's first healed
submission 500ed where the emulator's had matched context exactly and
never taken the heal path.
This commit is contained in:
John O'Keefe
2026-09-26 22:01:35 -04:00
parent b10bf3e8c7
commit 6aea6a4e2c
+8 -1
View File
@@ -1321,9 +1321,16 @@ func countTextChars(n *html.Node) int {
return count return count
} }
// countTextCharsBefore counts the text characters preceding target within
// its document: preceding siblings at each ancestor level, strictly
// EXCLUDING target's own subtree (callers add the in-node offset
// separately). Starting the walk at target itself — or recursing after
// counting the current node's subtree — would re-count the accumulated
// document once per ancestor level and multiply the result by the node's
// depth.
func countTextCharsBefore(target *html.Node) int { func countTextCharsBefore(target *html.Node) int {
count := 0 count := 0
for c := target; c != nil; c = c.PrevSibling { for c := target.PrevSibling; c != nil; c = c.PrevSibling {
count += countTextChars(c) count += countTextChars(c)
} }
if target.Parent != nil { if target.Parent != nil {