Skip to content
Merged
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
26 changes: 26 additions & 0 deletions src/cli/info/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,20 @@ const JSON_SCHEMA = {
author: { type: "string" },
date: { type: "string" },
revisionId: { type: "string" },
within: {
type: "array",
description:
"Enclosing revision wrappers, outermost first — present only when this wrapper is nested inside another; a run is visible in a view only when every entry is",
items: {
type: "object",
required: ["id", "kind", "author"],
properties: {
id: { type: "string" },
kind: { enum: ["ins", "del", "moveFrom", "moveTo"] },
author: { type: "string" },
},
},
},
},
},
hyperlink: { $ref: "#/$defs/Hyperlink" },
Expand Down Expand Up @@ -208,6 +222,18 @@ const JSON_SCHEMA = {
author: { type: "string" },
date: { type: "string" },
revisionId: { type: "string" },
within: {
type: "array",
items: {
type: "object",
required: ["id", "kind", "author"],
properties: {
id: { type: "string" },
kind: { enum: ["ins", "del", "moveFrom", "moveTo"] },
author: { type: "string" },
},
},
},
},
},
},
Expand Down
26 changes: 10 additions & 16 deletions src/cli/read/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import {
} from "@core/code-block";
import { extensionForImageMime } from "@core/image/formats";
import { inlineEscapeMask } from "@core/markdown";
import {
isRevisionVisible,
type RevisionView,
} from "@core/track-changes/revision-visibility";
import {
emuToInches,
formatNote,
Expand All @@ -35,7 +39,7 @@ import {
twipsToPoints,
} from "./annotations";

export type MarkdownView = "current" | "accepted" | "baseline";
export type MarkdownView = RevisionView;

export type MarkdownOptions = {
from?: string;
Expand Down Expand Up @@ -365,14 +369,11 @@ function emptyCommentIndex(): CommentIndex {
};
}

/** Whether a text run renders in `view` — the shared all-ancestors rule, so a
* `<w:del>` nested inside another author's `<w:ins>` is hidden in BOTH
* resolved views, exactly as `wc`/`find` and `accept`/`reject --all` treat it. */
function isRunVisible(run: TextRun, view: MarkdownView): boolean {
const kind = run.trackedChange?.kind;
if (!kind) return true;
if (view === "accepted" && (kind === "del" || kind === "moveFrom"))
return false;
if (view === "baseline" && (kind === "ins" || kind === "moveTo"))
return false;
return true;
return isRevisionVisible(run.trackedChange, view);
}

function buildCommentIndex(
Expand Down Expand Up @@ -457,7 +458,7 @@ function renderTextBoxes(block: Block, ctx: RenderContext): string[] {
// A box whose anchor run is tracked-deleted is gone in the accepted view
// (and a tracked-inserted one absent from the baseline) — same rule as
// `isRunVisible` for text.
if (!isTextBoxVisible(run, view)) continue;
if (!isRevisionVisible(run.trackedChange, view)) continue;
const pairs: NotePair[] = [["anchor", anchorId]];
if (run.wrap) pairs.push(["wrap", run.wrap]);
if (run.align) pairs.push(["align", run.align]);
Expand All @@ -476,13 +477,6 @@ function renderTextBoxes(block: Block, ctx: RenderContext): string[] {
return out;
}

function isTextBoxVisible(run: TextBoxRun, view: MarkdownView): boolean {
const kind = run.trackedChange?.kind;
if (view === "current" || !kind) return true;
if (view === "accepted") return kind !== "del" && kind !== "moveFrom";
return kind !== "ins" && kind !== "moveTo";
}

/** The text-box runs anchored DIRECTLY in `block` (its own runs, or its cells'
* paragraphs' runs for a table) — not the ones inside those boxes' stories,
* which `renderTextBoxes` reaches recursively. */
Expand Down
2 changes: 1 addition & 1 deletion src/cli/track-changes/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ What's deferred: `<w:rPrChange>` inside math runs (font-property revisions on in

## Adding a tracked-change kind

Extend `TrackedChange["kind"]` (and `TrackedChangeKind`) in `core/ast/types.ts`, widen the JSON schema enum in `cli/info/schema.ts`, register it in `walkRunContainer` (`core/ast/read.ts`) so it lands in `document.trackedChangeReferences`, update `paragraphTextAccepted`/`paragraphTextBaseline` in `core/ast/text.ts` and `isRunVisible`/`criticMarkerFor`/`trackedChangeLabelFor` in `cli/read/markdown.ts`. If the kind takes part in accept/reject, handle it in `actionFor`/`applyAccept`/`applyReject` in [apply.ts](apply.ts); if its wrapper tag is ambiguous, add it to `trackedChangeKindForTag` (apply.ts) or carry an explicit `kind` on the reference. Because there's only the one reader walk, you register in exactly one place — no second walker to keep in sync.
Extend `TrackedChange["kind"]` (and `TrackedChangeKind`) in `core/ast/types.ts`, widen the JSON schema enum in `cli/info/schema.ts`, register it in `walkRunContainer` (`core/ast/read.ts`) so it lands in `document.trackedChangeReferences`, teach `isRevisionVisible` in `core/track-changes/revision-visibility.ts` which views hide it (the one predicate `paragraphTextAccepted`/`paragraphTextBaseline` in `core/ast/text.ts`, `find`, `iterateBlocks` and `cli/read/markdown.ts` all read — a nested wrapper is visible only when every enclosing one is), and update `criticMarkerFor`/`trackedChangeLabelFor` in `cli/read/markdown.ts`. If the kind takes part in accept/reject, handle it in `actionFor`/`applyAccept`/`applyReject` in [apply.ts](apply.ts); if its wrapper tag is ambiguous, add it to `trackedChangeKindForTag` (apply.ts) or carry an explicit `kind` on the reference. Because there's only the one reader walk, you register in exactly one place — no second walker to keep in sync.

Table-structural kinds (rowIns/rowDel/cellIns/cellDel/tblGridChange) live outside the run/paragraph machinery: register them in `readTable` (`core/ast/read.ts`), give the `TrackedChangeReference` an explicit `kind` (their tags are ambiguous) plus any row/cell scope (`tableRow`/`tableCell` + parents) that accept/reject needs, handle them in `applyTableChange` (apply.ts), and filter whole tracked rows by view in `renderTable` (`cli/read/markdown.ts`) — they don't touch `text.ts` (no run text). GFM tables can't express cell-level changes, so only whole-row insert/delete are view-filtered.

Expand Down
11 changes: 9 additions & 2 deletions src/cli/track-changes/list-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ type ParagraphPropsSummary = {
indent?: Record<string, number>;
};

export type TrackedChangeRecord = TrackedChange & {
export type TrackedChangeRecord = Omit<TrackedChange, "within"> & {
blockId: string;
text: string;
prior?: SectionProperties | ParagraphPropsSummary;
Expand All @@ -39,8 +39,15 @@ export function collectTrackedChangeRecords(
existing.text += run.text;
continue;
}
// Explicit fields, not a spread: `TrackedChange.within` (the enclosing
// wrapper chain of a nested revision) is an AST/view concern and stays
// off the `list` record so its shape is the same for every change.
byId.set(change.id, {
...change,
id: change.id,
kind: change.kind,
author: change.author,
date: change.date,
revisionId: change.revisionId,
blockId: paragraph.id,
text: run.text,
});
Expand Down
4 changes: 3 additions & 1 deletion src/core/ast/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@

This is the **only walk that assigns `tcN` ids**. `cli/track-changes/list.ts` and `core/track-changes/apply.ts` both read `Document.trackedChangeReferences` (or `new TrackChanges(document).list()`) and never re-walk — so they can't drift from the AST.

**Nested revision wrappers: a run's `trackedChange` is its INNERMOST wrapper, plus the enclosing chain in `within`.** Word nests one author's `<w:del>` inside another's still-pending `<w:ins>` (and the mirror). `walkRunContainer` keeps `kind`/`id` as the innermost wrapper's (that's the `tcN` accept/reject acts on) and records the enclosing wrappers, outermost first, as `TrackedChange.within` (`{ id, kind, author }[]`) — absent on a top-level wrapper, so an un-nested document's AST is unchanged. **Every view consumer decides visibility with the ONE predicate `isRevisionVisible(change, view)`** ([core/track-changes/revision-visibility.ts](../track-changes/revision-visibility.ts)): a run is visible iff EVERY wrapper in the chain is (`accepted` hides `del`/`moveFrom`, `baseline` hides `ins`/`moveTo`, `current` shows all), so `read --baseline` ≡ `reject --all` and `read --accepted` ≡ `accept --all`. Consumers: `paragraphTextAccepted`/`paragraphTextBaseline` ([text.ts](text.ts), so `wc` follows), `find`'s per-view run text, `iterateBlocks({ view })` (text-box stories), and `cli/read/markdown.ts`. Never re-derive the rule from `trackedChange.kind` alone.

## Why the reader is free functions and not a `Body` method

The walk reads `document.relationships` + `document.numbering` (image/list resolution) and writes `document.trackedChangeReferences` — a Document-level map spanning body + notes. `readNoteTrackedChanges` operates on the footnote/endnote views directly. So the reader is genuinely cross-part orchestration; making it a `Body` method would force `Body` to reach sideways into its siblings, which is exactly the coupling the embedded-views split removed everywhere else. Free functions stay.
Expand All @@ -38,4 +40,4 @@ Add to [types.ts](types.ts), populate in [read.ts](read.ts), then widen `cli/inf

## Adding a tracked-change kind

See [src/cli/track-changes/CLAUDE.md](../../cli/track-changes/CLAUDE.md). The summary: register it in the right place in the reader's body walk (the single source of `tcN` ids), handle it in `actionFor` / `applyAccept` / `applyReject` (`core/track-changes/apply.ts`), and surface it in `paragraphTextAccepted` / `paragraphTextBaseline` ([text.ts](text.ts)) + `cli/read/markdown.ts`. One place to register — no second walker to mirror.
See [src/cli/track-changes/CLAUDE.md](../../cli/track-changes/CLAUDE.md). The summary: register it in the right place in the reader's body walk (the single source of `tcN` ids), handle it in `actionFor` / `applyAccept` / `applyReject` (`core/track-changes/apply.ts`), and, if it affects visible text, teach `isRevisionVisible` ([core/track-changes/revision-visibility.ts](../track-changes/revision-visibility.ts)) which views hide it — `paragraphTextAccepted` / `paragraphTextBaseline` ([text.ts](text.ts)), `find`, `iterateBlocks` and `cli/read/markdown.ts` all read that one predicate. One place to register — no second walker to mirror.
14 changes: 6 additions & 8 deletions src/core/ast/document/body.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { XmlNode } from "../../parser";
import {
isRevisionVisible,
type RevisionView,
} from "../../track-changes/revision-visibility";
import { flattenImageRuns } from "../text";
import type {
Block,
Expand Down Expand Up @@ -178,7 +182,7 @@ export class Body {
* `current` walks every story. */
export function* iterateBlocks(
blocks: Block[],
options: { view?: "current" | "accepted" | "baseline" } = {},
options: { view?: RevisionView } = {},
): IterableIterator<Block> {
const view = options.view ?? "current";
for (const block of blocks) {
Expand All @@ -194,13 +198,7 @@ export function* iterateBlocks(
if (block.type === "paragraph") {
for (const run of block.runs) {
if (run.type !== "textBox") continue;
const kind = run.trackedChange?.kind;
if (view === "accepted" && (kind === "del" || kind === "moveFrom")) {
continue;
}
if (view === "baseline" && (kind === "ins" || kind === "moveTo")) {
continue;
}
if (!isRevisionVisible(run.trackedChange, view)) continue;
yield* iterateBlocks(run.blocks, options);
}
}
Expand Down
15 changes: 15 additions & 0 deletions src/core/ast/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,21 @@ function walkRunContainer(
date: child.getAttribute("w:date") ?? "",
revisionId: child.getAttribute("w:id") ?? "",
};
// A wrapper nested inside another (Word's `<w:ins><w:del>…` for a
// deletion inside a pending insertion) records its enclosing chain,
// outermost first, so every view consumer can apply the
// all-ancestors rule (`isRevisionVisible`). Top-level wrappers carry
// no `within`, so the AST of an un-nested document is unchanged.
if (trackedChange) {
change.within = [
...(trackedChange.within ?? []),
{
id: trackedChange.id,
kind: trackedChange.kind,
author: trackedChange.author,
},
];
}
context.document.trackedChangeReferences.set(trackedChangeId, {
node: child,
parent: container.children,
Expand Down
14 changes: 7 additions & 7 deletions src/core/ast/text.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isRevisionVisible } from "../track-changes/revision-visibility";
import { iterateBlocks } from "./document/body";
import type { Block, ImageRun, Paragraph } from "./types";

Expand All @@ -10,28 +11,27 @@ export function paragraphText(paragraph: Paragraph): string {
}

/** Concatenate text as it would read in the accepted view: skip runs inside
* a tracked deletion (`<w:del>`) or a tracked-move source (`<w:moveFrom>`);
* keep insertions and move destinations. */
* a tracked deletion (`<w:del>`) or a tracked-move source (`<w:moveFrom>`) —
* or nested anywhere inside one; keep insertions and move destinations. */
export function paragraphTextAccepted(paragraph: Paragraph): string {
let out = "";
for (const run of paragraph.runs) {
if (run.type !== "text") continue;
const kind = run.trackedChange?.kind;
if (kind === "del" || kind === "moveFrom") continue;
if (!isRevisionVisible(run.trackedChange, "accepted")) continue;
out += run.text;
}
return out;
}

/** Concatenate text as it would read in the baseline (pre-change) view: skip
* runs inside a tracked insertion (`<w:ins>`) or a tracked-move destination
* (`<w:moveTo>`); keep deletions and move sources. */
* (`<w:moveTo>`) — or nested anywhere inside one; keep deletions and move
* sources. */
export function paragraphTextBaseline(paragraph: Paragraph): string {
let out = "";
for (const run of paragraph.runs) {
if (run.type !== "text") continue;
const kind = run.trackedChange?.kind;
if (kind === "ins" || kind === "moveTo") continue;
if (!isRevisionVisible(run.trackedChange, "baseline")) continue;
out += run.text;
}
return out;
Expand Down
8 changes: 8 additions & 0 deletions src/core/ast/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,14 @@ export type TrackedChange = {
author: string;
date: string;
revisionId: string;
/** The run-level revision wrappers ENCLOSING this one, outermost first.
* Present only when the wrapper is nested inside another — Word's shape for
* one author deleting inside another's still-pending insertion
* (`<w:ins><w:del>…</w:del></w:ins>`). `kind`/`id` stay the INNERMOST
* wrapper's; a run is visible in a view only when EVERY wrapper in the chain
* is (`isRevisionVisible` in `core/track-changes/revision-visibility.ts`): rejecting
* the outer insertion removes the nested deletion with it. */
within?: { id: string; kind: TrackedChangeKind; author: string }[];
};

/** OOXML revision-tracking wrappers we surface in the AST.
Expand Down
9 changes: 6 additions & 3 deletions src/core/comments/markers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ import {
export { generateParaId };

/** Whether a run-bearing wrapper's contents are visible in the chosen
* view. Mirrors `isWrapperVisibleInView` in `cli/replace/replace-span.tsx`
* and `isRunVisibleInView` in `core/find/index.ts` — they MUST agree, or
* `find → comments add` (and `find → replace`) misalign. */
* view. Mirrors `isWrapperVisibleInView` in `core/find/replace-span.tsx`
* and the AST-side `isRevisionVisible` in
* `core/track-changes/revision-visibility.ts` (which `find` reads) — they
* MUST agree, or `find → comments add` (and `find → replace`) misalign. A
* hidden wrapper is never descended, so anything nested inside it is hidden
* too — the same all-ancestors rule. */
function isWrapperVisibleInView(tag: string, view: FindView): boolean {
if (!isRunBearingWrapper(tag)) return false;
if (view === "current") return true;
Expand Down
24 changes: 9 additions & 15 deletions src/core/find/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { type Body, iterateBlocks } from "../ast/document/body";
import type { Block, Paragraph, TrackedChange } from "../ast/types";
import {
isRevisionVisible,
type RevisionView,
} from "../track-changes/revision-visibility";
import { replaceAcrossParagraphs } from "./replace-across";
import type { ReplacementFormatting } from "./replace-span";

Expand All @@ -19,7 +23,7 @@ export type TextMatch = {
trackedChanges?: TrackedChange[];
};

export type FindView = "accepted" | "current" | "baseline";
export type FindView = RevisionView;

export type FindOptions = {
regex?: boolean;
Expand Down Expand Up @@ -348,7 +352,7 @@ export function findFormattedSpans(
offset += runViewText(run, view).length;
continue;
}
if (!isRunVisibleInView(run.trackedChange?.kind, view)) continue;
if (!isRevisionVisible(run.trackedChange, view)) continue;
if (runMatchesFilter(run, filter)) {
if (spanStart === null) spanStart = offset;
spanText += run.text;
Expand Down Expand Up @@ -484,29 +488,19 @@ function paragraphTextForView(paragraph: Paragraph, view: FindView): string {
* widths from this one function, so they can't disagree. */
function runViewText(run: Paragraph["runs"][number], view: FindView): string {
if (run.type === "text") {
return isRunVisibleInView(run.trackedChange?.kind, view) ? run.text : "";
return isRevisionVisible(run.trackedChange, view) ? run.text : "";
}
if (run.type === "tab") {
return isRunVisibleInView(run.trackedChange?.kind, view) ? "\t" : "";
return isRevisionVisible(run.trackedChange, view) ? "\t" : "";
}
if (run.type === "break") {
return run.kind === "line" &&
isRunVisibleInView(run.trackedChange?.kind, view)
return run.kind === "line" && isRevisionVisible(run.trackedChange, view)
? "\n"
: "";
}
return "";
}

function isRunVisibleInView(
kind: TrackedChange["kind"] | undefined,
view: FindView,
): boolean {
if (view === "current") return true;
if (view === "accepted") return kind !== "del" && kind !== "moveFrom";
return kind !== "ins" && kind !== "moveTo";
}

function normalizeQuery(query: string): {
normalized: string;
applied: NormalizationKind[];
Expand Down
6 changes: 4 additions & 2 deletions src/core/find/replace-span.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ export type ReplacementFormatting = {
/** Whether a run-bearing wrapper's contents should be treated as VISIBLE in
* the chosen view. Invisible wrappers pass through replace's offset
* arithmetic untouched (their inner text adds nothing to the offset and
* spans don't slice into them). Mirrors `isRunVisibleInView` in
* src/core/find/index.ts so find/replace stay in sync. (Exported for
* spans don't slice into them). The tag-side twin of `isRevisionVisible` in
* core/track-changes/revision-visibility.ts (the AST-side rule `find` reads);
* because a hidden wrapper is never descended, a wrapper nested inside a
* hidden one is hidden too — the same all-ancestors rule. (Exported for
* replace-across.tsx, which walks the same offset space over whole
* paragraphs.) */
export function isWrapperVisibleInView(tag: string, view: FindView): boolean {
Expand Down
Loading
Loading