@stll/folio-core 0.9.0 → 0.11.0

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.
Files changed (69) hide show
  1. package/dist/ai-edits/headless.d.ts +52 -12
  2. package/dist/ai-edits/headless.js +181 -55
  3. package/dist/ai-edits/index.d.ts +2 -2
  4. package/dist/ai-edits/index.js +2 -2
  5. package/dist/controller/collaborationModules.d.ts +7 -0
  6. package/dist/controller/collaborationModules.js +15 -0
  7. package/dist/controller/folioEditor.d.ts +1 -1
  8. package/dist/controller/headerFooterEditorManager.d.ts +41 -0
  9. package/dist/controller/headerFooterEditorManager.js +182 -0
  10. package/dist/controller/hiddenEditorManager.d.ts +1 -1
  11. package/dist/controller/layoutPipeline.js +2 -1
  12. package/dist/document-operations.d.ts +11 -2
  13. package/dist/document-operations.js +7 -1
  14. package/dist/document-stories.d.ts +10 -0
  15. package/dist/document-stories.js +27 -0
  16. package/dist/docx/corePropertiesParser.d.ts +8 -0
  17. package/dist/docx/corePropertiesParser.js +53 -0
  18. package/dist/docx/index.d.ts +2 -1
  19. package/dist/docx/index.js +2 -1
  20. package/dist/docx/metadataPrivacy.d.ts +40 -0
  21. package/dist/docx/metadataPrivacy.js +131 -0
  22. package/dist/docx/parser.js +4 -1
  23. package/dist/docx/rezip.d.ts +5 -4
  24. package/dist/docx/rezip.js +6 -8
  25. package/dist/layout-bridge/convert/toFlowBlocks.js +1 -0
  26. package/dist/layout-engine/measure/cache.js +2 -7
  27. package/dist/layout-engine/measure/effectiveLineBreakPolicy.d.ts +29 -0
  28. package/dist/layout-engine/measure/effectiveLineBreakPolicy.js +60 -0
  29. package/dist/layout-engine/measure/measureParagraph.js +32 -36
  30. package/dist/layout-engine/measure/tableCellFloating.js +39 -33
  31. package/dist/layout-engine/types.d.ts +5 -3
  32. package/dist/managers/FindReplaceManager.d.ts +8 -0
  33. package/dist/managers/FindReplaceManager.js +18 -0
  34. package/dist/managers/TableSelectionManager.d.ts +6 -0
  35. package/dist/managers/TableSelectionManager.js +14 -0
  36. package/dist/prosemirror/attrs/index.js +1 -0
  37. package/dist/prosemirror/conversion/effectiveTableCellFormatting.d.ts +62 -0
  38. package/dist/prosemirror/conversion/effectiveTableCellFormatting.js +131 -0
  39. package/dist/prosemirror/conversion/fromProseDoc.js +1 -0
  40. package/dist/prosemirror/conversion/toProseDoc.js +40 -68
  41. package/dist/prosemirror/extensions/core/ParagraphExtension.js +1 -1
  42. package/dist/prosemirror/extensions/marks/HighlightExtension.js +1 -1
  43. package/dist/prosemirror/extensions/marks/RunShadingExtension.js +1 -1
  44. package/dist/prosemirror/extensions/marks/TextColorExtension.js +1 -1
  45. package/dist/prosemirror/extensions/nodes/ImageExtension.js +1 -0
  46. package/dist/prosemirror/extensions/nodes/TableExtension.js +1 -1
  47. package/dist/prosemirror/findReplaceSelection.d.ts +6 -1
  48. package/dist/prosemirror/findReplaceSelection.js +57 -13
  49. package/dist/prosemirror/schema/nodes.d.ts +2 -1
  50. package/dist/redline.d.ts +26 -11
  51. package/dist/redline.js +95 -62
  52. package/dist/render-dom/BodySelectionOverlay.d.ts +31 -0
  53. package/dist/render-dom/BodySelectionOverlay.js +76 -0
  54. package/dist/render-dom/HeaderFooterSelectionOverlay.d.ts +28 -0
  55. package/dist/render-dom/HeaderFooterSelectionOverlay.js +121 -0
  56. package/dist/render-dom/RemoteSelectionOverlay.d.ts +30 -0
  57. package/dist/render-dom/RemoteSelectionOverlay.js +67 -0
  58. package/dist/render-dom/RenderedDomContext.d.ts +40 -0
  59. package/dist/render-dom/RenderedDomContext.js +126 -0
  60. package/dist/render-dom/resolveSidebarItemPositions.d.ts +33 -0
  61. package/dist/render-dom/resolveSidebarItemPositions.js +45 -0
  62. package/dist/server.d.ts +5 -4
  63. package/dist/server.js +5 -4
  64. package/dist/utils/findReplace.d.ts +32 -0
  65. package/dist/utils/findReplace.js +118 -0
  66. package/dist/utils/headerFooter.js +24 -1
  67. package/dist/version-comparison.d.ts +65 -11
  68. package/dist/version-comparison.js +187 -29
  69. package/package.json +1 -1
