@stll/folio-core 0.37.1 → 0.37.2

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.
@@ -122,6 +122,22 @@ type FolioApplyDocumentOperationsToStoryOptions = FolioApplyDocumentOperationsOp
122
122
  */
123
123
  tableTemplates?: FolioTableTemplates;
124
124
  };
125
+ type FolioDocxComparisonProjectionMode = "with-revision-census" | "without-revision-census";
126
+ type FolioDocxComparisonStoryProjection = {
127
+ handle: FolioDocumentStoryHandle;
128
+ snapshot: FolioAIEditSnapshot | null;
129
+ };
130
+ type FolioDocxComparisonProjection = {
131
+ stories: readonly FolioDocxComparisonStoryProjection[];
132
+ revisions: {
133
+ highestId: number;
134
+ present: boolean;
135
+ };
136
+ };
137
+ type FolioDocxComparisonAccess = {
138
+ projectStories: (mode: FolioDocxComparisonProjectionMode) => FolioDocxComparisonProjection;
139
+ snapshotReviewedStory: (options?: FolioReadReviewedStoryOptions) => FolioAIEditSnapshot | null;
140
+ };
125
141
  declare const isFolioReviewedView: (value: unknown) => value is FolioReviewedView;
126
142
  declare const isFolioResolvedReviewedView: (value: unknown) => value is FolioResolvedReviewedView;
127
143
  /** Filter for {@link FolioDocxReviewer.getChanges}. */
