@stll/folio-core 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,8 +12,8 @@ changes, and footnotes.
12
12
 
13
13
  There is **no React** in the import graph, so the core runs on a server or under
14
14
  any framework. The React editor lives in
15
- [`@stll/folio-react`](https://www.npmjs.com/package/@stll/folio-react); a Vue or
16
- Tauri adapter could build on this same core.
15
+ [`@stll/folio-react`](https://www.npmjs.com/package/@stll/folio-react), and the
16
+ Vue editor lives in [`@stll/folio-vue`](https://www.npmjs.com/package/@stll/folio-vue).
17
17
 
18
18
  Part of [stella](https://github.com/stella/stella), an open-source legal workspace.
19
19
 
@@ -24,5 +24,25 @@ type CleanBlockText = {
24
24
  offsets: number[];
25
25
  };
26
26
  declare const buildCleanBlockText: (blockNode: Node, blockFrom: number) => CleanBlockText;
27
+ /**
28
+ * A "redline-aware" view of a textblock: the same left-to-right traversal as
29
+ * {@link buildCleanBlockText}, but every tracked change and comment anchor is
30
+ * rendered inline with a simple tag rather than being flattened away:
31
+ *
32
+ * - `<ins author="…">text</ins>` for insertion-marked runs,
33
+ * - `<del author="…">text</del>` for deletion-marked runs (tracked moves
34
+ * surface as plain ins/del, since a move carries the same marks),
35
+ * - `<comment id="N">quoted text</comment>` for comment-anchored runs.
36
+ *
37
+ * Nested annotations (e.g. an inserted run that is also commented) nest their
38
+ * tags in a stable `comment > ins > del` order. Text content and attribute
39
+ * values are XML-escaped so the tags stay unambiguous when embedded in a
40
+ * prompt. Adjacent runs sharing the same annotation coalesce into one tag.
41
+ *
42
+ * This is the view a consumer embeds when it wants the model to reason about
43
+ * the redline itself, in contrast to {@link buildCleanBlockText}'s
44
+ * post-tracked-changes view.
45
+ */
46
+ declare const buildAnnotatedBlockText: (blockNode: Node) => string;
27
47
  //#endregion
28
- export { CleanBlockText, buildCleanBlockText };
48
+ export { CleanBlockText, buildAnnotatedBlockText, buildCleanBlockText };
@@ -1,5 +1,7 @@
1
1
  //#region src/ai-edits/clean-text.ts
2
2
  const DELETION_MARK = "deletion";
3
+ const INSERTION_MARK = "insertion";
4
+ const COMMENT_MARK = "comment";
3
5
  const buildCleanBlockText = (blockNode, blockFrom) => {
4
6
  let text = "";
5
7
  const offsets = [];
@@ -19,5 +21,71 @@ const buildCleanBlockText = (blockNode, blockFrom) => {
19
21
  offsets
20
22
  };
21
23
  };
24
+ /**
25
+ * A "redline-aware" view of a textblock: the same left-to-right traversal as
26
+ * {@link buildCleanBlockText}, but every tracked change and comment anchor is
27
+ * rendered inline with a simple tag rather than being flattened away:
28
+ *
29
+ * - `<ins author="…">text</ins>` for insertion-marked runs,
30
+ * - `<del author="…">text</del>` for deletion-marked runs (tracked moves
31
+ * surface as plain ins/del, since a move carries the same marks),
32
+ * - `<comment id="N">quoted text</comment>` for comment-anchored runs.
33
+ *
34
+ * Nested annotations (e.g. an inserted run that is also commented) nest their
35
+ * tags in a stable `comment > ins > del` order. Text content and attribute
36
+ * values are XML-escaped so the tags stay unambiguous when embedded in a
37
+ * prompt. Adjacent runs sharing the same annotation coalesce into one tag.
38
+ *
39
+ * This is the view a consumer embeds when it wants the model to reason about
40
+ * the redline itself, in contrast to {@link buildCleanBlockText}'s
41
+ * post-tracked-changes view.
42
+ */
43
+ const buildAnnotatedBlockText = (blockNode) => {
44
+ const segments = [];
45
+ blockNode.descendants((node) => {
46
+ if (!node.isText || node.text === void 0) return true;
47
+ const annotation = annotationOf(node.marks);
48
+ const previous = segments.at(-1);
49
+ if (previous && sameAnnotation(previous.annotation, annotation)) {
50
+ previous.text += node.text;
51
+ return false;
52
+ }
53
+ segments.push({
54
+ annotation,
55
+ text: node.text
56
+ });
57
+ return false;
58
+ });
59
+ return segments.map(renderAnnotatedSegment).join("");
60
+ };
61
+ const authorOf = (attrs) => {
62
+ const author = attrs["author"];
63
+ return typeof author === "string" ? author : "";
64
+ };
65
+ const annotationOf = (marks) => {
66
+ let commentId = null;
67
+ let insertionAuthor = null;
68
+ let deletionAuthor = null;
69
+ for (const mark of marks) if (mark.type.name === COMMENT_MARK) {
70
+ const id = mark.attrs["commentId"];
71
+ if (typeof id === "number") commentId = id;
72
+ } else if (mark.type.name === INSERTION_MARK) insertionAuthor = authorOf(mark.attrs);
73
+ else if (mark.type.name === DELETION_MARK) deletionAuthor = authorOf(mark.attrs);
74
+ return {
75
+ commentId,
76
+ insertionAuthor,
77
+ deletionAuthor
78
+ };
79
+ };
80
+ const sameAnnotation = (a, b) => a.commentId === b.commentId && a.insertionAuthor === b.insertionAuthor && a.deletionAuthor === b.deletionAuthor;
81
+ const escapeText = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
82
+ const escapeAttr = (value) => escapeText(value).replaceAll("\"", "&quot;");
83
+ const renderAnnotatedSegment = ({ annotation, text }) => {
84
+ let inner = escapeText(text);
85
+ if (annotation.deletionAuthor !== null) inner = `<del author="${escapeAttr(annotation.deletionAuthor)}">${inner}</del>`;
86
+ if (annotation.insertionAuthor !== null) inner = `<ins author="${escapeAttr(annotation.insertionAuthor)}">${inner}</ins>`;
87
+ if (annotation.commentId !== null) inner = `<comment id="${annotation.commentId}">${inner}</comment>`;
88
+ return inner;
89
+ };
22
90
  //#endregion
23
- export { buildCleanBlockText };
91
+ export { buildAnnotatedBlockText, buildCleanBlockText };
@@ -1,5 +1,6 @@
1
1
  import { FolioAIBlock, FolioAIEditApplyMode, FolioAIEditApplyResult, FolioAIEditOperation, FolioAIEditSnapshot } from "./types.js";
2
2
  import { document_d_exports } from "../types/document.js";
3
+ import { FolioReviewChange, FolioReviewChangeKind } from "./read.js";
3
4
 
4
5
  //#region src/ai-edits/headless.d.ts
5
6
  /** Options for {@link FolioDocxReviewer.fromBuffer}. */
@@ -16,26 +17,14 @@ type FolioApplyOperationsOptions = {
16
17
  */
17
18
  snapshot?: FolioAIEditSnapshot;
18
19
  };
19
- type FolioReviewChangeKind = "insertion" | "deletion";
20
- /** A tracked change (insertion or deletion) discovered in the document body. */
21
- type FolioReviewChange = {
20
+ /** Options for {@link FolioDocxReviewer.getContentAsText}. */
21
+ type FolioGetContentAsTextOptions = {
22
22
  /**
23
- * The tracked-change revision id the OOXML `w:id` carried on the
24
- * insertion / deletion mark. Pass it (or the whole change) to
25
- * {@link FolioDocxReviewer.acceptChange} / `rejectChange`. A replace produces
26
- * two changes (a deletion side and an insertion side) with distinct ids.
23
+ * Render tracked changes and comment anchors inline as `<ins>` / `<del>` /
24
+ * `<comment>` tags instead of the default flattened, post-tracked-changes
25
+ * text. (default: `false`)
27
26
  */
28
- id: number;
29
- type: FolioReviewChangeKind;
30
- author: string; /** ISO date the change was authored, or `null` when the source omitted it. */
31
- date: string | null; /** Inserted text for insertions, removed text for deletions. */
32
- text: string;
33
- /**
34
- * Stable id of the containing body block (Word `w14:paraId` or `seq-NNNN`),
35
- * matching {@link FolioDocxReviewer.getContent}. `null` when the change sits
36
- * in a block with no surviving visible text, which has no snapshot block.
37
- */
38
- blockId: string | null;
27
+ annotated?: boolean;
39
28
  };
40
29
  /** Filter for {@link FolioDocxReviewer.getChanges}. */
41
30
  type FolioReviewChangeFilter = {
@@ -92,6 +81,15 @@ declare class FolioDocxReviewer {
92
81
  private readonly originalBuffer;
93
82
  private state;
94
83
  private readonly createdComments;
84
+ /**
85
+ * Resolved-state overrides recorded by {@link resolveComment}, keyed by
86
+ * comment id. Applied on read ({@link getComments}) and on write
87
+ * ({@link toDocument}) rather than mutating the parsed `Comment` objects in
88
+ * place, matching how the parser/serializer treat `Comment` as immutable
89
+ * (`commentParser.ts` replaces array slots via spread; the serializer
90
+ * types its inputs `readonly Comment[]`).
91
+ */
92
+ private readonly resolvedOverrides;
95
93
  private constructor();
96
94
  /** Parse a `.docx` buffer into a reviewer. */
97
95
  static fromBuffer(buffer: ArrayBuffer, options?: FolioDocxReviewerOptions): Promise<FolioDocxReviewer>;
@@ -116,8 +114,22 @@ declare class FolioDocxReviewer {
116
114
  * The body as LLM-ready plain text: one line per block, each prefixed with
117
115
  * its stable block id (`[<blockId>] text`). Copyable verbatim into a prompt
118
116
  * without JSON quote-escaping.
117
+ *
118
+ * With `{ annotated: true }` each block's text is rendered redline-aware:
119
+ * tracked insertions/deletions and comment anchors appear inline as
120
+ * `<ins>` / `<del>` / `<comment>` tags (see {@link buildAnnotatedBlockText})
121
+ * for prompt-embedding parity with a live editor's redline view. The default
122
+ * (clean) output flattens tracked changes and is unchanged.
119
123
  */
120
- getContentAsText(): string;
124
+ getContentAsText(options?: FolioGetContentAsTextOptions): string;
125
+ /**
126
+ * Header / footer and footnote / endnote text as labeled, LLM-ready lines,
127
+ * one per non-empty part: `[header default] …`, `[footer default] …`,
128
+ * `[footnote #N] …`, `[endnote #N] …`. Read-only — note bodies are outside
129
+ * the reviewer's body-only apply scope, so this reflects the parsed source.
130
+ * Empty parts and separator notes are omitted.
131
+ */
132
+ getNotesAsText(): string;
121
133
  /**
122
134
  * The tracked changes (insertions and deletions) present in the body, read
123
135
  * from the same `insertion` / `deletion` marks the editor renders. Each
@@ -141,6 +153,21 @@ declare class FolioDocxReviewer {
141
153
  * the created reply, or `null` when the target comment is absent.
142
154
  */
143
155
  replyTo(target: FolioReviewComment | number, input: FolioReviewReplyInput): FolioReviewCommentReply | null;
156
+ /**
157
+ * Mark a comment thread resolved, or reopen a previously resolved one. Pass
158
+ * the id from {@link getComments} / {@link FolioReviewComment.id}. Applies
159
+ * only to the target comment id, not cascaded to its replies: Word keys the
160
+ * resolved marker off the comment's own `w15:commentEx` entry
161
+ * (`commentSerializer.ts`'s `buildCommentExtendedEntries` reads only
162
+ * `comment.done` for the id it is building an entry for), so resolving the
163
+ * thread root is sufficient and reply entries need no `done` flag of their
164
+ * own. On {@link toBuffer} the state is written to `commentsExtended.xml`
165
+ * (`w15:done`) through the same channel `replyTo`-created comments use.
166
+ * Returns `false` when no comment with that id exists.
167
+ */
168
+ resolveComment(commentId: string, options?: {
169
+ resolved?: boolean;
170
+ }): boolean;
144
171
  /**
145
172
  * Accept an existing tracked change, keeping its text and dropping the
146
173
  * redline. Pass a {@link FolioReviewChange} from {@link getChanges} or its
@@ -187,8 +214,8 @@ declare class FolioDocxReviewer {
187
214
  * paragraph attrs rather than inline marks.
188
215
  */
189
216
  private countTrackedChanges;
190
- /** Map each snapshot block's start position to its stable id. */
191
- private blockStartIds;
217
+ /** Apply any {@link resolveComment} overrides recorded for these comments. */
218
+ private withResolvedOverrides;
192
219
  /** Map each anchored comment id to its anchored text and containing block id. */
193
220
  private commentAnchors;
194
221
  /**
@@ -222,4 +249,4 @@ type ApplyFolioAIEditsToBufferResult = FolioAIEditApplyResult & {
222
249
  */
223
250
  declare const applyFolioAIEditsToBuffer: (buffer: ArrayBuffer, operations: FolioAIEditOperation[], options?: ApplyFolioAIEditsToBufferOptions) => Promise<ApplyFolioAIEditsToBufferResult>;
224
251
  //#endregion
225
- export { ApplyFolioAIEditsToBufferOptions, ApplyFolioAIEditsToBufferResult, FolioApplyOperationsOptions, FolioDocxReviewer, FolioDocxReviewerOptions, FolioReviewChange, FolioReviewChangeFilter, FolioReviewChangeKind, FolioReviewComment, FolioReviewCommentFilter, FolioReviewCommentReply, FolioReviewReplyInput, applyFolioAIEditsToBuffer };
252
+ export { ApplyFolioAIEditsToBufferOptions, ApplyFolioAIEditsToBufferResult, FolioApplyOperationsOptions, FolioDocxReviewer, FolioDocxReviewerOptions, FolioGetContentAsTextOptions, type FolioReviewChange, FolioReviewChangeFilter, type FolioReviewChangeKind, FolioReviewComment, FolioReviewCommentFilter, FolioReviewCommentReply, FolioReviewReplyInput, applyFolioAIEditsToBuffer };
@@ -1,9 +1,13 @@
1
+ import { getEndnoteText, getFootnoteText, isSeparatorEndnote, isSeparatorFootnote } from "../docx/footnoteParser.js";
1
2
  import { deterministicHexId } from "../utils/hexId.js";
2
3
  import { repackDocx } from "../docx/rezip.js";
3
- import { createFolioAIEditSnapshot } from "./snapshot.js";
4
+ import { buildAnnotatedBlockText } from "./clean-text.js";
5
+ import { createFolioAIEditSnapshot, normalizeFolioAIBlockText } from "./snapshot.js";
4
6
  import { applyFolioAIEditOperations } from "./apply.js";
7
+ import { getCommentAnchorsFromDoc, getTrackedChangesFromDoc } from "./read.js";
5
8
  import { createReply } from "../docx/replyToComment.js";
6
9
  import { attemptSelectiveSave } from "../docx/selectiveSave.js";
10
+ import { getHeaderFooterText } from "../docx/headerFooterParser.js";
7
11
  import { parseDocx } from "../docx/parser.js";
8
12
  import { acceptAIEditRevision, acceptAllChanges, rejectAIEditRevision, rejectAllChanges } from "../prosemirror/commands/comments.js";
9
13
  import { updateDocumentContent } from "../prosemirror/conversion/fromProseDoc.js";
@@ -114,12 +118,13 @@ const ensureDeterministicParaIdsInState = (state) => {
114
118
  * for headings and the list marker for list items, so a model can copy the
115
119
  * block id straight back into an operation.
116
120
  */
117
- const formatBlockForLLM = (block) => {
121
+ const formatBlockLine = (block, text) => {
118
122
  const label = `[${block.id}]`;
119
- if (block.kind === "heading") return `${label} (h${headingLevel(block)}) ${block.text}`;
120
- if (block.kind === "listItem") return `${label} ${block.displayLabel ?? "•"} ${block.text}`;
121
- return `${label} ${block.text}`;
123
+ if (block.kind === "heading") return `${label} (h${headingLevel(block)}) ${text}`;
124
+ if (block.kind === "listItem") return `${label} ${block.displayLabel ?? "•"} ${text}`;
125
+ return `${label} ${text}`;
122
126
  };
127
+ const formatBlockForLLM = (block) => formatBlockLine(block, block.text);
123
128
  const headingLevel = (block) => {
124
129
  const digits = /(\d+)/u.exec(block.styleId ?? block.displayLabel ?? "")?.[1];
125
130
  const level = digits ? Number.parseInt(digits, 10) : 1;
@@ -157,6 +162,15 @@ var FolioDocxReviewer = class FolioDocxReviewer {
157
162
  originalBuffer;
158
163
  state;
159
164
  createdComments = [];
165
+ /**
166
+ * Resolved-state overrides recorded by {@link resolveComment}, keyed by
167
+ * comment id. Applied on read ({@link getComments}) and on write
168
+ * ({@link toDocument}) rather than mutating the parsed `Comment` objects in
169
+ * place, matching how the parser/serializer treat `Comment` as immutable
170
+ * (`commentParser.ts` replaces array slots via spread; the serializer
171
+ * types its inputs `readonly Comment[]`).
172
+ */
173
+ resolvedOverrides = /* @__PURE__ */ new Map();
160
174
  constructor(args) {
161
175
  this.baseDocument = args.baseDocument;
162
176
  this.originalBuffer = args.originalBuffer;
@@ -228,9 +242,54 @@ var FolioDocxReviewer = class FolioDocxReviewer {
228
242
  * The body as LLM-ready plain text: one line per block, each prefixed with
229
243
  * its stable block id (`[<blockId>] text`). Copyable verbatim into a prompt
230
244
  * without JSON quote-escaping.
245
+ *
246
+ * With `{ annotated: true }` each block's text is rendered redline-aware:
247
+ * tracked insertions/deletions and comment anchors appear inline as
248
+ * `<ins>` / `<del>` / `<comment>` tags (see {@link buildAnnotatedBlockText})
249
+ * for prompt-embedding parity with a live editor's redline view. The default
250
+ * (clean) output flattens tracked changes and is unchanged.
251
+ */
252
+ getContentAsText(options = {}) {
253
+ if (!options.annotated) return this.getContent().map(formatBlockForLLM).join("\n");
254
+ const snapshot = this.snapshot();
255
+ const startById = /* @__PURE__ */ new Map();
256
+ for (const anchor of Object.values(snapshot.anchors)) startById.set(anchor.id, anchor.from);
257
+ return snapshot.blocks.map((block) => {
258
+ const from = startById.get(block.id);
259
+ const node = from === void 0 ? null : this.state.doc.nodeAt(from);
260
+ return formatBlockLine(block, node ? buildAnnotatedBlockText(node) : block.text);
261
+ }).join("\n");
262
+ }
263
+ /**
264
+ * Header / footer and footnote / endnote text as labeled, LLM-ready lines,
265
+ * one per non-empty part: `[header default] …`, `[footer default] …`,
266
+ * `[footnote #N] …`, `[endnote #N] …`. Read-only — note bodies are outside
267
+ * the reviewer's body-only apply scope, so this reflects the parsed source.
268
+ * Empty parts and separator notes are omitted.
231
269
  */
232
- getContentAsText() {
233
- return this.getContent().map(formatBlockForLLM).join("\n");
270
+ getNotesAsText() {
271
+ const pkg = this.baseDocument.package;
272
+ const lines = [];
273
+ const pushHeaderFooter = (map, label) => {
274
+ if (!map) return;
275
+ for (const hf of map.values()) {
276
+ const text = normalizeFolioAIBlockText(getHeaderFooterText(hf));
277
+ if (text.length > 0) lines.push(`[${label} ${hf.hdrFtrType}] ${text}`);
278
+ }
279
+ };
280
+ pushHeaderFooter(pkg.headers, "header");
281
+ pushHeaderFooter(pkg.footers, "footer");
282
+ for (const footnote of pkg.footnotes ?? []) {
283
+ if (isSeparatorFootnote(footnote)) continue;
284
+ const text = normalizeFolioAIBlockText(getFootnoteText(footnote));
285
+ if (text.length > 0) lines.push(`[footnote #${footnote.id}] ${text}`);
286
+ }
287
+ for (const endnote of pkg.endnotes ?? []) {
288
+ if (isSeparatorEndnote(endnote)) continue;
289
+ const text = normalizeFolioAIBlockText(getEndnoteText(endnote));
290
+ if (text.length > 0) lines.push(`[endnote #${endnote.id}] ${text}`);
291
+ }
292
+ return lines.join("\n");
234
293
  }
235
294
  /**
236
295
  * The tracked changes (insertions and deletions) present in the body, read
@@ -239,44 +298,7 @@ var FolioDocxReviewer = class FolioDocxReviewer {
239
298
  * against. Runs of one revision within a block fold into a single entry.
240
299
  */
241
300
  getChanges(filter) {
242
- const insertionType = this.state.schema.marks["insertion"];
243
- const deletionType = this.state.schema.marks["deletion"];
244
- const blockStarts = this.blockStartIds();
245
- const grouped = /* @__PURE__ */ new Map();
246
- let currentBlockId = null;
247
- this.state.doc.descendants((node, pos) => {
248
- if (node.isTextblock) {
249
- currentBlockId = blockStarts.get(pos) ?? null;
250
- return true;
251
- }
252
- if (!node.isInline || node.text === void 0) return;
253
- const text = node.text;
254
- for (const mark of node.marks) {
255
- if (typeof mark.attrs["revisionId"] !== "number") continue;
256
- let kind;
257
- if (mark.type === insertionType) kind = "insertion";
258
- else if (mark.type === deletionType) kind = "deletion";
259
- else continue;
260
- const revisionId = mark.attrs["revisionId"];
261
- const key = `${currentBlockId ?? ""}:${kind}:${revisionId}`;
262
- const existing = grouped.get(key);
263
- if (existing) {
264
- existing.text += text;
265
- continue;
266
- }
267
- const author = mark.attrs["author"];
268
- const date = mark.attrs["date"];
269
- grouped.set(key, {
270
- id: revisionId,
271
- type: kind,
272
- author: typeof author === "string" ? author : "",
273
- date: typeof date === "string" ? date : null,
274
- text,
275
- blockId: currentBlockId
276
- });
277
- }
278
- });
279
- const changes = [...grouped.values()];
301
+ const changes = getTrackedChangesFromDoc(this.state.doc);
280
302
  if (!filter) return changes;
281
303
  return changes.filter((change) => (filter.author === void 0 || change.author === filter.author) && (filter.type === void 0 || change.type === filter.type));
282
304
  }
@@ -287,7 +309,7 @@ var FolioDocxReviewer = class FolioDocxReviewer {
287
309
  * (nested tracked changes, hyperlinks) is out of scope.
288
310
  */
289
311
  getComments(filter) {
290
- const definitions = [...this.baseDocument.package.document.comments ?? [], ...this.createdComments];
312
+ const definitions = this.withResolvedOverrides([...this.baseDocument.package.document.comments ?? [], ...this.createdComments]);
291
313
  if (definitions.length === 0) return [];
292
314
  const anchors = this.commentAnchors();
293
315
  const repliesByParent = /* @__PURE__ */ new Map();
@@ -347,6 +369,25 @@ var FolioDocxReviewer = class FolioDocxReviewer {
347
369
  };
348
370
  }
349
371
  /**
372
+ * Mark a comment thread resolved, or reopen a previously resolved one. Pass
373
+ * the id from {@link getComments} / {@link FolioReviewComment.id}. Applies
374
+ * only to the target comment id, not cascaded to its replies: Word keys the
375
+ * resolved marker off the comment's own `w15:commentEx` entry
376
+ * (`commentSerializer.ts`'s `buildCommentExtendedEntries` reads only
377
+ * `comment.done` for the id it is building an entry for), so resolving the
378
+ * thread root is sufficient and reply entries need no `done` flag of their
379
+ * own. On {@link toBuffer} the state is written to `commentsExtended.xml`
380
+ * (`w15:done`) through the same channel `replyTo`-created comments use.
381
+ * Returns `false` when no comment with that id exists.
382
+ */
383
+ resolveComment(commentId, options = {}) {
384
+ const resolved = options.resolved ?? true;
385
+ const target = [...this.baseDocument.package.document.comments ?? [], ...this.createdComments].find((comment) => String(comment.id) === commentId);
386
+ if (!target) return false;
387
+ this.resolvedOverrides.set(target.id, resolved);
388
+ return true;
389
+ }
390
+ /**
350
391
  * Accept an existing tracked change, keeping its text and dropping the
351
392
  * redline. Pass a {@link FolioReviewChange} from {@link getChanges} or its
352
393
  * revision id. Reuses the editor's own accept command headlessly, so the
@@ -384,7 +425,7 @@ var FolioDocxReviewer = class FolioDocxReviewer {
384
425
  /** The current document model with edits merged back in. */
385
426
  toDocument() {
386
427
  const document = updateDocumentContent(this.baseDocument, this.state.doc);
387
- if (this.createdComments.length > 0) document.package.document.comments = [...document.package.document.comments ?? [], ...this.createdComments];
428
+ if (this.createdComments.length > 0 || this.resolvedOverrides.size > 0) document.package.document.comments = this.withResolvedOverrides([...document.package.document.comments ?? [], ...this.createdComments]);
388
429
  return document;
389
430
  }
390
431
  /**
@@ -437,40 +478,23 @@ var FolioDocxReviewer = class FolioDocxReviewer {
437
478
  });
438
479
  return count;
439
480
  }
440
- /** Map each snapshot block's start position to its stable id. */
441
- blockStartIds() {
442
- const starts = /* @__PURE__ */ new Map();
443
- for (const anchor of Object.values(this.snapshot().anchors)) starts.set(anchor.from, anchor.id);
444
- return starts;
481
+ /** Apply any {@link resolveComment} overrides recorded for these comments. */
482
+ withResolvedOverrides(comments) {
483
+ if (this.resolvedOverrides.size === 0) return [...comments];
484
+ return comments.map((comment) => {
485
+ const override = this.resolvedOverrides.get(comment.id);
486
+ return override === void 0 ? comment : {
487
+ ...comment,
488
+ done: override
489
+ };
490
+ });
445
491
  }
446
492
  /** Map each anchored comment id to its anchored text and containing block id. */
447
493
  commentAnchors() {
448
- const commentType = this.state.schema.marks["comment"];
449
494
  const anchors = /* @__PURE__ */ new Map();
450
- if (!commentType) return anchors;
451
- const blockStarts = this.blockStartIds();
452
- let currentBlockId = null;
453
- this.state.doc.descendants((node, pos) => {
454
- if (node.isTextblock) {
455
- currentBlockId = blockStarts.get(pos) ?? null;
456
- return true;
457
- }
458
- if (!node.isInline || node.text === void 0) return;
459
- const text = node.text;
460
- for (const mark of node.marks) {
461
- if (mark.type !== commentType || typeof mark.attrs["commentId"] !== "number") continue;
462
- const commentId = mark.attrs["commentId"];
463
- const existing = anchors.get(commentId);
464
- if (!existing) {
465
- anchors.set(commentId, {
466
- text,
467
- blockId: currentBlockId
468
- });
469
- continue;
470
- }
471
- existing.text += text;
472
- existing.blockId ??= currentBlockId;
473
- }
495
+ for (const anchor of getCommentAnchorsFromDoc(this.state.doc)) anchors.set(anchor.commentId, {
496
+ text: anchor.quote,
497
+ blockId: anchor.blockId
474
498
  });
475
499
  return anchors;
476
500
  }
@@ -1,6 +1,8 @@
1
1
  import { FolioAIBlock, FolioAIBlockAnchor, FolioAIBlockKind, FolioAIBlockPreviewRun, FolioAIComment, FolioAIEditAppliedOperation, FolioAIEditApplyMode, FolioAIEditApplyResult, FolioAIEditOperation, FolioAIEditReviewMeta, FolioAIEditSeverity, FolioAIEditSkipReason, FolioAIEditSkippedOperation, FolioAIEditSnapshot, FolioAISignatureParty } from "./types.js";
2
2
  import { FolioAIEditView, applyFolioAIEditOperations } from "./apply.js";
3
+ import { buildAnnotatedBlockText } from "./clean-text.js";
4
+ import { FolioCommentAnchor, FolioReviewChange, FolioReviewChangeKind, getCommentAnchorsFromDoc, getTrackedChangesFromDoc } from "./read.js";
3
5
  import { createFolioAIEditSnapshot, hashFolioAIBlockText, normalizeFolioAIBlockText } from "./snapshot.js";
4
6
  import { getFolioParaIdFromBlockId } from "../types/block-id.js";
5
7
  import { WordDiffSegment, diffWordSegments } from "./word-diff.js";
6
- export { type FolioAIBlock, type FolioAIBlockAnchor, type FolioAIBlockKind, type FolioAIBlockPreviewRun, type FolioAIComment, type FolioAIEditAppliedOperation, type FolioAIEditApplyMode, type FolioAIEditApplyResult, type FolioAIEditOperation, type FolioAIEditReviewMeta, type FolioAIEditSeverity, type FolioAIEditSkipReason, type FolioAIEditSkippedOperation, type FolioAIEditSnapshot, type FolioAIEditView, type FolioAISignatureParty, type WordDiffSegment, applyFolioAIEditOperations, createFolioAIEditSnapshot, diffWordSegments, getFolioParaIdFromBlockId, hashFolioAIBlockText, normalizeFolioAIBlockText };
8
+ export { type FolioAIBlock, type FolioAIBlockAnchor, type FolioAIBlockKind, type FolioAIBlockPreviewRun, type FolioAIComment, type FolioAIEditAppliedOperation, type FolioAIEditApplyMode, type FolioAIEditApplyResult, type FolioAIEditOperation, type FolioAIEditReviewMeta, type FolioAIEditSeverity, type FolioAIEditSkipReason, type FolioAIEditSkippedOperation, type FolioAIEditSnapshot, type FolioAIEditView, type FolioAISignatureParty, type FolioCommentAnchor, type FolioReviewChange, type FolioReviewChangeKind, type WordDiffSegment, applyFolioAIEditOperations, buildAnnotatedBlockText, createFolioAIEditSnapshot, diffWordSegments, getCommentAnchorsFromDoc, getFolioParaIdFromBlockId, getTrackedChangesFromDoc, hashFolioAIBlockText, normalizeFolioAIBlockText };
@@ -1,5 +1,7 @@
1
1
  import { getFolioParaIdFromBlockId } from "../types/block-id.js";
2
+ import { buildAnnotatedBlockText } from "./clean-text.js";
2
3
  import { createFolioAIEditSnapshot, hashFolioAIBlockText, normalizeFolioAIBlockText } from "./snapshot.js";
3
4
  import { diffWordSegments } from "./word-diff.js";
4
5
  import { applyFolioAIEditOperations } from "./apply.js";
5
- export { applyFolioAIEditOperations, createFolioAIEditSnapshot, diffWordSegments, getFolioParaIdFromBlockId, hashFolioAIBlockText, normalizeFolioAIBlockText };
6
+ import { getCommentAnchorsFromDoc, getTrackedChangesFromDoc } from "./read.js";
7
+ export { applyFolioAIEditOperations, buildAnnotatedBlockText, createFolioAIEditSnapshot, diffWordSegments, getCommentAnchorsFromDoc, getFolioParaIdFromBlockId, getTrackedChangesFromDoc, hashFolioAIBlockText, normalizeFolioAIBlockText };
@@ -0,0 +1,43 @@
1
+ import { Node } from "prosemirror-model";
2
+
3
+ //#region src/ai-edits/read.d.ts
4
+ type FolioReviewChangeKind = "insertion" | "deletion";
5
+ /** A tracked change (insertion or deletion) discovered in the document body. */
6
+ type FolioReviewChange = {
7
+ /**
8
+ * The tracked-change revision id — the OOXML `w:id` carried on the
9
+ * insertion / deletion mark. A replace produces two changes (a deletion side
10
+ * and an insertion side) with distinct ids.
11
+ */
12
+ id: number;
13
+ type: FolioReviewChangeKind;
14
+ author: string; /** ISO date the change was authored, or `null` when the source omitted it. */
15
+ date: string | null; /** Inserted text for insertions, removed text for deletions. */
16
+ text: string;
17
+ /**
18
+ * Stable id of the containing body block (Word `w14:paraId` or `seq-NNNN`).
19
+ * `null` when the change sits in a block with no surviving visible text,
20
+ * which has no snapshot block.
21
+ */
22
+ blockId: string | null;
23
+ };
24
+ /** A comment anchor (the ranged text a comment marks) discovered in the body. */
25
+ type FolioCommentAnchor = {
26
+ commentId: number; /** Stable id of the anchored body block, or `null` when the anchor is absent. */
27
+ blockId: string | null; /** The document text the comment is anchored to. */
28
+ quote: string;
29
+ };
30
+ /**
31
+ * The tracked changes (insertions and deletions) present in the body, read from
32
+ * the `insertion` / `deletion` marks the editor renders. Runs of one revision
33
+ * within a block fold into a single entry.
34
+ */
35
+ declare const getTrackedChangesFromDoc: (doc: Node) => FolioReviewChange[];
36
+ /**
37
+ * The comment anchors present in the body, read from the `comment` mark the
38
+ * editor renders. Each entry carries the anchored text and containing block id;
39
+ * runs of one comment id within the body fold into a single anchor.
40
+ */
41
+ declare const getCommentAnchorsFromDoc: (doc: Node) => FolioCommentAnchor[];
42
+ //#endregion
43
+ export { FolioCommentAnchor, FolioReviewChange, FolioReviewChangeKind, getCommentAnchorsFromDoc, getTrackedChangesFromDoc };
@@ -0,0 +1,91 @@
1
+ import { createFolioAIEditSnapshot } from "./snapshot.js";
2
+ //#region src/ai-edits/read.ts
3
+ /** Map each snapshot block's start position to its stable id. */
4
+ const blockStartIdsFromDoc = (doc) => {
5
+ const starts = /* @__PURE__ */ new Map();
6
+ for (const anchor of Object.values(createFolioAIEditSnapshot(doc).anchors)) starts.set(anchor.from, anchor.id);
7
+ return starts;
8
+ };
9
+ /**
10
+ * The tracked changes (insertions and deletions) present in the body, read from
11
+ * the `insertion` / `deletion` marks the editor renders. Runs of one revision
12
+ * within a block fold into a single entry.
13
+ */
14
+ const getTrackedChangesFromDoc = (doc) => {
15
+ const insertionType = doc.type.schema.marks["insertion"];
16
+ const deletionType = doc.type.schema.marks["deletion"];
17
+ const blockStarts = blockStartIdsFromDoc(doc);
18
+ const grouped = /* @__PURE__ */ new Map();
19
+ let currentBlockId = null;
20
+ doc.descendants((node, pos) => {
21
+ if (node.isTextblock) {
22
+ currentBlockId = blockStarts.get(pos) ?? null;
23
+ return true;
24
+ }
25
+ if (!node.isInline || node.text === void 0) return;
26
+ const text = node.text;
27
+ for (const mark of node.marks) {
28
+ if (typeof mark.attrs["revisionId"] !== "number") continue;
29
+ let kind;
30
+ if (mark.type === insertionType) kind = "insertion";
31
+ else if (mark.type === deletionType) kind = "deletion";
32
+ else continue;
33
+ const revisionId = mark.attrs["revisionId"];
34
+ const key = `${currentBlockId ?? ""}:${kind}:${revisionId}`;
35
+ const existing = grouped.get(key);
36
+ if (existing) {
37
+ existing.text += text;
38
+ continue;
39
+ }
40
+ const author = mark.attrs["author"];
41
+ const date = mark.attrs["date"];
42
+ grouped.set(key, {
43
+ id: revisionId,
44
+ type: kind,
45
+ author: typeof author === "string" ? author : "",
46
+ date: typeof date === "string" ? date : null,
47
+ text,
48
+ blockId: currentBlockId
49
+ });
50
+ }
51
+ });
52
+ return [...grouped.values()];
53
+ };
54
+ /**
55
+ * The comment anchors present in the body, read from the `comment` mark the
56
+ * editor renders. Each entry carries the anchored text and containing block id;
57
+ * runs of one comment id within the body fold into a single anchor.
58
+ */
59
+ const getCommentAnchorsFromDoc = (doc) => {
60
+ const commentType = doc.type.schema.marks["comment"];
61
+ if (!commentType) return [];
62
+ const blockStarts = blockStartIdsFromDoc(doc);
63
+ const anchors = /* @__PURE__ */ new Map();
64
+ let currentBlockId = null;
65
+ doc.descendants((node, pos) => {
66
+ if (node.isTextblock) {
67
+ currentBlockId = blockStarts.get(pos) ?? null;
68
+ return true;
69
+ }
70
+ if (!node.isInline || node.text === void 0) return;
71
+ const text = node.text;
72
+ for (const mark of node.marks) {
73
+ if (mark.type !== commentType || typeof mark.attrs["commentId"] !== "number") continue;
74
+ const commentId = mark.attrs["commentId"];
75
+ const existing = anchors.get(commentId);
76
+ if (!existing) {
77
+ anchors.set(commentId, {
78
+ commentId,
79
+ blockId: currentBlockId,
80
+ quote: text
81
+ });
82
+ continue;
83
+ }
84
+ existing.quote += text;
85
+ existing.blockId ??= currentBlockId;
86
+ }
87
+ });
88
+ return [...anchors.values()];
89
+ };
90
+ //#endregion
91
+ export { getCommentAnchorsFromDoc, getTrackedChangesFromDoc };