@stll/folio-core 0.36.0 → 0.37.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 +59 -1
- package/dist/ai-edits/apply.js +689 -367
- package/dist/ai-edits/block-identity.d.ts +4 -1
- package/dist/ai-edits/block-identity.js +4 -1
- package/dist/ai-edits/blockRange.js +14 -14
- package/dist/ai-edits/clean-text.d.ts +40 -1
- package/dist/ai-edits/clean-text.js +52 -2
- package/dist/ai-edits/headless.d.ts +3 -1
- package/dist/ai-edits/headless.js +133 -71
- package/dist/ai-edits/index.d.ts +3 -3
- package/dist/ai-edits/index.js +2 -2
- package/dist/ai-edits/read.js +38 -20
- package/dist/ai-edits/snapshot.d.ts +14 -6
- package/dist/ai-edits/snapshot.js +99 -19
- package/dist/ai-edits/table-cell-mutations.js +1 -1
- package/dist/ai-edits/table-row-column-mutations.js +7 -0
- package/dist/ai-edits/table-template.d.ts +11 -1
- package/dist/ai-edits/table-template.js +49 -17
- package/dist/ai-edits/types.d.ts +60 -57
- package/dist/ai-suggestions/conflict.js +1 -4
- package/dist/ai-suggestions/text-positions.d.ts +8 -0
- package/dist/ai-suggestions/text-positions.js +27 -5
- package/dist/compare/__fixtures__/body-sequence.js +3 -4
- package/dist/compare/column-alignment.d.ts +13 -13
- package/dist/compare/column-alignment.js +78 -39
- package/dist/compare/compare.d.ts +4 -3
- package/dist/compare/compare.js +41 -5
- package/dist/compare/content-alignment.d.ts +95 -0
- package/dist/compare/content-alignment.js +1450 -0
- package/dist/compare/content-types.d.ts +100 -0
- package/dist/compare/content-types.js +0 -0
- package/dist/compare/content.d.ts +209 -0
- package/dist/compare/content.js +885 -0
- package/dist/compare/formatting.d.ts +5 -5
- package/dist/compare/formatting.js +6 -4
- package/dist/compare/plan.d.ts +6 -1
- package/dist/compare/plan.js +201 -569
- package/dist/compare/scenario.d.ts +2 -2
- package/dist/compare/types.d.ts +3 -3
- package/dist/compare/verification.d.ts +2 -2
- package/dist/compare/verification.js +25 -8
- package/dist/compat/eigenpal.d.ts +5 -3
- package/dist/compat/eigenpal.js +3 -2
- package/dist/controller/headerFooterEditorManager.js +14 -6
- package/dist/controller/hiddenEditorManager.d.ts +7 -2
- package/dist/controller/hiddenEditorManager.js +15 -6
- package/dist/controller/layoutPipeline.js +5 -3
- package/dist/controller/noteEditorManager.js +12 -7
- package/dist/display-list/build/headerFooterPrimitives.js +1 -0
- package/dist/display-list/build/storyPrimitives.d.ts +5 -2
- package/dist/display-list/build/storyPrimitives.js +60 -3
- package/dist/display-list/build/tablePrimitives.js +30 -0
- package/dist/display-list/primitives.d.ts +1 -1
- package/dist/document-operations.d.ts +8 -6
- package/dist/document-operations.js +121 -26
- package/dist/docx/blockContentParser.js +6 -27
- package/dist/docx/ensureParaIds.js +9 -4
- package/dist/docx/numberingParser.d.ts +3 -1
- package/dist/docx/numberingParser.js +4 -1
- package/dist/docx/paragraphParser.js +7 -6
- package/dist/docx/paragraphTraversal.js +30 -34
- package/dist/docx/rezip.d.ts +0 -8
- package/dist/docx/rezip.js +41 -16
- package/dist/docx/runParser.js +4 -2
- package/dist/docx/serializer/paragraphSerializer.js +2 -0
- package/dist/docx/serializer/runSerializer.js +5 -3
- package/dist/docx/serializer/trackedChangeAttributes.d.ts +3 -1
- package/dist/docx/serializer/trackedChangeAttributes.js +11 -1
- package/dist/docx/server/build.d.ts +1 -1
- package/dist/headless-layout.js +5 -4
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/internal/headlessRevisionResolution.d.ts +23 -0
- package/dist/internal/headlessRevisionResolution.js +176 -0
- package/dist/internal/headlessRevisionResolutionGuard.d.ts +12 -0
- package/dist/internal/headlessRevisionResolutionGuard.js +28 -0
- package/dist/internal/pageBreakRunDescendantIndex.d.ts +9 -0
- package/dist/internal/pageBreakRunDescendantIndex.js +27 -0
- package/dist/internal/pageBreakRunPartition.d.ts +26 -0
- package/dist/internal/pageBreakRunPartition.js +35 -0
- package/dist/internal/pageBreakRunSourceDescendantIndex.d.ts +15 -0
- package/dist/internal/pageBreakRunSourceDescendantIndex.js +126 -0
- package/dist/internal/sectionEndpointResolution.d.ts +32 -0
- package/dist/internal/sectionEndpointResolution.js +38 -0
- package/dist/layout-bridge/convert/footnoteLayout.js +1 -0
- package/dist/layout-bridge/convert/headerFooterLayout.d.ts +16 -4
- package/dist/layout-bridge/convert/headerFooterLayout.js +48 -5
- package/dist/layout-bridge/convert/toFlowBlocks.d.ts +4 -0
- package/dist/layout-bridge/convert/toFlowBlocks.js +379 -75
- package/dist/layout-bridge/dom/clickToPositionDom.d.ts +8 -1
- package/dist/layout-bridge/dom/clickToPositionDom.js +114 -40
- package/dist/layout-bridge/dom/textStreamDom.d.ts +24 -0
- package/dist/layout-bridge/dom/textStreamDom.js +75 -0
- package/dist/layout-bridge/headerFooterLayout.js +16 -30
- package/dist/layout-engine/index.js +12 -3
- package/dist/layout-engine/measure/tableFragmentBorderGeometry.d.ts +17 -0
- package/dist/layout-engine/measure/tableFragmentBorderGeometry.js +24 -0
- package/dist/layout-engine/renderedBreakReconciliation.js +1 -1
- package/dist/layout-engine/types.d.ts +7 -0
- package/dist/layout-painter/imageLayout.d.ts +1 -1
- package/dist/layout-painter/renderTable.js +20 -0
- package/dist/model.d.ts +1 -1
- package/dist/paged-layout/rangeProjection.js +6 -9
- package/dist/paged-layout/transactionDirtyRange.d.ts +3 -1
- package/dist/paged-layout/transactionDirtyRange.js +23 -1
- package/dist/prosemirror/attrs/index.d.ts +7 -3
- package/dist/prosemirror/attrs/index.js +113 -7
- package/dist/prosemirror/bookmarkBoundaryIntegrity.d.ts +9 -0
- package/dist/prosemirror/bookmarkBoundaryIntegrity.js +22 -0
- package/dist/prosemirror/commands/comments.d.ts +10 -1
- package/dist/prosemirror/commands/comments.js +167 -59
- package/dist/prosemirror/commands/formatPainter.d.ts +45 -10
- package/dist/prosemirror/commands/formatPainter.js +319 -63
- package/dist/prosemirror/commands/index.d.ts +2 -2
- package/dist/prosemirror/commands/pageBreak.d.ts +5 -2
- package/dist/prosemirror/commands/pageBreak.js +18 -36
- package/dist/prosemirror/commands/propertyChangeScope.d.ts +7 -5
- package/dist/prosemirror/commands/propertyChangeScope.js +27 -18
- package/dist/prosemirror/conversion/fromProseDoc.d.ts +7 -2
- package/dist/prosemirror/conversion/fromProseDoc.js +809 -209
- package/dist/prosemirror/conversion/runShadingMark.js +2 -2
- package/dist/prosemirror/conversion/toProseDoc.d.ts +26 -1
- package/dist/prosemirror/conversion/toProseDoc.js +416 -283
- package/dist/prosemirror/extensions/StarterKit.js +4 -0
- package/dist/prosemirror/extensions/core/ParagraphExtension.js +12 -8
- package/dist/prosemirror/extensions/features/ListExtension.js +95 -123
- package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.js +1 -1
- package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.d.ts +27 -1
- package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.js +134 -30
- package/dist/prosemirror/extensions/features/pasteCleanup.js +9 -15
- package/dist/prosemirror/extensions/marks/BoldExtension.js +4 -3
- package/dist/prosemirror/extensions/marks/CharacterStyleExtension.d.ts +4 -7
- package/dist/prosemirror/extensions/marks/CharacterStyleExtension.js +5 -11
- package/dist/prosemirror/extensions/marks/FontSizeExtension.js +3 -3
- package/dist/prosemirror/extensions/marks/ItalicExtension.js +4 -3
- package/dist/prosemirror/extensions/marks/PageBreakRunOwnerExtension.d.ts +13 -0
- package/dist/prosemirror/extensions/marks/PageBreakRunOwnerExtension.js +39 -0
- package/dist/prosemirror/extensions/marks/RunFormattingOverrideExtension.d.ts +7 -1
- package/dist/prosemirror/extensions/marks/RunFormattingOverrideExtension.js +67 -21
- package/dist/prosemirror/extensions/marks/markUtils.d.ts +8 -1
- package/dist/prosemirror/extensions/marks/markUtils.js +88 -2
- package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.js +44 -3
- package/dist/prosemirror/extensions/nodes/FieldExtension.js +1 -1
- package/dist/prosemirror/extensions/nodes/HardBreakExtension.js +15 -4
- package/dist/prosemirror/extensions/nodes/PageBreakExtension.d.ts +6 -0
- package/dist/prosemirror/extensions/nodes/PageBreakExtension.js +6 -0
- package/dist/prosemirror/extensions/nodes/PageBreakRunExtension.d.ts +6 -0
- package/dist/prosemirror/extensions/nodes/PageBreakRunExtension.js +36 -0
- package/dist/prosemirror/findReplaceSelection.js +8 -3
- package/dist/prosemirror/index.d.ts +3 -3
- package/dist/prosemirror/listMarker.d.ts +26 -1
- package/dist/prosemirror/listMarker.js +108 -14
- package/dist/prosemirror/pageBreakRunProjection.d.ts +28 -0
- package/dist/prosemirror/pageBreakRunProjection.js +47 -0
- package/dist/prosemirror/paraText.js +6 -0
- package/dist/prosemirror/paragraphSpacing.d.ts +29 -0
- package/dist/prosemirror/paragraphSpacing.js +125 -0
- package/dist/prosemirror/plugins/documentNumbering.d.ts +8 -0
- package/dist/prosemirror/plugins/documentNumbering.js +18 -0
- package/dist/prosemirror/plugins/pmTextScan.d.ts +3 -2
- package/dist/prosemirror/plugins/pmTextScan.js +14 -4
- package/dist/prosemirror/plugins/suggestionMode.js +8 -10
- package/dist/prosemirror/rebaseParagraphRunFormatting.d.ts +16 -0
- package/dist/prosemirror/rebaseParagraphRunFormatting.js +61 -0
- package/dist/prosemirror/runFormattingInlineCarriers.d.ts +64 -0
- package/dist/prosemirror/runFormattingInlineCarriers.js +121 -0
- package/dist/prosemirror/runFormattingProvenance.d.ts +12 -0
- package/dist/prosemirror/runFormattingProvenance.js +48 -0
- package/dist/prosemirror/runFormattingReconciliation.d.ts +25 -0
- package/dist/prosemirror/runFormattingReconciliation.js +54 -0
- package/dist/prosemirror/runPropertyChangeResolution.d.ts +18 -0
- package/dist/prosemirror/runPropertyChangeResolution.js +18 -0
- package/dist/prosemirror/runStyleFormatting.d.ts +50 -0
- package/dist/prosemirror/runStyleFormatting.js +105 -0
- package/dist/prosemirror/schema/index.d.ts +3 -3
- package/dist/prosemirror/schema/marks.d.ts +82 -9
- package/dist/prosemirror/schema/marks.js +73 -1
- package/dist/prosemirror/schema/nodes.d.ts +18 -5
- package/dist/prosemirror/styles/resolvedStyleAttrs.d.ts +14 -2
- package/dist/prosemirror/styles/resolvedStyleAttrs.js +44 -23
- package/dist/prosemirror/styles/styleResolver.d.ts +10 -13
- package/dist/prosemirror/styles/styleResolver.js +3 -3
- package/dist/prosemirror/styles/styleToggleCascade.d.ts +8 -1
- package/dist/prosemirror/styles/styleToggleCascade.js +24 -1
- package/dist/prosemirror/tableGridMutation.d.ts +20 -0
- package/dist/prosemirror/tableGridMutation.js +30 -0
- package/dist/prosemirror/trackedRunInlineAtoms.d.ts +31 -0
- package/dist/prosemirror/trackedRunInlineAtoms.js +39 -0
- package/dist/prosemirror/utils/visualLineNavigation.js +27 -31
- package/dist/prosemirror/validation.js +23 -4
- package/dist/render-dom/HeaderFooterSelectionOverlay.js +20 -20
- package/dist/render-dom/RenderedDomContext.js +17 -34
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/style-engine/styleEngine.d.ts +1 -1
- package/dist/utils/clipboard.js +1 -1
- package/dist/utils/findReplace.js +38 -21
- package/dist/version-comparison.d.ts +38 -44
- package/dist/version-comparison.js +270 -401
- package/package.json +3 -2
package/dist/compare/plan.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { folioAIBlockIdStability } from "../ai-edits/block-identity.js";
|
|
1
2
|
import { createFolioAITextRangeHandle, trailingBodyBlockId } from "../ai-edits/snapshot.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
3
|
+
import { alignFolioContentStructure, contentBlocksShareContainer, createFolioContentAlignmentWorkSession, groupFolioContentTableRows } from "./content-alignment.js";
|
|
4
|
+
import { changedFolioContentParagraphFormatting, createContentComparisonWorkSession, detectFolioContentMoves, detectFolioContentParagraphMarkPlans } from "./content.js";
|
|
4
5
|
import { inlineFormattingSegments } from "./formatting.js";
|
|
5
6
|
import { panic } from "better-result";
|
|
6
7
|
//#region src/compare/plan.ts
|
|
@@ -10,7 +11,7 @@ import { panic } from "better-result";
|
|
|
10
11
|
* generated tracked change is accepted.
|
|
11
12
|
*
|
|
12
13
|
* Pure: no parsing, no serialization, no clock. Every alignment runs through
|
|
13
|
-
* {@link
|
|
14
|
+
* {@link alignFolioContentStructure}, shared with representation-neutral comparison.
|
|
14
15
|
*
|
|
15
16
|
* ## Aligning by container, not by block
|
|
16
17
|
*
|
|
@@ -34,445 +35,113 @@ import { panic } from "better-result";
|
|
|
34
35
|
*
|
|
35
36
|
* ## Move detection
|
|
36
37
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* durable "moved from here" mark on this path — but the change list reports a
|
|
41
|
-
* single `move` so the relocation is not read as unrelated churn. The word
|
|
42
|
-
* floor keeps boilerplate one-liners from pairing as spurious moves.
|
|
38
|
+
* Move, split/merge, and formatting classification come from the same neutral
|
|
39
|
+
* comparison core used by non-DOCX callers. The operations remain specific to
|
|
40
|
+
* the tracked-change format, but the meaning of the comparison does not fork.
|
|
43
41
|
*/
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Cap on same-text base-only blocks the move pass keeps per text. Without it a
|
|
48
|
-
* document repeating one paragraph thousands of times would make the pass
|
|
49
|
-
* quadratic on attacker-controlled input.
|
|
50
|
-
*/
|
|
51
|
-
const MAX_MOVE_CANDIDATES_PER_TEXT = 64;
|
|
52
|
-
/**
|
|
53
|
-
* How much of a relocated paragraph must survive the relocation for it still
|
|
54
|
-
* to read as one. Below it the two paragraphs are a deletion and an unrelated
|
|
55
|
-
* insertion, and calling them a move would tell the reader the wrong story
|
|
56
|
-
* about where the text came from.
|
|
57
|
-
*/
|
|
58
|
-
const MOVE_SIMILARITY_THRESHOLD = .8;
|
|
59
|
-
/**
|
|
60
|
-
* Total pair comparisons the similarity pass may make in one story. Exact text
|
|
61
|
-
* matches are found by lookup; only what is left pays this, and it is capped
|
|
62
|
-
* so two documents of unmatched paragraphs cannot make the pass quadratic.
|
|
63
|
-
*/
|
|
64
|
-
const MAX_MOVE_SIMILARITY_COMPARISONS = 2e4;
|
|
65
|
-
/**
|
|
66
|
-
* Dice coefficient over word tokens: twice the shared tokens over the two
|
|
67
|
-
* token counts. Multiset rather than set, so a paragraph repeating a word does
|
|
68
|
-
* not match one that says it once.
|
|
69
|
-
*/
|
|
70
|
-
const tokenSimilarity = (left, right) => {
|
|
71
|
-
const leftTokens = left.split(/\s+/u).filter((token) => token.length > 0);
|
|
72
|
-
const rightTokens = right.split(/\s+/u).filter((token) => token.length > 0);
|
|
73
|
-
if (leftTokens.length === 0 || rightTokens.length === 0) return 0;
|
|
74
|
-
const remaining = /* @__PURE__ */ new Map();
|
|
75
|
-
for (const token of leftTokens) remaining.set(token, (remaining.get(token) ?? 0) + 1);
|
|
76
|
-
let shared = 0;
|
|
77
|
-
for (const token of rightTokens) {
|
|
78
|
-
const count = remaining.get(token) ?? 0;
|
|
79
|
-
if (count > 0) {
|
|
80
|
-
remaining.set(token, count - 1);
|
|
81
|
-
shared += 1;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return 2 * shared / (leftTokens.length + rightTokens.length);
|
|
85
|
-
};
|
|
86
|
-
const wordCountReaches = (text, minimum) => {
|
|
87
|
-
let count = 0;
|
|
88
|
-
for (const word of text.split(/\s+/u)) if (word.length > 0 && ++count >= minimum) return true;
|
|
89
|
-
return false;
|
|
90
|
-
};
|
|
91
|
-
/**
|
|
92
|
-
* The document's segments, normalized to strictly alternate body, table, body,
|
|
93
|
-
* ... and to begin and end with a body segment — inserting empty body segments
|
|
94
|
-
* where the document has none.
|
|
95
|
-
*
|
|
96
|
-
* Without the padding the two sides can hold different segment shapes (a
|
|
97
|
-
* document that opens with a table against one that opens with a heading), and
|
|
98
|
-
* the alignment's positional fallback would then put a body segment opposite a
|
|
99
|
-
* table. That pair is unusable, so the whole table would be reissued as a
|
|
100
|
-
* deletion plus an insertion. Padding keeps like opposite like.
|
|
101
|
-
*/
|
|
102
|
-
const splitSegments = (blocks) => {
|
|
103
|
-
const segments = [];
|
|
104
|
-
let currentTableIndex = null;
|
|
105
|
-
for (const block of blocks) {
|
|
106
|
-
const tableIndex = block.table?.outerTableIndex ?? null;
|
|
107
|
-
const current = segments.at(-1);
|
|
108
|
-
if (current !== void 0 && currentTableIndex === tableIndex) {
|
|
109
|
-
current.blocks.push(block);
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
if (block.table && segments.at(-1)?.kind !== "body") segments.push({
|
|
113
|
-
kind: "body",
|
|
114
|
-
blocks: []
|
|
115
|
-
});
|
|
116
|
-
segments.push({
|
|
117
|
-
kind: block.table ? "table" : "body",
|
|
118
|
-
blocks: [block]
|
|
119
|
-
});
|
|
120
|
-
currentTableIndex = tableIndex;
|
|
121
|
-
}
|
|
122
|
-
if (segments.length === 0 || segments.at(-1)?.kind === "table") segments.push({
|
|
123
|
-
kind: "body",
|
|
124
|
-
blocks: []
|
|
125
|
-
});
|
|
126
|
-
return segments;
|
|
127
|
-
};
|
|
128
|
-
/**
|
|
129
|
-
* Blocks of one table segment grouped into rows, in document order.
|
|
130
|
-
*
|
|
131
|
-
* Keyed by table AND row, not by row alone: a segment holds the whole
|
|
132
|
-
* outermost table, so a nested table's first row would otherwise merge with
|
|
133
|
-
* its parent's first row and the alignment would compare one against the
|
|
134
|
-
* other.
|
|
135
|
-
*/
|
|
136
|
-
const groupRows = (blocks) => {
|
|
137
|
-
const rows = /* @__PURE__ */ new Map();
|
|
138
|
-
for (const block of blocks) {
|
|
139
|
-
if (!block.table) continue;
|
|
140
|
-
const key = `${String(block.table.tableIndex)}:${String(block.table.rowIndex)}`;
|
|
141
|
-
const row = rows.get(key);
|
|
142
|
-
if (row) row.push(block);
|
|
143
|
-
else rows.set(key, [block]);
|
|
144
|
-
}
|
|
145
|
-
return [...rows.values()];
|
|
146
|
-
};
|
|
147
|
-
const rowText = (row) => row.map(({ text }) => text).join(" ");
|
|
148
|
-
const rowLocation = (row) => row.at(0)?.table ?? null;
|
|
149
|
-
/** Zip one paired row's cells by physical cell index, then by paragraph order. */
|
|
150
|
-
const alignRowCells = (baseRow, targetRow, baseColumnKeys, targetColumnKeys) => {
|
|
151
|
-
const byCell = (row, columnKeys) => {
|
|
152
|
-
const cells = /* @__PURE__ */ new Map();
|
|
153
|
-
for (const block of row) {
|
|
154
|
-
const table = block.table;
|
|
155
|
-
let cellIndex = table?.cellIndex ?? 0;
|
|
156
|
-
if (table && columnKeys) {
|
|
157
|
-
const alignedColumn = columnKeys.get(table.gridColumnIndex);
|
|
158
|
-
if (alignedColumn === void 0) return panic("A paired table cell has no aligned grid column", { gridColumnIndex: table.gridColumnIndex });
|
|
159
|
-
cellIndex = alignedColumn;
|
|
160
|
-
}
|
|
161
|
-
const blocks = cells.get(cellIndex);
|
|
162
|
-
if (blocks) blocks.push(block);
|
|
163
|
-
else cells.set(cellIndex, [block]);
|
|
164
|
-
}
|
|
165
|
-
return cells;
|
|
166
|
-
};
|
|
167
|
-
const baseCells = byCell(baseRow, baseColumnKeys);
|
|
168
|
-
const targetCells = byCell(targetRow, targetColumnKeys);
|
|
169
|
-
const cellIndexes = [.../* @__PURE__ */ new Set([...baseCells.keys(), ...targetCells.keys()])].toSorted((left, right) => left - right);
|
|
170
|
-
const steps = [];
|
|
171
|
-
for (const cellIndex of cellIndexes) {
|
|
172
|
-
const baseBlocks = baseCells.get(cellIndex) ?? [];
|
|
173
|
-
const targetBlocks = targetCells.get(cellIndex) ?? [];
|
|
174
|
-
const paired = Math.min(baseBlocks.length, targetBlocks.length);
|
|
175
|
-
for (let index = 0; index < paired; index++) {
|
|
176
|
-
const baseBlock = baseBlocks[index];
|
|
177
|
-
const targetBlock = targetBlocks[index];
|
|
178
|
-
if (baseBlock && targetBlock) steps.push({
|
|
179
|
-
type: "pair",
|
|
180
|
-
baseBlock,
|
|
181
|
-
targetBlock
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
for (const block of baseBlocks.slice(paired)) steps.push({
|
|
185
|
-
type: "baseOnly",
|
|
186
|
-
block
|
|
187
|
-
});
|
|
188
|
-
for (const block of targetBlocks.slice(paired)) steps.push({
|
|
189
|
-
type: "targetOnly",
|
|
190
|
-
block
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
return steps;
|
|
194
|
-
};
|
|
195
|
-
/**
|
|
196
|
-
* Align one table's rows, then its cells.
|
|
197
|
-
*
|
|
198
|
-
* Running the block alignment straight over a table's paragraphs cannot see
|
|
199
|
-
* rows: it happily pairs a cell of one row with a cell of another, and a row
|
|
200
|
-
* that was wholly added or removed shows up as a scatter of unmatched
|
|
201
|
-
* paragraphs. Aligning rows first — each row standing in as one proxy block of
|
|
202
|
-
* its joined cell text — keeps a whole-row change whole, and confines every
|
|
203
|
-
* other difference to a cell that really corresponds.
|
|
204
|
-
*/
|
|
205
|
-
/**
|
|
206
|
-
* One segment's blocks split per table, in first-appearance order. A segment
|
|
207
|
-
* is a whole outermost table, so it holds the parent's blocks and every nested
|
|
208
|
-
* table's; a row alignment that mixed them would compare the parent's first
|
|
209
|
-
* row against a nested table's.
|
|
210
|
-
*/
|
|
211
|
-
const groupTables = (blocks) => {
|
|
212
|
-
const tables = /* @__PURE__ */ new Map();
|
|
213
|
-
for (const block of blocks) {
|
|
214
|
-
if (!block.table) continue;
|
|
215
|
-
const existing = tables.get(block.table.tableIndex);
|
|
216
|
-
if (existing) existing.push(block);
|
|
217
|
-
else tables.set(block.table.tableIndex, [block]);
|
|
218
|
-
}
|
|
219
|
-
return [...tables.values()];
|
|
220
|
-
};
|
|
221
|
-
/**
|
|
222
|
-
* Align a paired table segment: each table in it against the table at the same
|
|
223
|
-
* place on the other side, then that table's rows, then its cells. Tables are
|
|
224
|
-
* paired by order within the segment because the segment IS one table plus
|
|
225
|
-
* whatever nests inside it — the nth nested table of one answers to the nth of
|
|
226
|
-
* the other.
|
|
227
|
-
*/
|
|
228
|
-
const buildTableSegmentSteps = (baseBlocks, targetBlocks) => {
|
|
229
|
-
const baseTables = groupTables(baseBlocks);
|
|
230
|
-
const targetTables = groupTables(targetBlocks);
|
|
231
|
-
const steps = [];
|
|
232
|
-
const paired = Math.min(baseTables.length, targetTables.length);
|
|
233
|
-
for (let index = 0; index < paired; index++) steps.push(...buildTableSteps(baseTables[index] ?? [], targetTables[index] ?? []));
|
|
234
|
-
for (const blocks of baseTables.slice(paired)) {
|
|
235
|
-
const location = blocks.at(0)?.table;
|
|
236
|
-
if (location) steps.push({
|
|
237
|
-
type: "baseTable",
|
|
238
|
-
blocks,
|
|
239
|
-
location
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
for (const blocks of targetTables.slice(paired)) {
|
|
243
|
-
const location = blocks.at(0)?.table;
|
|
244
|
-
if (location) steps.push({
|
|
245
|
-
type: "targetTable",
|
|
246
|
-
blocks,
|
|
247
|
-
location
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
return steps;
|
|
251
|
-
};
|
|
252
|
-
/**
|
|
253
|
-
* How alike two rows must be for one to be read as the other, edited. Below
|
|
254
|
-
* it the pair is a deleted row and an inserted one.
|
|
255
|
-
*/
|
|
256
|
-
const ROW_PAIR_SIMILARITY = .5;
|
|
257
|
-
/**
|
|
258
|
-
* How alike two rows are: their text, halved when their shapes differ.
|
|
259
|
-
*
|
|
260
|
-
* A row's shape is its physical cell count. Two rows with the same words in a
|
|
261
|
-
* different number of cells are not the same row edited, and pairing them
|
|
262
|
-
* would report every cell as changed rather than the row as replaced.
|
|
263
|
-
*/
|
|
264
|
-
const rowSimilarity = (base, target) => {
|
|
265
|
-
if (!base || !target) return 0;
|
|
266
|
-
const text = tokenSimilarity(rowText(base), rowText(target));
|
|
267
|
-
return rowCellTexts(base).length === rowCellTexts(target).length ? text : text / 2;
|
|
268
|
-
};
|
|
269
|
-
/**
|
|
270
|
-
* Align one table's rows.
|
|
271
|
-
*
|
|
272
|
-
* Exact text first, then SIMILARITY rather than position. Positional fallback
|
|
273
|
-
* is what made a deleted row plus a few cell edits report as a change in every
|
|
274
|
-
* row of the table: each row was paired with the one below it, so every cell
|
|
275
|
-
* differed. Pairing on similarity, and stepping one row on the side whose next
|
|
276
|
-
* row matches better, keeps the deletion where it happened.
|
|
277
|
-
*/
|
|
278
|
-
const alignTableRows = (baseRows, targetRows, baseColumnKeys, targetColumnKeys) => {
|
|
279
|
-
const steps = [];
|
|
280
|
-
const pushRow = (row, side) => {
|
|
281
|
-
const location = rowLocation(row);
|
|
282
|
-
if (location) steps.push({
|
|
283
|
-
type: side === "base" ? "baseRow" : "targetRow",
|
|
284
|
-
blocks: row,
|
|
285
|
-
location
|
|
286
|
-
});
|
|
287
|
-
};
|
|
288
|
-
let baseCursor = 0;
|
|
289
|
-
let targetCursor = 0;
|
|
290
|
-
while (baseCursor < baseRows.length && targetCursor < targetRows.length) {
|
|
291
|
-
const baseRow = baseRows[baseCursor];
|
|
292
|
-
const targetRow = targetRows[targetCursor];
|
|
293
|
-
if (!baseRow || !targetRow) break;
|
|
294
|
-
const here = rowSimilarity(baseRow, targetRow);
|
|
295
|
-
if (here < 1) {
|
|
296
|
-
const baseAhead = rowSimilarity(baseRows[baseCursor + 1], targetRow);
|
|
297
|
-
const targetAhead = rowSimilarity(baseRow, targetRows[targetCursor + 1]);
|
|
298
|
-
if (baseAhead >= ROW_PAIR_SIMILARITY && baseAhead > here && baseAhead >= targetAhead) {
|
|
299
|
-
pushRow(baseRow, "base");
|
|
300
|
-
baseCursor += 1;
|
|
301
|
-
continue;
|
|
302
|
-
}
|
|
303
|
-
if (targetAhead >= ROW_PAIR_SIMILARITY && targetAhead > here) {
|
|
304
|
-
pushRow(targetRow, "target");
|
|
305
|
-
targetCursor += 1;
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
steps.push(...alignRowCells(baseRow, targetRow, baseColumnKeys, targetColumnKeys));
|
|
310
|
-
baseCursor += 1;
|
|
311
|
-
targetCursor += 1;
|
|
312
|
-
}
|
|
313
|
-
for (const row of baseRows.slice(baseCursor)) pushRow(row, "base");
|
|
314
|
-
for (const row of targetRows.slice(targetCursor)) pushRow(row, "target");
|
|
315
|
-
return steps;
|
|
316
|
-
};
|
|
317
|
-
const buildTableSteps = (baseBlocks, targetBlocks) => {
|
|
318
|
-
const columns = alignTableColumns(baseBlocks, targetBlocks);
|
|
319
|
-
return columns ? [...columns.steps, ...alignTableRows(groupRows(columns.baseBlocks), groupRows(columns.targetBlocks), columns.baseColumnKeys, columns.targetColumnKeys)] : alignTableRows(groupRows(baseBlocks), groupRows(targetBlocks));
|
|
320
|
-
};
|
|
321
|
-
const buildBodySteps = (baseBlocks, targetBlocks) => alignFolioBlocks(baseBlocks, targetBlocks).map((event) => {
|
|
322
|
-
switch (event.type) {
|
|
42
|
+
const toCompareStep = (step) => {
|
|
43
|
+
switch (step.type) {
|
|
323
44
|
case "pair": return {
|
|
324
45
|
type: "pair",
|
|
325
|
-
baseBlock:
|
|
326
|
-
targetBlock:
|
|
46
|
+
baseBlock: step.baseBlock,
|
|
47
|
+
targetBlock: step.revisedBlock
|
|
327
48
|
};
|
|
328
49
|
case "baseOnly": return {
|
|
329
50
|
type: "baseOnly",
|
|
330
|
-
block:
|
|
51
|
+
block: step.block,
|
|
52
|
+
moveScope: step.moveScope
|
|
331
53
|
};
|
|
332
54
|
case "revisedOnly": return {
|
|
333
55
|
type: "targetOnly",
|
|
334
|
-
block:
|
|
56
|
+
block: step.block,
|
|
57
|
+
moveScope: step.moveScope
|
|
58
|
+
};
|
|
59
|
+
case "baseRow": return {
|
|
60
|
+
type: "baseRow",
|
|
61
|
+
blocks: step.blocks,
|
|
62
|
+
location: step.location
|
|
63
|
+
};
|
|
64
|
+
case "revisedRow": return {
|
|
65
|
+
type: "targetRow",
|
|
66
|
+
blocks: step.blocks,
|
|
67
|
+
location: step.location
|
|
335
68
|
};
|
|
336
|
-
|
|
69
|
+
case "baseTable": return {
|
|
70
|
+
type: "baseTable",
|
|
71
|
+
blocks: step.blocks,
|
|
72
|
+
location: step.location
|
|
73
|
+
};
|
|
74
|
+
case "revisedTable": return {
|
|
75
|
+
type: "targetTable",
|
|
76
|
+
blocks: step.blocks,
|
|
77
|
+
location: step.location
|
|
78
|
+
};
|
|
79
|
+
case "baseColumn": return {
|
|
80
|
+
type: "baseColumn",
|
|
81
|
+
blocks: step.blocks,
|
|
82
|
+
location: step.location,
|
|
83
|
+
columnIndex: step.columnIndex
|
|
84
|
+
};
|
|
85
|
+
case "revisedColumn": return {
|
|
86
|
+
type: "targetColumn",
|
|
87
|
+
blocks: step.blocks,
|
|
88
|
+
location: step.location,
|
|
89
|
+
columnIndex: step.columnIndex,
|
|
90
|
+
anchor: step.anchor
|
|
91
|
+
};
|
|
92
|
+
default: return panic("Unhandled content alignment step", { step });
|
|
337
93
|
}
|
|
338
|
-
});
|
|
339
|
-
/**
|
|
340
|
-
* Every block of an unpaired segment, as one-sided steps. A whole table stays
|
|
341
|
-
* whole: reissuing it row by row would need a table to put the rows in, and
|
|
342
|
-
* the point of an unpaired table segment is that there is none.
|
|
343
|
-
*/
|
|
344
|
-
const unpairedSegmentSteps = (segment, side) => {
|
|
345
|
-
if (segment.kind !== "table") return segment.blocks.map((block) => side === "base" ? {
|
|
346
|
-
type: "baseOnly",
|
|
347
|
-
block
|
|
348
|
-
} : {
|
|
349
|
-
type: "targetOnly",
|
|
350
|
-
block
|
|
351
|
-
});
|
|
352
|
-
const location = segment.blocks.at(0)?.table;
|
|
353
|
-
if (!location) return [];
|
|
354
|
-
return [{
|
|
355
|
-
type: side === "base" ? "baseTable" : "targetTable",
|
|
356
|
-
blocks: segment.blocks,
|
|
357
|
-
location
|
|
358
|
-
}];
|
|
359
94
|
};
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const alignSegments = (baseSegments, targetSegments) => {
|
|
395
|
-
const paired = [];
|
|
396
|
-
const similarity = (left, right) => left === void 0 || right === void 0 || left.kind !== right.kind ? 0 : tokenSimilarity(segmentText(left), segmentText(right));
|
|
397
|
-
let baseCursor = 0;
|
|
398
|
-
let targetCursor = 0;
|
|
399
|
-
while (baseCursor < baseSegments.length && targetCursor < targetSegments.length) {
|
|
400
|
-
const baseSegment = baseSegments[baseCursor];
|
|
401
|
-
const targetSegment = targetSegments[targetCursor];
|
|
402
|
-
if (!baseSegment || !targetSegment) break;
|
|
403
|
-
if (baseSegment.kind !== targetSegment.kind) {
|
|
404
|
-
paired.push({
|
|
405
|
-
baseSegment,
|
|
406
|
-
targetSegment: null
|
|
407
|
-
});
|
|
408
|
-
baseCursor += 1;
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
411
|
-
if (baseSegment.kind === "body") {
|
|
412
|
-
paired.push({
|
|
413
|
-
baseSegment,
|
|
414
|
-
targetSegment
|
|
415
|
-
});
|
|
416
|
-
baseCursor += 1;
|
|
417
|
-
targetCursor += 1;
|
|
418
|
-
continue;
|
|
419
|
-
}
|
|
420
|
-
const here = similarity(baseSegment, targetSegment);
|
|
421
|
-
const baseAhead = similarity(baseSegments[baseCursor + 2], targetSegment);
|
|
422
|
-
const targetAhead = similarity(baseSegment, targetSegments[targetCursor + 2]);
|
|
423
|
-
if (baseAhead >= TABLE_PAIR_SIMILARITY && baseAhead > here && baseAhead >= targetAhead) {
|
|
424
|
-
paired.push({
|
|
425
|
-
baseSegment,
|
|
426
|
-
targetSegment: null
|
|
427
|
-
});
|
|
428
|
-
baseCursor += 1;
|
|
429
|
-
continue;
|
|
430
|
-
}
|
|
431
|
-
if (targetAhead >= TABLE_PAIR_SIMILARITY && targetAhead > here) {
|
|
432
|
-
paired.push({
|
|
433
|
-
baseSegment: null,
|
|
434
|
-
targetSegment
|
|
435
|
-
});
|
|
436
|
-
targetCursor += 1;
|
|
437
|
-
continue;
|
|
438
|
-
}
|
|
439
|
-
paired.push({
|
|
440
|
-
baseSegment,
|
|
441
|
-
targetSegment
|
|
442
|
-
});
|
|
443
|
-
baseCursor += 1;
|
|
444
|
-
targetCursor += 1;
|
|
95
|
+
const toContentAlignmentStep = (step) => {
|
|
96
|
+
switch (step.type) {
|
|
97
|
+
case "pair": return {
|
|
98
|
+
type: "pair",
|
|
99
|
+
baseBlock: step.baseBlock,
|
|
100
|
+
revisedBlock: step.targetBlock
|
|
101
|
+
};
|
|
102
|
+
case "baseOnly": return step;
|
|
103
|
+
case "targetOnly": return {
|
|
104
|
+
type: "revisedOnly",
|
|
105
|
+
block: step.block,
|
|
106
|
+
moveScope: step.moveScope
|
|
107
|
+
};
|
|
108
|
+
case "baseRow":
|
|
109
|
+
case "baseTable":
|
|
110
|
+
case "baseColumn": return step;
|
|
111
|
+
case "targetRow": return {
|
|
112
|
+
type: "revisedRow",
|
|
113
|
+
blocks: step.blocks,
|
|
114
|
+
location: step.location
|
|
115
|
+
};
|
|
116
|
+
case "targetTable": return {
|
|
117
|
+
type: "revisedTable",
|
|
118
|
+
blocks: step.blocks,
|
|
119
|
+
location: step.location
|
|
120
|
+
};
|
|
121
|
+
case "targetColumn": return {
|
|
122
|
+
type: "revisedColumn",
|
|
123
|
+
blocks: step.blocks,
|
|
124
|
+
location: step.location,
|
|
125
|
+
columnIndex: step.columnIndex,
|
|
126
|
+
anchor: step.anchor
|
|
127
|
+
};
|
|
128
|
+
default: return panic("Unhandled compare step", { step });
|
|
445
129
|
}
|
|
446
|
-
for (const segment of baseSegments.slice(baseCursor)) paired.push({
|
|
447
|
-
baseSegment: segment,
|
|
448
|
-
targetSegment: null
|
|
449
|
-
});
|
|
450
|
-
for (const segment of targetSegments.slice(targetCursor)) paired.push({
|
|
451
|
-
baseSegment: null,
|
|
452
|
-
targetSegment: segment
|
|
453
|
-
});
|
|
454
|
-
return paired;
|
|
455
130
|
};
|
|
456
131
|
const isEmptyParagraphNode = (block, snapshot) => {
|
|
457
132
|
const anchor = snapshot.anchors[block.id] ?? panic("A comparison snapshot block has no matching anchor", { blockId: block.id });
|
|
458
133
|
return block.text === "" && block.table === void 0 && anchor.to - anchor.from === 2;
|
|
459
134
|
};
|
|
460
135
|
/** Align both stories, optionally holding the two last paragraphs out as a pair. */
|
|
461
|
-
const alignedSteps = (baseBlocks, targetBlocks, terminalCarrierPair) => {
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (baseSegment) {
|
|
471
|
-
steps.push(...unpairedSegmentSteps(baseSegment, "base"));
|
|
472
|
-
continue;
|
|
473
|
-
}
|
|
474
|
-
if (targetSegment) steps.push(...unpairedSegmentSteps(targetSegment, "target"));
|
|
475
|
-
}
|
|
136
|
+
const alignedSteps = (baseBlocks, targetBlocks, terminalCarrierPair, wholeTableReplacement, workSession = createFolioContentAlignmentWorkSession()) => {
|
|
137
|
+
const steps = alignFolioContentStructure({
|
|
138
|
+
baseBlocks: terminalCarrierPair ? baseBlocks.slice(0, -1) : baseBlocks,
|
|
139
|
+
revisedBlocks: terminalCarrierPair ? targetBlocks.slice(0, -1) : targetBlocks,
|
|
140
|
+
wholeTableReplacement,
|
|
141
|
+
workSession,
|
|
142
|
+
stableIdMismatch: "pair",
|
|
143
|
+
idStability: folioAIBlockIdStability
|
|
144
|
+
}).map(toCompareStep);
|
|
476
145
|
if (terminalCarrierPair) steps.push(terminalCarrierPair);
|
|
477
146
|
return steps;
|
|
478
147
|
};
|
|
@@ -520,7 +189,7 @@ const addedTerminalCarrierIsStranded = (steps, baseBlocks, targetBlocks) => {
|
|
|
520
189
|
const baseLastStep = steps.findIndex((step) => step.type === "pair" && step.baseBlock.id === baseLast.id || step.type === "baseOnly" && step.block.id === baseLast.id);
|
|
521
190
|
return baseLastStep === -1 || steps.slice(baseLastStep + 1).some((step) => step.type !== "targetOnly");
|
|
522
191
|
};
|
|
523
|
-
const buildSteps = ({ baseSnapshot, targetSnapshot }) => {
|
|
192
|
+
const buildSteps = ({ baseSnapshot, targetSnapshot, wholeTableReplacement, workSession }) => {
|
|
524
193
|
const baseBlocks = baseSnapshot.blocks;
|
|
525
194
|
const targetBlocks = targetSnapshot.blocks;
|
|
526
195
|
const baseLast = baseBlocks.at(-1);
|
|
@@ -531,9 +200,13 @@ const buildSteps = ({ baseSnapshot, targetSnapshot }) => {
|
|
|
531
200
|
targetBlock: targetLast
|
|
532
201
|
} : null;
|
|
533
202
|
const bothStoriesEndBlank = carrierPair !== null && isEmptyParagraphNode(carrierPair.baseBlock, baseSnapshot) && isEmptyParagraphNode(carrierPair.targetBlock, targetSnapshot);
|
|
534
|
-
const
|
|
203
|
+
const alignmentBudgetBeforeAttempt = workSession.remainingLcsCells;
|
|
204
|
+
const structuralTokenBudgetBeforeAttempt = workSession.remainingStructuralTokenLookups;
|
|
205
|
+
const steps = alignedSteps(baseBlocks, targetBlocks, bothStoriesEndBlank ? carrierPair : null, wholeTableReplacement, workSession);
|
|
535
206
|
if (carrierPair === null || bothStoriesEndBlank || !shareAContainer(carrierPair.baseBlock, carrierPair.targetBlock) || !(terminalCarrierIsStranded(steps, baseBlocks) || addedTerminalCarrierIsStranded(steps, baseBlocks, targetBlocks))) return steps;
|
|
536
|
-
|
|
207
|
+
workSession.remainingLcsCells = alignmentBudgetBeforeAttempt;
|
|
208
|
+
workSession.remainingStructuralTokenLookups = structuralTokenBudgetBeforeAttempt;
|
|
209
|
+
return alignedSteps(baseBlocks, targetBlocks, carrierPair, wholeTableReplacement, workSession);
|
|
537
210
|
};
|
|
538
211
|
/**
|
|
539
212
|
* Two blocks are in the same container when a paragraph mark between them
|
|
@@ -542,103 +215,15 @@ const buildSteps = ({ baseSnapshot, targetSnapshot }) => {
|
|
|
542
215
|
* paragraph-mark edit however similar the text looks.
|
|
543
216
|
*/
|
|
544
217
|
const shareAContainer = (left, right) => {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
if (head.length === 0 || tail.length === 0 || whole.length < head.length + tail.length) return null;
|
|
555
|
-
if (!whole.startsWith(head) || !whole.endsWith(tail)) return null;
|
|
556
|
-
const separator = whole.slice(head.length, whole.length - tail.length);
|
|
557
|
-
return separator.length === 0 || /^\s+$/u.test(separator) ? separator : null;
|
|
558
|
-
};
|
|
559
|
-
/**
|
|
560
|
-
* Where the alignment produced a rewrite plus an insertion or a deletion that
|
|
561
|
-
* is really one paragraph mark moving, by step index of the PAIR step. The
|
|
562
|
-
* step after it is consumed with it.
|
|
563
|
-
*
|
|
564
|
-
* The alignment cannot see this: it pairs the base paragraph with the target
|
|
565
|
-
* half that still matches it and leaves the other half unpaired, which is a
|
|
566
|
-
* correct alignment and a misleading redline.
|
|
567
|
-
*/
|
|
568
|
-
const detectParagraphMarkEdits = (steps) => {
|
|
569
|
-
const plans = /* @__PURE__ */ new Map();
|
|
570
|
-
for (const [index, step] of steps.entries()) {
|
|
571
|
-
const next = steps[index + 1];
|
|
572
|
-
if (step.type !== "pair" || next === void 0) continue;
|
|
573
|
-
if (next.type === "targetOnly") {
|
|
574
|
-
const separator = separatorBetween(step.baseBlock.text, step.targetBlock.text, next.block.text);
|
|
575
|
-
if (separator !== null && shareAContainer(step.targetBlock, next.block)) plans.set(index, {
|
|
576
|
-
type: "split",
|
|
577
|
-
baseBlock: step.baseBlock,
|
|
578
|
-
targetBlocks: [step.targetBlock, next.block],
|
|
579
|
-
offset: step.targetBlock.text.length,
|
|
580
|
-
separator
|
|
581
|
-
});
|
|
582
|
-
continue;
|
|
583
|
-
}
|
|
584
|
-
if (next.type !== "baseOnly") continue;
|
|
585
|
-
const separator = separatorBetween(step.targetBlock.text, step.baseBlock.text, next.block.text);
|
|
586
|
-
if (separator !== null && shareAContainer(step.baseBlock, next.block)) plans.set(index, {
|
|
587
|
-
type: "merge",
|
|
588
|
-
baseBlocks: [step.baseBlock, next.block],
|
|
589
|
-
targetBlock: step.targetBlock,
|
|
590
|
-
separator
|
|
591
|
-
});
|
|
592
|
-
}
|
|
593
|
-
return plans;
|
|
594
|
-
};
|
|
595
|
-
/** Base block id -> target block id for every relocation the move pass found. */
|
|
596
|
-
const detectMoves = (steps, consumed) => {
|
|
597
|
-
const candidatesByText = /* @__PURE__ */ new Map();
|
|
598
|
-
for (const [index, step] of steps.entries()) {
|
|
599
|
-
if (consumed.has(index) || step.type !== "baseOnly" || !wordCountReaches(step.block.text, MOVE_MINIMUM_WORD_COUNT)) continue;
|
|
600
|
-
const queue = candidatesByText.get(step.block.text);
|
|
601
|
-
if (!queue) {
|
|
602
|
-
candidatesByText.set(step.block.text, [step.block.id]);
|
|
603
|
-
continue;
|
|
604
|
-
}
|
|
605
|
-
if (queue.length < MAX_MOVE_CANDIDATES_PER_TEXT) queue.push(step.block.id);
|
|
606
|
-
}
|
|
607
|
-
const unmatched = [];
|
|
608
|
-
for (const [index, step] of steps.entries()) {
|
|
609
|
-
if (consumed.has(index) || step.type !== "baseOnly" || !wordCountReaches(step.block.text, MOVE_MINIMUM_WORD_COUNT)) continue;
|
|
610
|
-
unmatched.push(step.block);
|
|
611
|
-
}
|
|
612
|
-
const movesByBaseBlockId = /* @__PURE__ */ new Map();
|
|
613
|
-
const takenBaseBlockIds = /* @__PURE__ */ new Set();
|
|
614
|
-
let comparisonBudget = MAX_MOVE_SIMILARITY_COMPARISONS;
|
|
615
|
-
for (const [index, step] of steps.entries()) {
|
|
616
|
-
if (consumed.has(index) || step.type !== "targetOnly") continue;
|
|
617
|
-
const exact = candidatesByText.get(step.block.text)?.shift();
|
|
618
|
-
if (exact !== void 0) {
|
|
619
|
-
takenBaseBlockIds.add(exact);
|
|
620
|
-
movesByBaseBlockId.set(exact, step.block.id);
|
|
621
|
-
continue;
|
|
622
|
-
}
|
|
623
|
-
if (!wordCountReaches(step.block.text, MOVE_MINIMUM_WORD_COUNT)) continue;
|
|
624
|
-
let best = null;
|
|
625
|
-
for (const candidate of unmatched) {
|
|
626
|
-
if (comparisonBudget <= 0) break;
|
|
627
|
-
if (takenBaseBlockIds.has(candidate.id)) continue;
|
|
628
|
-
comparisonBudget -= 1;
|
|
629
|
-
const similarity = tokenSimilarity(candidate.text, step.block.text);
|
|
630
|
-
if (similarity >= MOVE_SIMILARITY_THRESHOLD && (best === null || similarity > best.similarity)) best = {
|
|
631
|
-
block: candidate,
|
|
632
|
-
similarity
|
|
633
|
-
};
|
|
634
|
-
}
|
|
635
|
-
if (best) {
|
|
636
|
-
takenBaseBlockIds.add(best.block.id);
|
|
637
|
-
movesByBaseBlockId.set(best.block.id, step.block.id);
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
return movesByBaseBlockId;
|
|
641
|
-
};
|
|
218
|
+
return contentBlocksShareContainer(left, right);
|
|
219
|
+
};
|
|
220
|
+
/** Narrow the neutral patch to the operation contract without explicit undefined fields. */
|
|
221
|
+
const toFolioAIBlockParagraphProperties = (properties) => ({
|
|
222
|
+
...properties.styleId !== void 0 && { styleId: properties.styleId },
|
|
223
|
+
...properties.listLevel !== void 0 && { listLevel: properties.listLevel },
|
|
224
|
+
...properties.alignment !== void 0 && { alignment: properties.alignment },
|
|
225
|
+
...properties.spacing !== void 0 && { spacing: properties.spacing }
|
|
226
|
+
});
|
|
642
227
|
/**
|
|
643
228
|
* For each step, the id of the next base block at or after it — the anchor a
|
|
644
229
|
* target-only insertion is placed before. `null` once no base block follows.
|
|
@@ -709,7 +294,7 @@ const findRowAnchor = (steps, stepIndex) => {
|
|
|
709
294
|
* actually occupies; the empty strings are the cells a `w:gridSpan` covers.
|
|
710
295
|
*/
|
|
711
296
|
const tableCellTexts = (blocks) => {
|
|
712
|
-
const rows =
|
|
297
|
+
const rows = groupFolioContentTableRows(blocks).map((row) => rowCellTexts(row));
|
|
713
298
|
let width = 0;
|
|
714
299
|
for (const row of rows) width = Math.max(width, row.length);
|
|
715
300
|
for (const row of rows) while (row.length < width) row.push("");
|
|
@@ -735,19 +320,6 @@ const columnCellTexts = (blocks) => {
|
|
|
735
320
|
}
|
|
736
321
|
return [...byCell.values()];
|
|
737
322
|
};
|
|
738
|
-
/**
|
|
739
|
-
* The paragraph properties that differ, or `null` when they agree. Only the
|
|
740
|
-
* ones a block projection can see and an operation can set: a list level, a
|
|
741
|
-
* paragraph style, and direct alignment. These edits move no words and are
|
|
742
|
-
* invisible in a text diff.
|
|
743
|
-
*/
|
|
744
|
-
const changedParagraphProperties = (baseBlock, targetBlock) => {
|
|
745
|
-
const properties = {};
|
|
746
|
-
if ((baseBlock.styleId ?? null) !== (targetBlock.styleId ?? null)) properties.styleId = targetBlock.styleId ?? null;
|
|
747
|
-
if (baseBlock.listLevel !== targetBlock.listLevel) properties.listLevel = targetBlock.listLevel ?? null;
|
|
748
|
-
if (baseBlock.directAlignment !== targetBlock.directAlignment) properties.alignment = targetBlock.directAlignment ?? null;
|
|
749
|
-
return Object.keys(properties).length > 0 ? properties : null;
|
|
750
|
-
};
|
|
751
323
|
const locationOf = (story, block) => block.table ? {
|
|
752
324
|
story,
|
|
753
325
|
cell: block.table
|
|
@@ -798,11 +370,11 @@ const lastBlockByContainer = (blocks) => {
|
|
|
798
370
|
* rather than an edit of its own, so it adds no entry to the change list: what
|
|
799
371
|
* the reader is told is that paragraphs were removed and paragraphs added.
|
|
800
372
|
*/
|
|
801
|
-
const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, nextOperationId }) => {
|
|
373
|
+
const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, tableTemplates, nextOperationId }) => {
|
|
802
374
|
const plan = [...operations];
|
|
803
375
|
const deletionIndexByBlockId = /* @__PURE__ */ new Map();
|
|
804
376
|
const insertIndexesByAnchor = /* @__PURE__ */ new Map();
|
|
805
|
-
const
|
|
377
|
+
const tableInsertIndexesByAnchor = /* @__PURE__ */ new Map();
|
|
806
378
|
const markedBlockIds = /* @__PURE__ */ new Set();
|
|
807
379
|
for (const [index, operation] of plan.entries()) switch (operation.type) {
|
|
808
380
|
case "deleteBlock":
|
|
@@ -815,9 +387,12 @@ const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, n
|
|
|
815
387
|
insertIndexesByAnchor.set(operation.blockId, placed);
|
|
816
388
|
break;
|
|
817
389
|
}
|
|
818
|
-
case "insertTable":
|
|
819
|
-
|
|
390
|
+
case "insertTable": {
|
|
391
|
+
const placed = tableInsertIndexesByAnchor.get(operation.blockId) ?? [];
|
|
392
|
+
placed.push(index);
|
|
393
|
+
tableInsertIndexesByAnchor.set(operation.blockId, placed);
|
|
820
394
|
break;
|
|
395
|
+
}
|
|
821
396
|
case "splitBlock":
|
|
822
397
|
case "mergeBlockWithNext":
|
|
823
398
|
markedBlockIds.add(operation.blockId);
|
|
@@ -836,8 +411,8 @@ const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, n
|
|
|
836
411
|
const trailingRunOf = (container, carrier) => {
|
|
837
412
|
const blockIds = [];
|
|
838
413
|
const insertIndexes = [];
|
|
414
|
+
const tableInsertIndexes = [];
|
|
839
415
|
let chainStart = null;
|
|
840
|
-
let holdsAnInsertedTable = false;
|
|
841
416
|
let index = indexById.get(carrier.id) ?? panic("A container's last block is not in the snapshot it came from", { blockId: carrier.id });
|
|
842
417
|
for (; index >= 0; index--) {
|
|
843
418
|
const block = blocks[index];
|
|
@@ -848,23 +423,35 @@ const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, n
|
|
|
848
423
|
}
|
|
849
424
|
blockIds.push(block.id);
|
|
850
425
|
insertIndexes.push(...insertIndexesByAnchor.get(block.id) ?? []);
|
|
851
|
-
|
|
426
|
+
tableInsertIndexes.push(...tableInsertIndexesByAnchor.get(block.id) ?? []);
|
|
852
427
|
}
|
|
853
428
|
return {
|
|
854
429
|
blockIds,
|
|
855
430
|
chainStart,
|
|
856
431
|
insertIndexes: insertIndexes.toSorted((left, right) => left - right),
|
|
857
|
-
|
|
432
|
+
tableInsertIndexes: tableInsertIndexes.toSorted((left, right) => left - right)
|
|
858
433
|
};
|
|
859
434
|
};
|
|
860
435
|
const targetLastByContainer = lastBlockByContainer(targetSnapshot.blocks);
|
|
436
|
+
const terminalTargetTableIndex = targetSnapshot.blocks.at(-1)?.table?.outerTableIndex;
|
|
437
|
+
const targetTableIndexByOperationId = new Map(tableTemplates.map(({ operationId, targetTableIndex }) => [operationId, targetTableIndex]));
|
|
861
438
|
const dropped = /* @__PURE__ */ new Set();
|
|
862
439
|
const appended = [];
|
|
863
440
|
for (const [container, carrier] of lastBlockByContainer(blocks)) {
|
|
864
441
|
const carrierDeletionIndex = deletionIndexByBlockId.get(carrier.id);
|
|
865
442
|
if (carrierDeletionIndex === void 0) continue;
|
|
866
443
|
const run = trailingRunOf(container, carrier);
|
|
867
|
-
if (run.
|
|
444
|
+
if (run.tableInsertIndexes.length > 0) {
|
|
445
|
+
const tableInsertIndex = run.tableInsertIndexes.length === 1 ? run.tableInsertIndexes.at(0) : void 0;
|
|
446
|
+
const tableInsert = tableInsertIndex === void 0 ? void 0 : plan[tableInsertIndex];
|
|
447
|
+
if (container !== "body" || !isEmptyParagraphNode(carrier, baseSnapshot) || tableInsertIndex === void 0 || terminalTargetTableIndex === void 0 || tableInsert?.type !== "insertTable" || targetTableIndexByOperationId.get(tableInsert.id) !== terminalTargetTableIndex) continue;
|
|
448
|
+
plan[tableInsertIndex] = {
|
|
449
|
+
...tableInsert,
|
|
450
|
+
blockId: carrier.id,
|
|
451
|
+
position: "after"
|
|
452
|
+
};
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
868
455
|
const lastInsertIndex = run.insertIndexes.at(-1);
|
|
869
456
|
if (lastInsertIndex === void 0) {
|
|
870
457
|
if (run.chainStart) appended.push({
|
|
@@ -897,18 +484,22 @@ const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, n
|
|
|
897
484
|
...insert.moveId !== void 0 && { moveId: insert.moveId },
|
|
898
485
|
styleId: insert.styleId ?? null,
|
|
899
486
|
listLevel: insert.listLevel ?? null,
|
|
900
|
-
alignment: insert.alignment ?? null
|
|
487
|
+
alignment: insert.alignment ?? null,
|
|
488
|
+
spacing: insert.spacing ?? null
|
|
901
489
|
};
|
|
902
490
|
}
|
|
903
491
|
}
|
|
904
492
|
const targetCarrier = targetLastByContainer.get(container);
|
|
905
|
-
const
|
|
906
|
-
if (
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
493
|
+
const contentProperties = targetCarrier && changedFolioContentParagraphFormatting(carrier, targetCarrier);
|
|
494
|
+
if (contentProperties) {
|
|
495
|
+
const properties = toFolioAIBlockParagraphProperties(contentProperties);
|
|
496
|
+
appended.push({
|
|
497
|
+
id: nextOperationId(),
|
|
498
|
+
type: "setBlockParagraphProperties",
|
|
499
|
+
blockId: carrier.id,
|
|
500
|
+
properties
|
|
501
|
+
});
|
|
502
|
+
}
|
|
912
503
|
}
|
|
913
504
|
return [...plan.filter((_, index) => !dropped.has(index)), ...appended];
|
|
914
505
|
};
|
|
@@ -944,14 +535,23 @@ const tableGeometryPairingsOf = (steps) => {
|
|
|
944
535
|
* Plan one story's comparison, or `null` when it needs more operations than
|
|
945
536
|
* `maxOperations`.
|
|
946
537
|
*/
|
|
947
|
-
const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }) => {
|
|
538
|
+
const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations, wholeTableReplacement = "allow", workSession = createContentComparisonWorkSession() }) => {
|
|
539
|
+
const effectiveWholeTableReplacement = wholeTableReplacement === "allow" && trailingBodyBlockId(baseSnapshot) === null ? "avoid" : wholeTableReplacement;
|
|
948
540
|
const steps = buildSteps({
|
|
949
541
|
baseSnapshot,
|
|
950
|
-
targetSnapshot
|
|
542
|
+
targetSnapshot,
|
|
543
|
+
wholeTableReplacement: effectiveWholeTableReplacement,
|
|
544
|
+
workSession: workSession.alignment
|
|
951
545
|
});
|
|
952
|
-
const
|
|
546
|
+
const contentSteps = steps.map(toContentAlignmentStep);
|
|
547
|
+
const paragraphMarkPlans = detectFolioContentParagraphMarkPlans(contentSteps);
|
|
953
548
|
const consumedSteps = new Set([...paragraphMarkPlans.keys()].map((index) => index + 1));
|
|
954
|
-
const movesByBaseBlockId =
|
|
549
|
+
const movesByBaseBlockId = new Map(detectFolioContentMoves({
|
|
550
|
+
steps: contentSteps,
|
|
551
|
+
consumedStepIndexes: consumedSteps,
|
|
552
|
+
workSession,
|
|
553
|
+
idStability: folioAIBlockIdStability
|
|
554
|
+
}).map(({ baseBlock, revisedBlock }) => [baseBlock.id, revisedBlock.id]));
|
|
955
555
|
const moveSourceByTargetBlockId = /* @__PURE__ */ new Map();
|
|
956
556
|
for (const [baseBlockId, targetBlockId] of movesByBaseBlockId) moveSourceByTargetBlockId.set(targetBlockId, baseBlockId);
|
|
957
557
|
const anchorIds = nextBaseBlockIdByStep(steps);
|
|
@@ -960,11 +560,10 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
|
|
|
960
560
|
const tableTemplates = [];
|
|
961
561
|
/**
|
|
962
562
|
* The anchor everything past the base document's content hangs from: its
|
|
963
|
-
* last BODY-LEVEL paragraph,
|
|
964
|
-
*
|
|
965
|
-
*
|
|
966
|
-
*
|
|
967
|
-
* paragraph mark can express the break it added.
|
|
563
|
+
* last BODY-LEVEL paragraph, when it has one. Anchoring to the last block
|
|
564
|
+
* instead puts the anchor inside a terminal table, and a paragraph insertion
|
|
565
|
+
* then escapes to the table's boundary where no paragraph mark can express
|
|
566
|
+
* the break it added.
|
|
968
567
|
*
|
|
969
568
|
* The applier orders insertions that resolve to one position by their order
|
|
970
569
|
* in this array, so the tail is emitted where its step sits rather than
|
|
@@ -988,7 +587,8 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
|
|
|
988
587
|
...moveSourceId !== void 0 && { moveId: moveIdOf(moveSourceId) },
|
|
989
588
|
styleId: block.styleId ?? null,
|
|
990
589
|
listLevel: block.listLevel ?? null,
|
|
991
|
-
alignment: block.directAlignment ?? null
|
|
590
|
+
alignment: block.directAlignment ?? null,
|
|
591
|
+
spacing: block.directSpacing ?? null
|
|
992
592
|
};
|
|
993
593
|
if (anchorId !== null) {
|
|
994
594
|
operations.push({
|
|
@@ -1011,7 +611,11 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
|
|
|
1011
611
|
if (consumedSteps.has(stepIndex)) continue;
|
|
1012
612
|
const paragraphMarkPlan = paragraphMarkPlans.get(stepIndex);
|
|
1013
613
|
if (paragraphMarkPlan?.type === "split") {
|
|
1014
|
-
const { baseBlock,
|
|
614
|
+
const { baseBlock, revisedBlocks: splitInto, offset, separator } = paragraphMarkPlan;
|
|
615
|
+
const firstParagraphFormatting = changedFolioContentParagraphFormatting(baseBlock, splitInto[0]);
|
|
616
|
+
const secondParagraphFormatting = changedFolioContentParagraphFormatting(baseBlock, splitInto[1]);
|
|
617
|
+
const firstParagraphProperties = firstParagraphFormatting ? toFolioAIBlockParagraphProperties(firstParagraphFormatting) : void 0;
|
|
618
|
+
const secondParagraphProperties = secondParagraphFormatting ? toFolioAIBlockParagraphProperties(secondParagraphFormatting) : void 0;
|
|
1015
619
|
changes.push({
|
|
1016
620
|
kind: "split",
|
|
1017
621
|
location: locationOf(story, baseBlock),
|
|
@@ -1019,17 +623,35 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
|
|
|
1019
623
|
targetBlockIds: splitInto.map(({ id }) => id),
|
|
1020
624
|
text: baseBlock.text
|
|
1021
625
|
});
|
|
626
|
+
if (firstParagraphProperties) changes.push({
|
|
627
|
+
kind: "paragraph-format",
|
|
628
|
+
location: locationOf(story, baseBlock),
|
|
629
|
+
baseBlockId: baseBlock.id,
|
|
630
|
+
targetBlockId: splitInto[0].id,
|
|
631
|
+
properties: firstParagraphProperties
|
|
632
|
+
});
|
|
633
|
+
if (secondParagraphProperties) changes.push({
|
|
634
|
+
kind: "paragraph-format",
|
|
635
|
+
location: locationOf(story, baseBlock),
|
|
636
|
+
baseBlockId: baseBlock.id,
|
|
637
|
+
targetBlockId: splitInto[1].id,
|
|
638
|
+
properties: secondParagraphProperties
|
|
639
|
+
});
|
|
1022
640
|
operations.push({
|
|
1023
641
|
id: nextOperationId(),
|
|
1024
642
|
type: "splitBlock",
|
|
1025
643
|
blockId: baseBlock.id,
|
|
1026
644
|
offset,
|
|
1027
|
-
...separator.length > 0 && { separator }
|
|
645
|
+
...separator.length > 0 && { separator },
|
|
646
|
+
...firstParagraphProperties && { firstParagraphProperties },
|
|
647
|
+
...secondParagraphProperties && { secondParagraphProperties }
|
|
1028
648
|
});
|
|
1029
649
|
continue;
|
|
1030
650
|
}
|
|
1031
651
|
if (paragraphMarkPlan?.type === "merge") {
|
|
1032
|
-
const { baseBlocks, targetBlock, separator } = paragraphMarkPlan;
|
|
652
|
+
const { baseBlocks, revisedBlock: targetBlock, separator } = paragraphMarkPlan;
|
|
653
|
+
const mergedParagraphFormatting = changedFolioContentParagraphFormatting(baseBlocks[0], targetBlock);
|
|
654
|
+
const mergedParagraphProperties = mergedParagraphFormatting ? toFolioAIBlockParagraphProperties(mergedParagraphFormatting) : void 0;
|
|
1033
655
|
changes.push({
|
|
1034
656
|
kind: "merge",
|
|
1035
657
|
location: locationOf(story, baseBlocks[0]),
|
|
@@ -1037,19 +659,28 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
|
|
|
1037
659
|
targetBlockId: targetBlock.id,
|
|
1038
660
|
text: targetBlock.text
|
|
1039
661
|
});
|
|
662
|
+
if (mergedParagraphProperties) changes.push({
|
|
663
|
+
kind: "paragraph-format",
|
|
664
|
+
location: locationOf(story, baseBlocks[0]),
|
|
665
|
+
baseBlockId: baseBlocks[0].id,
|
|
666
|
+
targetBlockId: targetBlock.id,
|
|
667
|
+
properties: mergedParagraphProperties
|
|
668
|
+
});
|
|
1040
669
|
operations.push({
|
|
1041
670
|
id: nextOperationId(),
|
|
1042
671
|
type: "mergeBlockWithNext",
|
|
1043
672
|
blockId: baseBlocks[0].id,
|
|
1044
|
-
...separator.length > 0 && { separator }
|
|
673
|
+
...separator.length > 0 && { separator },
|
|
674
|
+
...mergedParagraphProperties && { mergedParagraphProperties }
|
|
1045
675
|
});
|
|
1046
676
|
continue;
|
|
1047
677
|
}
|
|
1048
678
|
switch (step.type) {
|
|
1049
679
|
case "pair": {
|
|
1050
680
|
const { baseBlock, targetBlock } = step;
|
|
1051
|
-
const
|
|
1052
|
-
if (
|
|
681
|
+
const contentProperties = changedFolioContentParagraphFormatting(baseBlock, targetBlock);
|
|
682
|
+
if (contentProperties) {
|
|
683
|
+
const properties = toFolioAIBlockParagraphProperties(contentProperties);
|
|
1053
684
|
changes.push({
|
|
1054
685
|
kind: "paragraph-format",
|
|
1055
686
|
location: locationOf(story, baseBlock),
|
|
@@ -1298,6 +929,7 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
|
|
|
1298
929
|
baseSnapshot,
|
|
1299
930
|
targetSnapshot,
|
|
1300
931
|
operations,
|
|
932
|
+
tableTemplates,
|
|
1301
933
|
nextOperationId
|
|
1302
934
|
});
|
|
1303
935
|
return planned.length > maxOperations ? null : {
|