@@ -222,6 +238,8 @@ declare class FolioDocxReviewer {
222
238
  getDocumentProperties(): Readonly<NonNullable<document_d_exports.Document["package"]["properties"]>> | null;
223
239
  /** Snapshot one editable story into stable, operation-ready blocks. */
224
240
  snapshotStory(story: FolioEditableDocumentStoryHandle): FolioAIEditSnapshot | null;
241
+ private snapshotReviewedStoryInternal;
242
+ private projectComparisonStoriesInternal;
225
243
  /**
226
244
  * One story's tables, in the document order {@link snapshotStory} numbers
227
245
  * them with, read through a reviewed view.
@@ -249,6 +267,7 @@ declare class FolioDocxReviewer {
249
267
  readReviewedStory(options?: FolioReadReviewedStoryOptions): FolioReviewedStory | null;
250
268
  /** Resolve one editable story to its original or final state. */
251
269
  resolveReviewedStory({ story, view }: FolioResolveReviewedStoryOptions): boolean;
270
+ private resolveReviewedStorySnapshotInternal;
252
271
  /**
253
272
  * Apply operations against the current state. Reuses the live-editor applier
254
273
  * verbatim via a headless `{ state, dispatch }` seam; the resulting state
@@ -292,6 +311,7 @@ declare class FolioDocxReviewer {
292
311
  * parts and separator notes are omitted.
293
312
  */
294
313
  getNotesAsText(): string;
314
+ private listStoryHandlesInternal;
295
315
  /** Discover every readable document story through a typed, serializable handle. */
296
316
  listStories(): FolioDocumentStory[];
297
317
  /** Read one discovered story; returns null when its handle is no longer present. */
@@ -386,6 +406,7 @@ declare class FolioDocxReviewer {
386
406
  private getNoteStory;
387
407
  private getHeaderFooterStoryText;
388
408
  private getNoteStoryText;
409
+ private getStoryText;
389
410
  private mergeEditedSecondaryStories;
390
411
  /** Apply any {@link resolveComment} overrides recorded for these comments. */
391
412
  private withResolvedOverrides;
@@ -401,6 +422,8 @@ declare class FolioDocxReviewer {
401
422
  private runCommand;
402
423
  private runStoryCommand;
403
424
  }
425
+ /** @internal Comparison-only access to atomic and fresh reviewer projections. */
426
+ declare const getFolioDocxComparisonAccess: (reviewer: FolioDocxReviewer) => FolioDocxComparisonAccess;
404
427
  /** Options for {@link applyFolioAIEditsToBuffer}. */
405
428
  type ApplyFolioAIEditsToBufferOptions = {
406
429
  /** Default author for tracked changes and comments. (default: `"AI"`) */
@@ -428,4 +451,4 @@ type ApplyFolioAIEditsToBufferResult = FolioAIEditApplyResult & {
428
451
  */
429
452
  declare const applyFolioAIEditsToBuffer: (buffer: ArrayBuffer, operations: FolioAIEditOperation[], options?: ApplyFolioAIEditsToBufferOptions) => Promise<ApplyFolioAIEditsToBufferResult>;
430
453
  //#endregion
431
- export { ApplyFolioAIEditsToBufferOptions, ApplyFolioAIEditsToBufferResult, FOLIO_RESOLVED_REVIEWED_VIEWS, FOLIO_REVIEWED_VIEWS, FolioApplyDocumentOperationsOptions, FolioApplyDocumentOperationsToStoryOptions, FolioApplyOperationsOptions, FolioDocumentStory, FolioDocumentStoryHandle, FolioDocumentStoryNotFoundError, FolioDocxReviewer, FolioDocxReviewerOptions, FolioEditableDocumentStoryHandle, FolioGetContentAsTextOptions, FolioMatchStoryTableGeometryOptions, FolioNumberingLevel, FolioReadReviewedStoryOptions, FolioResolveReviewedStoryOptions, FolioResolvedReviewedView, type FolioReviewChange, FolioReviewChangeFilter, type FolioReviewChangeKind, FolioReviewComment, FolioReviewCommentFilter, FolioReviewCommentReply, FolioReviewReplyInput, FolioReviewedStory, FolioReviewedView, type FolioRevisionStamp, UnsupportedFolioReviewedViewError, applyFolioAIEditsToBuffer, isFolioResolvedReviewedView, isFolioReviewedView };
454
+ export { ApplyFolioAIEditsToBufferOptions, ApplyFolioAIEditsToBufferResult, FOLIO_RESOLVED_REVIEWED_VIEWS, FOLIO_REVIEWED_VIEWS, FolioApplyDocumentOperationsOptions, FolioApplyDocumentOperationsToStoryOptions, FolioApplyOperationsOptions, FolioDocumentStory, FolioDocumentStoryHandle, FolioDocumentStoryNotFoundError, FolioDocxReviewer, FolioDocxReviewerOptions, FolioEditableDocumentStoryHandle, FolioGetContentAsTextOptions, FolioMatchStoryTableGeometryOptions, FolioNumberingLevel, FolioReadReviewedStoryOptions, FolioResolveReviewedStoryOptions, FolioResolvedReviewedView, type FolioReviewChange, FolioReviewChangeFilter, type FolioReviewChangeKind, FolioReviewComment, FolioReviewCommentFilter, FolioReviewCommentReply, FolioReviewReplyInput, FolioReviewedStory, FolioReviewedView, type FolioRevisionStamp, UnsupportedFolioReviewedViewError, applyFolioAIEditsToBuffer, getFolioDocxComparisonAccess, isFolioResolvedReviewedView, isFolioReviewedView };
@@ -18,10 +18,10 @@ import { createDocumentStylesPlugin, getDocumentStyleResolver } from "../prosemi
18
18
  import { schema, singletonManager } from "../prosemirror/schema/index.js";
19
19
  import { deterministicHexId } from "../utils/hexId.js";
20
20
  import { buildAnnotatedBlockText } from "./clean-text.js";
21
- import { getCommentAnchorsFromDoc, getTrackedChangesFromDoc } from "./read.js";
22
- import { createFolioAIEditSnapshotWithStyleResolver, folioStoryTables, isFolioAIContentBlock, normalizeFolioAIBlockText } from "./snapshot.js";
21
+ import { getCommentAnchorsFromDoc, getTrackedChangeStatsFromDoc, getTrackedChangesFromDoc, getTrackedChangesFromSnapshot } from "./read.js";
22
+ import { createFolioAIEditSnapshotWithStyleResolver, folioStoryTables, isFolioAIContentBlock, normalizeFolioAIBlockText, sourceDocumentOf } from "./snapshot.js";
23
23
  import { matchTableGeometry } from "./table-geometry.js";
24
- import { TaggedError } from "better-result";
24
+ import { TaggedError, panic } from "better-result";
25
25
  import { Fragment } from "prosemirror-model";
26
26
  import { EditorState } from "prosemirror-state";
27
27
  //#region src/ai-edits/headless.ts
@@ -160,6 +160,7 @@ const resolvedStoryBlockProjection = (block) => {
160
160
  delete persisted.idStability;
161
161
  return persisted;
162
162
  };
163
+ const comparisonAccessByReviewer = /* @__PURE__ */ new WeakMap();
163
164
  const MAIN_STORY = Object.freeze({ type: "main" });
164
165
  const headerFooterStoryKey = ({ type, relationshipId }) => `${type}:${relationshipId}`;
165
166
  const noteStoryKey = ({ type, noteId }) => `${type}:${noteId}`;
@@ -177,12 +178,11 @@ const createHeadlessPlugins = (styles, numbering) => [
177
178
  createDocumentNumberingPlugin(numbering)
178
179
  ];
179
180
  const createStateSnapshot = (state) => createFolioAIEditSnapshotWithStyleResolver(state.doc, getDocumentStyleResolver(state));
180
- const formatStoryStateForLLM = (state, annotated) => {
181
- const snapshot = createStateSnapshot(state);
181
+ const formatStorySnapshotForLLM = (snapshot, annotated) => {
182
182
  const blocks = snapshot.blocks.filter(isFolioAIContentBlock);
183
183
  if (!annotated) return blocks.map(formatBlockForLLM).join("\n");
184
184
  const nodeByStart = /* @__PURE__ */ new Map();
185
- state.doc.descendants((node, pos) => {
185
+ sourceDocumentOf(snapshot).descendants((node, pos) => {
186
186
  if (!node.isTextblock) return true;
187
187
  nodeByStart.set(pos, node);
188
188
  return false;
@@ -264,6 +264,10 @@ var FolioDocxReviewer = class FolioDocxReviewer {
264
264
  this.state = args.state;
265
265
  this.author = args.author;
266
266
  this.usedCommentIds = new Set((args.baseDocument.package.document.comments ?? []).map(({ id }) => id));
267
+ comparisonAccessByReviewer.set(this, Object.freeze({
268
+ projectStories: (mode) => this.projectComparisonStoriesInternal(mode),
269
+ snapshotReviewedStory: (options) => this.snapshotReviewedStoryInternal(options)
270
+ }));
267
271
  }
268
272
  /** Parse a `.docx` buffer into a reviewer. */
269
273
  static async fromBuffer(buffer, options = {}) {
@@ -339,6 +343,43 @@ var FolioDocxReviewer = class FolioDocxReviewer {
339
343
  const state = this.getEditableStoryState(story);
340
344
  return state ? createStateSnapshot(state) : null;
341
345
  }
346
+ snapshotReviewedStoryInternal(options = {}) {
347
+ const story = options.story ?? MAIN_STORY;
348
+ const view = options.view ?? "final";
349
+ if (!isFolioReviewedView(view)) throw new UnsupportedFolioReviewedViewError({
350
+ message: "Unsupported reviewed document view.",
351
+ receivedView: view
352
+ });
353
+ const sourceState = this.getEditableStoryState(story);
354
+ return sourceState ? createStateSnapshot(resolveReviewedState(sourceState, view)) : null;
355
+ }
356
+ projectComparisonStoriesInternal(mode) {
357
+ const handles = this.listStoryHandlesInternal();
358
+ let highestId = 0;
359
+ let present = false;
360
+ if (mode === "with-revision-census") for (const handle of handles) {
361
+ const state = this.getEditableStoryState(handle);
362
+ if (!state) continue;
363
+ const stats = getTrackedChangeStatsFromDoc(state.doc);
364
+ highestId = Math.max(highestId, stats.highestId);
365
+ present ||= stats.present;
366
+ }
367
+ const stories = [];
368
+ for (const handle of handles) stories.push({
369
+ handle,
370
+ snapshot: this.resolveReviewedStorySnapshotInternal({
371
+ story: handle,
372
+ view: "final"
373
+ })
374
+ });
375
+ return {
376
+ stories,
377
+ revisions: {
378
+ highestId,
379
+ present
380
+ }
381
+ };
382
+ }
342
383
  /**
343
384
  * One story's tables, in the document order {@link snapshotStory} numbers
344
385
  * them with, read through a reviewed view.
@@ -390,37 +431,42 @@ var FolioDocxReviewer = class FolioDocxReviewer {
390
431
  readReviewedStory(options = {}) {
391
432
  const story = options.story ?? MAIN_STORY;
392
433
  const view = options.view ?? "final";
393
- if (!isFolioReviewedView(view)) throw new UnsupportedFolioReviewedViewError({
394
- message: "Unsupported reviewed document view.",
395
- receivedView: view
434
+ const snapshot = this.snapshotReviewedStoryInternal({
435
+ story,
436
+ view
396
437
  });
397
- const sourceState = this.getEditableStoryState(story);
398
- if (!sourceState) return null;
399
- const state = resolveReviewedState(sourceState, view);
438
+ if (!snapshot) return null;
400
439
  return {
401
440
  story,
402
441
  view,
403
- snapshot: createStateSnapshot(state),
404
- text: formatStoryStateForLLM(state, view === "current-markup"),
405
- changes: getTrackedChangesFromDoc(state.doc)
442
+ snapshot,
443
+ text: formatStorySnapshotForLLM(snapshot, view === "current-markup"),
444
+ changes: getTrackedChangesFromSnapshot(snapshot)
406
445
  };
407
446
  }
408
447
  /** Resolve one editable story to its original or final state. */
409
448
  resolveReviewedStory({ story = MAIN_STORY, view }) {
449
+ return this.resolveReviewedStorySnapshotInternal({
450
+ story,
451
+ view
452
+ }) !== null;
453
+ }
454
+ resolveReviewedStorySnapshotInternal({ story = MAIN_STORY, view }) {
410
455
  if (!isFolioResolvedReviewedView(view)) throw new UnsupportedFolioReviewedViewError({
411
456
  message: "Only original and final views can replace editable story state.",
412
457
  receivedView: view
413
458
  });
414
459
  const sourceState = this.getEditableStoryState(story);
415
- if (!sourceState) return false;
460
+ if (!sourceState) return null;
416
461
  const resolvedState = resolveReviewedState(sourceState, view);
417
462
  this.setEditableStoryState(story, resolvedState);
463
+ const snapshot = createStateSnapshot(resolvedState);
418
464
  this.resolvedStoryExpectations.set(editableStoryKey(story), {
419
465
  story,
420
- text: formatStoryStateForLLM(resolvedState, false),
421
- blocks: createStateSnapshot(resolvedState).blocks.map(resolvedStoryBlockProjection)
466
+ text: formatStorySnapshotForLLM(snapshot, false),
467
+ blocks: snapshot.blocks.map(resolvedStoryBlockProjection)
422
468
  });
423
- return true;
469
+ return snapshot;
424
470
  }
425
471
  /**
426
472
  * Apply operations against the current state. Reuses the live-editor applier
@@ -564,7 +610,7 @@ var FolioDocxReviewer = class FolioDocxReviewer {
564
610
  * (clean) output flattens tracked changes and is unchanged.
565
611
  */
566
612
  getContentAsText(options = {}) {
567
- return formatStoryStateForLLM(this.state, options.annotated === true);
613
+ return formatStorySnapshotForLLM(this.snapshot(), options.annotated === true);
568
614
  }
569
615
  /**
570
616
  * Header / footer and footnote / endnote text as labeled, LLM-ready lines,
@@ -608,54 +654,33 @@ var FolioDocxReviewer = class FolioDocxReviewer {
608
654
  }
609
655
  return lines.join("\n");
610
656
  }
657
+ listStoryHandlesInternal() {
658
+ const pkg = this.baseDocument.package;
659
+ const handles = [{ type: "main" }];
660
+ for (const relationshipId of pkg.headers?.keys() ?? []) handles.push({
661
+ type: "header",
662
+ relationshipId
663
+ });
664
+ for (const relationshipId of pkg.footers?.keys() ?? []) handles.push({
665
+ type: "footer",
666
+ relationshipId
667
+ });
668
+ for (const footnote of pkg.footnotes ?? []) if (!isSeparatorFootnote(footnote)) handles.push({
669
+ type: "footnote",
670
+ noteId: footnote.id
671
+ });
672
+ for (const endnote of pkg.endnotes ?? []) if (!isSeparatorEndnote(endnote)) handles.push({
673
+ type: "endnote",
674
+ noteId: endnote.id
675
+ });
676
+ return handles;
677
+ }
611
678
  /** Discover every readable document story through a typed, serializable handle. */
612
679
  listStories() {
613
- const pkg = this.baseDocument.package;
614
- const stories = [{
615
- handle: { type: "main" },
616
- text: this.getContentAsText()
617
- }];
618
- for (const [relationshipId, header] of pkg.headers ?? []) {
619
- const handle = {
620
- type: "header",
621
- relationshipId
622
- };
623
- stories.push({
624
- handle,
625
- text: this.getHeaderFooterStoryText(handle, header)
626
- });
627
- }
628
- for (const [relationshipId, footer] of pkg.footers ?? []) {
629
- const handle = {
630
- type: "footer",
631
- relationshipId
632
- };
633
- stories.push({
634
- handle,
635
- text: this.getHeaderFooterStoryText(handle, footer)
636
- });
637
- }
638
- for (const footnote of pkg.footnotes ?? []) if (!isSeparatorFootnote(footnote)) {
639
- const handle = {
640
- type: "footnote",
641
- noteId: footnote.id
642
- };
643
- stories.push({
644
- handle,
645
- text: this.getNoteStoryText(handle, footnote)
646
- });
647
- }
648
- for (const endnote of pkg.endnotes ?? []) if (!isSeparatorEndnote(endnote)) {
649
- const handle = {
650
- type: "endnote",
651
- noteId: endnote.id
652
- };
653
- stories.push({
654
- handle,
655
- text: this.getNoteStoryText(handle, endnote)
656
- });
657
- }
658
- return stories;
680
+ return this.listStoryHandlesInternal().map((handle) => ({
681
+ handle,
682
+ text: this.getStoryText(handle)
683
+ }));
659
684
  }
660
685
  /** Read one discovered story; returns null when its handle is no longer present. */
661
686
  readStory(handle) {
@@ -887,11 +912,10 @@ var FolioDocxReviewer = class FolioDocxReviewer {
887
912
  story,
888
913
  view: "current-markup"
889
914
  });
890
- const serializedState = reopened.getEditableStoryState(story);
891
- const serializedText = serializedState ? formatStoryStateForLLM(serializedState, false) : null;
892
- const serializedBlocks = serializedState ? createStateSnapshot(serializedState).blocks.map(resolvedStoryBlockProjection) : null;
915
+ const serializedText = serialized ? formatStorySnapshotForLLM(serialized.snapshot, false) : null;
916
+ const serializedBlocks = serialized ? serialized.snapshot.blocks.map(resolvedStoryBlockProjection) : null;
893
917
  const mismatches = [];
894
- if (!serialized || !serializedState) mismatches.push(FOLIO_RESOLVED_STORY_SERIALIZATION_MISMATCHES.storyMissing);
918
+ if (!serialized) mismatches.push(FOLIO_RESOLVED_STORY_SERIALIZATION_MISMATCHES.storyMissing);
895
919
  else {
896
920
  if (serialized.changes.length > 0) mismatches.push(FOLIO_RESOLVED_STORY_SERIALIZATION_MISMATCHES.revisionMarkupRemains);
897
921
  if (serializedText !== text) mismatches.push(FOLIO_RESOLVED_STORY_SERIALIZATION_MISMATCHES.textProjection);
@@ -986,6 +1010,17 @@ var FolioDocxReviewer = class FolioDocxReviewer {
986
1010
  const sourceText = source.type === "footnote" ? getFootnoteText(source) : getEndnoteText(source);
987
1011
  return normalizeFolioAIBlockText(state?.doc.textContent ?? sourceText);
988
1012
  }
1013
+ getStoryText(story) {
1014
+ if (story.type === "main") return this.getContentAsText();
1015
+ if (story.type === "header" || story.type === "footer") {
1016
+ const source = this.getHeaderFooterStory(story);
1017
+ if (!source) return panic("A listed document story no longer exists", { story });
1018
+ return this.getHeaderFooterStoryText(story, source);
1019
+ }
1020
+ const source = this.getNoteStory(story);
1021
+ if (!source) return panic("A listed document story no longer exists", { story });
1022
+ return this.getNoteStoryText(story, source);
1023
+ }
989
1024
  mergeEditedSecondaryStories(document, secondaryStoryStates = this.secondaryStoryStates.values()) {
990
1025
  let headers;
991
1026
  let footers;
@@ -1087,6 +1122,8 @@ var FolioDocxReviewer = class FolioDocxReviewer {
1087
1122
  return handled;
1088
1123
  }
1089
1124
  };
1125
+ /** @internal Comparison-only access to atomic and fresh reviewer projections. */
1126
+ const getFolioDocxComparisonAccess = (reviewer) => comparisonAccessByReviewer.get(reviewer) ?? panic("Comparison access was requested for an uninitialized DOCX reviewer");
1090
1127
  /**
1091
1128
  * One-shot headless review: parse `buffer`, apply `operations`, and return the
1092
1129
  * reviewed `.docx` buffer alongside the applied / skipped breakdown. Convenience
@@ -1106,4 +1143,4 @@ const applyFolioAIEditsToBuffer = async (buffer, operations, options = {}) => {
1106
1143
  };
1107
1144
  };
1108
1145
  //#endregion
1109
- export { FOLIO_RESOLVED_REVIEWED_VIEWS, FOLIO_REVIEWED_VIEWS, FolioDocumentStoryNotFoundError, FolioDocxReviewer, UnsupportedFolioReviewedViewError, applyFolioAIEditsToBuffer, isFolioResolvedReviewedView, isFolioReviewedView };
1146
+ export { FOLIO_RESOLVED_REVIEWED_VIEWS, FOLIO_REVIEWED_VIEWS, FolioDocumentStoryNotFoundError, FolioDocxReviewer, UnsupportedFolioReviewedViewError, applyFolioAIEditsToBuffer, getFolioDocxComparisonAccess, isFolioResolvedReviewedView, isFolioReviewedView };
@@ -1,3 +1,4 @@
1
+ import { FolioAIEditSnapshot } from "./types.js";
1
2
  import { FolioNodeRevisionKind } from "../prosemirror/revisionCarriers.js";
2
3
  import { Node } from "prosemirror-model";
3
4
  //#region src/ai-edits/read.d.ts
@@ -56,6 +57,8 @@ type FolioCommentAnchor = {
56
57
  /** The document text the comment is anchored to. */
57
58
  quote: string;
58
59
  };
60
+ /** Read revisions from the exact immutable document that produced a snapshot. */
61
+ declare const getTrackedChangesFromSnapshot: (snapshot: FolioAIEditSnapshot) => FolioReviewChange[];
59
62
  /**
60
63
  * The tracked changes present in the body, read from inline marks and
61
64
  * structural node attributes. Runs of one inline revision within a block fold
@@ -66,6 +69,11 @@ type FolioCommentAnchor = {
66
69
  * fold into the row's entry rather than reporting a second time.
67
70
  */
68
71
  declare const getTrackedChangesFromDoc: (doc: Node) => FolioReviewChange[];
72
+ /** @internal Read revision statistics without paying for an unrelated block projection. */
73
+ declare const getTrackedChangeStatsFromDoc: (doc: Node) => {
74
+ highestId: number;
75
+ present: boolean;
76
+ };
69
77
  /**
70
78
  * The comment anchors present in the body, read from the `comment` mark the
71
79
  * editor renders. Each entry carries the anchored text and containing block id;
@@ -73,4 +81,4 @@ declare const getTrackedChangesFromDoc: (doc: Node) => FolioReviewChange[];
73
81
  */
74
82
  declare const getCommentAnchorsFromDoc: (doc: Node) => FolioCommentAnchor[];
75
83
  //#endregion
76
- export { FOLIO_REVIEW_CHANGE_KINDS, FolioCommentAnchor, FolioReviewChange, FolioReviewChangeKind, getCommentAnchorsFromDoc, getTrackedChangesFromDoc };
84
+ export { FOLIO_REVIEW_CHANGE_KINDS, FolioCommentAnchor, FolioReviewChange, FolioReviewChangeKind, getCommentAnchorsFromDoc, getTrackedChangeStatsFromDoc, getTrackedChangesFromDoc, getTrackedChangesFromSnapshot };
@@ -2,7 +2,7 @@ import { expectRunPropertyChangeMarkAttrs } from "../prosemirror/attrs/index.js"
2
2
  import { getFolioNodeRevisionCarriers } from "../prosemirror/revisionCarriers.js";
3
3
  import { expandRunFormattingCarrier, runFormattingCarrierReviewText, runFormattingInlineAtomDisposition } from "../prosemirror/runFormattingInlineCarriers.js";
4
4
  import { getTableCellMergeChange } from "../prosemirror/tableCellMergeRevision.js";
5
- import { createFolioAIEditSnapshot } from "./snapshot.js";
5
+ import { createFolioAIEditSnapshot, sourceDocumentOf } from "./snapshot.js";
6
6
  //#region src/ai-edits/read.ts
7
7
  /**
8
8
  * Runtime census of every tracked-change discriminator the reviewer exposes.
@@ -31,11 +31,16 @@ const FOLIO_REVIEW_CHANGE_KINDS = Object.freeze({
31
31
  });
32
32
  /** Map each snapshot block's start position to its stable id. */
33
33
  const blockStartIdsFromDoc = (doc) => {
34
+ return blockStartIdsFromSnapshot(createFolioAIEditSnapshot(doc));
35
+ };
36
+ /** Map an existing snapshot's block starts without projecting its document again. */
37
+ const blockStartIdsFromSnapshot = (snapshot) => {
34
38
  const starts = /* @__PURE__ */ new Map();
35
- for (const anchor of Object.values(createFolioAIEditSnapshot(doc).anchors)) starts.set(anchor.from, anchor.id);
39
+ for (const anchor of Object.values(snapshot.anchors)) starts.set(anchor.from, anchor.id);
36
40
  return starts;
37
41
  };
38
42
  const firstBlockIdWithin = ({ node, nodePos, blockStarts }) => {
43
+ if (!blockStarts) return null;
39
44
  let blockId = null;
40
45
  node.descendants((child, relativePos) => {
41
46
  if (blockId !== null || !child.isTextblock) return blockId === null;
@@ -51,19 +56,10 @@ const firstBlockIdWithin = ({ node, nodePos, blockStarts }) => {
51
56
  * matches neither and keeps its own entry.
52
57
  */
53
58
  const belongsToRowRevision = (scope, revision) => revision.id === scope.change.id || revision.author === scope.change.author && revision.date === scope.change.date;
54
- /**
55
- * The tracked changes present in the body, read from inline marks and
56
- * structural node attributes. Runs of one inline revision within a block fold
57
- * into a single entry.
58
- *
59
- * A tracked row insertion or deletion marks the row AND every run in its
60
- * cells, the way Word writes it. Both halves are one change, so the run marks
61
- * fold into the row's entry rather than reporting a second time.
62
- */
63
- const getTrackedChangesFromDoc = (doc) => {
59
+ /** Shared revision interpreter; a null block map omits unrelated block-id projection. */
60
+ const getTrackedChangesFromProjectedDoc = (doc, blockStarts) => {
64
61
  const insertionType = doc.type.schema.marks["insertion"];
65
62
  const deletionType = doc.type.schema.marks["deletion"];
66
- const blockStarts = blockStartIdsFromDoc(doc);
67
63
  const grouped = /* @__PURE__ */ new Map();
68
64
  const structuralChangeCellPositions = /* @__PURE__ */ new Map();
69
65
  const rowRevisionScopes = [];
@@ -73,7 +69,7 @@ const getTrackedChangesFromDoc = (doc) => {
73
69
  while (pos >= (rowRevisionScopes.at(-1)?.end ?? Number.POSITIVE_INFINITY)) rowRevisionScopes.pop();
74
70
  const nodeRevisionCarriers = getFolioNodeRevisionCarriers(node, pos);
75
71
  if (nodeRevisionCarriers.length > 0) {
76
- const blockId = node.isTextblock ? blockStarts.get(pos) ?? null : firstBlockIdWithin({
72
+ const blockId = node.isTextblock ? blockStarts?.get(pos) ?? null : firstBlockIdWithin({
77
73
  node,
78
74
  nodePos: pos,
79
75
  blockStarts
@@ -171,7 +167,7 @@ const getTrackedChangesFromDoc = (doc) => {
171
167
  }
172
168
  }
173
169
  if (node.isTextblock) {
174
- currentBlockId = blockStarts.get(pos) ?? null;
170
+ currentBlockId = blockStarts?.get(pos) ?? null;
175
171
  return true;
176
172
  }
177
173
  if (!node.isInline) return;
@@ -244,6 +240,28 @@ const getTrackedChangesFromDoc = (doc) => {
244
240
  });
245
241
  return [...grouped.values()];
246
242
  };
243
+ /** Read revisions from the exact immutable document that produced a snapshot. */
244
+ const getTrackedChangesFromSnapshot = (snapshot) => getTrackedChangesFromProjectedDoc(sourceDocumentOf(snapshot), blockStartIdsFromSnapshot(snapshot));
245
+ /**
246
+ * The tracked changes present in the body, read from inline marks and
247
+ * structural node attributes. Runs of one inline revision within a block fold
248
+ * into a single entry.
249
+ *
250
+ * A tracked row insertion or deletion marks the row AND every run in its
251
+ * cells, the way Word writes it. Both halves are one change, so the run marks
252
+ * fold into the row's entry rather than reporting a second time.
253
+ */
254
+ const getTrackedChangesFromDoc = (doc) => getTrackedChangesFromProjectedDoc(doc, blockStartIdsFromDoc(doc));
255
+ /** @internal Read revision statistics without paying for an unrelated block projection. */
256
+ const getTrackedChangeStatsFromDoc = (doc) => {
257
+ let highestId = 0;
258
+ const changes = getTrackedChangesFromProjectedDoc(doc, null);
259
+ for (const change of changes) highestId = Math.max(highestId, change.id);
260
+ return {
261
+ highestId,
262
+ present: changes.length > 0
263
+ };
264
+ };
247
265
  /**
248
266
  * The comment anchors present in the body, read from the `comment` mark the
249
267
  * editor renders. Each entry carries the anchored text and containing block id;
@@ -281,4 +299,4 @@ const getCommentAnchorsFromDoc = (doc) => {
281
299
  return [...anchors.values()];
282
300
  };
283
301
  //#endregion
284
- export { FOLIO_REVIEW_CHANGE_KINDS, getCommentAnchorsFromDoc, getTrackedChangesFromDoc };
302
+ export { FOLIO_REVIEW_CHANGE_KINDS, getCommentAnchorsFromDoc, getTrackedChangeStatsFromDoc, getTrackedChangesFromDoc, getTrackedChangesFromSnapshot };
@@ -5,6 +5,10 @@ import { Node } from "prosemirror-model";
5
5
  //#region src/ai-edits/snapshot.d.ts
6
6
  /** @internal Numbering references collected during the snapshot's document walk. */
7
7
  declare const numberingReferenceKeysOf: (snapshot: FolioAIEditSnapshot) => readonly string[];
8
+ /** @internal Tables collected during the snapshot's document walk. */
9
+ declare const storyTablesOf: (snapshot: FolioAIEditSnapshot) => readonly FolioStoryTable[];
10
+ /** @internal The immutable ProseMirror document that produced this snapshot. */
11
+ declare const sourceDocumentOf: (snapshot: FolioAIEditSnapshot) => Node;
8
12
  declare const normalizeFolioAIBlockText: (text: string) => string;
9
13
  /**
10
14
  * Whether a block carries text a reader would see.
@@ -70,14 +74,12 @@ type FolioStoryTable = {
70
74
  * Every table of one story in document order, nested tables included and
71
75
  * hidden rows' subtrees excluded.
72
76
  *
73
- * The single place a story's tables are numbered. `table.tableIndex` on a
74
- * block, the geometry a comparison copies out of the target and the geometry
75
- * it checks its own work against all read this list, so no second walk can
76
- * number the same document differently.
77
+ * Uses the same traversal classification as the snapshot's integrated census,
78
+ * so both surfaces skip and number the same nodes in the same order.
77
79
  */
78
80
  declare const folioStoryTables: (doc: Node) => FolioStoryTable[];
79
81
  declare const createFolioAIEditSnapshot: (doc: Node) => FolioAIEditSnapshot;
80
82
  /** @internal Use for an EditorState that owns the document's style resolver. */
81
83
  declare const createFolioAIEditSnapshotWithStyleResolver: (doc: Node, styleResolver: RunStyleResolver | null) => FolioAIEditSnapshot;
82
84
  //#endregion
83
- export { FolioStoryTable, createFolioAIEditSnapshot, createFolioAIEditSnapshotWithStyleResolver, createFolioAITextRangeHandle, folioStoryTables, hashFolioAIBlockStructuralBoundaries, hashFolioAIBlockText, isFolioAIContentBlock, isHiddenTableRow, normalizeFolioAIBlockText, numberingReferenceKeysOf, projectFolioAIBlockStructuralBoundaries, trailingBodyBlockId };
85
+ export { FolioStoryTable, createFolioAIEditSnapshot, createFolioAIEditSnapshotWithStyleResolver, createFolioAITextRangeHandle, folioStoryTables, hashFolioAIBlockStructuralBoundaries, hashFolioAIBlockText, isFolioAIContentBlock, isHiddenTableRow, normalizeFolioAIBlockText, numberingReferenceKeysOf, projectFolioAIBlockStructuralBoundaries, sourceDocumentOf, storyTablesOf, trailingBodyBlockId };
@@ -9,9 +9,14 @@ import { buildCleanBlockText } from "./clean-text.js";
9
9
  import { panic } from "better-result";
10
10
  import { TableMap } from "prosemirror-tables";
11
11
  //#region src/ai-edits/snapshot.ts
12
- const numberingReferenceKeysBySnapshot = /* @__PURE__ */ new WeakMap();
12
+ const metadataBySnapshot = /* @__PURE__ */ new WeakMap();
13
+ const metadataOf = (snapshot) => metadataBySnapshot.get(snapshot) ?? panic("Metadata was requested for a snapshot that did not record it");
13
14
  /** @internal Numbering references collected during the snapshot's document walk. */
14
- const numberingReferenceKeysOf = (snapshot) => numberingReferenceKeysBySnapshot.get(snapshot) ?? panic("A numbering census was requested for a snapshot that did not record one");
15
+ const numberingReferenceKeysOf = (snapshot) => metadataOf(snapshot).numberingReferenceKeys;
16
+ /** @internal Tables collected during the snapshot's document walk. */
17
+ const storyTablesOf = (snapshot) => metadataOf(snapshot).storyTables;
18
+ /** @internal The immutable ProseMirror document that produced this snapshot. */
19
+ const sourceDocumentOf = (snapshot) => metadataOf(snapshot).sourceDocument;
15
20
  const normalizeFolioAIBlockText = (text) => text.replace(/\s+/gu, " ").trim();
16
21
  /**
17
22
  * Whether a block carries text a reader would see.
@@ -96,21 +101,28 @@ const TABLE_ROLE_ROW = "row";
96
101
  * whole subtree, so a table nested inside a hidden row is hidden with it.
97
102
  */
98
103
  const isHiddenTableRow = (node) => node.type.name === TABLE_ROW_NODE_NAME && node.attrs["hidden"] === true;
104
+ const STORY_TABLE_CONTAINER = "container";
105
+ const STORY_TABLE_HIDDEN_SUBTREE = "hidden-subtree";
106
+ const STORY_TABLE = "table";
107
+ /** Shared visibility and table-role rule for both story projection walks. */
108
+ const classifyNonTextblockStoryTableNode = (node) => {
109
+ if (isHiddenTableRow(node)) return STORY_TABLE_HIDDEN_SUBTREE;
110
+ return node.type.spec["tableRole"] === TABLE_ROLE_TABLE ? STORY_TABLE : STORY_TABLE_CONTAINER;
111
+ };
99
112
  /**
100
113
  * Every table of one story in document order, nested tables included and
101
114
  * hidden rows' subtrees excluded.
102
115
  *
103
- * The single place a story's tables are numbered. `table.tableIndex` on a
104
- * block, the geometry a comparison copies out of the target and the geometry
105
- * it checks its own work against all read this list, so no second walk can
106
- * number the same document differently.
116
+ * Uses the same traversal classification as the snapshot's integrated census,
117
+ * so both surfaces skip and number the same nodes in the same order.
107
118
  */
108
119
  const folioStoryTables = (doc) => {
109
120
  const tables = [];
110
121
  doc.descendants((node, pos) => {
111
122
  if (node.isTextblock) return false;
112
- if (isHiddenTableRow(node)) return false;
113
- if (node.type.spec["tableRole"] === TABLE_ROLE_TABLE) tables.push({
123
+ const disposition = classifyNonTextblockStoryTableNode(node);
124
+ if (disposition === STORY_TABLE_HIDDEN_SUBTREE) return false;
125
+ if (disposition === STORY_TABLE) tables.push({
114
126
  index: tables.length,
115
127
  start: pos,
116
128
  node
@@ -154,14 +166,25 @@ const createFolioAIEditSnapshotInternal = (doc, styleResolver) => {
154
166
  const hashCounts = /* @__PURE__ */ new Map();
155
167
  const usedBlockIds = /* @__PURE__ */ new Set();
156
168
  const numberingReferenceKeys = /* @__PURE__ */ new Set();
157
- const tableIndexByStart = new Map(folioStoryTables(doc).map(({ start, index }) => [start, index]));
169
+ const tables = [];
170
+ const tableIndexByStart = /* @__PURE__ */ new Map();
158
171
  const path = [];
159
172
  let blockIndex = 0;
160
173
  let blankIndex = 0;
161
174
  doc.descendants((node, pos, _parent, index) => {
162
175
  while (pos >= (path.at(-1)?.end ?? Number.POSITIVE_INFINITY)) path.pop();
163
176
  if (!node.isTextblock) {
164
- if (isHiddenTableRow(node)) return false;
177
+ const disposition = classifyNonTextblockStoryTableNode(node);
178
+ if (disposition === STORY_TABLE_HIDDEN_SUBTREE) return false;
179
+ if (disposition === STORY_TABLE) {
180
+ const tableIndex = tables.length;
181
+ tables.push({
182
+ index: tableIndex,
183
+ start: pos,
184
+ node
185
+ });
186
+ tableIndexByStart.set(pos, tableIndex);
187
+ }
165
188
  if (!node.isLeaf) path.push({
166
189
  node,
167
190
  start: pos,
@@ -254,7 +277,11 @@ const createFolioAIEditSnapshotInternal = (doc, styleResolver) => {
254
277
  blocks,
255
278
  anchors
256
279
  };
257
- numberingReferenceKeysBySnapshot.set(snapshot, [...numberingReferenceKeys]);
280
+ metadataBySnapshot.set(snapshot, {
281
+ numberingReferenceKeys: [...numberingReferenceKeys],
282
+ sourceDocument: doc,
283
+ storyTables: tables
284
+ });
258
285
  return snapshot;
259
286
  };
260
287
  const createFolioAIEditSnapshot = (doc) => createFolioAIEditSnapshotInternal(doc, null);
@@ -479,4 +506,4 @@ const isEmptyPreviewRunStyle = ({ bold, italic, underline, strike, fontFamily, f
479
506
  const sameDirectFormatting = (left, right) => left === void 0 && isEmptyPreviewRunStyle(right) || left !== void 0 && left.bold === right.bold && left.italic === right.italic && left.underline === right.underline && left.strike === right.strike && left.fontFamily === right.fontFamily && left.fontSizePt === right.fontSizePt && left.color === right.color;
480
507
  const isUnstyledPreviewRun = ({ bold, italic, underline, strike, fontFamily, fontSizePt, color }) => bold === void 0 && italic === void 0 && underline === void 0 && strike === void 0 && fontFamily === void 0 && fontSizePt === void 0 && color === void 0;
481
508
  //#endregion
482
- export { createFolioAIEditSnapshot, createFolioAIEditSnapshotWithStyleResolver, createFolioAITextRangeHandle, folioStoryTables, hashFolioAIBlockStructuralBoundaries, hashFolioAIBlockText, isFolioAIContentBlock, isHiddenTableRow, normalizeFolioAIBlockText, numberingReferenceKeysOf, projectFolioAIBlockStructuralBoundaries, trailingBodyBlockId };
509
+ export { createFolioAIEditSnapshot, createFolioAIEditSnapshotWithStyleResolver, createFolioAITextRangeHandle, folioStoryTables, hashFolioAIBlockStructuralBoundaries, hashFolioAIBlockText, isFolioAIContentBlock, isHiddenTableRow, normalizeFolioAIBlockText, numberingReferenceKeysOf, projectFolioAIBlockStructuralBoundaries, sourceDocumentOf, storyTablesOf, trailingBodyBlockId };
@@ -31,7 +31,6 @@ type ParsedComparison = {
31
31
  baseBuffer: ArrayBuffer;
32
32
  baseCarriedRevisions: boolean;
33
33
  reviewer: FolioDocxReviewer;
34
- targetReviewer: FolioDocxReviewer;
35
34
  revisionStamp: FolioRevisionStamp;
36
35
  packageDate: Date;
37
36
  pairs: readonly ComparedStoryPair[];
@@ -50,7 +49,7 @@ type PlannedStoryComparison = {
50
49
  * Stage 2: align every paired story and derive its operations. Pure — no
51
50
  * parsing, no serialization, no clock.
52
51
  */
53
- declare const planComparison: ({ pairs, targetReviewer }: ParsedComparison) => Result<readonly PlannedStoryComparison[], CompareDocxOperationLimitError>;
52
+ declare const planComparison: ({ pairs }: ParsedComparison) => Result<readonly PlannedStoryComparison[], CompareDocxOperationLimitError>;
54
53
  declare const getCompareSkipDisposition: (reason: FolioAIEditSkipReason) => "fatal" | "unwritable";
55
54
  /** What stage 3 produced: the change list, whether it was proven, and whether it wrote anything. */
56
55
  type AppliedComparison = {
@@ -77,7 +76,7 @@ type AppliedComparison = {
77
76
  * what you could not represent" and "give me nothing unless you can prove it"
78
77
  * are both legitimate asks and only the caller knows which one it is making.
79
78
  */
80
- declare const applyComparison: ({ reviewer, targetReviewer, revisionStamp, granularity, numberingChanges }: ParsedComparison, planned: readonly PlannedStoryComparison[]) => Result<AppliedComparison, CompareDocxApplyError>;
79
+ declare const applyComparison: ({ reviewer, revisionStamp, granularity, numberingChanges }: ParsedComparison, planned: readonly PlannedStoryComparison[]) => Result<AppliedComparison, CompareDocxApplyError>;
81
80
  /**
82
81
  * Stage 4: the result package, with every ZIP entry date pinned.
83
82
  *
@@ -1,5 +1,5 @@
1
- import { FolioDocxReviewer } from "../ai-edits/headless.js";
2
- import { numberingReferenceKeysOf } from "../ai-edits/snapshot.js";
1
+ import { FolioDocxReviewer, getFolioDocxComparisonAccess } from "../ai-edits/headless.js";
2
+ import { numberingReferenceKeysOf, storyTablesOf } from "../ai-edits/snapshot.js";
3
3
  import { projectTableGeometry } from "../ai-edits/table-geometry.js";
4
4
  import { tableTemplateCanCrossPackageLosslessly } from "../ai-edits/table-template.js";
5
5
  import { createScopedWordDiffOptions } from "../ai-edits/word-diff.js";
@@ -60,25 +60,6 @@ const parseSide = async (buffer, side, author) => await Result.tryPromise({
60
60
  cause
61
61
  })
62
62
  });
63
- /** Read before either side is resolved, so it describes the package as it arrived. */
64
- const existingRevisionsOf = (reviewer) => {
65
- let highest = 0;
66
- let present = false;
67
- for (const { handle } of reviewer.listStories()) {
68
- const story = reviewer.readReviewedStory({
69
- story: handle,
70
- view: "current-markup"
71
- });
72
- for (const change of story?.changes ?? []) {
73
- highest = Math.max(highest, change.id);
74
- present = true;
75
- }
76
- }
77
- return {
78
- idSeed: highest + 1,
79
- present
80
- };
81
- };
82
63
  /** Verify formatting only where the plan claims a text-equal formatting change. */
83
64
  const formattingRoundTripFailure = ({ invariant, story, changes, actualBlocks, expectedBlocks, expectedBlockId }) => {
84
65
  const expectedIndexById = new Map(expectedBlocks.map(({ id }, index) => [id, index]));
@@ -146,15 +127,20 @@ const parseComparison = async (base, target, options) => {
146
127
  if (targetParse.isErr()) return Result.err(targetParse.error);
147
128
  const reviewer = baseParse.value;
148
129
  const targetReviewer = targetParse.value;
149
- const existing = existingRevisionsOf(reviewer);
150
- for (const { handle } of reviewer.listStories()) reviewer.resolveReviewedStory({
151
- story: handle,
152
- view: "final"
153
- });
154
- for (const { handle } of targetReviewer.listStories()) targetReviewer.resolveReviewedStory({
155
- story: handle,
156
- view: "final"
157
- });
130
+ const baseProjection = getFolioDocxComparisonAccess(reviewer).projectStories("with-revision-census");
131
+ const targetProjection = getFolioDocxComparisonAccess(targetReviewer).projectStories("without-revision-census");
132
+ const baseStories = [];
133
+ const targetStories = [];
134
+ const baseSnapshots = /* @__PURE__ */ new Map();
135
+ const targetSnapshots = /* @__PURE__ */ new Map();
136
+ for (const { handle, snapshot } of baseProjection.stories) {
137
+ baseStories.push(handle);
138
+ baseSnapshots.set(handle, snapshot);
139
+ }
140
+ for (const { handle, snapshot } of targetProjection.stories) {
141
+ targetStories.push(handle);
142
+ targetSnapshots.set(handle, snapshot);
143
+ }
158
144
  const pairs = [];
159
145
  const unsupported = [];
160
146
  const referencedNumberingLevels = /* @__PURE__ */ new Set();
@@ -162,10 +148,10 @@ const parseComparison = async (base, target, options) => {
162
148
  if (!snapshot) return;
163
149
  for (const referenceKey of numberingReferenceKeysOf(snapshot)) referencedNumberingLevels.add(referenceKey);
164
150
  };
165
- for (const { baseStory, revisedStory: targetStory } of pairFolioDocumentStories(reviewer.listStories().map(({ handle }) => handle), targetReviewer.listStories().map(({ handle }) => handle))) {
151
+ for (const { baseStory, revisedStory: targetStory } of pairFolioDocumentStories(baseStories, targetStories)) {
166
152
  if (!baseStory) {
167
153
  if (!targetStory) panic("A story pair contained neither a base nor a target story");
168
- collectNumberingReferences(targetReviewer.snapshotStory(targetStory));
154
+ collectNumberingReferences(targetSnapshots.get(targetStory));
169
155
  unsupported.push({
170
156
  reason: "story-missing-in-base",
171
157
  baseStory: null,
@@ -174,7 +160,7 @@ const parseComparison = async (base, target, options) => {
174
160
  continue;
175
161
  }
176
162
  if (!targetStory) {
177
- collectNumberingReferences(reviewer.snapshotStory(baseStory));
163
+ collectNumberingReferences(baseSnapshots.get(baseStory));
178
164
  unsupported.push({
179
165
  reason: "story-missing-in-target",
180
166
  baseStory,
@@ -182,8 +168,8 @@ const parseComparison = async (base, target, options) => {
182
168
  });
183
169
  continue;
184
170
  }
185
- const baseSnapshot = reviewer.snapshotStory(baseStory);
186
- const targetSnapshot = targetReviewer.snapshotStory(targetStory);
171
+ const baseSnapshot = baseSnapshots.get(baseStory);
172
+ const targetSnapshot = targetSnapshots.get(targetStory);
187
173
  collectNumberingReferences(baseSnapshot);
188
174
  collectNumberingReferences(targetSnapshot);
189
175
  if (!baseSnapshot || !targetSnapshot) {
@@ -204,12 +190,11 @@ const parseComparison = async (base, target, options) => {
204
190
  return Result.ok({
205
191
  granularity: options.granularity ?? "word",
206
192
  baseBuffer: base,
207
- baseCarriedRevisions: existing.present,
193
+ baseCarriedRevisions: baseProjection.revisions.present,
208
194
  reviewer,
209
- targetReviewer,
210
195
  revisionStamp: {
211
196
  date: options.timestamp,
212
- idSeed: existing.idSeed
197
+ idSeed: baseProjection.revisions.highestId + 1
213
198
  },
214
199
  packageDate,
215
200
  pairs,
@@ -217,8 +202,8 @@ const parseComparison = async (base, target, options) => {
217
202
  unsupported
218
203
  });
219
204
  };
220
- const planCopiesNonPortableWholeTable = (targetReviewer, pair, plan) => {
221
- const targetTables = new Map(targetReviewer.storyTables({ story: pair.targetStory }).map(({ index, node }) => [index, node]));
205
+ const planCopiesNonPortableWholeTable = (pair, plan) => {
206
+ const targetTables = new Map(storyTablesOf(pair.targetSnapshot).map(({ index, node }) => [index, node]));
222
207
  return plan.tableTemplates.some(({ targetRowIndex, targetTableIndex }) => {
223
208
  if (targetRowIndex !== void 0) return false;
224
209
  const table = targetTables.get(targetTableIndex);
@@ -229,7 +214,7 @@ const planCopiesNonPortableWholeTable = (targetReviewer, pair, plan) => {
229
214
  * Stage 2: align every paired story and derive its operations. Pure — no
230
215
  * parsing, no serialization, no clock.
231
216
  */
232
- const planComparison = ({ pairs, targetReviewer }) => {
217
+ const planComparison = ({ pairs }) => {
233
218
  const planned = [];
234
219
  const workSession = createContentComparisonWorkSession();
235
220
  let remainingOperations = MAX_COMPARE_OPERATIONS;
@@ -246,7 +231,7 @@ const planComparison = ({ pairs, targetReviewer }) => {
246
231
  wholeTableReplacement: "allow",
247
232
  workSession
248
233
  });
249
- if (plan && planCopiesNonPortableWholeTable(targetReviewer, pair, plan)) {
234
+ if (plan && planCopiesNonPortableWholeTable(pair, plan)) {
250
235
  workSession.alignment.remainingLcsCells = remainingLcsCells;
251
236
  workSession.alignment.remainingStructuralTokenLookups = remainingStructuralTokenLookups;
252
237
  workSession.remainingMoveComparisons = remainingMoveComparisons;
@@ -343,7 +328,8 @@ const resolveTableTemplates = (targetTables, requests) => {
343
328
  * what you could not represent" and "give me nothing unless you can prove it"
344
329
  * are both legitimate asks and only the caller knows which one it is making.
345
330
  */
346
- const applyComparison = ({ reviewer, targetReviewer, revisionStamp, granularity, numberingChanges }, planned) => {
331
+ const applyComparison = ({ reviewer, revisionStamp, granularity, numberingChanges }, planned) => {
332
+ const comparisonAccess = getFolioDocxComparisonAccess(reviewer);
347
333
  const changes = [...numberingChanges];
348
334
  const failures = [];
349
335
  let idSeed = revisionStamp.idSeed;
@@ -352,12 +338,9 @@ const applyComparison = ({ reviewer, targetReviewer, revisionStamp, granularity,
352
338
  for (const { pair, plan } of planned) {
353
339
  changes.push(...plan.changes);
354
340
  if (plan.operations.length === 0 && plan.tableGeometryPairings.length === 0) continue;
355
- const baseBefore = reviewer.readReviewedStory({
356
- story: pair.baseStory,
357
- view: "final"
358
- })?.snapshot.blocks ?? [];
359
- const baseBeforeGeometry = projectTableGeometry(reviewer.storyTables({ story: pair.baseStory }));
360
- const targetTables = new Map(targetReviewer.storyTables({ story: pair.targetStory }).map(({ index, node }) => [index, node]));
341
+ const baseBefore = pair.baseSnapshot.blocks;
342
+ const baseBeforeGeometry = projectTableGeometry(storyTablesOf(pair.baseSnapshot));
343
+ const targetTables = new Map(storyTablesOf(pair.targetSnapshot).map(({ index, node }) => [index, node]));
361
344
  const afterGeometry = reviewer.matchStoryTableGeometry({
362
345
  story: pair.baseStory,
363
346
  targetTables,
@@ -396,14 +379,14 @@ const applyComparison = ({ reviewer, targetReviewer, revisionStamp, granularity,
396
379
  skipped: refused
397
380
  }));
398
381
  }
399
- const acceptedStory = reviewer.readReviewedStory({
382
+ const acceptedSnapshot = comparisonAccess.snapshotReviewedStory({
400
383
  story: pair.baseStory,
401
384
  view: "final"
402
385
  });
403
386
  const acceptFailure = classifyProjectionMismatch({
404
387
  invariant: "accept-reproduces-target",
405
388
  story: pair.baseStory,
406
- actual: acceptedStory?.snapshot.blocks ?? [],
389
+ actual: acceptedSnapshot?.blocks ?? [],
407
390
  expected: pair.targetSnapshot.blocks
408
391
  });
409
392
  if (acceptFailure) failures.push(acceptFailure);
@@ -412,20 +395,20 @@ const applyComparison = ({ reviewer, targetReviewer, revisionStamp, granularity,
412
395
  invariant: "accept-reproduces-target",
413
396
  story: pair.baseStory,
414
397
  changes: plan.changes,
415
- actualBlocks: acceptedStory?.snapshot.blocks ?? [],
398
+ actualBlocks: acceptedSnapshot?.blocks ?? [],
416
399
  expectedBlocks: pair.targetSnapshot.blocks,
417
400
  expectedBlockId: ({ targetBlockId }) => targetBlockId
418
401
  });
419
402
  if (formattingFailure) failures.push(formattingFailure);
420
403
  }
421
- const rejectedStory = reviewer.readReviewedStory({
404
+ const rejectedSnapshot = comparisonAccess.snapshotReviewedStory({
422
405
  story: pair.baseStory,
423
406
  view: "original"
424
407
  });
425
408
  const rejectFailure = classifyProjectionMismatch({
426
409
  invariant: "reject-reproduces-base",
427
410
  story: pair.baseStory,
428
- actual: rejectedStory?.snapshot.blocks ?? [],
411
+ actual: rejectedSnapshot?.blocks ?? [],
429
412
  expected: baseBefore
430
413
  });
431
414
  if (rejectFailure) failures.push(rejectFailure);
@@ -434,7 +417,7 @@ const applyComparison = ({ reviewer, targetReviewer, revisionStamp, granularity,
434
417
  invariant: "reject-reproduces-base",
435
418
  story: pair.baseStory,
436
419
  changes: plan.changes,
437
- actualBlocks: rejectedStory?.snapshot.blocks ?? [],
420
+ actualBlocks: rejectedSnapshot?.blocks ?? [],
438
421
  expectedBlocks: pair.baseSnapshot.blocks,
439
422
  expectedBlockId: ({ baseBlockId }) => baseBlockId
440
423
  });
@@ -443,20 +426,14 @@ const applyComparison = ({ reviewer, targetReviewer, revisionStamp, granularity,
443
426
  const geometryAcceptFailure = classifyGeometryMismatch({
444
427
  invariant: "accept-reproduces-target",
445
428
  story: pair.baseStory,
446
- actual: projectTableGeometry(reviewer.storyTables({
447
- story: pair.baseStory,
448
- view: "final"
449
- })),
450
- expected: projectTableGeometry(targetReviewer.storyTables({ story: pair.targetStory }))
429
+ actual: projectTableGeometry(acceptedSnapshot ? storyTablesOf(acceptedSnapshot) : []),
430
+ expected: projectTableGeometry(storyTablesOf(pair.targetSnapshot))
451
431
  });
452
432
  if (geometryAcceptFailure) failures.push(geometryAcceptFailure);
453
433
  const geometryRejectFailure = classifyGeometryMismatch({
454
434
  invariant: "reject-reproduces-base",
455
435
  story: pair.baseStory,
456
- actual: projectTableGeometry(reviewer.storyTables({
457
- story: pair.baseStory,
458
- view: "original"
459
- })),
436
+ actual: projectTableGeometry(rejectedSnapshot ? storyTablesOf(rejectedSnapshot) : []),
460
437
  expected: baseBeforeGeometry
461
438
  });
462
439
  if (geometryRejectFailure) failures.push(geometryRejectFailure);
@@ -601,7 +601,6 @@ const trustedContentSequencePairs = ({ exactPairs, stablePairs, revisedLength, p
601
601
  return [...primary, ...secondary].toSorted((left, right) => left.baseIndex - right.baseIndex || left.revisedIndex - right.revisedIndex);
602
602
  };
603
603
  const persistedContentSequencePairs = ({ base, revised, anchors, canPair }) => {
604
- if (anchors.length === 0) return /* @__PURE__ */ new Set();
605
604
  const uniqueIndexesByFirstId = (items) => {
606
605
  const indexes = /* @__PURE__ */ new Map();
607
606
  items.forEach(({ profile }, itemIndex) => {
@@ -1,7 +1,15 @@
1
- import { FolioAIInlineFormattingPatch } from "../ai-edits/types.js";
1
+ import { FolioAIEditOperation, FolioAIInlineFormattingPatch } from "../ai-edits/types.js";
2
2
  import { CompareDocxParseError, CompareDocxSerializeError } from "./types.js";
3
3
  import { Result } from "better-result";
4
4
  //#region src/compare/scenario.d.ts
5
+ type InsertTableRowOperation = Extract<FolioAIEditOperation, {
6
+ type: "insertTableRow";
7
+ }>;
8
+ type TableRowPosition = NonNullable<InsertTableRowOperation["position"]>;
9
+ declare const EDIT_SCRIPT_TABLE_ROW_POSITION: {
10
+ readonly after: "after";
11
+ readonly before: "before";
12
+ };
5
13
  type EditScriptStep = {
6
14
  type: "insertParagraphAfter";
7
15
  blockIndex: number;
@@ -46,6 +54,7 @@ type EditScriptStep = {
46
54
  } | {
47
55
  type: "insertTableRow";
48
56
  blockIndex: number;
57
+ position?: TableRowPosition;
49
58
  cellTexts: readonly string[];
50
59
  } | {
51
60
  type: "deleteTableRow";
@@ -74,4 +83,4 @@ type EditScriptResult = {
74
83
  */
75
84
  declare const applyEditScript: (base: ArrayBuffer, script: EditScript) => Promise<Result<EditScriptResult, CompareDocxParseError | CompareDocxSerializeError>>;
76
85
  //#endregion
77
- export { EDIT_SCRIPT_UNRESOLVED_REASONS, EditScript, EditScriptResult, EditScriptStep, EditScriptUnresolvedReason, EditScriptUnresolvedStep, applyEditScript };
86
+ export { EDIT_SCRIPT_TABLE_ROW_POSITION, EDIT_SCRIPT_UNRESOLVED_REASONS, EditScript, EditScriptResult, EditScriptStep, EditScriptUnresolvedReason, EditScriptUnresolvedStep, applyEditScript };
@@ -24,6 +24,10 @@ import { Result, panic } from "better-result";
24
24
  * dropped: a script silently doing less than it says would weaken every
25
25
  * property built on it.
26
26
  */
27
+ const EDIT_SCRIPT_TABLE_ROW_POSITION = {
28
+ after: "after",
29
+ before: "before"
30
+ };
27
31
  const EDIT_SCRIPT_UNRESOLVED_REASONS = Object.freeze([
28
32
  "block-out-of-range",
29
33
  "block-not-in-table",
@@ -160,7 +164,7 @@ const planStep = ({ step, blocks, nextOperationId }) => {
160
164
  id: nextOperationId(),
161
165
  type: "insertTableRow",
162
166
  blockId: block.id,
163
- position: "after",
167
+ position: step.position ?? EDIT_SCRIPT_TABLE_ROW_POSITION.after,
164
168
  cellTexts: [...step.cellTexts]
165
169
  }]
166
170
  } : {
@@ -260,4 +264,4 @@ const applyEditScript = async (base, script) => {
260
264
  });
261
265
  };
262
266
  //#endregion
263
- export { EDIT_SCRIPT_UNRESOLVED_REASONS, applyEditScript };
267
+ export { EDIT_SCRIPT_TABLE_ROW_POSITION, EDIT_SCRIPT_UNRESOLVED_REASONS, applyEditScript };
@@ -15,7 +15,7 @@ import { hasComplexScript, isComplexScriptCodePoint } from "../utils/scriptSegme
15
15
  * advances rightward from its origin whatever the paragraph does.
16
16
  */
17
17
  declare const glyphCellOffsetsPx: (run: DisplayGlyphRun) => readonly number[];
18
- declare const DISPLAY_PRIMITIVE_KINDS: ("image" | "glyphRun" | "rect" | "line" | "clipGroup" | "rotateGroup" | "opacityGroup")[];
18
+ declare const DISPLAY_PRIMITIVE_KINDS: ("glyphRun" | "rect" | "line" | "image" | "clipGroup" | "rotateGroup" | "opacityGroup")[];
19
19
  /**
20
20
  * Dash geometry of each stroke pattern, as multiples of the stroke thickness.
21
21
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.37.1",
3
+ "version": "0.37.2",
4
4
  "description": "Headless, framework-neutral core of folio: the OOXML (.docx) parser, document model, ProseMirror integration, and page-layout engine. No React.",
5
5
  "keywords": [
6
6
  "document-model",