Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ size="…in" margins="…in" text-width="…in" -->` note when the page deviates
headers`/`docx footers`. A header/footer holding a reviewer's **pending tracked
changes** names them in every view — `<!-- docx:header hdr0 pending="tc2 tc3"
text="Acme Report (Draft)" -->` — so you can `track-changes accept/reject --at
tc2` without guessing; the `text` follows the view: accepted by default, the
tc2` without guessing (text, paragraph marks, paragraph formatting and a
header table's row/cell/property revisions all list and accept/reject, as in
the body); the `text` follows the view: accepted by default, the
original under `--baseline`, and the body's CriticMarkup under `--current`
(`text="Acme Report{++ (Draft)++}[^tc2]{-- v1--}[^tc3]"`; author/date are in
`track-changes list`, not the appendix). The `--` of a deletion is safe inside
Expand Down
4 changes: 2 additions & 2 deletions src/cli/track-changes/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ Accept/reject **bypass tracking** — they mutate the XML tree directly without
- moveFrom/moveTo halves process independently — `--all` handles a complete move; targeting one half by `tcN` leaves the other in place.
- Targets are read from `document.trackedChangeReferences` — the single inventory the AST reader builds during `Document.open` (one walk over the body, then the note parts). `collectTrackedChanges` just reconstructs `ChangeFound` records from that map; it does **not** re-walk. Since each `Document` is opened fresh per CLI invocation, the map is current for that invocation. Apply still processes targets in reverse pre-order so nested changes apply before parents.

Paragraph-mark trackings (a self-closing `<w:ins>`/`<w:del>` inside `<w:pPr><w:rPr>`) are full citizens: accept-ins / reject-del removes the marker (paragraph stays); reject-ins removes the entire owning paragraph (the inserted break disappears — for `docx sections` sentinels this collapses the section break too); accept-del merges the owning paragraph with the next (next paragraph's runs append here, next paragraph removed) per ECMA-376 §17.13.5.4.
Paragraph-mark trackings (a self-closing `<w:ins>`/`<w:del>` inside `<w:pPr><w:rPr>`) are full citizens: accept-ins / reject-del removes the marker (paragraph stays); reject-ins removes the entire owning paragraph (the inserted break disappears — for `docx sections` sentinels this collapses the section break too) unless it is its story's ONLY block (a header part, a cell, a text box — `isOnlyBlock`), where it strips the marker and keeps the paragraph, since an empty `<w:hdr/>`/`<w:tc>` is not a valid story; accept-del merges the owning paragraph with the next (next paragraph's runs append here, next paragraph removed) per ECMA-376 §17.13.5.4.

Table-structural trackings are full citizens too. **Which constructs we emit was settled empirically against Microsoft Word (AppleScript accept/reject), not from the schema** — see [src/cli/tables](../tables/CLAUDE.md): `rowIns`/`rowDel` (`<w:trPr><w:ins>`/`<w:del>` — accept/reject acts on the whole `<w:tr>`), `cellIns`/`cellDel` (`<w:tcPr><w:cellIns>`/`<w:cellDel>` — per-cell record of a tracked column insert/delete), `tblGridChange` (`<w:tblGrid><w:tblGridChange>` — grid-width snapshot, restored on reject like `sectPrChange`), and `tcPrChange` (`<w:tcPr><w:tcPrChange>` — prior-`tcPr` snapshot; `set-widths` pairs one per cell with the `tblGridChange`, since Word's reject of a width change is driven by the per-cell `tcPrChange`). `tblPrChange` is read/accept/rejected (Word authors it) but we don't emit it — Word doesn't revert a hand-authored one. Cell merges and border changes are NOT tracked (Word applies them immediately and warns "won't be marked as a change"), so `merge`/`unmerge`/`borders` apply in place + drop a `[docx-cli]` audit comment. Cell removals (accept-cellDel / reject-cellIns) shrink rows without touching `<w:tblGrid>`, so `runApply` runs `resyncTableGrids` afterward to trim the grid to the widest row. Row markers are ambiguous by tag (`<w:ins>` is also run-level), so the `TrackedChangeReference` carries an explicit `kind` that `list.ts` prefers; `apply.ts` disambiguates by walk context.

Footnote/endnote trackings span two parts. **Empirically validated against Microsoft Word** (see [scripts/word-redlines.sh](../../../scripts/word-redlines.sh) and the AppleScript probes in `/tmp/fn-probe/{add,delete,edit}.docx`): adding a footnote under tracking wraps the reference run in `document.xml` AND the body's content (`<w:footnoteRef/>` + text runs) in `footnotes.xml` in independent `<w:ins>` wrappers; deleting wraps both sides in `<w:del>` (with `<w:t>`→`<w:delText>` rename on the body) and marks the paragraph-mark of the body's `<w:p>` with a third `<w:del>` inside `<w:pPr><w:rPr>`; editing the body only touches `footnotes.xml`, emitting `<w:ins>NEW</w:ins><w:del>OLD</w:del>` with ins preceding del. The two sides are **paired structurally by the footnote id** (the `w:id` on `<w:footnoteReference>` / `<w:footnote>`), not by revision id — each side allocates its own revision id from the same monotonic counter (`computeMaxRevisionId` walks document/footnotes/endnotes so ids never collide).
- The AST reader (`core/ast/read.ts`) registers note-body revisions into `document.trackedChangeReferences` after the body walk (`readNoteTrackedChanges`), continuing the same `tcN` counter; the body-side `<w:ins>`/`<w:del>` of a paired note is hidden so add/delete surface as ONE tcN (the reference-side). Body-only revisions (footnote edits) stay visible — they're standalone.
- `applyNotePairing` (post-pass after all targets are applied) handles the paired side via the footnote-id linkage: if no live `<w:footnoteReference>` remains, GC the entire `<w:footnote>` from footnotes.xml (Word does the same — orphan bodies disappear on accept-del / reject-ins); otherwise unwrap any remaining body-side `<w:ins>`/`<w:del>` and drop the paragraph-mark del marker. The emitters live in [src/core/notes/emit.tsx](../../core/notes/emit.tsx).

**Header/footer revisions** (a reviewer's edit inside `word/header{N}.xml`/`footer{N}.xml` — Word keeps it in that part) register after the notes (`readMarginalTrackedChanges`): run-level `<w:ins>`/`<w:del>`/`<w:moveFrom>`/`<w:moveTo>` only, with `blockId` = the marginal's `hdrN`/`ftrN` (the first reference, when one part serves several sections — registered once). The walk reaches every paragraph in the part — inside block-level `<w:sdt>` (Word's page-number gallery), tables, and text boxes — and descends an `<mc:AlternateContent>` through its chosen branch only, so a text box's Choice/Fallback twin isn't listed twice. Paragraph-mark and property revisions (`pPrChange`/`rPrChange`) in a marginal aren't registered. Accept/reject mutate the part tree in place and `MarginalsView.writeTo` saves it. Registration goes through the same `registerTrackedChange` as the body (one id-minting site), and an equation through `registerMathRevisions`, so Word's per-math-run markers in a note or marginal are ONE revision, exactly as in the body. `list` reads a note or marginal wrapper's text off its own runs (`wrapperRecordText` in [list-view.ts](list-view.ts) — text, plus the `[line break]`/`[tab]`/`[drawing]`/`$…$` tokens `recordText` uses for AST runs, so a deleted header break doesn't read as "¶ paragraph mark"), and `Marginal.text` (`headers list`) reads the accepted view. Each `Marginal` also lists its part's pending revisions (`Marginal.trackedChanges`, collected through the walk's `RevisionScope.registered` sink), which the `docx:header`/`docx:footer` hint names as `pending="tcN …"` in every view; under `--baseline`/`--current` the hint's text comes from `Marginals.text(marginal, view, mark)` (core/marginals), which reads the part in that view and lets `read` wrap each revision in CriticMarkup. Because a note's `""` and a marginal's `hdrN`/`ftrN` name a whole part, not a paragraph, `revisionGroups` pairs a del with an ins there only when they share a container — edits in two header lines stay two decisions.
**Header/footer revisions** (a reviewer's edit inside `word/header{N}.xml`/`footer{N}.xml` — Word keeps it in that part) register after the notes (`readMarginalTrackedChanges`): every kind the body registers except `sectPrChange` (a part has no sections) and `checkboxToggle` — run-level `<w:ins>`/`<w:del>`/`<w:moveFrom>`/`<w:moveTo>`, `pPrChange`, paragraph marks, and the table kinds (tblPr → tblGrid → per row: row marker → per cell: cell marker → tcPrChange → content, the body's order) — with `blockId` = the marginal's `hdrN`/`ftrN` (the first reference, when one part serves several sections — registered once). The walk reaches every paragraph in the part — inside block-level `<w:sdt>` (Word's page-number gallery), tables, and text boxes — and descends an `<mc:AlternateContent>` through its chosen branch only, so a text box's Choice/Fallback twin isn't listed twice. A header paragraph isn't in `Body.blockReferences`, so its paragraph-mark reference stores the owning `paragraph`/`paragraphParent` itself (the body derives them from `blockId`), and `applyResolvedTargets` runs the table-grid resync + empty-hyperlink prune over every marginal tree as well as the body. (`rPrChange` isn't modeled anywhere — see below.) Accept/reject mutate the part tree in place and `MarginalsView.writeTo` saves it. Registration goes through the same `registerTrackedChange` as the body (one id-minting site), and an equation through `registerMathRevisions`, so Word's per-math-run markers in a note or marginal are ONE revision, exactly as in the body. `list` reads a note or marginal wrapper's text off its own runs (`wrapperRecordText` in [list-view.ts](list-view.ts) — text, plus the `[line break]`/`[tab]`/`[drawing]`/`$…$` tokens `recordText` uses for AST runs, so a deleted header break doesn't read as "¶ paragraph mark"), and `Marginal.text` (`headers list`) reads the accepted view. Each `Marginal` also lists its part's pending revisions (`Marginal.trackedChanges`, collected through the walk's `RevisionScope.registered` sink), which the `docx:header`/`docx:footer` hint names as `pending="tcN …"` in every view; under `--baseline`/`--current` the hint's text comes from `Marginals.text(marginal, view, mark)` (core/marginals), which reads the part in that view and lets `read` wrap each revision in CriticMarkup. Because a note's `""` and a marginal's `hdrN`/`ftrN` name a whole part, not a paragraph, `revisionGroups` pairs a del with an ins there only when they share a container — edits in two header lines stay two decisions.

**Checkbox toggles** in `<w14:checkbox>` content controls surface as the `checkboxToggle` kind. **Empirically validated against Microsoft Word for Mac**: when the user clicks a checkbox under tracking, Word emits an `<w:ins>` (new glyph ☒ or ☐) and `<w:del>` (old glyph) pair INSIDE `<w:sdtContent>`, and flips the `w14:checked` attribute in place — no `<w14:checkedChange>` element exists. The detection + accept/reject live in [src/core/task-list/](../../core/task-list/); the AST reader's `detectTaskListState` calls `findCheckboxToggle` and registers the `checkboxToggle` `tcN` into `document.trackedChangeReferences` (the single source apply reads back). Accept (`applyToggle`) keeps the ins glyph and drops the del; reject (`revertToggle`) drops the ins, unwraps the del, AND flips `w14:checked` back, inferring the prior value from the kept glyph (☐ → "0", ☒ → "1"). Structural inserts/deletes of whole checkboxes (Word wraps the SDT in `<w:customXmlDel/InsRangeStart/End>`) survive in the XmlNode tree on round-trip but aren't enumerated as a dedicated kind yet; `track-changes accept --all` won't honor them.

Expand Down
4 changes: 2 additions & 2 deletions src/cli/track-changes/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ handle; paired halves additionally carry "group": "revN". kind is one of: "ins",
their blockId is the owning paragraph's pN. Table-structural entries
(rowIns/rowDel/cellIns/cellDel and the property revisions
tblGridChange/tblPrChange/tcPrChange) have text "" and blockId set to the
owning table's tN. Revisions inside a page header/footer have blockId set to
its hdrN/ftrN. checkboxToggle entries surface a tracked checkbox toggle
owning table's tN. Revisions inside a page header/footer (any of these kinds)
have blockId set to its hdrN/ftrN. checkboxToggle entries surface a tracked checkbox toggle
(☐↔☒); reject restores the prior state. (Structural inserts/deletes of a
whole checkbox aren't yet enumerated as their own kind.)

Expand Down
9 changes: 7 additions & 2 deletions src/core/ast/document/body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,13 +289,18 @@ export type TrackedChangeReference = {
* run-level changes (kind derived from tag). */
kind?: TrackedChangeKind;
/** Set for rowIns/rowDel: the owning `<w:tr>` and the table's children, so
* accept/reject can remove the whole row. (Paragraph-mark scope is NOT
* stored — it's derivable from `blockId` via `Body.blockReferences`.) */
* accept/reject can remove the whole row. */
tableRow?: XmlNode;
tableRowParent?: XmlNode[];
/** Set for cellIns/cellDel: the owning `<w:tc>` and the row's children. */
tableCell?: XmlNode;
tableCellParent?: XmlNode[];
/** Set for a paragraph-mark `<w:ins>`/`<w:del>` in a header/footer part:
* the owning `<w:p>` and its parent list. A BODY paragraph mark doesn't
* store it — its scope is derivable from `blockId` via
* `Body.blockReferences`, which a header paragraph isn't in. */
paragraph?: XmlNode;
paragraphParent?: XmlNode[];
/** Every element of a revision Word spreads over several markers — an
* equation edit puts a `<w:del>`/`<w:ins>` in each math run it touched.
* `node`/`parent` are the first of them; accept/reject act on each. */
Expand Down
Loading
Loading