@@ -0,0 +1,32 @@
1
+ import { document_d_exports } from "../types/document.js";
2
+ import { Paragraph } from "../types/content.js";
3
+
4
+ //#region src/utils/findReplace.d.ts
5
+ type FindMatch = {
6
+ paragraphIndex: number;
7
+ contentIndex: number;
8
+ startOffset: number;
9
+ endOffset: number;
10
+ text: string;
11
+ };
12
+ type FindOptions = {
13
+ matchCase: boolean;
14
+ matchWholeWord: boolean;
15
+ useRegex?: boolean;
16
+ };
17
+ type FindResult = {
18
+ matches: FindMatch[];
19
+ totalCount: number;
20
+ currentIndex: number;
21
+ };
22
+ declare const createDefaultFindOptions: () => FindOptions;
23
+ declare const escapeRegexString: (value: string) => string;
24
+ declare const createSearchPattern: (searchText: string, options: FindOptions) => RegExp | null;
25
+ declare const findAllMatches: (content: string, searchText: string, options: FindOptions) => Array<{
26
+ start: number;
27
+ end: number;
28
+ }>;
29
+ declare const findInDocument: (document: document_d_exports.Document | null | undefined, searchText: string, options: FindOptions) => FindMatch[];
30
+ declare const findInParagraph: (paragraph: Paragraph, searchText: string, options: FindOptions, paragraphIndex: number) => FindMatch[];
31
+ //#endregion
32
+ export { FindMatch, FindOptions, FindResult, createDefaultFindOptions, createSearchPattern, escapeRegexString, findAllMatches, findInDocument, findInParagraph };
@@ -0,0 +1,118 @@
1
+ //#region src/utils/findReplace.ts
2
+ const createDefaultFindOptions = () => ({
3
+ matchCase: false,
4
+ matchWholeWord: false,
5
+ useRegex: false
6
+ });
7
+ const escapeRegexString = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
8
+ const createSearchPattern = (searchText, options) => {
9
+ if (!searchText) return null;
10
+ try {
11
+ const source = options.useRegex ? searchText : escapeRegexString(searchText);
12
+ const pattern = options.matchWholeWord ? `\\b${source}\\b` : source;
13
+ return new RegExp(pattern, options.matchCase ? "gu" : "giu");
14
+ } catch {
15
+ return null;
16
+ }
17
+ };
18
+ const findAllMatches = (content, searchText, options) => {
19
+ if (!content || !searchText) return [];
20
+ const searchFor = options.matchCase ? searchText : searchText.toLowerCase();
21
+ const source = escapeRegexString(searchFor);
22
+ const pattern = new RegExp(options.matchWholeWord ? `\\b${source}\\b` : source, options.matchCase ? "gu" : "giu");
23
+ const matches = [];
24
+ let match;
25
+ while ((match = pattern.exec(content)) !== null) {
26
+ matches.push({
27
+ start: match.index,
28
+ end: match.index + match[0].length
29
+ });
30
+ if (match[0].length === 0) pattern.lastIndex++;
31
+ }
32
+ return matches;
33
+ };
34
+ const findInDocument = (document, searchText, options) => {
35
+ if (!document || !searchText) return [];
36
+ const body = document.package.document;
37
+ if (!isRecord(body) || !Array.isArray(body.content)) return [];
38
+ const matches = [];
39
+ forEachParagraph(body.content, (paragraph, paragraphIndex) => {
40
+ matches.push(...findInParagraph(paragraph, searchText, options, paragraphIndex));
41
+ });
42
+ return matches;
43
+ };
44
+ const findInParagraph = (paragraph, searchText, options, paragraphIndex) => {
45
+ const paragraphText = getParagraphPlainText(paragraph);
46
+ if (!paragraphText) return [];
47
+ return findAllMatches(paragraphText, searchText, options).map(({ start, end }) => ({
48
+ paragraphIndex,
49
+ contentIndex: findContentIndexAtOffset(paragraph, start),
50
+ startOffset: start,
51
+ endOffset: end,
52
+ text: paragraphText.slice(start, end)
53
+ }));
54
+ };
55
+ const forEachParagraph = (blocks, visit) => {
56
+ let paragraphIndex = 0;
57
+ const walkBlocks = (items) => {
58
+ for (const block of items) {
59
+ if (isParagraph(block)) {
60
+ visit(block, paragraphIndex);
61
+ paragraphIndex++;
62
+ continue;
63
+ }
64
+ if (isTable(block)) {
65
+ walkTable(block);
66
+ continue;
67
+ }
68
+ if (isBlockSdt(block)) walkBlocks(block.content);
69
+ }
70
+ };
71
+ const walkTable = (table) => {
72
+ for (const row of table.rows) {
73
+ if (!isTableRow(row)) continue;
74
+ for (const cell of row.cells) if (isTableCell(cell)) walkBlocks(cell.content);
75
+ }
76
+ };
77
+ walkBlocks(blocks);
78
+ };
79
+ const getRunText = (run) => {
80
+ let text = "";
81
+ for (const item of run.content) if (item.type === "text") text += item.text;
82
+ else if (item.type === "tab") text += " ";
83
+ else if (item.type === "break" && item.breakType === "textWrapping") text += "\n";
84
+ return text;
85
+ };
86
+ const getHyperlinkText = (hyperlink) => {
87
+ let text = "";
88
+ for (const child of hyperlink.children) if (child.type === "run") text += getRunText(child);
89
+ return text;
90
+ };
91
+ const getParagraphContentText = (content) => {
92
+ if (content.type === "run") return getRunText(content);
93
+ if (content.type === "hyperlink") return getHyperlinkText(content);
94
+ if (content.type === "inlineSdt") return content.content.map(getParagraphContentText).join("");
95
+ if (content.type === "simpleField") return content.content.map((child) => child.type === "run" ? getRunText(child) : getHyperlinkText(child)).join("");
96
+ if (content.type === "complexField") return content.fieldResult.map(getRunText).join("");
97
+ return "";
98
+ };
99
+ const getParagraphPlainText = (paragraph) => paragraph.content.map(getParagraphContentText).join("");
100
+ const findContentIndexAtOffset = (paragraph, offset) => {
101
+ let currentOffset = 0;
102
+ for (let contentIndex = 0; contentIndex < paragraph.content.length; contentIndex++) {
103
+ const item = paragraph.content[contentIndex];
104
+ if (!item) continue;
105
+ const itemLength = getParagraphContentText(item).length;
106
+ if (currentOffset + itemLength > offset) return contentIndex;
107
+ currentOffset += itemLength;
108
+ }
109
+ return Math.max(0, paragraph.content.length - 1);
110
+ };
111
+ const isRecord = (value) => typeof value === "object" && value !== null;
112
+ const isParagraph = (value) => isRecord(value) && value["type"] === "paragraph" && Array.isArray(value["content"]);
113
+ const isTable = (value) => isRecord(value) && value["type"] === "table" && Array.isArray(value["rows"]);
114
+ const isTableRow = (value) => isRecord(value) && Array.isArray(value["cells"]);
115
+ const isTableCell = (value) => isRecord(value) && Array.isArray(value["content"]);
116
+ const isBlockSdt = (value) => isRecord(value) && value["type"] === "blockSdt" && Array.isArray(value["content"]);
117
+ //#endregion
118
+ export { createDefaultFindOptions, createSearchPattern, escapeRegexString, findAllMatches, findInDocument, findInParagraph };
@@ -156,7 +156,18 @@ const createEmptyHeaderFooter = (document, position, isFirstPage) => {
156
156
  const sectionProps = pkg.document.finalSectionProperties;
157
157
  if (!sectionProps) return null;
158
158
  const hdrFtrType = isFirstPage ? "first" : "default";
159
- const rId = `rId_new_${position}_${hdrFtrType}`;
159
+ const baseRId = `rId_new_${position}_${hdrFtrType}`;
160
+ const usedRIds = /* @__PURE__ */ new Set([
161
+ ...pkg.headers?.keys() ?? [],
162
+ ...pkg.footers?.keys() ?? [],
163
+ ...pkg.relationships?.keys() ?? []
164
+ ]);
165
+ let rId = baseRId;
166
+ let rIdSuffix = 2;
167
+ while (usedRIds.has(rId)) {
168
+ rId = `${baseRId}_${rIdSuffix}`;
169
+ rIdSuffix++;
170
+ }
160
171
  const emptyHf = {
161
172
  type: position,
162
173
  hdrFtrType,
@@ -174,11 +185,23 @@ const createEmptyHeaderFooter = (document, position, isFirstPage) => {
174
185
  type: hdrFtrType,
175
186
  rId
176
187
  };
188
+ const usedTargets = /* @__PURE__ */ new Set();
189
+ for (const relationship of pkg.relationships?.values() ?? []) if (relationship.target) usedTargets.add(relationship.target);
190
+ let targetNumber = 1;
191
+ while (usedTargets.has(`${position}${targetNumber}.xml`)) targetNumber++;
192
+ const relationshipType = position === "header" ? "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" : "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
193
+ const relationships = new Map(pkg.relationships);
194
+ relationships.set(rId, {
195
+ id: rId,
196
+ type: relationshipType,
197
+ target: `${position}${targetNumber}.xml`
198
+ });
177
199
  return {
178
200
  ...document,
179
201
  package: {
180
202
  ...pkg,
181
203
  [mapKey]: newMap,
204
+ relationships,
182
205
  document: {
183
206
  ...pkg.document,
184
207
  finalSectionProperties: {
@@ -1,59 +1,111 @@
1
1
  import { FolioAIBlock } from "./ai-edits/types.js";
2
+ import { FolioDocumentStoryHandle } from "./ai-edits/headless.js";
2
3
  import { WordDiffSegment } from "./ai-edits/word-diff.js";
4
+ import { FOLIO_DOCUMENT_METADATA_PROPERTIES, FolioDocumentMetadataProperty, FolioDocumentPrivacyOptions, FolioDocumentPrivacyReport, FolioDocumentPrivacyTransform } from "./docx/metadataPrivacy.js";
3
5
 
4
6
  //#region src/version-comparison.d.ts
5
7
  /** One word-level diff segment within a `modified` block. Mirrors {@link WordDiffSegment}. */
6
8
  type FolioVersionDiffSegment = WordDiffSegment;
9
+ /** Independently selectable comparison scopes. */
10
+ declare const FOLIO_VERSION_COMPARISON_SCOPES: readonly ["text", "formatting", "metadata"];
11
+ type FolioVersionComparisonScope = (typeof FOLIO_VERSION_COMPARISON_SCOPES)[number];
12
+ declare const isFolioVersionComparisonScope: (value: unknown) => value is FolioVersionComparisonScope;
13
+ type FolioCompareDocxVersionsOptions = {
14
+ /** Selected scopes; defaults to text and formatting. */include?: readonly FolioVersionComparisonScope[]; /** Optional output-only privacy transforms. Source buffers are never mutated. */
15
+ privacy?: FolioVersionDiffPrivacyOptions;
16
+ };
17
+ declare const InvalidFolioVersionComparisonOptionsError_base: import("better-result").TaggedErrorClass<"InvalidFolioVersionComparisonOptionsError", {
18
+ message: string;
19
+ option: "include" | "privacy.transforms";
20
+ receivedValue: unknown;
21
+ }>;
22
+ declare class InvalidFolioVersionComparisonOptionsError extends InvalidFolioVersionComparisonOptionsError_base {}
23
+ type FolioDocumentMetadataValue = string | number | null;
24
+ type FolioMetadataDiff = {
25
+ property: FolioDocumentMetadataProperty;
26
+ baseValue: FolioDocumentMetadataValue;
27
+ revisedValue: FolioDocumentMetadataValue;
28
+ };
29
+ declare const FOLIO_VERSION_COMPARISON_PRIVACY_TRANSFORMS: readonly ["remove-attribution", "remove-timestamps", "remove-descriptive-metadata"];
30
+ type FolioVersionComparisonPrivacyTransform = FolioDocumentPrivacyTransform;
31
+ declare const isFolioVersionComparisonPrivacyTransform: (value: unknown) => value is FolioVersionComparisonPrivacyTransform;
32
+ type FolioVersionDiffPrivacyOptions = FolioDocumentPrivacyOptions;
33
+ type FolioVersionDiffPrivacyReport = FolioDocumentPrivacyReport;
7
34
  /** Run-level formatting properties compared for `formatChanged` detection. */
8
35
  declare const FORMAT_PROPERTIES: readonly ["bold", "italic", "underline", "strike", "fontFamily", "fontSizePt", "color"];
9
36
  /** A run-level formatting property that can differ in a `formatChanged` block. */
10
37
  type FolioFormatProperty = (typeof FORMAT_PROPERTIES)[number];
38
+ /** Stable location of one compared block within its source document. */
39
+ type FolioVersionBlockHandle = {
40
+ story: FolioDocumentStoryHandle;
41
+ blockId: string;
42
+ };
11
43
  /** One block-level change between two document versions, in revised-side document order. */
12
44
  type FolioBlockDiff = {
13
45
  type: "added";
14
46
  blockId: string;
15
47
  kind: string;
16
48
  text: string;
49
+ revisedHandle: FolioVersionBlockHandle;
17
50
  } | {
18
51
  type: "deleted";
19
52
  blockId: string;
20
53
  kind: string;
21
54
  text: string;
55
+ baseHandle: FolioVersionBlockHandle;
22
56
  } | {
23
57
  type: "modified";
24
58
  blockId: string;
25
59
  kind: string;
26
60
  segments: FolioVersionDiffSegment[];
61
+ baseHandle: FolioVersionBlockHandle;
62
+ revisedHandle: FolioVersionBlockHandle;
27
63
  } | {
28
64
  type: "formatChanged";
29
65
  blockId: string;
30
66
  kind: string;
31
67
  text: string;
32
68
  changedProperties: FolioFormatProperty[];
69
+ baseHandle: FolioVersionBlockHandle;
70
+ revisedHandle: FolioVersionBlockHandle;
33
71
  } | {
34
72
  type: "movedFrom";
35
73
  blockId: string;
36
74
  kind: string;
37
75
  text: string;
38
76
  moveGroupId: number;
77
+ baseHandle: FolioVersionBlockHandle;
39
78
  } | {
40
79
  type: "movedTo";
41
80
  blockId: string;
42
81
  kind: string;
43
82
  text: string;
44
83
  moveGroupId: number;
84
+ revisedHandle: FolioVersionBlockHandle;
85
+ };
86
+ type FolioVersionDiffSummaryCounts = {
87
+ added: number;
88
+ deleted: number;
89
+ modified: number;
90
+ formatChanged: number;
91
+ moved: number;
92
+ metadataChanged: number;
93
+ unchanged: number;
94
+ };
95
+ /** Changes within one matched, added, or deleted document story. */
96
+ type FolioStoryDiff = {
97
+ baseStory: FolioDocumentStoryHandle | null;
98
+ revisedStory: FolioDocumentStoryHandle | null;
99
+ changes: FolioBlockDiff[];
100
+ summaryCounts: FolioVersionDiffSummaryCounts;
45
101
  };
46
102
  /** Result of {@link compareDocxVersions}. */
47
103
  type FolioVersionDiff = {
48
- /** Every changed block, in revised-side document order (deletions and move sources slotted where they sat). */changes: FolioBlockDiff[]; /** Counts across every paired/unpaired block, including the unchanged blocks `changes` omits. `moved` counts pairs, not entries. */
49
- summaryCounts: {
50
- added: number;
51
- deleted: number;
52
- modified: number;
53
- formatChanged: number;
54
- moved: number;
55
- unchanged: number;
56
- };
104
+ /** Every changed block, in revised-side document order (deletions and move sources slotted where they sat). */changes: FolioBlockDiff[]; /** Per-story results in base order followed by stories added in the revised document. */
105
+ stories: FolioStoryDiff[]; /** Changed package metadata fields in stable property order. */
106
+ metadataChanges: FolioMetadataDiff[]; /** Applied privacy policy and the fields it removed from this result. */
107
+ privacyReport: FolioVersionDiffPrivacyReport; /** Counts across every paired/unpaired block, including the unchanged blocks `changes` omits. `moved` counts pairs, not entries. */
108
+ summaryCounts: FolioVersionDiffSummaryCounts;
57
109
  };
58
110
  /** True when an `unpairedBaseCount * unpairedRevisedCount` LCS table would exceed {@link MAX_LCS_CELLS}. */
59
111
  declare const exceedsLcsBudget: (unpairedBaseCount: number, unpairedRevisedCount: number) => boolean;
@@ -81,12 +133,14 @@ type FolioAlignedBlockEvent = {
81
133
  * one document walk instead of re-deriving it.
82
134
  */
83
135
  declare const alignFolioBlocks: (baseBlocks: readonly FolioAIBlock[], revisedBlocks: readonly FolioAIBlock[]) => FolioAlignedBlockEvent[];
136
+ /** Apply auditable, output-only privacy transforms to a structured version diff. */
137
+ declare const applyFolioVersionDiffPrivacy: (diff: FolioVersionDiff, options: FolioVersionDiffPrivacyOptions) => FolioVersionDiff;
84
138
  /**
85
139
  * Compare two `.docx` buffers and return a structured, block-level diff.
86
140
  * See the module doc comment for the as-accepted comparison semantics, the
87
141
  * three-pass alignment algorithm, move detection, and format-only change
88
142
  * detection.
89
143
  */
90
- declare const compareDocxVersions: (base: ArrayBuffer, revised: ArrayBuffer) => Promise<FolioVersionDiff>;
144
+ declare const compareDocxVersions: (base: ArrayBuffer, revised: ArrayBuffer, options?: FolioCompareDocxVersionsOptions) => Promise<FolioVersionDiff>;
91
145
  //#endregion
92
- export { FolioAlignedBlockEvent, FolioBlockDiff, FolioFormatProperty, FolioVersionDiff, FolioVersionDiffSegment, alignFolioBlocks, compareDocxVersions, exceedsLcsBudget };
146
+ export { FOLIO_DOCUMENT_METADATA_PROPERTIES, FOLIO_VERSION_COMPARISON_PRIVACY_TRANSFORMS, FOLIO_VERSION_COMPARISON_SCOPES, FolioAlignedBlockEvent, FolioBlockDiff, FolioCompareDocxVersionsOptions, type FolioDocumentMetadataProperty, FolioDocumentMetadataValue, FolioFormatProperty, FolioMetadataDiff, FolioStoryDiff, FolioVersionBlockHandle, FolioVersionComparisonPrivacyTransform, FolioVersionComparisonScope, FolioVersionDiff, FolioVersionDiffPrivacyOptions, FolioVersionDiffPrivacyReport, FolioVersionDiffSegment, FolioVersionDiffSummaryCounts, InvalidFolioVersionComparisonOptionsError, alignFolioBlocks, applyFolioVersionDiffPrivacy, compareDocxVersions, exceedsLcsBudget, isFolioVersionComparisonPrivacyTransform, isFolioVersionComparisonScope };
@@ -1,6 +1,9 @@
1
1
  import { getFolioParaIdFromBlockId } from "./types/block-id.js";
2
2
  import { diffWordSegments } from "./ai-edits/word-diff.js";
3
+ import { pairFolioDocumentStories } from "./document-stories.js";
3
4
  import { FolioDocxReviewer } from "./ai-edits/headless.js";
5
+ import { FOLIO_DOCUMENT_METADATA_PROPERTIES, FOLIO_DOCUMENT_PRIVACY_TRANSFORMS, PRIVATE_METADATA_PROPERTIES_BY_TRANSFORM, isFolioDocumentPrivacyTransform } from "./docx/metadataPrivacy.js";
6
+ import { TaggedError, panic } from "better-result";
4
7
  //#region src/version-comparison.ts
5
8
  /**
6
9
  * Document version-diff engine: compare two `.docx` buffers block by block
@@ -76,6 +79,16 @@ import { FolioDocxReviewer } from "./ai-edits/headless.js";
76
79
  * content that makes the preview texts disagree, detection backs off to
77
80
  * `unchanged` rather than misattribute properties.
78
81
  */
82
+ /** Independently selectable comparison scopes. */
83
+ const FOLIO_VERSION_COMPARISON_SCOPES = Object.freeze([
84
+ "text",
85
+ "formatting",
86
+ "metadata"
87
+ ]);
88
+ const isFolioVersionComparisonScope = (value) => FOLIO_VERSION_COMPARISON_SCOPES.some((scope) => scope === value);
89
+ var InvalidFolioVersionComparisonOptionsError = class extends TaggedError("InvalidFolioVersionComparisonOptionsError")() {};
90
+ const FOLIO_VERSION_COMPARISON_PRIVACY_TRANSFORMS = FOLIO_DOCUMENT_PRIVACY_TRANSFORMS;
91
+ const isFolioVersionComparisonPrivacyTransform = (value) => isFolioDocumentPrivacyTransform(value);
79
92
  /** Run-level formatting properties compared for `formatChanged` detection. */
80
93
  const FORMAT_PROPERTIES = [
81
94
  "bold",
@@ -324,7 +337,7 @@ const meetsMoveWordCount = (text) => {
324
337
  * per text, so duplicated boilerplate above the word floor pairs
325
338
  * first-to-first rather than fanning out.
326
339
  */
327
- const detectMoves = (changes, counts) => {
340
+ const detectMoves = (changes, counts, firstMoveGroupId) => {
328
341
  const deletedIndexesByText = /* @__PURE__ */ new Map();
329
342
  changes.forEach((change, index) => {
330
343
  if (change.type === "deleted" && meetsMoveWordCount(change.text)) {
@@ -334,7 +347,7 @@ const detectMoves = (changes, counts) => {
334
347
  }
335
348
  });
336
349
  if (deletedIndexesByText.size === 0) return;
337
- let moveGroupId = 0;
350
+ let moveGroupId = firstMoveGroupId - 1;
338
351
  changes.forEach((change, index) => {
339
352
  if (change.type !== "added") return;
340
353
  const deletedIndex = deletedIndexesByText.get(change.text)?.shift();
@@ -347,53 +360,72 @@ const detectMoves = (changes, counts) => {
347
360
  blockId: deleted.blockId,
348
361
  kind: deleted.kind,
349
362
  text: deleted.text,
350
- moveGroupId
363
+ moveGroupId,
364
+ baseHandle: deleted.baseHandle
351
365
  };
352
366
  changes[index] = {
353
367
  type: "movedTo",
354
368
  blockId: change.blockId,
355
369
  kind: change.kind,
356
370
  text: change.text,
357
- moveGroupId
371
+ moveGroupId,
372
+ revisedHandle: change.revisedHandle
358
373
  };
359
374
  counts.deleted--;
360
375
  counts.added--;
361
376
  counts.moved++;
362
377
  });
363
378
  };
364
- /**
365
- * Compare two `.docx` buffers and return a structured, block-level diff.
366
- * See the module doc comment for the as-accepted comparison semantics, the
367
- * three-pass alignment algorithm, move detection, and format-only change
368
- * detection.
369
- */
370
- const compareDocxVersions = async (base, revised) => {
371
- const [baseReviewer, revisedReviewer] = await Promise.all([FolioDocxReviewer.fromBuffer(base), FolioDocxReviewer.fromBuffer(revised)]);
372
- const baseBlocks = baseReviewer.snapshot().blocks;
373
- const revisedBlocks = revisedReviewer.snapshot().blocks;
379
+ const createSummaryCounts = () => ({
380
+ added: 0,
381
+ deleted: 0,
382
+ modified: 0,
383
+ formatChanged: 0,
384
+ moved: 0,
385
+ metadataChanged: 0,
386
+ unchanged: 0
387
+ });
388
+ const addSummaryCounts = (target, source) => {
389
+ target.added += source.added;
390
+ target.deleted += source.deleted;
391
+ target.modified += source.modified;
392
+ target.formatChanged += source.formatChanged;
393
+ target.moved += source.moved;
394
+ target.metadataChanged += source.metadataChanged;
395
+ target.unchanged += source.unchanged;
396
+ };
397
+ const compareStoryBlocks = ({ baseStory, revisedStory, baseBlocks, revisedBlocks, firstMoveGroupId, includeText, includeFormatting }) => {
374
398
  const changes = [];
375
- const counts = {
376
- added: 0,
377
- deleted: 0,
378
- modified: 0,
379
- formatChanged: 0,
380
- moved: 0,
381
- unchanged: 0
382
- };
399
+ const counts = createSummaryCounts();
383
400
  for (const event of alignFolioBlocks(baseBlocks, revisedBlocks)) {
384
401
  if (event.type === "pair") {
402
+ if (!baseStory || !revisedStory) panic("A paired comparison event requires both story handles");
385
403
  const { baseBlock, revisedBlock } = event;
404
+ const baseHandle = {
405
+ story: baseStory,
406
+ blockId: baseBlock.id
407
+ };
408
+ const revisedHandle = {
409
+ story: revisedStory,
410
+ blockId: revisedBlock.id
411
+ };
386
412
  if (baseBlock.text !== revisedBlock.text) {
413
+ if (!includeText) {
414
+ counts.unchanged++;
415
+ continue;
416
+ }
387
417
  counts.modified++;
388
418
  changes.push({
389
419
  type: "modified",
390
420
  blockId: revisedBlock.id,
391
421
  kind: revisedBlock.kind,
392
- segments: diffWordSegments(baseBlock.text, revisedBlock.text)
422
+ segments: diffWordSegments(baseBlock.text, revisedBlock.text),
423
+ baseHandle,
424
+ revisedHandle
393
425
  });
394
426
  continue;
395
427
  }
396
- const changedProperties = diffPreviewRunFormatting(baseBlock, revisedBlock);
428
+ const changedProperties = includeFormatting ? diffPreviewRunFormatting(baseBlock, revisedBlock) : [];
397
429
  if (changedProperties.length > 0) {
398
430
  counts.formatChanged++;
399
431
  changes.push({
@@ -401,7 +433,9 @@ const compareDocxVersions = async (base, revised) => {
401
433
  blockId: revisedBlock.id,
402
434
  kind: revisedBlock.kind,
403
435
  text: revisedBlock.text,
404
- changedProperties
436
+ changedProperties,
437
+ baseHandle,
438
+ revisedHandle
405
439
  });
406
440
  continue;
407
441
  }
@@ -409,28 +443,152 @@ const compareDocxVersions = async (base, revised) => {
409
443
  continue;
410
444
  }
411
445
  if (event.type === "baseOnly") {
446
+ if (!includeText) continue;
447
+ if (!baseStory) panic("A base-only comparison event requires a base story handle");
412
448
  counts.deleted++;
413
449
  changes.push({
414
450
  type: "deleted",
415
451
  blockId: event.block.id,
416
452
  kind: event.block.kind,
417
- text: event.block.text
453
+ text: event.block.text,
454
+ baseHandle: {
455
+ story: baseStory,
456
+ blockId: event.block.id
457
+ }
418
458
  });
419
459
  continue;
420
460
  }
461
+ if (!includeText) continue;
462
+ if (!revisedStory) panic("A revised-only comparison event requires a revised story handle");
421
463
  counts.added++;
422
464
  changes.push({
423
465
  type: "added",
424
466
  blockId: event.block.id,
425
467
  kind: event.block.kind,
426
- text: event.block.text
468
+ text: event.block.text,
469
+ revisedHandle: {
470
+ story: revisedStory,
471
+ blockId: event.block.id
472
+ }
427
473
  });
428
474
  }
429
- detectMoves(changes, counts);
475
+ detectMoves(changes, counts, firstMoveGroupId);
476
+ return {
477
+ baseStory,
478
+ revisedStory,
479
+ changes,
480
+ summaryCounts: counts
481
+ };
482
+ };
483
+ const DEFAULT_COMPARISON_SCOPES = Object.freeze(["text", "formatting"]);
484
+ const resolveComparisonScopes = (options) => {
485
+ const include = options.include ?? DEFAULT_COMPARISON_SCOPES;
486
+ if (include.length === 0 || include.some((scope) => !isFolioVersionComparisonScope(scope))) throw new InvalidFolioVersionComparisonOptionsError({
487
+ message: "Version comparison requires at least one recognized scope",
488
+ option: "include",
489
+ receivedValue: include
490
+ });
491
+ return new Set(include);
492
+ };
493
+ const resolvePrivacyTransforms = (transforms) => {
494
+ if (!Array.isArray(transforms) || transforms.some((transform) => !isFolioVersionComparisonPrivacyTransform(transform))) throw new InvalidFolioVersionComparisonOptionsError({
495
+ message: "Version comparison received an unrecognized privacy transform",
496
+ option: "privacy.transforms",
497
+ receivedValue: transforms
498
+ });
499
+ const requested = new Set(transforms);
500
+ return FOLIO_VERSION_COMPARISON_PRIVACY_TRANSFORMS.filter((transform) => requested.has(transform));
501
+ };
502
+ /** Apply auditable, output-only privacy transforms to a structured version diff. */
503
+ const applyFolioVersionDiffPrivacy = (diff, options) => {
504
+ const requestedTransforms = resolvePrivacyTransforms(options.transforms);
505
+ const appliedTransformSet = /* @__PURE__ */ new Set([...diff.privacyReport.appliedTransforms, ...requestedTransforms]);
506
+ const appliedTransforms = FOLIO_VERSION_COMPARISON_PRIVACY_TRANSFORMS.filter((transform) => appliedTransformSet.has(transform));
507
+ const removedPropertySet = /* @__PURE__ */ new Set();
508
+ for (const transform of appliedTransforms) for (const property of PRIVATE_METADATA_PROPERTIES_BY_TRANSFORM[transform]) removedPropertySet.add(property);
509
+ const actuallyRemovedPropertySet = /* @__PURE__ */ new Set([...diff.privacyReport.removedMetadataProperties, ...diff.metadataChanges.filter(({ property }) => removedPropertySet.has(property)).map(({ property }) => property)]);
510
+ const removedMetadataProperties = FOLIO_DOCUMENT_METADATA_PROPERTIES.filter((property) => actuallyRemovedPropertySet.has(property));
511
+ const metadataChanges = diff.metadataChanges.filter(({ property }) => !removedPropertySet.has(property));
430
512
  return {
513
+ ...diff,
514
+ metadataChanges,
515
+ privacyReport: {
516
+ appliedTransforms,
517
+ removedMetadataProperties
518
+ },
519
+ summaryCounts: {
520
+ ...diff.summaryCounts,
521
+ metadataChanged: metadataChanges.length
522
+ }
523
+ };
524
+ };
525
+ const normalizeMetadataValue = (properties, property) => {
526
+ const value = properties?.[property];
527
+ return value instanceof Date ? value.toISOString() : value ?? null;
528
+ };
529
+ const compareMetadata = (base, revised) => {
530
+ const changes = [];
531
+ for (const property of FOLIO_DOCUMENT_METADATA_PROPERTIES) {
532
+ const baseValue = normalizeMetadataValue(base, property);
533
+ const revisedValue = normalizeMetadataValue(revised, property);
534
+ if (baseValue !== revisedValue) changes.push({
535
+ property,
536
+ baseValue,
537
+ revisedValue
538
+ });
539
+ }
540
+ return changes;
541
+ };
542
+ /**
543
+ * Compare two `.docx` buffers and return a structured, block-level diff.
544
+ * See the module doc comment for the as-accepted comparison semantics, the
545
+ * three-pass alignment algorithm, move detection, and format-only change
546
+ * detection.
547
+ */
548
+ const compareDocxVersions = async (base, revised, options = {}) => {
549
+ const scopes = resolveComparisonScopes(options);
550
+ const [baseReviewer, revisedReviewer] = await Promise.all([FolioDocxReviewer.fromBuffer(base), FolioDocxReviewer.fromBuffer(revised)]);
551
+ const changes = [];
552
+ const stories = [];
553
+ const counts = createSummaryCounts();
554
+ const baseStories = baseReviewer.listStories().map(({ handle }) => handle);
555
+ const revisedStories = revisedReviewer.listStories().map(({ handle }) => handle);
556
+ let nextMoveGroupId = 1;
557
+ for (const pair of pairFolioDocumentStories(baseStories, revisedStories)) {
558
+ const baseBlocks = pair.baseStory ? baseReviewer.readReviewedStory({
559
+ story: pair.baseStory,
560
+ view: "final"
561
+ })?.snapshot.blocks ?? [] : [];
562
+ const revisedBlocks = pair.revisedStory ? revisedReviewer.readReviewedStory({
563
+ story: pair.revisedStory,
564
+ view: "final"
565
+ })?.snapshot.blocks ?? [] : [];
566
+ const storyDiff = compareStoryBlocks({
567
+ ...pair,
568
+ baseBlocks,
569
+ revisedBlocks,
570
+ firstMoveGroupId: nextMoveGroupId,
571
+ includeText: scopes.has("text"),
572
+ includeFormatting: scopes.has("formatting")
573
+ });
574
+ stories.push(storyDiff);
575
+ for (const change of storyDiff.changes) changes.push(change);
576
+ addSummaryCounts(counts, storyDiff.summaryCounts);
577
+ nextMoveGroupId += storyDiff.summaryCounts.moved;
578
+ }
579
+ const metadataChanges = scopes.has("metadata") ? compareMetadata(baseReviewer.getDocumentProperties(), revisedReviewer.getDocumentProperties()) : [];
580
+ counts.metadataChanged = metadataChanges.length;
581
+ const diff = {
431
582
  changes,
583
+ stories,
584
+ metadataChanges,
585
+ privacyReport: {
586
+ appliedTransforms: [],
587
+ removedMetadataProperties: []
588
+ },
432
589
  summaryCounts: counts
433
590
  };
591
+ return options.privacy ? applyFolioVersionDiffPrivacy(diff, options.privacy) : diff;
434
592
  };
435
593
  //#endregion
436
- export { alignFolioBlocks, compareDocxVersions, exceedsLcsBudget };
594
+ export { FOLIO_DOCUMENT_METADATA_PROPERTIES, FOLIO_VERSION_COMPARISON_PRIVACY_TRANSFORMS, FOLIO_VERSION_COMPARISON_SCOPES, InvalidFolioVersionComparisonOptionsError, alignFolioBlocks, applyFolioVersionDiffPrivacy, compareDocxVersions, exceedsLcsBudget, isFolioVersionComparisonPrivacyTransform, isFolioVersionComparisonScope };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
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",