codex-overleaf-link 2.5.0 → 2.6.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.
- package/README.md +155 -392
- package/README.zh-CN.md +154 -393
- package/extension/bootstrap/background.js +52 -9
- package/extension/bootstrap/manifest.template.json +1 -1
- package/extension/bootstrap/popup.html +1 -0
- package/extension/bootstrap/popupFallback.js +55 -0
- package/extension/bootstrap/updateStatus.js +1 -0
- package/extension/src/background.js +283 -55
- package/extension/src/backgroundBootstrapMigration.js +181 -0
- package/extension/src/backgroundSharedSessions.js +129 -16
- package/extension/src/backgroundUpdateCoordinator.js +242 -29
- package/extension/src/backgroundUpdateSafety.js +173 -0
- package/extension/src/content/activeTurnControl.js +171 -8
- package/extension/src/content/applyResultFormatters.js +46 -73
- package/extension/src/content/assetTransferBroker.js +72 -14
- package/extension/src/content/composerAttachments.js +104 -47
- package/extension/src/content/composerPanel.js +110 -12
- package/extension/src/content/contentRuntime.js +1555 -926
- package/extension/src/content/contextTray.js +8 -2
- package/extension/src/content/contextUsageView.js +267 -0
- package/extension/src/content/diagnosticsController.js +122 -96
- package/extension/src/content/diagnosticsExport.js +19 -5
- package/extension/src/content/diagnosticsPanel.js +48 -0
- package/extension/src/content/directoryCleanupRecovery.js +54 -0
- package/extension/src/content/generated/content.bundle.js +11077 -3412
- package/extension/src/content/generated/content.bundle.meta.json +14 -3
- package/extension/src/content/globalPreferencesController.js +1 -1
- package/extension/src/content/legacyGlobalRegistry.js +10 -0
- package/extension/src/content/localSkillsPanel.js +104 -14
- package/extension/src/content/markdownDomRenderer.js +5 -3
- package/extension/src/content/markdownText.js +47 -11
- package/extension/src/content/modelPicker.js +8 -4
- package/extension/src/content/nativeCompatibilityController.js +74 -30
- package/extension/src/content/otWarmMirror.js +5 -6
- package/extension/src/content/otWarmMirrorController.js +17 -20
- package/extension/src/content/pageBridgeClient.js +17 -4
- package/extension/src/content/panelMaintenance.js +46 -14
- package/extension/src/content/panelRenderer.js +57 -27
- package/extension/src/content/pendingInputView.js +1 -1
- package/extension/src/content/projectProviderSelection.js +13 -4
- package/extension/src/content/projectSessionCleanup.js +109 -62
- package/extension/src/content/projectSettingsCoordinator.js +67 -6
- package/extension/src/content/providerSettingsCoordinator.js +46 -8
- package/extension/src/content/providerSettingsDialog.js +73 -14
- package/extension/src/content/recentProjects.js +15 -8
- package/extension/src/content/runActivitySummary.js +160 -18
- package/extension/src/content/runCompletionMeta.js +532 -0
- package/extension/src/content/runController.js +39 -15
- package/extension/src/content/runFailureNotice.js +29 -13
- package/extension/src/content/runGuidanceController.js +72 -27
- package/extension/src/content/runGuidanceView.js +59 -10
- package/extension/src/content/runPresence.js +18 -8
- package/extension/src/content/runQueueScheduler.js +157 -47
- package/extension/src/content/runRecoveryActions.js +147 -0
- package/extension/src/content/runResultActions.js +28 -143
- package/extension/src/content/runReviewController.js +633 -0
- package/extension/src/content/runScrollLayout.js +21 -2
- package/extension/src/content/runTimelineView.js +367 -175
- package/extension/src/content/scopedPersistencePanelState.js +134 -20
- package/extension/src/content/selectionContextView.js +1 -1
- package/extension/src/content/sensitivePreflight.js +153 -0
- package/extension/src/content/sessionForkController.js +80 -17
- package/extension/src/content/sessionManager.js +50 -4
- package/extension/src/content/sessionMenuView.js +54 -18
- package/extension/src/content/sessionPersistence.js +25 -5
- package/extension/src/content/settingsPanel.js +22 -5
- package/extension/src/content/settingsWorkbench.js +132 -89
- package/extension/src/content/sharedSessionViewSync.js +49 -11
- package/extension/src/content/subagentActivityView.js +23 -7
- package/extension/src/content/updateIdle.js +4 -1
- package/extension/src/content/updateNotice.js +118 -12
- package/extension/src/content/updateSafetyReceiver.js +58 -0
- package/extension/src/content/writebackController.js +53 -4
- package/extension/src/content/writebackOrchestrator.js +116 -94
- package/extension/src/content/writingStyleSettings.js +35 -11
- package/extension/src/page/binaryAssetUploader.js +136 -14
- package/extension/src/page/compileBridge.js +193 -180
- package/extension/src/page/fileMover.js +256 -0
- package/extension/src/page/overleafCapabilities.js +4 -1
- package/extension/src/page/ownedDirectoryCleanup.js +94 -0
- package/extension/src/page/referenceProjects.js +1 -0
- package/extension/src/page/reviewContext.js +113 -0
- package/extension/src/page/saveState.js +6 -4
- package/extension/src/page/snapshotRouter.js +84 -39
- package/extension/src/page/textCoordinates.js +17 -1
- package/extension/src/page/textFileCreator.js +114 -26
- package/extension/src/page/trackedChangeCapture.js +12 -2
- package/extension/src/page/trackedChangeOwnership.js +19 -1
- package/extension/src/page/trackedChangeReplay.js +41 -21
- package/extension/src/page/trackedChangesLifecycle.js +217 -199
- package/extension/src/page/treeOperations.js +7 -10
- package/extension/src/page/updateSafetyProbe.js +41 -0
- package/extension/src/page/writebackReceiptJournal.js +2 -0
- package/extension/src/page/writebackRouter.js +103 -155
- package/extension/src/pageBridge.js +126 -69
- package/extension/src/popup.js +62 -10
- package/extension/src/shared/agentTranscript.js +373 -17
- package/extension/src/shared/auditRecords.js +4 -0
- package/extension/src/shared/compatibility.js +22 -6
- package/extension/src/shared/compileAdapter.js +129 -66
- package/extension/src/shared/composerAttachmentDraft.js +156 -0
- package/extension/src/shared/contextUsage.js +125 -0
- package/extension/src/shared/directoryCleanupProof.js +43 -0
- package/extension/src/shared/failureReasons.js +349 -4
- package/extension/src/shared/globalPreferences.js +30 -13
- package/extension/src/shared/governanceRules.js +4 -37
- package/extension/src/shared/i18n.js +590 -24
- package/extension/src/shared/lineReferences.js +38 -31
- package/extension/src/shared/managedUpdateProjection.js +2 -2
- package/extension/src/shared/nativeRequestIdentity.js +1 -0
- package/extension/src/shared/pageRpcContract.js +5 -4
- package/extension/src/shared/panelIcons.js +39 -0
- package/extension/src/shared/projectFiles.js +21 -44
- package/extension/src/shared/providerProfiles.js +5 -3
- package/extension/src/shared/reviewing.js +130 -1
- package/extension/src/shared/runActivityModel.js +70 -4
- package/extension/src/shared/runExecutionSnapshot.js +13 -0
- package/extension/src/shared/runInputQueue.js +48 -5
- package/extension/src/shared/selectionContext.js +12 -3
- package/extension/src/shared/sessionState.js +132 -28
- package/extension/src/shared/sessionTitle.js +14 -1
- package/extension/src/shared/settlementFacts.js +10 -2
- package/extension/src/shared/sharedSessionBridge.js +118 -27
- package/extension/src/shared/sharedSessionTransfer.js +140 -0
- package/extension/src/shared/staleGuard.js +166 -52
- package/extension/src/shared/storageDb.js +90 -26
- package/extension/src/shared/storageMigration.js +78 -112
- package/extension/src/shared/storageRunActions.js +267 -30
- package/extension/src/shared/subagentActivityModel.js +1 -1
- package/extension/src/shared/undoOperations.js +253 -32
- package/extension/src/shared/updateConsent.js +6 -4
- package/extension/src/shared/updateRuntimeIdentity.js +17 -3
- package/extension/src/shared/writebackIntent.js +16 -0
- package/extension/src/shared/writebackOperationEvidence.js +4 -15
- package/extension/src/shared/writebackSettlement.js +58 -40
- package/extension/styles/panel.css +1123 -493
- package/native-host/src/anthropicBridgeRequest.js +4 -2
- package/native-host/src/anthropicBridgeResponse.js +6 -2
- package/native-host/src/anthropicMessagesBridge.js +24 -16
- package/native-host/src/bootstrapMigration.js +296 -0
- package/native-host/src/bootstrapMigrationContract.js +101 -0
- package/native-host/src/bootstrapUpdate.js +269 -0
- package/native-host/src/bootstrapUpdateWatchdog.js +44 -0
- package/native-host/src/chatBridgeRequest.js +60 -6
- package/native-host/src/chatBridgeResponse.js +20 -1
- package/native-host/src/chatCompletionsBridge.js +48 -15
- package/native-host/src/codexConfigPaths.js +106 -0
- package/native-host/src/codexHome.js +66 -73
- package/native-host/src/codexHomeScope.js +124 -0
- package/native-host/src/codexInheritedAuth.js +90 -0
- package/native-host/src/codexLocalSkills.js +301 -0
- package/native-host/src/codexPromptAssembly.js +114 -127
- package/native-host/src/codexProviderTest.js +24 -26
- package/native-host/src/codexSessionRunner.js +234 -198
- package/native-host/src/codexSessionTiming.js +18 -8
- package/native-host/src/codexSkillIsolation.js +170 -0
- package/native-host/src/codexThreadFork.js +19 -3
- package/native-host/src/index.js +19 -7
- package/native-host/src/managedInstall.js +110 -35
- package/native-host/src/managedInstallRecovery.js +140 -0
- package/native-host/src/managedRuntimeEvidence.js +57 -0
- package/native-host/src/mirrorWorkspace.js +92 -22
- package/native-host/src/nativeDoctor.js +15 -0
- package/native-host/src/nativeHelperLifecycle.js +21 -0
- package/native-host/src/nativeHostRegistration.js +33 -0
- package/native-host/src/nativeRunLifecycle.js +71 -0
- package/native-host/src/processStopBarrier.js +42 -7
- package/native-host/src/projectPathPlatform.js +30 -0
- package/native-host/src/providerContextError.js +20 -0
- package/native-host/src/providerHttp.js +80 -0
- package/native-host/src/providerProfile.js +4 -2
- package/native-host/src/providerReasoning.js +2 -7
- package/native-host/src/providerRuntime.js +1 -0
- package/native-host/src/providerStore.js +72 -14
- package/native-host/src/responsesPassthroughBridge.js +266 -52
- package/native-host/src/runtimeInstaller.js +21 -1
- package/native-host/src/selectionScope.js +36 -8
- package/native-host/src/subagentAdmission.js +26 -0
- package/native-host/src/subagentBroker.js +79 -66
- package/native-host/src/subagentTelemetry.js +19 -10
- package/native-host/src/subagentWorkspacePath.js +5 -2
- package/native-host/src/taskRunnerRuntime.js +92 -57
- package/native-host/src/textPatch.js +473 -377
- package/native-host/src/tomlStatements.js +140 -0
- package/native-host/src/updateManager.js +409 -46
- package/native-host/src/writingStyleRuntime.js +57 -29
- package/native-host/src/writingStyleStore.js +73 -0
- package/package.json +5 -8
- package/scripts/install-managed.mjs +1 -0
|
@@ -49,37 +49,17 @@ function isValidNaturalPatchSet(oldText, newText, patches) {
|
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
// Builds the natural patches for one line-anchored change group.
|
|
53
|
+
//
|
|
54
|
+
// Annotated `% [original]` / `% [revised]` blocks are always replaced as one
|
|
55
|
+
// unit. Every other group goes through sentence alignment
|
|
56
|
+
// (`computeSentenceAlignedPatches`): unchanged sentences are skipped and each
|
|
57
|
+
// changed sentence is patched on its own.
|
|
52
58
|
function computeNaturalGroupPatches(group) {
|
|
53
|
-
|
|
54
|
-
group.oldText,
|
|
55
|
-
group.newText,
|
|
56
|
-
group.oldStart
|
|
57
|
-
);
|
|
58
|
-
const metrics = computeGroupMetrics(group, tokenPatches);
|
|
59
|
-
const classification = classifyChangedGroup(group, tokenPatches, metrics);
|
|
60
|
-
|
|
61
|
-
if (classification.type === 'annotated_block') {
|
|
59
|
+
if (hasAnyAnnotatedMarker(group.oldText) || hasAnyAnnotatedMarker(group.newText)) {
|
|
62
60
|
return singleGroupPatch(group);
|
|
63
61
|
}
|
|
64
|
-
|
|
65
|
-
const paragraphPatches = computeParagraphPatches(group);
|
|
66
|
-
return paragraphPatches !== null
|
|
67
|
-
? paragraphPatches
|
|
68
|
-
: singleGroupPatch(group);
|
|
69
|
-
}
|
|
70
|
-
if (classification.type === 'sentence_rewrite') {
|
|
71
|
-
const sentencePatches = computeSentencePatches(group, tokenPatches);
|
|
72
|
-
return sentencePatches !== null
|
|
73
|
-
? sentencePatches
|
|
74
|
-
: coalesceTokenPatches(group, tokenPatches);
|
|
75
|
-
}
|
|
76
|
-
if (classification.type === 'small_edit') {
|
|
77
|
-
return coalesceTokenPatches(group, tokenPatches);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return tokenPatches.length > 0
|
|
81
|
-
? coalesceTokenPatches(group, tokenPatches)
|
|
82
|
-
: singleGroupPatch(group);
|
|
62
|
+
return computeSentenceAlignedPatches(group);
|
|
83
63
|
}
|
|
84
64
|
|
|
85
65
|
function computeSingleTextPatch(oldValue, newValue, offset = 0) {
|
|
@@ -108,6 +88,58 @@ function computeSingleTextPatch(oldValue, newValue, offset = 0) {
|
|
|
108
88
|
};
|
|
109
89
|
}
|
|
110
90
|
|
|
91
|
+
// Characters that form a word for boundary alignment: the word class of
|
|
92
|
+
// `splitTextTokens` plus `\`, so a LaTeX command name stays attached to its
|
|
93
|
+
// backslash. CJK characters are deliberately excluded: they have no word
|
|
94
|
+
// boundaries, and widening across them would swallow whole clauses.
|
|
95
|
+
function isWordBoundaryChar(char) {
|
|
96
|
+
return char === '\\' || isWordTokenChar(char);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Like `computeSingleTextPatch`, but neither patch boundary may fall inside a
|
|
100
|
+
// word. The common-prefix / common-suffix trim can land mid-word in either the
|
|
101
|
+
// old or the new text (`" performs"` → `"s perform"`); such a cut is widened
|
|
102
|
+
// outward to the enclosing word boundaries, with the widened characters copied
|
|
103
|
+
// into both `expected` and `insert`, so the written bytes are unchanged and
|
|
104
|
+
// the tracked change reads as whole words.
|
|
105
|
+
function computeWordAlignedTextPatch(oldValue, newValue, offset = 0) {
|
|
106
|
+
const patch = computeSingleTextPatch(oldValue, newValue, offset);
|
|
107
|
+
let from = patch.from - offset;
|
|
108
|
+
let to = patch.to - offset;
|
|
109
|
+
// Offset in `newValue` where the shared suffix begins; it moves with `to`.
|
|
110
|
+
let newTo = from + patch.insert.length;
|
|
111
|
+
|
|
112
|
+
// The prefix is shared, so `newValue[index] === oldValue[index]` for every
|
|
113
|
+
// index below the original cut; checking both sides only matters at the cut.
|
|
114
|
+
while (
|
|
115
|
+
from > 0
|
|
116
|
+
&& isWordBoundaryChar(oldValue[from - 1])
|
|
117
|
+
&& (isWordBoundaryChar(oldValue[from]) || isWordBoundaryChar(newValue[from]))
|
|
118
|
+
) {
|
|
119
|
+
from -= 1;
|
|
120
|
+
}
|
|
121
|
+
while (
|
|
122
|
+
to < oldValue.length
|
|
123
|
+
&& isWordBoundaryChar(oldValue[to])
|
|
124
|
+
&& (isWordBoundaryChar(oldValue[to - 1]) || isWordBoundaryChar(newValue[newTo - 1]))
|
|
125
|
+
) {
|
|
126
|
+
to += 1;
|
|
127
|
+
newTo += 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (from === patch.from - offset && to === patch.to - offset) {
|
|
131
|
+
return patch;
|
|
132
|
+
}
|
|
133
|
+
const prefix = oldValue.slice(from, patch.from - offset);
|
|
134
|
+
const suffix = oldValue.slice(patch.to - offset, to);
|
|
135
|
+
return {
|
|
136
|
+
from: offset + from,
|
|
137
|
+
to: offset + to,
|
|
138
|
+
expected: oldValue.slice(from, to),
|
|
139
|
+
insert: prefix + patch.insert + suffix
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
111
143
|
|
|
112
144
|
function computeLineAnchoredChangeGroups(oldValue, newValue) {
|
|
113
145
|
if (oldValue === newValue) {
|
|
@@ -690,6 +722,18 @@ function discoverNaturalTokenAnchors(
|
|
|
690
722
|
return selectNaturalMonotonicAnchors(candidates, ANCHOR_SPAN);
|
|
691
723
|
}
|
|
692
724
|
|
|
725
|
+
// Scripts without word boundaries: each character is its own token so an
|
|
726
|
+
// edit inside a Chinese or Japanese sentence stays local instead of covering
|
|
727
|
+
// the whole run between two spaces.
|
|
728
|
+
const CJK_TOKEN_CHAR = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
729
|
+
|
|
730
|
+
// Letters, digits, and `_` of any script except the CJK scripts above.
|
|
731
|
+
function isWordTokenChar(char) {
|
|
732
|
+
return char !== undefined
|
|
733
|
+
&& /[\p{L}\p{N}_]/u.test(char)
|
|
734
|
+
&& !CJK_TOKEN_CHAR.test(char);
|
|
735
|
+
}
|
|
736
|
+
|
|
693
737
|
function splitTextTokens(text) {
|
|
694
738
|
const tokens = [];
|
|
695
739
|
let index = 0;
|
|
@@ -710,9 +754,11 @@ function splitTextTokens(text) {
|
|
|
710
754
|
} else if (index < text.length) {
|
|
711
755
|
index += 1;
|
|
712
756
|
}
|
|
713
|
-
} else if (
|
|
757
|
+
} else if (CJK_TOKEN_CHAR.test(char)) {
|
|
714
758
|
index += 1;
|
|
715
|
-
|
|
759
|
+
} else if (isWordTokenChar(char)) {
|
|
760
|
+
index += 1;
|
|
761
|
+
while (index < text.length && isWordTokenChar(text[index])) {
|
|
716
762
|
index += 1;
|
|
717
763
|
}
|
|
718
764
|
} else {
|
|
@@ -721,7 +767,8 @@ function splitTextTokens(text) {
|
|
|
721
767
|
index < text.length
|
|
722
768
|
&& !/\s/.test(text[index])
|
|
723
769
|
&& text[index] !== '\\'
|
|
724
|
-
&&
|
|
770
|
+
&& !isWordTokenChar(text[index])
|
|
771
|
+
&& !CJK_TOKEN_CHAR.test(text[index])
|
|
725
772
|
) {
|
|
726
773
|
index += 1;
|
|
727
774
|
}
|
|
@@ -739,98 +786,6 @@ function splitTextParts(text) {
|
|
|
739
786
|
return String(text || '').match(/[^\n]*\n|[^\n]+/g) || [];
|
|
740
787
|
}
|
|
741
788
|
|
|
742
|
-
function computePartEdits(oldParts, newParts) {
|
|
743
|
-
const n = oldParts.length;
|
|
744
|
-
const m = newParts.length;
|
|
745
|
-
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
746
|
-
|
|
747
|
-
for (let i = n - 1; i >= 0; i -= 1) {
|
|
748
|
-
for (let j = m - 1; j >= 0; j -= 1) {
|
|
749
|
-
dp[i][j] = oldParts[i] === newParts[j]
|
|
750
|
-
? dp[i + 1][j + 1] + 1
|
|
751
|
-
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
const edits = [];
|
|
756
|
-
let i = 0;
|
|
757
|
-
let j = 0;
|
|
758
|
-
while (i < n && j < m) {
|
|
759
|
-
if (oldParts[i] === newParts[j]) {
|
|
760
|
-
edits.push({ type: 'equal', oldIndex: i, newIndex: j });
|
|
761
|
-
i += 1;
|
|
762
|
-
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
763
|
-
edits.push({ type: 'remove', oldIndex: i });
|
|
764
|
-
i += 1;
|
|
765
|
-
continue;
|
|
766
|
-
} else {
|
|
767
|
-
edits.push({ type: 'add', newIndex: j });
|
|
768
|
-
}
|
|
769
|
-
j += 1;
|
|
770
|
-
}
|
|
771
|
-
while (i < n) {
|
|
772
|
-
edits.push({ type: 'remove', oldIndex: i });
|
|
773
|
-
i += 1;
|
|
774
|
-
}
|
|
775
|
-
while (j < m) {
|
|
776
|
-
edits.push({ type: 'add', newIndex: j });
|
|
777
|
-
j += 1;
|
|
778
|
-
}
|
|
779
|
-
return edits;
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
function countNonEmptyLines(text) {
|
|
783
|
-
const value = String(text ?? '');
|
|
784
|
-
let count = 0;
|
|
785
|
-
for (const line of value.split('\n')) {
|
|
786
|
-
if (line.trim() !== '') {
|
|
787
|
-
count += 1;
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
return count;
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
function countSentenceTerminators(text) {
|
|
794
|
-
const value = String(text ?? '');
|
|
795
|
-
let count = 0;
|
|
796
|
-
for (let index = 0; index < value.length; index += 1) {
|
|
797
|
-
const char = value[index];
|
|
798
|
-
if (char === '。' || char === '?' || char === '!') {
|
|
799
|
-
count += 1;
|
|
800
|
-
continue;
|
|
801
|
-
}
|
|
802
|
-
if (char === '.' || char === '?' || char === '!') {
|
|
803
|
-
if (
|
|
804
|
-
char === '.'
|
|
805
|
-
&& /[0-9]/.test(value[index - 1] || '')
|
|
806
|
-
&& /[0-9]/.test(value[index + 1] || '')
|
|
807
|
-
) {
|
|
808
|
-
// Decimal point inside a number such as `1.23` is not a boundary.
|
|
809
|
-
continue;
|
|
810
|
-
}
|
|
811
|
-
count += 1;
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
return count;
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
function hasOriginalMarkerLine(text) {
|
|
818
|
-
return String(text ?? '')
|
|
819
|
-
.split('\n')
|
|
820
|
-
.some(line => /^\s*%\s*\[original\]\s*$/.test(line));
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
function hasLaterRevisedMarkerLine(text) {
|
|
824
|
-
const lines = String(text ?? '').split('\n');
|
|
825
|
-
const originalIndex = lines.findIndex(line => /^\s*%\s*\[original\]\s*$/.test(line));
|
|
826
|
-
if (originalIndex === -1) {
|
|
827
|
-
return false;
|
|
828
|
-
}
|
|
829
|
-
return lines.some((line, index) => (
|
|
830
|
-
index > originalIndex && /^\s*%\s*\[revised\]\s*$/.test(line)
|
|
831
|
-
));
|
|
832
|
-
}
|
|
833
|
-
|
|
834
789
|
function hasAnyAnnotatedMarker(text) {
|
|
835
790
|
return String(text ?? '')
|
|
836
791
|
.split('\n')
|
|
@@ -839,24 +794,6 @@ function hasAnyAnnotatedMarker(text) {
|
|
|
839
794
|
));
|
|
840
795
|
}
|
|
841
796
|
|
|
842
|
-
function splitParagraphs(text) {
|
|
843
|
-
const value = String(text ?? '');
|
|
844
|
-
const separator = /\n\s*\n/g;
|
|
845
|
-
const segments = [];
|
|
846
|
-
let lastIndex = 0;
|
|
847
|
-
let match = separator.exec(value);
|
|
848
|
-
|
|
849
|
-
while (match) {
|
|
850
|
-
segments.push({ text: value.slice(lastIndex, match.index), start: lastIndex });
|
|
851
|
-
segments.push({ text: match[0], start: match.index });
|
|
852
|
-
lastIndex = match.index + match[0].length;
|
|
853
|
-
match = separator.exec(value);
|
|
854
|
-
}
|
|
855
|
-
segments.push({ text: value.slice(lastIndex), start: lastIndex });
|
|
856
|
-
|
|
857
|
-
return segments;
|
|
858
|
-
}
|
|
859
|
-
|
|
860
797
|
// Lowercase abbreviations whose trailing `.` is conservatively NOT a sentence
|
|
861
798
|
// boundary. The word ending in the dot is matched case-insensitively, so this
|
|
862
799
|
// also covers `Fig.`, `Eq.`, `No.`, etc.
|
|
@@ -983,267 +920,355 @@ function splitSentences(text) {
|
|
|
983
920
|
}
|
|
984
921
|
|
|
985
922
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
const oldNonEmptyLineCount = countNonEmptyLines(group.oldText);
|
|
989
|
-
const newNonEmptyLineCount = countNonEmptyLines(group.newText);
|
|
990
|
-
return {
|
|
991
|
-
oldNonEmptyLineCount,
|
|
992
|
-
newNonEmptyLineCount,
|
|
993
|
-
maxNonEmptyLineCount: Math.max(oldNonEmptyLineCount, newNonEmptyLineCount),
|
|
994
|
-
changedSpanChars: Math.max(group.oldText.length, group.newText.length),
|
|
995
|
-
tokenPatchCount: Array.isArray(tokenPatches) ? tokenPatches.length : null,
|
|
996
|
-
totalTokenChangedChars: Array.isArray(tokenPatches)
|
|
997
|
-
? tokenPatches.reduce(
|
|
998
|
-
(sum, patch) => sum + Math.max(patch.expected.length, patch.insert.length),
|
|
999
|
-
0
|
|
1000
|
-
)
|
|
1001
|
-
: null,
|
|
1002
|
-
oldSentenceTerminatorCount: countSentenceTerminators(group.oldText),
|
|
1003
|
-
newSentenceTerminatorCount: countSentenceTerminators(group.newText)
|
|
1004
|
-
};
|
|
923
|
+
function singleGroupPatch(group) {
|
|
924
|
+
return [computeWordAlignedTextPatch(group.oldText, group.newText, group.oldStart)];
|
|
1005
925
|
}
|
|
1006
926
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
927
|
+
// Sentence alignment: the layer between line windows and token patches.
|
|
928
|
+
//
|
|
929
|
+
// A changed group is split into sentence spans on both sides
|
|
930
|
+
// (`splitSentences`, which partitions the text exactly). An LCS over the span
|
|
931
|
+
// texts keeps every unchanged sentence as an anchor. Inside each run between
|
|
932
|
+
// anchors, old and new sentences are paired by similarity
|
|
933
|
+
// (`pairSentencesBySimilarity`); every pair is patched by
|
|
934
|
+
// `computeSentencePairPatches`, sentences left without a partner on one side
|
|
935
|
+
// are inserted or deleted whole, and leftovers on both sides (a sentence that
|
|
936
|
+
// was split, merged, or rewritten beyond recognition) are patched as one run.
|
|
937
|
+
//
|
|
938
|
+
// Finally `mergeSemanticNeighbourPatches` joins adjacent results that are
|
|
939
|
+
// separated only by short whitespace / punctuation, so two rewritten
|
|
940
|
+
// neighbouring sentences surface as one tracked change.
|
|
941
|
+
//
|
|
942
|
+
// A group with one side empty (lines inserted or deleted) is a single patch.
|
|
943
|
+
// A group whose sentence count product exceeds the LCS budget is patched as
|
|
944
|
+
// one sentence pair instead.
|
|
945
|
+
const MAX_SENTENCE_ALIGN_PRODUCT = 4_000_000;
|
|
946
|
+
|
|
947
|
+
// Largest old×new sentence count of a run for which pairwise similarities are
|
|
948
|
+
// computed. Larger runs are patched as a single pair.
|
|
949
|
+
const MAX_SENTENCE_PAIRING_PRODUCT = 400;
|
|
950
|
+
|
|
951
|
+
// Fraction of a sentence that must have changed before the sentence is
|
|
952
|
+
// replaced as a whole instead of being patched word by word. Measured with
|
|
953
|
+
// trimmed edit sizes (`computeTrimmedChangeSize`), so `model` → `models`
|
|
954
|
+
// counts as one character, not six.
|
|
955
|
+
const SENTENCE_REWRITE_RATIO = 0.5;
|
|
956
|
+
|
|
957
|
+
// A sentence is also replaced as a whole when its edits are scattered: at
|
|
958
|
+
// least `SENTENCE_REWRITE_MIN_SITES` separate patches remain after
|
|
959
|
+
// coalescing and together they change at least
|
|
960
|
+
// `SENTENCE_REWRITE_SCATTERED_RATIO` of the sentence. Many word swaps spread
|
|
961
|
+
// over one sentence read as a retyped sentence, not as separate corrections;
|
|
962
|
+
// a handful of typo fixes (tiny mass) or a few word swaps stay word-level.
|
|
963
|
+
// Calibrated on a 37-word sentence: 6 swaps (21%) stay word-level, 9 swaps
|
|
964
|
+
// (30%) replace the sentence.
|
|
965
|
+
const SENTENCE_REWRITE_MIN_SITES = 3;
|
|
966
|
+
const SENTENCE_REWRITE_SCATTERED_RATIO = 0.25;
|
|
967
|
+
|
|
968
|
+
// Minimum similarity for two sentences to be treated as the same sentence
|
|
969
|
+
// edited: a sentence that changed by less than the rewrite threshold is still
|
|
970
|
+
// recognisably the same sentence.
|
|
971
|
+
const SENTENCE_PAIR_MIN_SIMILARITY = 1 - SENTENCE_REWRITE_RATIO;
|
|
972
|
+
|
|
973
|
+
function computeSentenceAlignedPatches(group) {
|
|
974
|
+
if (group.oldText.length === 0 || group.newText.length === 0) {
|
|
975
|
+
return singleGroupPatch(group);
|
|
1017
976
|
}
|
|
1018
977
|
|
|
1019
|
-
const
|
|
1020
|
-
|
|
978
|
+
const oldSpans = splitSentences(group.oldText);
|
|
979
|
+
const newSpans = splitSentences(group.newText);
|
|
980
|
+
if (oldSpans.length * newSpans.length > MAX_SENTENCE_ALIGN_PRODUCT) {
|
|
981
|
+
return computeSentencePairPatches(group);
|
|
982
|
+
}
|
|
1021
983
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
if (
|
|
1032
|
-
|
|
1033
|
-
// patch sitting on a span boundary). Not a confident single sentence.
|
|
1034
|
-
return empty;
|
|
984
|
+
const matches = computeNaturalLcsMatches(
|
|
985
|
+
oldSpans.map(span => span.text),
|
|
986
|
+
newSpans.map(span => span.text)
|
|
987
|
+
);
|
|
988
|
+
const patches = [];
|
|
989
|
+
let oldCursor = 0;
|
|
990
|
+
let newCursor = 0;
|
|
991
|
+
|
|
992
|
+
const appendRun = (oldTo, newTo) => {
|
|
993
|
+
if (oldCursor === oldTo && newCursor === newTo) {
|
|
994
|
+
return;
|
|
1035
995
|
}
|
|
1036
|
-
|
|
1037
|
-
|
|
996
|
+
patches.push(...computeSentenceRunPatches(
|
|
997
|
+
group,
|
|
998
|
+
oldSpans,
|
|
999
|
+
newSpans,
|
|
1000
|
+
oldCursor,
|
|
1001
|
+
oldTo,
|
|
1002
|
+
newCursor,
|
|
1003
|
+
newTo
|
|
1004
|
+
));
|
|
1005
|
+
};
|
|
1038
1006
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1007
|
+
for (const match of matches) {
|
|
1008
|
+
appendRun(match.oldIndex, match.newIndex);
|
|
1009
|
+
oldCursor = match.oldIndex + 1;
|
|
1010
|
+
newCursor = match.newIndex + 1;
|
|
1041
1011
|
}
|
|
1042
|
-
|
|
1043
|
-
fitsOneSpan: true,
|
|
1044
|
-
spanChars: containingSpan.text.length,
|
|
1045
|
-
spanTokenCount: splitTextTokens(containingSpan.text).length,
|
|
1046
|
-
spanStart: containingSpan.start,
|
|
1047
|
-
spanEnd: containingSpan.end
|
|
1048
|
-
};
|
|
1049
|
-
}
|
|
1012
|
+
appendRun(oldSpans.length, newSpans.length);
|
|
1050
1013
|
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
// `group` is `{oldStart, oldText, newText}`; `tokenPatches` is the array from
|
|
1054
|
-
// `computeTokenAnchoredPatches(group.oldText, group.newText, group.oldStart)`
|
|
1055
|
-
// or `null`; `metrics` is the object from `computeGroupMetrics(group,
|
|
1056
|
-
// tokenPatches)`.
|
|
1057
|
-
//
|
|
1058
|
-
// Returns `{type}` where `type` is one of `annotated_block`,
|
|
1059
|
-
// `paragraph_rewrite`, `sentence_rewrite`, `small_edit`, `fallback`. The
|
|
1060
|
-
// predicates are evaluated in first-match order: annotated_block →
|
|
1061
|
-
// paragraph_rewrite → sentence_rewrite → small_edit → fallback. When
|
|
1062
|
-
// `tokenPatches === null`, every token-dependent predicate is false, so the
|
|
1063
|
-
// only reachable results are `annotated_block`, `paragraph_rewrite` (via the
|
|
1064
|
-
// line-count or sentence-terminator branch), and `fallback`.
|
|
1014
|
+
return mergeSemanticNeighbourPatches(group, patches);
|
|
1015
|
+
}
|
|
1065
1016
|
|
|
1017
|
+
function resolveSpanBoundary(spans, index, textLength) {
|
|
1018
|
+
return index < spans.length ? spans[index].start : textLength;
|
|
1019
|
+
}
|
|
1066
1020
|
|
|
1021
|
+
// The sub-group covering old spans `[oldFrom, oldTo)` and new spans
|
|
1022
|
+
// `[newFrom, newTo)`, with an absolute `oldStart`.
|
|
1023
|
+
function resolveSentenceRun(group, oldSpans, newSpans, oldFrom, oldTo, newFrom, newTo) {
|
|
1024
|
+
const oldStart = resolveSpanBoundary(oldSpans, oldFrom, group.oldText.length);
|
|
1025
|
+
const oldEnd = resolveSpanBoundary(oldSpans, oldTo, group.oldText.length);
|
|
1026
|
+
const newStart = resolveSpanBoundary(newSpans, newFrom, group.newText.length);
|
|
1027
|
+
const newEnd = resolveSpanBoundary(newSpans, newTo, group.newText.length);
|
|
1028
|
+
return {
|
|
1029
|
+
oldStart: group.oldStart + oldStart,
|
|
1030
|
+
oldText: group.oldText.slice(oldStart, oldEnd),
|
|
1031
|
+
newText: group.newText.slice(newStart, newEnd)
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1067
1034
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
0
|
|
1082
|
-
)
|
|
1083
|
-
: null;
|
|
1084
|
-
const editDensity = hasTokenPatches
|
|
1085
|
-
? metrics.totalTokenChangedChars / Math.max(1, metrics.changedSpanChars)
|
|
1086
|
-
: null;
|
|
1087
|
-
const sentenceSpan = hasTokenPatches
|
|
1088
|
-
? resolveTokenPatchSentenceSpan(group, tokenPatches)
|
|
1089
|
-
: null;
|
|
1090
|
-
const sentenceEditDensity = sentenceSpan?.fitsOneSpan
|
|
1091
|
-
? metrics.totalTokenChangedChars / Math.max(1, sentenceSpan.spanChars)
|
|
1092
|
-
: null;
|
|
1093
|
-
|
|
1094
|
-
const isDenseTokenRewrite = hasTokenPatches
|
|
1095
|
-
&& metrics.tokenPatchCount >= 6
|
|
1096
|
-
&& metrics.changedSpanChars >= 160
|
|
1097
|
-
&& editDensity >= 0.20
|
|
1098
|
-
&& metrics.tokenPatchCount / Math.max(1, metrics.maxNonEmptyLineCount) >= 2;
|
|
1099
|
-
if (isDenseTokenRewrite) {
|
|
1100
|
-
return { type: 'paragraph_rewrite' };
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
// A dense rewrite wholly contained in one confident sentence stays a
|
|
1104
|
-
// sentence patch even when the surrounding line contains unchanged prose.
|
|
1105
|
-
if (
|
|
1106
|
-
hasTokenPatches
|
|
1107
|
-
&& metrics.tokenPatchCount >= 3
|
|
1108
|
-
&& sentenceSpan.fitsOneSpan
|
|
1109
|
-
&& sentenceSpan.spanChars >= 80
|
|
1110
|
-
&& sentenceEditDensity >= 0.20
|
|
1111
|
-
) {
|
|
1112
|
-
return { type: 'sentence_rewrite' };
|
|
1035
|
+
// Patches a run of sentences that has no exact anchor inside it.
|
|
1036
|
+
function computeSentenceRunPatches(
|
|
1037
|
+
group,
|
|
1038
|
+
oldSpans,
|
|
1039
|
+
newSpans,
|
|
1040
|
+
oldFrom,
|
|
1041
|
+
oldTo,
|
|
1042
|
+
newFrom,
|
|
1043
|
+
newTo
|
|
1044
|
+
) {
|
|
1045
|
+
const run = resolveSentenceRun(group, oldSpans, newSpans, oldFrom, oldTo, newFrom, newTo);
|
|
1046
|
+
if (run.oldText === run.newText) {
|
|
1047
|
+
return [];
|
|
1113
1048
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
if (
|
|
1118
|
-
hasTokenPatches
|
|
1119
|
-
&& maxTokenPatchChars <= 96
|
|
1120
|
-
&& editDensity < 0.20
|
|
1121
|
-
) {
|
|
1122
|
-
return { type: 'small_edit' };
|
|
1049
|
+
if (oldFrom === oldTo || newFrom === newTo) {
|
|
1050
|
+
// Whole sentences inserted or deleted.
|
|
1051
|
+
return singleGroupPatch(run);
|
|
1123
1052
|
}
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
return { type: 'paragraph_rewrite' };
|
|
1053
|
+
if ((oldTo - oldFrom) * (newTo - newFrom) > MAX_SENTENCE_PAIRING_PRODUCT) {
|
|
1054
|
+
return computeSentencePairPatches(run);
|
|
1127
1055
|
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
)
|
|
1133
|
-
|
|
1056
|
+
|
|
1057
|
+
// Segments partition the run in order: paired sentences and the leftovers
|
|
1058
|
+
// between them (one-sided = inserted/deleted sentences, two-sided =
|
|
1059
|
+
// sentences with no recognisable partner).
|
|
1060
|
+
const pairs = pairSentencesBySimilarity(oldSpans, newSpans, oldFrom, oldTo, newFrom, newTo);
|
|
1061
|
+
const segments = [];
|
|
1062
|
+
let oldCursor = oldFrom;
|
|
1063
|
+
let newCursor = newFrom;
|
|
1064
|
+
const pushLeftover = (oldEnd, newEnd) => {
|
|
1065
|
+
if (oldCursor !== oldEnd || newCursor !== newEnd) {
|
|
1066
|
+
segments.push({ oldFrom: oldCursor, oldTo: oldEnd, newFrom: newCursor, newTo: newEnd, paired: false });
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
for (const pair of pairs) {
|
|
1070
|
+
pushLeftover(pair.oldIndex, pair.newIndex);
|
|
1071
|
+
segments.push({
|
|
1072
|
+
oldFrom: pair.oldIndex,
|
|
1073
|
+
oldTo: pair.oldIndex + 1,
|
|
1074
|
+
newFrom: pair.newIndex,
|
|
1075
|
+
newTo: pair.newIndex + 1,
|
|
1076
|
+
paired: true
|
|
1077
|
+
});
|
|
1078
|
+
oldCursor = pair.oldIndex + 1;
|
|
1079
|
+
newCursor = pair.newIndex + 1;
|
|
1134
1080
|
}
|
|
1081
|
+
pushLeftover(oldTo, newTo);
|
|
1082
|
+
absorbSplitSentences(group, oldSpans, newSpans, segments);
|
|
1135
1083
|
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1084
|
+
const patches = [];
|
|
1085
|
+
for (const segment of segments) {
|
|
1086
|
+
const segmentRun = resolveSentenceRun(
|
|
1087
|
+
group,
|
|
1088
|
+
oldSpans,
|
|
1089
|
+
newSpans,
|
|
1090
|
+
segment.oldFrom,
|
|
1091
|
+
segment.oldTo,
|
|
1092
|
+
segment.newFrom,
|
|
1093
|
+
segment.newTo
|
|
1094
|
+
);
|
|
1095
|
+
if (segmentRun.oldText === segmentRun.newText) {
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
if (segment.oldFrom === segment.oldTo || segment.newFrom === segment.newTo) {
|
|
1099
|
+
patches.push(...singleGroupPatch(segmentRun));
|
|
1100
|
+
} else {
|
|
1101
|
+
patches.push(...computeSentencePairPatches(segmentRun));
|
|
1102
|
+
}
|
|
1143
1103
|
}
|
|
1104
|
+
return patches;
|
|
1105
|
+
}
|
|
1144
1106
|
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1107
|
+
// A sentence that was split in two, or two sentences merged into one, shows
|
|
1108
|
+
// up as a pair next to a one-sided leftover. Such a leftover is absorbed into
|
|
1109
|
+
// a neighbouring pair when the combined pair still clears
|
|
1110
|
+
// `SENTENCE_PAIR_MIN_SIMILARITY`, i.e. still reads as the same sentence with
|
|
1111
|
+
// a local edit; when both neighbours qualify, the one that explains the
|
|
1112
|
+
// leftover with fewer changed characters wins. Genuine insertions and
|
|
1113
|
+
// deletions fail the similarity test and stay separate. Mutates `segments`.
|
|
1114
|
+
function absorbSplitSentences(group, oldSpans, newSpans, segments) {
|
|
1115
|
+
const isOneSided = segment => segment.oldFrom === segment.oldTo || segment.newFrom === segment.newTo;
|
|
1116
|
+
// Change mass of the pair that would result from merging `first` and
|
|
1117
|
+
// `second`, or `null` when that pair would no longer read as one sentence.
|
|
1118
|
+
const combinedCost = (first, second) => {
|
|
1119
|
+
const run = resolveSentenceRun(
|
|
1120
|
+
group,
|
|
1121
|
+
oldSpans,
|
|
1122
|
+
newSpans,
|
|
1123
|
+
first.oldFrom,
|
|
1124
|
+
second.oldTo,
|
|
1125
|
+
first.newFrom,
|
|
1126
|
+
second.newTo
|
|
1127
|
+
);
|
|
1128
|
+
const changeMass = computeSentenceChangeMass(run.oldText, run.newText);
|
|
1129
|
+
const similarity = 1 - Math.min(
|
|
1130
|
+
1,
|
|
1131
|
+
changeMass / Math.max(run.oldText.length, run.newText.length, 1)
|
|
1132
|
+
);
|
|
1133
|
+
return similarity >= SENTENCE_PAIR_MIN_SIMILARITY ? changeMass : null;
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
1137
|
+
const segment = segments[index];
|
|
1138
|
+
if (!isOneSided(segment)) {
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
const previous = segments[index - 1];
|
|
1142
|
+
const next = segments[index + 1];
|
|
1143
|
+
const previousCost = previous?.paired ? combinedCost(previous, segment) : null;
|
|
1144
|
+
const nextCost = next?.paired ? combinedCost(segment, next) : null;
|
|
1145
|
+
if (previousCost === null && nextCost === null) {
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
if (nextCost !== null && (previousCost === null || nextCost <= previousCost)) {
|
|
1149
|
+
next.oldFrom = segment.oldFrom;
|
|
1150
|
+
next.newFrom = segment.newFrom;
|
|
1151
|
+
} else {
|
|
1152
|
+
previous.oldTo = segment.oldTo;
|
|
1153
|
+
previous.newTo = segment.newTo;
|
|
1154
|
+
}
|
|
1155
|
+
segments.splice(index, 1);
|
|
1156
|
+
index -= 1;
|
|
1153
1157
|
}
|
|
1158
|
+
}
|
|
1154
1159
|
|
|
1155
|
-
|
|
1160
|
+
// Summed trimmed size of the token patches between two texts: how many
|
|
1161
|
+
// characters actually have to change to turn one into the other.
|
|
1162
|
+
function computeSentenceChangeMass(oldText, newText) {
|
|
1163
|
+
if (oldText === newText) {
|
|
1164
|
+
return 0;
|
|
1165
|
+
}
|
|
1166
|
+
return computeTokenAnchoredPatches(oldText, newText, 0).reduce(
|
|
1167
|
+
(sum, patch) => sum + computeTrimmedChangeSize(patch),
|
|
1168
|
+
0
|
|
1169
|
+
);
|
|
1156
1170
|
}
|
|
1157
1171
|
|
|
1158
|
-
|
|
1159
|
-
|
|
1172
|
+
// `1 - changeMass / length`. Identical sentences score 1.
|
|
1173
|
+
function computeSentenceSimilarity(oldText, newText) {
|
|
1174
|
+
const changeMass = computeSentenceChangeMass(oldText, newText);
|
|
1175
|
+
return 1 - Math.min(1, changeMass / Math.max(oldText.length, newText.length, 1));
|
|
1160
1176
|
}
|
|
1161
1177
|
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
if (oldSegments.length !== newSegments.length) {
|
|
1179
|
-
return null;
|
|
1180
|
-
}
|
|
1181
|
-
// splitParagraphs always yields an odd count: content at even indices,
|
|
1182
|
-
// blank-line separators at odd indices. Every separator must be unchanged
|
|
1183
|
-
// for positional pairing of the content paragraphs to be sound.
|
|
1184
|
-
for (let index = 1; index < oldSegments.length; index += 2) {
|
|
1185
|
-
if (oldSegments[index].text !== newSegments[index].text) {
|
|
1186
|
-
return null;
|
|
1178
|
+
// Monotonic pairing of old spans `[oldFrom, oldTo)` with new spans
|
|
1179
|
+
// `[newFrom, newTo)` that maximises the summed similarity of the pairs, only
|
|
1180
|
+
// allowing pairs at or above `SENTENCE_PAIR_MIN_SIMILARITY`. Returns
|
|
1181
|
+
// `[{oldIndex, newIndex}]` in ascending order.
|
|
1182
|
+
function pairSentencesBySimilarity(oldSpans, newSpans, oldFrom, oldTo, newFrom, newTo) {
|
|
1183
|
+
const oldCount = oldTo - oldFrom;
|
|
1184
|
+
const newCount = newTo - newFrom;
|
|
1185
|
+
const threshold = SENTENCE_PAIR_MIN_SIMILARITY;
|
|
1186
|
+
const similarity = new Float64Array(oldCount * newCount);
|
|
1187
|
+
for (let i = 0; i < oldCount; i += 1) {
|
|
1188
|
+
for (let j = 0; j < newCount; j += 1) {
|
|
1189
|
+
similarity[i * newCount + j] = computeSentenceSimilarity(
|
|
1190
|
+
oldSpans[oldFrom + i].text,
|
|
1191
|
+
newSpans[newFrom + j].text
|
|
1192
|
+
);
|
|
1187
1193
|
}
|
|
1188
1194
|
}
|
|
1189
1195
|
|
|
1190
|
-
const
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
+
const width = newCount + 1;
|
|
1197
|
+
const score = new Float64Array((oldCount + 1) * width);
|
|
1198
|
+
for (let i = 1; i <= oldCount; i += 1) {
|
|
1199
|
+
for (let j = 1; j <= newCount; j += 1) {
|
|
1200
|
+
let best = Math.max(score[(i - 1) * width + j], score[i * width + j - 1]);
|
|
1201
|
+
const pairScore = similarity[(i - 1) * newCount + (j - 1)];
|
|
1202
|
+
if (pairScore >= threshold) {
|
|
1203
|
+
best = Math.max(best, score[(i - 1) * width + (j - 1)] + pairScore);
|
|
1204
|
+
}
|
|
1205
|
+
score[i * width + j] = best;
|
|
1196
1206
|
}
|
|
1197
|
-
patches.push(computeSingleTextPatch(
|
|
1198
|
-
oldParagraph.text,
|
|
1199
|
-
newParagraph.text,
|
|
1200
|
-
group.oldStart + oldParagraph.start
|
|
1201
|
-
));
|
|
1202
1207
|
}
|
|
1203
|
-
|
|
1208
|
+
|
|
1209
|
+
const pairs = [];
|
|
1210
|
+
let i = oldCount;
|
|
1211
|
+
let j = newCount;
|
|
1212
|
+
while (i > 0 && j > 0) {
|
|
1213
|
+
const cell = score[i * width + j];
|
|
1214
|
+
const pairScore = similarity[(i - 1) * newCount + (j - 1)];
|
|
1215
|
+
if (
|
|
1216
|
+
pairScore >= threshold
|
|
1217
|
+
&& cell === score[(i - 1) * width + (j - 1)] + pairScore
|
|
1218
|
+
) {
|
|
1219
|
+
pairs.push({ oldIndex: oldFrom + i - 1, newIndex: newFrom + j - 1 });
|
|
1220
|
+
i -= 1;
|
|
1221
|
+
j -= 1;
|
|
1222
|
+
} else if (cell === score[(i - 1) * width + j]) {
|
|
1223
|
+
i -= 1;
|
|
1224
|
+
} else {
|
|
1225
|
+
j -= 1;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return pairs.reverse();
|
|
1204
1229
|
}
|
|
1205
1230
|
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1208
|
-
//
|
|
1209
|
-
// `
|
|
1210
|
-
// `
|
|
1211
|
-
//
|
|
1212
|
-
//
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
const
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1231
|
+
// Patches one old/new sentence pair. Token patches are computed inside the
|
|
1232
|
+
// pair and coalesced; the pair becomes a single word-aligned replacement (a
|
|
1233
|
+
// human would have retyped the sentence) when the trimmed change mass reaches
|
|
1234
|
+
// `SENTENCE_REWRITE_RATIO` of the sentence, or when the edits are scattered
|
|
1235
|
+
// over `SENTENCE_REWRITE_MIN_SITES` or more sites that together reach
|
|
1236
|
+
// `SENTENCE_REWRITE_SCATTERED_RATIO`. Otherwise the coalesced token patches
|
|
1237
|
+
// are returned.
|
|
1238
|
+
function computeSentencePairPatches(pair) {
|
|
1239
|
+
const tokenPatches = computeTokenAnchoredPatches(
|
|
1240
|
+
pair.oldText,
|
|
1241
|
+
pair.newText,
|
|
1242
|
+
pair.oldStart
|
|
1243
|
+
);
|
|
1244
|
+
if (tokenPatches.length === 0) {
|
|
1245
|
+
return singleGroupPatch(pair);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
const changeMass = tokenPatches.reduce(
|
|
1249
|
+
(sum, patch) => sum + computeTrimmedChangeSize(patch),
|
|
1250
|
+
0
|
|
1251
|
+
);
|
|
1252
|
+
const changeRatio = changeMass / Math.max(pair.oldText.length, pair.newText.length, 1);
|
|
1253
|
+
if (changeRatio >= SENTENCE_REWRITE_RATIO) {
|
|
1254
|
+
return singleGroupPatch(pair);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const coalesced = coalesceTokenPatches(pair, tokenPatches);
|
|
1230
1258
|
if (
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|| group.newText.length < oldPrefix.length + oldSuffix.length
|
|
1259
|
+
coalesced.length >= SENTENCE_REWRITE_MIN_SITES
|
|
1260
|
+
&& changeRatio >= SENTENCE_REWRITE_SCATTERED_RATIO
|
|
1234
1261
|
) {
|
|
1235
|
-
return
|
|
1262
|
+
return singleGroupPatch(pair);
|
|
1236
1263
|
}
|
|
1264
|
+
return coalesced;
|
|
1265
|
+
}
|
|
1237
1266
|
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
);
|
|
1242
|
-
return
|
|
1243
|
-
oldSentence,
|
|
1244
|
-
newSentence,
|
|
1245
|
-
group.oldStart + spanStart
|
|
1246
|
-
)];
|
|
1267
|
+
// Size of the part of a patch that actually differs, after trimming the
|
|
1268
|
+
// common prefix and suffix of `expected` / `insert`.
|
|
1269
|
+
function computeTrimmedChangeSize(patch) {
|
|
1270
|
+
const trimmed = computeSingleTextPatch(patch.expected, patch.insert);
|
|
1271
|
+
return Math.max(trimmed.expected.length, trimmed.insert.length);
|
|
1247
1272
|
}
|
|
1248
1273
|
|
|
1249
1274
|
// Short function words whose presence inside a coalescing gap does not block a
|
|
@@ -1267,7 +1292,7 @@ function isCoalesceFillerGap(gap) {
|
|
|
1267
1292
|
if (/^\s+$/.test(text)) {
|
|
1268
1293
|
continue;
|
|
1269
1294
|
}
|
|
1270
|
-
if (!/[
|
|
1295
|
+
if (!/[\p{L}\p{N}]/u.test(text)) {
|
|
1271
1296
|
// Pure punctuation / symbols.
|
|
1272
1297
|
continue;
|
|
1273
1298
|
}
|
|
@@ -1287,6 +1312,11 @@ function isCoalesceFillerGap(gap) {
|
|
|
1287
1312
|
// `expected` the original slice and `insert` the merged inserts interleaved
|
|
1288
1313
|
// with the unchanged gap text. When nothing qualifies the token patches are
|
|
1289
1314
|
// returned unchanged. Absolute offsets are preserved throughout.
|
|
1315
|
+
//
|
|
1316
|
+
// The run-based pass is followed by `mergeSemanticNeighbourPatches`, which
|
|
1317
|
+
// joins any two remaining neighbours whose unchanged gap is pure whitespace /
|
|
1318
|
+
// punctuation and no longer than either edit, so one continuous rewrite
|
|
1319
|
+
// surfaces as a single tracked change.
|
|
1290
1320
|
|
|
1291
1321
|
function coalesceTokenPatches(group, tokenPatches) {
|
|
1292
1322
|
if (!Array.isArray(tokenPatches) || tokenPatches.length <= 1) {
|
|
@@ -1328,7 +1358,78 @@ function coalesceTokenPatches(group, tokenPatches) {
|
|
|
1328
1358
|
}
|
|
1329
1359
|
}
|
|
1330
1360
|
flushRun();
|
|
1331
|
-
return result;
|
|
1361
|
+
return mergeSemanticNeighbourPatches(group, result);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// Semantic neighbour merge, using the diff-match-patch `diff_cleanupSemantic`
|
|
1365
|
+
// criterion: an unchanged stretch that is shorter than the edits on both sides
|
|
1366
|
+
// of it is noise, not an anchor. Two neighbouring patches (sorted by `from`)
|
|
1367
|
+
// are merged when the original text between them is at most 40 chars made
|
|
1368
|
+
// only of whitespace and punctuation (no letters, digits, or line breaks) and
|
|
1369
|
+
// is no longer than the smaller of the two edits, where an edit's size is
|
|
1370
|
+
// `max(expected.length, insert.length)`. The gap is kept verbatim in both
|
|
1371
|
+
// `expected` and `insert`, so the bytes written do not change. Merging chains:
|
|
1372
|
+
// the merged patch is compared with the next neighbour. Gaps that contain a
|
|
1373
|
+
// line break never merge, preserving the line-window split.
|
|
1374
|
+
function mergeSemanticNeighbourPatches(group, patches) {
|
|
1375
|
+
if (!Array.isArray(patches) || patches.length <= 1) {
|
|
1376
|
+
return patches;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const merged = [patches[0]];
|
|
1380
|
+
for (let index = 1; index < patches.length; index += 1) {
|
|
1381
|
+
const previous = merged[merged.length - 1];
|
|
1382
|
+
const current = patches[index];
|
|
1383
|
+
if (current.from < previous.to) {
|
|
1384
|
+
merged.push(current);
|
|
1385
|
+
continue;
|
|
1386
|
+
}
|
|
1387
|
+
const gap = group.oldText.slice(
|
|
1388
|
+
previous.to - group.oldStart,
|
|
1389
|
+
current.from - group.oldStart
|
|
1390
|
+
);
|
|
1391
|
+
if (isSemanticNoiseGap(gap, previous, current)) {
|
|
1392
|
+
merged[merged.length - 1] = mergeTokenPatchRun(group, [previous, current]);
|
|
1393
|
+
} else {
|
|
1394
|
+
merged.push(current);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
return merged;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Largest trimmed edit that still counts as a "small" edit for the sentence
|
|
1401
|
+
// boundary rule below: a word or two, not a rewritten clause.
|
|
1402
|
+
const SMALL_EDIT_CHARS = 24;
|
|
1403
|
+
|
|
1404
|
+
function isSemanticNoiseGap(gap, previous, current) {
|
|
1405
|
+
if (gap.length > 40 || /[\r\n]/.test(gap)) {
|
|
1406
|
+
return false;
|
|
1407
|
+
}
|
|
1408
|
+
if (!/^[\s\p{P}\p{S}]*$/u.test(gap)) {
|
|
1409
|
+
return false;
|
|
1410
|
+
}
|
|
1411
|
+
const previousSize = Math.max(previous.expected.length, previous.insert.length);
|
|
1412
|
+
const currentSize = Math.max(current.expected.length, current.insert.length);
|
|
1413
|
+
if (gap.length > Math.min(previousSize, currentSize)) {
|
|
1414
|
+
return false;
|
|
1415
|
+
}
|
|
1416
|
+
// The last word of one sentence and the first word of the next are two
|
|
1417
|
+
// edits, not one; only rewrites large enough to be whole clauses merge
|
|
1418
|
+
// across a sentence boundary.
|
|
1419
|
+
if (
|
|
1420
|
+
isSentenceBoundaryGap(gap)
|
|
1421
|
+
&& Math.min(computeTrimmedChangeSize(previous), computeTrimmedChangeSize(current)) <= SMALL_EDIT_CHARS
|
|
1422
|
+
) {
|
|
1423
|
+
return false;
|
|
1424
|
+
}
|
|
1425
|
+
return true;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// True when the unchanged text between two patches ends a sentence: a CJK
|
|
1429
|
+
// terminator anywhere, or an ASCII terminator followed by whitespace (so the
|
|
1430
|
+
// `.` inside `91.2` does not count).
|
|
1431
|
+
function isSentenceBoundaryGap(gap) {
|
|
1432
|
+
return /[。?!]/.test(gap) || /[.?!]\s/.test(gap);
|
|
1332
1433
|
}
|
|
1333
1434
|
|
|
1334
1435
|
function computeNaturalPatchRunDensity(group, run) {
|
|
@@ -1370,19 +1471,14 @@ function mergeTokenPatchRun(group, run) {
|
|
|
1370
1471
|
module.exports = {
|
|
1371
1472
|
computeTextPatches,
|
|
1372
1473
|
computeSingleTextPatch,
|
|
1474
|
+
computeWordAlignedTextPatch,
|
|
1475
|
+
computeSentenceAlignedPatches,
|
|
1476
|
+
computeSentencePairPatches,
|
|
1477
|
+
computeTrimmedChangeSize,
|
|
1373
1478
|
computeLineAnchoredChangeGroups,
|
|
1374
1479
|
computeTokenAnchoredPatches,
|
|
1375
|
-
computeGroupMetrics,
|
|
1376
|
-
classifyChangedGroup,
|
|
1377
|
-
splitParagraphs,
|
|
1378
1480
|
splitSentences,
|
|
1379
|
-
hasOriginalMarkerLine,
|
|
1380
|
-
hasLaterRevisedMarkerLine,
|
|
1381
1481
|
hasAnyAnnotatedMarker,
|
|
1382
|
-
countNonEmptyLines,
|
|
1383
|
-
countSentenceTerminators,
|
|
1384
1482
|
singleGroupPatch,
|
|
1385
|
-
computeParagraphPatches,
|
|
1386
|
-
computeSentencePatches,
|
|
1387
1483
|
coalesceTokenPatches
|
|
1388
1484
|
};
|