Skip to content

Bug sweep: escaping, storage correctness, round-trip losses, style scrub, caret preservation + canonical export formatting#417

Merged
maboa merged 8 commits into
mainfrom
bug-sweep
Jul 23, 2026
Merged

Bug sweep: escaping, storage correctness, round-trip losses, style scrub, caret preservation + canonical export formatting#417
maboa merged 8 commits into
mainfrom
bug-sweep

Conversation

@maboa

@maboa maboa commented Jul 23, 2026

Copy link
Copy Markdown
Member

Fixes five bugs from the recent review batch, plus canonical export formatting and caret preservation during editing.

Tests: +17 new across unit and e2e (escaping, storage order-shift/corruption/markup-name, serialization format, round-trip, style scrub, caret/selection/IME/re-index). Full suite 36 e2e + 15 unit passing.

Closes #406, #408, #409, #410, #415.

maboa added 8 commits July 23, 2026 15:03
Words like <inaudible> or AT&T corrupted the inline-timestamp stream — parsers
read them as malformed tags and swallowed the word. Serialization-only:
buildWordChunks stays raw for the burn-in renderer, which draws text.
jsonToHTML interpolated raw text into markup that lands in innerHTML —
<inaudible> vanished as a bogus tag on round-trip, and a hostile payload in
third-party transcript JSON executed (stored XSS). All three interpolation
sites now escape & < > "; htmlToJSON reads textContent so the decode is free.
WebKit injects inline styles (font-size etc.) on paste/splits plus style-only
wrapper spans with no data-m; both persisted into exports. scrubEditingArtifacts
drops them while preserving the two functional styles: line-through (the
strikeout/cut model) and display on speaker labels (the Speakers toggle).
New js/transcript-serializer.js emits clean two-space-indented markup for the
HTML download, the interactive-transcript dialog, and the export modal's
re-timed transcript — replacing raw contenteditable innerHTML (and the old
strip-all-classes regex, which wrongly removed the semantic speaker class).
Runtime classes and non-functional styles never serialize; text re-escapes
from textContent.
…scape names (#410)

Three stacked hazards with one failure story: the picker referenced entries by
storage.key(i) POSITION, but key order is implementation-defined and shifts
whenever any module writes a key (the transcribe prefs do so on every toggle) —
so a click could load a different entry than the one listed, including
non-transcript keys that parse as JSON and then throw half-way through
rendering. Entries are now referenced by their key string (data-key / option
value), the JSON.parse is guarded so a corrupted entry can't permanently break
the click handler, entries missing hypertranscript/video fields are rejected
before rendering, and filenames are escaped so a name containing markup renders
as text (self-XSS). e2e coverage: order-shift, corruption, and markup-name
cases.
Three silent-loss paths fixed:
- jsonToHTML's half-open range filter deleted any word it couldn't bracket — a
  zero-duration last word failed its own paragraph's range (end == start), and
  diarizer paragraph times that don't exactly enclose word times orphaned gap
  and trailing words. Words are now assigned to the last paragraph that has
  started by the word's start time (words before the first paragraph go to the
  first), so every word renders exactly once.
- htmlToJSON's selector required data-d, which the hyperaudio format treats as
  optional (caption.js handles its absence) — those spans are now included
  with duration 0, in both the word extractor and the paragraph-range reader.

Unit tests cover the zero-duration/gap/trailing cases; an e2e round-trip test
(HTML→JSON→HTML in the real DOM) proves nothing is lost.
The join/split/reflow repairs rewrite text nodes (textContent =, replaceWith),
which destroyed the selection anchor — on the debounced sanitise pass the caret
jumped from mid-word to the start of the joined/split word. All the passes
preserve the transcript's character content, so the caret is now saved as an
absolute character offset before the repairs and re-resolved onto the new nodes
after — only when the transcript has focus and a rewrite actually happened, so
blur and no-op passes never touch the selection.
Self-review of the caret work surfaced three issues:
- a non-collapsed SELECTION was collapsed to a caret by the restore — both
  endpoints are now saved as character offsets and restored as a range
- the wordArr re-index keyed on span-COUNT change, so a merge (−1) plus a
  split (+1) in one pass netted zero and skipped the rebuild, leaving the index
  holding removed nodes and missing new ones — it now keys on merge/split
  having fired (reflow keeps the span elements, so it needs no re-index)
- normalization now skips while an IME composition is in flight (rewriting
  text nodes under an active CJK composition breaks it); the skipped pass
  runs on the next keyup/blur

e2e regression tests for all three.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

html-json-converter: speaker names and word text interpolated into HTML unescaped — round-trip corruption and stored XSS

1 participant