overleaf-review 0.4.0 → 0.5.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 +105 -1
- package/dist/cli.js +714 -193
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -128,8 +128,10 @@ first safe push. If an upgraded repository already has unpushed edits, copy or s
|
|
|
128
128
|
| `pull [--out <dir>]` | Read comments + tracked changes into a sidecar |
|
|
129
129
|
| `fetch [--file <f>] [--dry-run]` | Write Overleaf text locally, snapshot replaced files, and save Base (read-only on Overleaf) |
|
|
130
130
|
| `review start [--file <f>] [--out <dir>]` | Fetch text, save the synchronization base, and pull the review sidecar |
|
|
131
|
-
| `review plan --out <plan.json> [--file <f>] [--doc <name>] [--direct] [--allow-overlap] [--unsafe-no-base]` | Create a complete binding plan without changing Overleaf |
|
|
131
|
+
| `review plan --out <plan.json> [--file <f>] [--edits <blocks.json>] [--doc <name>] [--direct] [--allow-overlap] [--unsafe-no-base]` | Create a complete binding plan without changing Overleaf |
|
|
132
132
|
| `review submit --plan <plan.json> [--acknowledge-ambiguous]` | Submit that plan only if all recorded preconditions still match |
|
|
133
|
+
| `review consolidate --doc <path> --author <user-id> --out <preview.json> [--change <id> …]` | Preview consolidation of existing suggestions and save a full backup; no Overleaf changes |
|
|
134
|
+
| `review consolidate --apply --plan <plan.json>` | Apply and verify a checked consolidation plan for your own suggestions |
|
|
133
135
|
| `upload <path…> [--folder <name>]` | Upload figures / new files into Overleaf |
|
|
134
136
|
| `push [--file <f>] [--doc <name>] [--direct] [--dry-run] [--plan-out <path>] [--plan <path>] [--allow-overlap] [--unsafe-no-base] [--acknowledge-ambiguous]` | Safely merge and send local edits (all changed `.tex` if no `--file`) |
|
|
135
137
|
| `comment --anchor <text> --message <text> [--doc <name>] [--nth <n>] [--force]` | Add an anchored comment; recent identical retries are skipped unless forced |
|
|
@@ -155,6 +157,108 @@ overlapping Base→Local and Base→Live edits abort instead of silently undoing
|
|
|
155
157
|
work. A proposed edit that intersects an active tracked-change range is also blocked by default and
|
|
156
158
|
the relevant change ids are listed.
|
|
157
159
|
|
|
160
|
+
For intentional sentence or paragraph rewrites, agents should preserve their chosen edit boundaries
|
|
161
|
+
with `--edits`, rather than relying on automatic diff grouping. Edit the local file normally, then
|
|
162
|
+
describe every change in a JSON manifest:
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
{
|
|
166
|
+
"replacements": [
|
|
167
|
+
{
|
|
168
|
+
"before": "This is the old paragraph. Its explanation is unclear.",
|
|
169
|
+
"after": "This is the revised paragraph. Its explanation is clearer."
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
overleaf-review review plan --file main.tex --edits blocks.json --out plan.json
|
|
177
|
+
overleaf-review review submit --plan plan.json
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Each block becomes one deletion and/or insertion, even across multiple sentences or LaTeX markup.
|
|
181
|
+
Overleaf controls the resulting review UI; a replacement is generally two tracked ranges, not a
|
|
182
|
+
guaranteed single accept/reject button. Keep typo corrections small, group a coherent rewrite,
|
|
183
|
+
and keep independent decisions separate. Author attribution remains unchanged.
|
|
184
|
+
|
|
185
|
+
`before` must match the saved Base exactly. Repeated text requires a 1-based `occurrence`.
|
|
186
|
+
Blocks must be separated and must explain the entire local diff. The plan embeds the resolved
|
|
187
|
+
blocks; it never rereads or silently reinterprets the manifest during submission. Concurrent edits
|
|
188
|
+
outside blocks are preserved; any comment, pending suggestion, or concurrent edit touching a block
|
|
189
|
+
stops planning rather than silently fragmenting it. Narrow the block or resolve the conflict.
|
|
190
|
+
`--edits` requires `--file` and a saved base; it cannot be combined with `--direct` or `--unsafe-no-base`.
|
|
191
|
+
|
|
192
|
+
Without explicit blocks, tracked suggestions group nearby word edits into phrase replacements. For example, rewriting
|
|
193
|
+
“old model predicts low” as “revised model explains high” produces one deletion and one insertion,
|
|
194
|
+
instead of six separate word operations. Isolated corrections stay small. Grouping bridges at most
|
|
195
|
+
three unchanged words (40 characters), limits combined spans to 320 characters, and stops at detected
|
|
196
|
+
sentence/clause boundaries, paragraph breaks, and LaTeX markup. This is a deterministic readability
|
|
197
|
+
heuristic; it does not infer which scientific claims should be accepted together. An existing
|
|
198
|
+
multiword insertion remains a single insertion.
|
|
199
|
+
|
|
200
|
+
Unchanged comment anchors, tracked ranges, and edits made on Live since Base prevent grouping across
|
|
201
|
+
them. Overlap checks use the full grouped footprint, and submission reproduces the same grouping.
|
|
202
|
+
`--direct` retains the narrower word-level operations. Plans from before this grouping change must
|
|
203
|
+
be regenerated; submitting an old plan does not silently regroup its approved operations.
|
|
204
|
+
|
|
205
|
+
Planning and submission check a conservative range budget: existing tracked ranges plus proposed
|
|
206
|
+
insert/delete operations must not exceed 2,000 per document. The preview shows both counts.
|
|
207
|
+
Overlap transformations can alter the actual count, so this is a preflight guard rather than an
|
|
208
|
+
exact prediction. Splitting a revision into batches does not remove accumulated pending ranges.
|
|
209
|
+
Grouping applies to new pushes; it does not consolidate or accept suggestions already on Overleaf.
|
|
210
|
+
|
|
211
|
+
### Consolidation of existing suggestions
|
|
212
|
+
|
|
213
|
+
`review consolidate --doc main.tex --author <user-id> --out .overleaf/consolidation.json`
|
|
214
|
+
captures the live text, all document ranges, and project comment threads. It reconstructs the text
|
|
215
|
+
with that author's selected suggestions rejected, then plans a grouped revision back to the current
|
|
216
|
+
proposed text. By default it considers all current suggestions by that author, including older
|
|
217
|
+
fragments; repeat `--change` to select a subset. Use the author user ID from range metadata.
|
|
218
|
+
|
|
219
|
+
The artifact records both text hashes, a reverse/forward reconstruction proof, projected range
|
|
220
|
+
counts, and any comment or unselected-suggestion blockers. It preserves the *current pending
|
|
221
|
+
proposal*, not a historical review state before older suggestions were absorbed. Consolidation
|
|
222
|
+
would assign new IDs and timestamps. Counts remain projections until server transformations are
|
|
223
|
+
verified. Touching comment anchors or unselected changes blocks the candidate; these are not
|
|
224
|
+
automatically removed or accepted.
|
|
225
|
+
|
|
226
|
+
For offline analysis, use `--snapshot <file.json>` instead of `--doc`. The snapshot must contain
|
|
227
|
+
`projectId`, `docId`, `docPath`, `version`, `text`, `ranges: { changes, comments }`, and `threads`;
|
|
228
|
+
the preview embeds this complete object as `backup`. A normal review sidecar alone is insufficient.
|
|
229
|
+
|
|
230
|
+
Planning sends nothing. Inspect the complete plan, then apply it separately:
|
|
231
|
+
|
|
232
|
+
```sh
|
|
233
|
+
overleaf-review review consolidate --apply --plan .overleaf/consolidation.json
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Only suggestions owned by the authenticated account can be reapplied. Blocked/no-reduction plans
|
|
237
|
+
cannot be submitted. The command reproduces the plan from its backup and requires unchanged text,
|
|
238
|
+
document version, ranges, and project threads. It journals the full snapshot before sending undo
|
|
239
|
+
and reapply operations together in one update. It never accepts suggestions as a prerequisite.
|
|
240
|
+
|
|
241
|
+
Verification checks the proposed text, text with selected suggestions rejected, text with all
|
|
242
|
+
suggestions rejected, disappearance of old IDs, new-ID attribution, reduced range count, and
|
|
243
|
+
unchanged unselected ranges, comment anchors and thread messages. New IDs and timestamps are
|
|
244
|
+
expected. Any uncertain send or failed verification quarantines subsequent attempts: inspect the
|
|
245
|
+
receipt and live document before manually reconciling it; there is no automatic rollback/retry.
|
|
246
|
+
Reusing an already successful plan reports its receipt without sending it again.
|
|
247
|
+
|
|
248
|
+
Coordinate a quiet editing window for consolidation. Overleaf has no conditional transaction
|
|
249
|
+
covering text, ranges and threads, so preflight cannot prevent every last-moment collaborator race;
|
|
250
|
+
readback detects discrepancies but cannot make an already-sent operation un-happen. A backup is
|
|
251
|
+
recovery evidence, not a promise of automatic review-history restoration.
|
|
252
|
+
|
|
253
|
+
Development check `npm run probe:consolidation-model` exercises pinned upstream range logic in
|
|
254
|
+
memory without accessing an Overleaf project. The opt-in `npm run probe:review-live -- --project
|
|
255
|
+
<id> --confirm-test-project` creates and retains a new scratch document in the specified test
|
|
256
|
+
project, verifies consolidation and protected review state, and checks that existing documents
|
|
257
|
+
were unchanged. It does not require or spoof another account. Foreign-author preservation is also
|
|
258
|
+
covered by the upstream model check. Never run this probe against a manuscript project.
|
|
259
|
+
|
|
260
|
+
### Push overrides and submission
|
|
261
|
+
|
|
158
262
|
Use `--allow-overlap` only after inspecting those tracked changes. `--unsafe-no-base` is a deliberate
|
|
159
263
|
legacy escape hatch that treats the current Live document as Base; it loses the protection against
|
|
160
264
|
co-author edits made since your local file was obtained. `--direct` changes how the validated ops
|
package/dist/cli.js
CHANGED
|
@@ -537,6 +537,7 @@ async function validateSession(baseUrl, session2) {
|
|
|
537
537
|
redirect: "follow"
|
|
538
538
|
});
|
|
539
539
|
const html = await res.text();
|
|
540
|
+
if (!res.ok) throw new Error(`Session validation failed (HTTP ${res.status}); log in again or check access.`);
|
|
540
541
|
const looksLikeLogin = res.url.includes("/login") || /name="ol-page"\s+content="login"/.test(html) || html.includes('id="loginForm"');
|
|
541
542
|
if (looksLikeLogin) {
|
|
542
543
|
throw new Error("Session cookie is invalid or expired (got the login page).");
|
|
@@ -544,6 +545,33 @@ async function validateSession(baseUrl, session2) {
|
|
|
544
545
|
const m = html.match(/name="ol-usersEmail"\s+content="([^"]+)"/) ?? html.match(/"email":"([^"@]+@[^"]+)"/);
|
|
545
546
|
return m ? m[1] : "your Overleaf account";
|
|
546
547
|
}
|
|
548
|
+
function accountIdFromSettings(html) {
|
|
549
|
+
const tag = html.match(/<meta\b[^>]*\bname=["']ol-user["'][^>]*>/i)?.[0];
|
|
550
|
+
const encoded = tag?.match(/\bcontent=(?:"([^"]*)"|'([^']*)')/i);
|
|
551
|
+
if (!encoded) throw new Error("Authenticated account ID not found; refusing author-sensitive mutation.");
|
|
552
|
+
const json = (encoded[1] ?? encoded[2]).replace(/&(?:quot|apos|amp|lt|gt|#\d+|#x[0-9a-f]+);/gi, (entity) => {
|
|
553
|
+
const named = { """: '"', "'": "'", "&": "&", "<": "<", ">": ">" };
|
|
554
|
+
if (named[entity.toLowerCase()]) return named[entity.toLowerCase()];
|
|
555
|
+
const hex = entity.toLowerCase().startsWith("&#x");
|
|
556
|
+
return String.fromCodePoint(parseInt(entity.slice(hex ? 3 : 2, -1), hex ? 16 : 10));
|
|
557
|
+
});
|
|
558
|
+
const user = JSON.parse(json);
|
|
559
|
+
const id = user?.id ?? user?._id;
|
|
560
|
+
if (user?.id && user?._id && user.id !== user._id) throw new Error("Conflicting authenticated account IDs.");
|
|
561
|
+
if (typeof id !== "string" || !/^[0-9a-f]{24}$/i.test(id)) {
|
|
562
|
+
throw new Error("Invalid authenticated account ID; refusing author-sensitive mutation.");
|
|
563
|
+
}
|
|
564
|
+
return id;
|
|
565
|
+
}
|
|
566
|
+
async function getAuthenticatedUserId() {
|
|
567
|
+
const res = await fetch(`${config.baseUrl}/user/settings`, {
|
|
568
|
+
headers: headers(),
|
|
569
|
+
redirect: "error",
|
|
570
|
+
signal: AbortSignal.timeout(15e3)
|
|
571
|
+
});
|
|
572
|
+
if (!res.ok) throw new Error(`Account verification failed (HTTP ${res.status}); refresh login.`);
|
|
573
|
+
return accountIdFromSettings(await res.text());
|
|
574
|
+
}
|
|
547
575
|
|
|
548
576
|
// src/lib/anchors.ts
|
|
549
577
|
function offsetToLine(lines, offset) {
|
|
@@ -852,8 +880,292 @@ import {
|
|
|
852
880
|
} from "fs";
|
|
853
881
|
import { createHash as createHash3 } from "crypto";
|
|
854
882
|
import { dirname as dirname5, relative as relative2, resolve as resolvePath, sep as sep2 } from "path";
|
|
883
|
+
|
|
884
|
+
// src/lib/review-edits.ts
|
|
855
885
|
import { diffWordsWithSpace } from "diff";
|
|
856
886
|
|
|
887
|
+
// src/lib/three-way.ts
|
|
888
|
+
import { diffChars } from "diff";
|
|
889
|
+
function textEdits(base, target) {
|
|
890
|
+
const edits = [];
|
|
891
|
+
let basePos = 0;
|
|
892
|
+
let pending;
|
|
893
|
+
const flush = () => {
|
|
894
|
+
if (!pending) return;
|
|
895
|
+
if (pending.start !== pending.end || pending.text.length) edits.push(pending);
|
|
896
|
+
pending = void 0;
|
|
897
|
+
};
|
|
898
|
+
for (const part of diffChars(base, target)) {
|
|
899
|
+
if (!part.added && !part.removed) {
|
|
900
|
+
flush();
|
|
901
|
+
basePos += part.value.length;
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
pending ??= { start: basePos, end: basePos, text: "" };
|
|
905
|
+
if (part.removed) {
|
|
906
|
+
pending.end += part.value.length;
|
|
907
|
+
basePos += part.value.length;
|
|
908
|
+
} else {
|
|
909
|
+
pending.text += part.value;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
flush();
|
|
913
|
+
return edits;
|
|
914
|
+
}
|
|
915
|
+
function sameEdit(a, b) {
|
|
916
|
+
return a.start === b.start && a.end === b.end && a.text === b.text;
|
|
917
|
+
}
|
|
918
|
+
function reverseCodePoints(text) {
|
|
919
|
+
return Array.from(text).reverse().join("");
|
|
920
|
+
}
|
|
921
|
+
function ambiguousEditAnchors(base, target) {
|
|
922
|
+
const forward = textEdits(base, target);
|
|
923
|
+
const reverse = textEdits(reverseCodePoints(base), reverseCodePoints(target)).map((edit) => ({
|
|
924
|
+
start: base.length - edit.end,
|
|
925
|
+
end: base.length - edit.start,
|
|
926
|
+
text: reverseCodePoints(edit.text)
|
|
927
|
+
})).sort((a, b) => a.start - b.start || a.end - b.end);
|
|
928
|
+
const ambiguities = [];
|
|
929
|
+
const count = Math.max(forward.length, reverse.length);
|
|
930
|
+
for (let index = 0; index < count; index++) {
|
|
931
|
+
const forwardEdit = forward[index] ?? reverse[index];
|
|
932
|
+
const reverseEdit = reverse[index] ?? forward[index];
|
|
933
|
+
if (sameEdit(forwardEdit, reverseEdit)) continue;
|
|
934
|
+
ambiguities.push({
|
|
935
|
+
forward: forwardEdit,
|
|
936
|
+
reverse: reverseEdit,
|
|
937
|
+
envelopeStart: Math.min(forwardEdit.start, reverseEdit.start),
|
|
938
|
+
envelopeEnd: Math.max(
|
|
939
|
+
forwardEdit.start,
|
|
940
|
+
forwardEdit.end,
|
|
941
|
+
reverseEdit.start,
|
|
942
|
+
reverseEdit.end
|
|
943
|
+
)
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
return ambiguities;
|
|
947
|
+
}
|
|
948
|
+
function editTouchesEnvelope(edit, start, end) {
|
|
949
|
+
if (edit.start === edit.end) return edit.start >= start && edit.start <= end;
|
|
950
|
+
return edit.start <= end && edit.end >= start;
|
|
951
|
+
}
|
|
952
|
+
function editsConflict(a, b) {
|
|
953
|
+
const aInsert = a.start === a.end;
|
|
954
|
+
const bInsert = b.start === b.end;
|
|
955
|
+
if (aInsert && bInsert) return a.start === b.start;
|
|
956
|
+
if (aInsert) return a.start > b.start && a.start < b.end;
|
|
957
|
+
if (bInsert) return b.start > a.start && b.start < a.end;
|
|
958
|
+
return a.start < b.end && b.start < a.end;
|
|
959
|
+
}
|
|
960
|
+
function mapBasePosition(position, liveEdits, includeInsertionAtPosition) {
|
|
961
|
+
let mapped = position;
|
|
962
|
+
for (const edit of liveEdits) {
|
|
963
|
+
if (edit.start === edit.end) {
|
|
964
|
+
if (edit.start < position || includeInsertionAtPosition && edit.start === position) {
|
|
965
|
+
mapped += edit.text.length;
|
|
966
|
+
}
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
if (edit.end <= position) mapped += edit.text.length - (edit.end - edit.start);
|
|
970
|
+
}
|
|
971
|
+
return mapped;
|
|
972
|
+
}
|
|
973
|
+
function applyEdits(source, edits) {
|
|
974
|
+
let result = source;
|
|
975
|
+
const ordered = edits.map((edit, index) => ({ edit, index })).sort(
|
|
976
|
+
(a, b) => b.edit.start - a.edit.start || b.edit.end - a.edit.end || b.index - a.index
|
|
977
|
+
);
|
|
978
|
+
for (const { edit } of ordered) {
|
|
979
|
+
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
|
980
|
+
}
|
|
981
|
+
return result;
|
|
982
|
+
}
|
|
983
|
+
function threeWayMerge(base, local, live) {
|
|
984
|
+
const localEdits = textEdits(base, local);
|
|
985
|
+
const liveEdits = textEdits(base, live);
|
|
986
|
+
const ambiguousAnchors = ambiguousEditAnchors(base, local);
|
|
987
|
+
const conflicts = [];
|
|
988
|
+
const alreadyAppliedLocalEdits = [];
|
|
989
|
+
const toApply = [];
|
|
990
|
+
for (const localEdit of localEdits) {
|
|
991
|
+
if (liveEdits.some((liveEdit) => sameEdit(localEdit, liveEdit))) {
|
|
992
|
+
alreadyAppliedLocalEdits.push(localEdit);
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
const ambiguity = ambiguousAnchors.find((candidate) => sameEdit(candidate.forward, localEdit));
|
|
996
|
+
if (ambiguity) {
|
|
997
|
+
const touching = liveEdits.filter(
|
|
998
|
+
(liveEdit) => editTouchesEnvelope(liveEdit, ambiguity.envelopeStart, ambiguity.envelopeEnd)
|
|
999
|
+
);
|
|
1000
|
+
if (touching.length) {
|
|
1001
|
+
for (const liveEdit of touching) {
|
|
1002
|
+
conflicts.push({ local: localEdit, live: liveEdit, reason: "ambiguous-local-anchor" });
|
|
1003
|
+
}
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
const overlapping = liveEdits.filter((liveEdit) => editsConflict(localEdit, liveEdit));
|
|
1008
|
+
if (overlapping.length) {
|
|
1009
|
+
for (const liveEdit of overlapping) {
|
|
1010
|
+
conflicts.push({ local: localEdit, live: liveEdit, reason: "overlapping-edits" });
|
|
1011
|
+
}
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
if (localEdit.start === localEdit.end) {
|
|
1015
|
+
const point = mapBasePosition(localEdit.start, liveEdits, false);
|
|
1016
|
+
toApply.push({ start: point, end: point, text: localEdit.text });
|
|
1017
|
+
} else {
|
|
1018
|
+
const start = mapBasePosition(localEdit.start, liveEdits, true);
|
|
1019
|
+
const end = mapBasePosition(localEdit.end, liveEdits, false);
|
|
1020
|
+
toApply.push({ start, end, text: localEdit.text });
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return {
|
|
1024
|
+
text: conflicts.length ? void 0 : applyEdits(live, toApply),
|
|
1025
|
+
localEdits,
|
|
1026
|
+
liveEdits,
|
|
1027
|
+
appliedLocalEdits: toApply,
|
|
1028
|
+
alreadyAppliedLocalEdits,
|
|
1029
|
+
conflicts
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// src/lib/review-edits.ts
|
|
1034
|
+
var MAX_GAP_CHARS = 40;
|
|
1035
|
+
var MAX_GAP_WORDS = 3;
|
|
1036
|
+
var MAX_GROUP_CHARS = 320;
|
|
1037
|
+
var BOUNDARY = /[.!?;:](?:["'”’\)\]]*)\s|[.!?;:]$|\r?\n\s*\r?\n|[\\$%{}&]/u;
|
|
1038
|
+
function buildReviewEdits(source, target, options = {}) {
|
|
1039
|
+
if (options.explicitEdits) {
|
|
1040
|
+
const edits2 = validateExplicitEdits(source, options.explicitEdits);
|
|
1041
|
+
if (applyTextEdits(source, edits2) !== target) throw new Error("Explicit blocks do not reconstruct the intended file.");
|
|
1042
|
+
for (const edit of edits2) {
|
|
1043
|
+
if (options.protectedSpans?.some((span) => edit.start <= span.end && span.start <= edit.end)) {
|
|
1044
|
+
throw new Error("Explicit block touches a comment, pending suggestion, or concurrent edit; narrow the block or resolve the conflict first.");
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
return edits2;
|
|
1048
|
+
}
|
|
1049
|
+
const edits = [];
|
|
1050
|
+
let sourcePos = 0;
|
|
1051
|
+
let pending;
|
|
1052
|
+
const flush = () => {
|
|
1053
|
+
if (pending) edits.push(pending);
|
|
1054
|
+
pending = void 0;
|
|
1055
|
+
};
|
|
1056
|
+
for (const part of diffWordsWithSpace(source, target)) {
|
|
1057
|
+
if (!part.added && !part.removed) {
|
|
1058
|
+
flush();
|
|
1059
|
+
sourcePos += part.value.length;
|
|
1060
|
+
} else {
|
|
1061
|
+
pending ??= { start: sourcePos, end: sourcePos, text: "" };
|
|
1062
|
+
if (part.removed) {
|
|
1063
|
+
sourcePos += part.value.length;
|
|
1064
|
+
pending.end = sourcePos;
|
|
1065
|
+
} else {
|
|
1066
|
+
pending.text += part.value;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
flush();
|
|
1071
|
+
if (options.group === false) return edits;
|
|
1072
|
+
const groups = [];
|
|
1073
|
+
for (const edit of edits) {
|
|
1074
|
+
const previous = groups[groups.length - 1];
|
|
1075
|
+
if (!previous) {
|
|
1076
|
+
groups.push({ ...edit });
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
const gap = source.slice(previous.end, edit.start);
|
|
1080
|
+
const protectedGap = options.protectedSpans?.some(
|
|
1081
|
+
(span) => span.start === span.end ? span.start >= previous.end && span.start <= edit.start : span.start < edit.start && span.end > previous.end
|
|
1082
|
+
);
|
|
1083
|
+
const canGroup = !protectedGap && gap.length <= MAX_GAP_CHARS && (gap.match(/\S+/gu)?.length ?? 0) <= MAX_GAP_WORDS && !BOUNDARY.test(source.slice(previous.start, edit.start)) && !BOUNDARY.test(previous.text + gap) && edit.end - previous.start <= MAX_GROUP_CHARS && previous.text.length + gap.length + edit.text.length <= MAX_GROUP_CHARS;
|
|
1084
|
+
if (canGroup) {
|
|
1085
|
+
previous.end = edit.end;
|
|
1086
|
+
previous.text += gap + edit.text;
|
|
1087
|
+
} else {
|
|
1088
|
+
groups.push({ ...edit });
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
return groups;
|
|
1092
|
+
}
|
|
1093
|
+
function validateExplicitEdits(source, value) {
|
|
1094
|
+
if (!Array.isArray(value) || !value.length) throw new Error("Explicit blocks must be a nonempty array.");
|
|
1095
|
+
let previousEnd = -1;
|
|
1096
|
+
return value.map((edit) => {
|
|
1097
|
+
if (!edit || !Number.isSafeInteger(edit.start) || !Number.isSafeInteger(edit.end) || edit.start < 0 || edit.end < edit.start || edit.end > source.length || typeof edit.text !== "string" || edit.start <= previousEnd || source.slice(edit.start, edit.end) === edit.text) {
|
|
1098
|
+
throw new Error("Explicit blocks must be valid, ordered, separated replacements with a text change.");
|
|
1099
|
+
}
|
|
1100
|
+
previousEnd = edit.end;
|
|
1101
|
+
return { start: edit.start, end: edit.end, text: edit.text };
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
function applyTextEdits(source, edits) {
|
|
1105
|
+
for (const edit of [...edits].reverse()) {
|
|
1106
|
+
source = source.slice(0, edit.start) + edit.text + source.slice(edit.end);
|
|
1107
|
+
}
|
|
1108
|
+
return source;
|
|
1109
|
+
}
|
|
1110
|
+
function parseReplacementManifest(base, value) {
|
|
1111
|
+
const manifest = value;
|
|
1112
|
+
if (!Array.isArray(manifest?.replacements) || !manifest.replacements.length) {
|
|
1113
|
+
throw new Error("Replacement manifest requires a nonempty replacements array.");
|
|
1114
|
+
}
|
|
1115
|
+
const edits = manifest.replacements.map((block) => {
|
|
1116
|
+
if (!block || typeof block.before !== "string" || !block.before.length || typeof block.after !== "string" || block.occurrence !== void 0 && (!Number.isSafeInteger(block.occurrence) || block.occurrence < 1)) {
|
|
1117
|
+
throw new Error("Each replacement requires nonempty before, after text, and optionally a positive occurrence.");
|
|
1118
|
+
}
|
|
1119
|
+
const matches = [];
|
|
1120
|
+
for (let p = base.indexOf(block.before); p !== -1; p = base.indexOf(block.before, p + 1)) matches.push(p);
|
|
1121
|
+
if (!matches.length || matches.length > 1 && block.occurrence === void 0) {
|
|
1122
|
+
throw new Error("Replacement before text is absent or ambiguous in the saved base; specify occurrence for repeated text.");
|
|
1123
|
+
}
|
|
1124
|
+
const start = matches[(block.occurrence ?? 1) - 1];
|
|
1125
|
+
if (start === void 0) throw new Error("Replacement occurrence is absent from the saved base.");
|
|
1126
|
+
return { start, end: start + block.before.length, text: block.after };
|
|
1127
|
+
}).sort((a, b) => a.start - b.start);
|
|
1128
|
+
return validateExplicitEdits(base, edits);
|
|
1129
|
+
}
|
|
1130
|
+
function bindExplicitEdits(base, local, live, edits, options) {
|
|
1131
|
+
const checked = validateExplicitEdits(base, edits);
|
|
1132
|
+
if (applyTextEdits(base, checked) !== local) {
|
|
1133
|
+
throw new Error("Replacement manifest must describe every local change exactly.");
|
|
1134
|
+
}
|
|
1135
|
+
const changes = textEdits(base, live);
|
|
1136
|
+
const mapped = checked.map((edit) => {
|
|
1137
|
+
let offset = 0;
|
|
1138
|
+
for (const change of changes) {
|
|
1139
|
+
if (change.start <= edit.end && edit.start <= change.end) {
|
|
1140
|
+
throw new Error("A concurrent edit touches an explicit replacement block; refresh and re-plan.");
|
|
1141
|
+
}
|
|
1142
|
+
if (change.end < edit.start) offset += change.text.length - (change.end - change.start);
|
|
1143
|
+
}
|
|
1144
|
+
return { ...edit, start: edit.start + offset, end: edit.end + offset };
|
|
1145
|
+
});
|
|
1146
|
+
return { ...options, explicitEdits: mapped };
|
|
1147
|
+
}
|
|
1148
|
+
function reviewGroupingOptions(base, live, ranges, direct = false) {
|
|
1149
|
+
const protectedSpans = [];
|
|
1150
|
+
for (const range of ranges.changes ?? []) {
|
|
1151
|
+
if (typeof range.op?.p === "number") {
|
|
1152
|
+
protectedSpans.push({ start: range.op.p, end: range.op.p + (range.op.i?.length ?? 0) });
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
for (const range of ranges.comments ?? []) {
|
|
1156
|
+
if (typeof range.op?.p === "number") {
|
|
1157
|
+
protectedSpans.push({ start: range.op.p, end: range.op.p + (range.op.c?.length ?? 0) });
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
let offset = 0;
|
|
1161
|
+
for (const edit of textEdits(base, live)) {
|
|
1162
|
+
const start = edit.start + offset;
|
|
1163
|
+
protectedSpans.push({ start, end: start + edit.text.length });
|
|
1164
|
+
offset += edit.text.length - (edit.end - edit.start);
|
|
1165
|
+
}
|
|
1166
|
+
return { group: !direct, protectedSpans };
|
|
1167
|
+
}
|
|
1168
|
+
|
|
857
1169
|
// src/lib/document-match.ts
|
|
858
1170
|
var AmbiguousDocumentError = class extends Error {
|
|
859
1171
|
constructor(identifier, matches) {
|
|
@@ -1078,152 +1390,6 @@ function mergeBaseDocuments(projectId, documents, path = BASE_STATE_PATH) {
|
|
|
1078
1390
|
return state;
|
|
1079
1391
|
}
|
|
1080
1392
|
|
|
1081
|
-
// src/lib/three-way.ts
|
|
1082
|
-
import { diffChars } from "diff";
|
|
1083
|
-
function textEdits(base, target) {
|
|
1084
|
-
const edits = [];
|
|
1085
|
-
let basePos = 0;
|
|
1086
|
-
let pending;
|
|
1087
|
-
const flush = () => {
|
|
1088
|
-
if (!pending) return;
|
|
1089
|
-
if (pending.start !== pending.end || pending.text.length) edits.push(pending);
|
|
1090
|
-
pending = void 0;
|
|
1091
|
-
};
|
|
1092
|
-
for (const part of diffChars(base, target)) {
|
|
1093
|
-
if (!part.added && !part.removed) {
|
|
1094
|
-
flush();
|
|
1095
|
-
basePos += part.value.length;
|
|
1096
|
-
continue;
|
|
1097
|
-
}
|
|
1098
|
-
pending ??= { start: basePos, end: basePos, text: "" };
|
|
1099
|
-
if (part.removed) {
|
|
1100
|
-
pending.end += part.value.length;
|
|
1101
|
-
basePos += part.value.length;
|
|
1102
|
-
} else {
|
|
1103
|
-
pending.text += part.value;
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
flush();
|
|
1107
|
-
return edits;
|
|
1108
|
-
}
|
|
1109
|
-
function sameEdit(a, b) {
|
|
1110
|
-
return a.start === b.start && a.end === b.end && a.text === b.text;
|
|
1111
|
-
}
|
|
1112
|
-
function reverseCodePoints(text) {
|
|
1113
|
-
return Array.from(text).reverse().join("");
|
|
1114
|
-
}
|
|
1115
|
-
function ambiguousEditAnchors(base, target) {
|
|
1116
|
-
const forward = textEdits(base, target);
|
|
1117
|
-
const reverse = textEdits(reverseCodePoints(base), reverseCodePoints(target)).map((edit) => ({
|
|
1118
|
-
start: base.length - edit.end,
|
|
1119
|
-
end: base.length - edit.start,
|
|
1120
|
-
text: reverseCodePoints(edit.text)
|
|
1121
|
-
})).sort((a, b) => a.start - b.start || a.end - b.end);
|
|
1122
|
-
const ambiguities = [];
|
|
1123
|
-
const count = Math.max(forward.length, reverse.length);
|
|
1124
|
-
for (let index = 0; index < count; index++) {
|
|
1125
|
-
const forwardEdit = forward[index] ?? reverse[index];
|
|
1126
|
-
const reverseEdit = reverse[index] ?? forward[index];
|
|
1127
|
-
if (sameEdit(forwardEdit, reverseEdit)) continue;
|
|
1128
|
-
ambiguities.push({
|
|
1129
|
-
forward: forwardEdit,
|
|
1130
|
-
reverse: reverseEdit,
|
|
1131
|
-
envelopeStart: Math.min(forwardEdit.start, reverseEdit.start),
|
|
1132
|
-
envelopeEnd: Math.max(
|
|
1133
|
-
forwardEdit.start,
|
|
1134
|
-
forwardEdit.end,
|
|
1135
|
-
reverseEdit.start,
|
|
1136
|
-
reverseEdit.end
|
|
1137
|
-
)
|
|
1138
|
-
});
|
|
1139
|
-
}
|
|
1140
|
-
return ambiguities;
|
|
1141
|
-
}
|
|
1142
|
-
function editTouchesEnvelope(edit, start, end) {
|
|
1143
|
-
if (edit.start === edit.end) return edit.start >= start && edit.start <= end;
|
|
1144
|
-
return edit.start <= end && edit.end >= start;
|
|
1145
|
-
}
|
|
1146
|
-
function editsConflict(a, b) {
|
|
1147
|
-
const aInsert = a.start === a.end;
|
|
1148
|
-
const bInsert = b.start === b.end;
|
|
1149
|
-
if (aInsert && bInsert) return a.start === b.start;
|
|
1150
|
-
if (aInsert) return a.start > b.start && a.start < b.end;
|
|
1151
|
-
if (bInsert) return b.start > a.start && b.start < a.end;
|
|
1152
|
-
return a.start < b.end && b.start < a.end;
|
|
1153
|
-
}
|
|
1154
|
-
function mapBasePosition(position, liveEdits, includeInsertionAtPosition) {
|
|
1155
|
-
let mapped = position;
|
|
1156
|
-
for (const edit of liveEdits) {
|
|
1157
|
-
if (edit.start === edit.end) {
|
|
1158
|
-
if (edit.start < position || includeInsertionAtPosition && edit.start === position) {
|
|
1159
|
-
mapped += edit.text.length;
|
|
1160
|
-
}
|
|
1161
|
-
continue;
|
|
1162
|
-
}
|
|
1163
|
-
if (edit.end <= position) mapped += edit.text.length - (edit.end - edit.start);
|
|
1164
|
-
}
|
|
1165
|
-
return mapped;
|
|
1166
|
-
}
|
|
1167
|
-
function applyEdits(source, edits) {
|
|
1168
|
-
let result = source;
|
|
1169
|
-
const ordered = edits.map((edit, index) => ({ edit, index })).sort(
|
|
1170
|
-
(a, b) => b.edit.start - a.edit.start || b.edit.end - a.edit.end || b.index - a.index
|
|
1171
|
-
);
|
|
1172
|
-
for (const { edit } of ordered) {
|
|
1173
|
-
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
|
1174
|
-
}
|
|
1175
|
-
return result;
|
|
1176
|
-
}
|
|
1177
|
-
function threeWayMerge(base, local, live) {
|
|
1178
|
-
const localEdits = textEdits(base, local);
|
|
1179
|
-
const liveEdits = textEdits(base, live);
|
|
1180
|
-
const ambiguousAnchors = ambiguousEditAnchors(base, local);
|
|
1181
|
-
const conflicts = [];
|
|
1182
|
-
const alreadyAppliedLocalEdits = [];
|
|
1183
|
-
const toApply = [];
|
|
1184
|
-
for (const localEdit of localEdits) {
|
|
1185
|
-
if (liveEdits.some((liveEdit) => sameEdit(localEdit, liveEdit))) {
|
|
1186
|
-
alreadyAppliedLocalEdits.push(localEdit);
|
|
1187
|
-
continue;
|
|
1188
|
-
}
|
|
1189
|
-
const ambiguity = ambiguousAnchors.find((candidate) => sameEdit(candidate.forward, localEdit));
|
|
1190
|
-
if (ambiguity) {
|
|
1191
|
-
const touching = liveEdits.filter(
|
|
1192
|
-
(liveEdit) => editTouchesEnvelope(liveEdit, ambiguity.envelopeStart, ambiguity.envelopeEnd)
|
|
1193
|
-
);
|
|
1194
|
-
if (touching.length) {
|
|
1195
|
-
for (const liveEdit of touching) {
|
|
1196
|
-
conflicts.push({ local: localEdit, live: liveEdit, reason: "ambiguous-local-anchor" });
|
|
1197
|
-
}
|
|
1198
|
-
continue;
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
const overlapping = liveEdits.filter((liveEdit) => editsConflict(localEdit, liveEdit));
|
|
1202
|
-
if (overlapping.length) {
|
|
1203
|
-
for (const liveEdit of overlapping) {
|
|
1204
|
-
conflicts.push({ local: localEdit, live: liveEdit, reason: "overlapping-edits" });
|
|
1205
|
-
}
|
|
1206
|
-
continue;
|
|
1207
|
-
}
|
|
1208
|
-
if (localEdit.start === localEdit.end) {
|
|
1209
|
-
const point = mapBasePosition(localEdit.start, liveEdits, false);
|
|
1210
|
-
toApply.push({ start: point, end: point, text: localEdit.text });
|
|
1211
|
-
} else {
|
|
1212
|
-
const start = mapBasePosition(localEdit.start, liveEdits, true);
|
|
1213
|
-
const end = mapBasePosition(localEdit.end, liveEdits, false);
|
|
1214
|
-
toApply.push({ start, end, text: localEdit.text });
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
return {
|
|
1218
|
-
text: conflicts.length ? void 0 : applyEdits(live, toApply),
|
|
1219
|
-
localEdits,
|
|
1220
|
-
liveEdits,
|
|
1221
|
-
appliedLocalEdits: toApply,
|
|
1222
|
-
alreadyAppliedLocalEdits,
|
|
1223
|
-
conflicts
|
|
1224
|
-
};
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
1393
|
// src/lib/tracked-overlap.ts
|
|
1228
1394
|
function spanOverlapsEdit(start, end, edit) {
|
|
1229
1395
|
const editIsPoint = edit.start === edit.end;
|
|
@@ -1361,7 +1527,7 @@ function snapshotRelativePath(timestamp, projectPath, root = process.cwd()) {
|
|
|
1361
1527
|
}
|
|
1362
1528
|
|
|
1363
1529
|
// src/commands/push.ts
|
|
1364
|
-
var PUSH_PLAN_SCHEMA_VERSION =
|
|
1530
|
+
var PUSH_PLAN_SCHEMA_VERSION = 4;
|
|
1365
1531
|
var PUSH_PLAN_KIND = "overleaf-review-push-plan";
|
|
1366
1532
|
var PushSubmissionError = class extends Error {
|
|
1367
1533
|
constructor(message, receiptPath, status, documents, cause) {
|
|
@@ -1392,22 +1558,22 @@ var PushPlanValidationError = class extends Error {
|
|
|
1392
1558
|
}
|
|
1393
1559
|
};
|
|
1394
1560
|
function validatePushOptions(opts) {
|
|
1561
|
+
if (opts.edits && (opts.plan || !opts.file || opts.direct || opts.unsafeNoBase)) {
|
|
1562
|
+
throw new Error("--edits requires --file and a saved base, and cannot be combined with --plan, --direct or --unsafe-no-base.");
|
|
1563
|
+
}
|
|
1395
1564
|
if (opts.docName && !opts.file) {
|
|
1396
1565
|
throw new Error("--doc requires --file; bulk pushes cannot map multiple local files to one document.");
|
|
1397
1566
|
}
|
|
1398
1567
|
}
|
|
1399
|
-
function buildOps(source, target) {
|
|
1568
|
+
function buildOps(source, target, options = {}) {
|
|
1400
1569
|
const ops = [];
|
|
1401
|
-
let
|
|
1402
|
-
for (const
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
} else {
|
|
1409
|
-
p += part.value.length;
|
|
1410
|
-
}
|
|
1570
|
+
let offset = 0;
|
|
1571
|
+
for (const edit of buildReviewEdits(source, target, options)) {
|
|
1572
|
+
const p = edit.start + offset;
|
|
1573
|
+
const deleted = source.slice(edit.start, edit.end);
|
|
1574
|
+
if (deleted) ops.push({ p, d: deleted });
|
|
1575
|
+
if (edit.text) ops.push({ p, i: edit.text });
|
|
1576
|
+
offset += edit.text.length - deleted.length;
|
|
1411
1577
|
}
|
|
1412
1578
|
const rebuilt = applyOps(source, ops);
|
|
1413
1579
|
if (rebuilt !== target) {
|
|
@@ -1415,31 +1581,8 @@ function buildOps(source, target) {
|
|
|
1415
1581
|
}
|
|
1416
1582
|
return ops;
|
|
1417
1583
|
}
|
|
1418
|
-
function buildOperationFootprint(source, target) {
|
|
1419
|
-
|
|
1420
|
-
let sourcePos = 0;
|
|
1421
|
-
let pending;
|
|
1422
|
-
const flush = () => {
|
|
1423
|
-
if (!pending) return;
|
|
1424
|
-
if (pending.start !== pending.end || pending.text.length) edits.push(pending);
|
|
1425
|
-
pending = void 0;
|
|
1426
|
-
};
|
|
1427
|
-
for (const part of diffWordsWithSpace(source, target)) {
|
|
1428
|
-
if (!part.added && !part.removed) {
|
|
1429
|
-
flush();
|
|
1430
|
-
sourcePos += part.value.length;
|
|
1431
|
-
continue;
|
|
1432
|
-
}
|
|
1433
|
-
pending ??= { start: sourcePos, end: sourcePos, text: "" };
|
|
1434
|
-
if (part.removed) {
|
|
1435
|
-
pending.end += part.value.length;
|
|
1436
|
-
sourcePos += part.value.length;
|
|
1437
|
-
} else {
|
|
1438
|
-
pending.text += part.value;
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
flush();
|
|
1442
|
-
return edits;
|
|
1584
|
+
function buildOperationFootprint(source, target, options = {}) {
|
|
1585
|
+
return buildReviewEdits(source, target, options);
|
|
1443
1586
|
}
|
|
1444
1587
|
function applyOps(source, ops) {
|
|
1445
1588
|
let text = source;
|
|
@@ -1549,6 +1692,7 @@ function serializeCommentOverlaps(overlaps) {
|
|
|
1549
1692
|
}
|
|
1550
1693
|
async function createPlan(opts = {}) {
|
|
1551
1694
|
validatePushOptions(opts);
|
|
1695
|
+
const manifest = opts.edits ? JSON.parse(readFileSync6(workspaceReadPath(opts.edits), "utf8")) : void 0;
|
|
1552
1696
|
if (opts.plan) throw new Error("createPlan does not accept an existing plan");
|
|
1553
1697
|
const basePath = opts.basePath ?? BASE_STATE_PATH;
|
|
1554
1698
|
const baseState = loadBaseState(basePath);
|
|
@@ -1612,10 +1756,14 @@ async function createPlan(opts = {}) {
|
|
|
1612
1756
|
continue;
|
|
1613
1757
|
}
|
|
1614
1758
|
const expected = merge.text;
|
|
1615
|
-
const
|
|
1759
|
+
const explicitEdits = manifest ? parseReplacementManifest(base, manifest) : void 0;
|
|
1760
|
+
let grouping = reviewGroupingOptions(base, live, state.ranges, opts.direct);
|
|
1761
|
+
if (explicitEdits) grouping = bindExplicitEdits(base, local, live, explicitEdits, grouping);
|
|
1762
|
+
const ops = buildOps(live, expected, grouping);
|
|
1616
1763
|
if (!ops.length) continue;
|
|
1617
|
-
const proposedEdits = buildOperationFootprint(live, expected);
|
|
1764
|
+
const proposedEdits = buildOperationFootprint(live, expected, grouping);
|
|
1618
1765
|
const activeTrackedRanges = serializeActiveTrackedRanges(state.ranges.changes);
|
|
1766
|
+
assertTrackedRangeBudget(activeTrackedRanges.length, ops.length, Boolean(opts.direct), doc.path);
|
|
1619
1767
|
const overlaps = serializeOverlaps(
|
|
1620
1768
|
findTrackedChangeOverlaps(state.ranges.changes, proposedEdits)
|
|
1621
1769
|
);
|
|
@@ -1627,6 +1775,7 @@ async function createPlan(opts = {}) {
|
|
|
1627
1775
|
continue;
|
|
1628
1776
|
}
|
|
1629
1777
|
documents.push({
|
|
1778
|
+
...explicitEdits ? { explicitEdits } : {},
|
|
1630
1779
|
localPath: toOverleafPath(file),
|
|
1631
1780
|
docId: doc._id,
|
|
1632
1781
|
docPath: doc.path,
|
|
@@ -1688,7 +1837,7 @@ function validatePushPlan(value) {
|
|
|
1688
1837
|
if (!value || typeof value !== "object") throw new PushPlanValidationError("Push plan is not an object");
|
|
1689
1838
|
const plan = value;
|
|
1690
1839
|
if (plan.kind !== PUSH_PLAN_KIND || plan.schemaVersion !== PUSH_PLAN_SCHEMA_VERSION) {
|
|
1691
|
-
throw new PushPlanValidationError("Unsupported push-plan kind or schema version");
|
|
1840
|
+
throw new PushPlanValidationError("Unsupported push-plan kind or schema version; create a new plan with the current tool.");
|
|
1692
1841
|
}
|
|
1693
1842
|
if (typeof plan.projectId !== "string" || typeof plan.projectName !== "string" || typeof plan.createdAt !== "string" || typeof plan.direct !== "boolean" || typeof plan.unsafeNoBase !== "boolean" || typeof plan.allowOverlap !== "boolean" || !Array.isArray(plan.documents)) {
|
|
1694
1843
|
throw new PushPlanValidationError("Push plan is missing required fields");
|
|
@@ -1696,6 +1845,7 @@ function validatePushPlan(value) {
|
|
|
1696
1845
|
const seen = /* @__PURE__ */ new Set();
|
|
1697
1846
|
const seenLocalPaths = /* @__PURE__ */ new Set();
|
|
1698
1847
|
for (const doc of plan.documents) {
|
|
1848
|
+
if (doc?.explicitEdits !== void 0 && (!Array.isArray(doc.explicitEdits) || !doc.explicitEdits.length || !doc.explicitEdits.every(validTextEdit) || plan.direct || doc.baseSource !== "saved")) throw new PushPlanValidationError("Invalid explicit replacement blocks in push plan.");
|
|
1699
1849
|
if (!doc || typeof doc.localPath !== "string" || typeof doc.docId !== "string" || typeof doc.docPath !== "string" || doc.baseSource !== "saved" && doc.baseSource !== "live-unsafe" || !Number.isSafeInteger(doc.liveVersion) || doc.liveVersion < 0 || !Array.isArray(doc.ops) || doc.ops.length === 0 || !Array.isArray(doc.activeTrackedRanges) || !Array.isArray(doc.trackedChangeOverlaps) || !Array.isArray(doc.commentOverlaps)) {
|
|
1700
1850
|
throw new PushPlanValidationError("Push plan contains an invalid document");
|
|
1701
1851
|
}
|
|
@@ -1797,7 +1947,7 @@ function errorMessage(error) {
|
|
|
1797
1947
|
function overleafSnapshotHash(text) {
|
|
1798
1948
|
return createHash3("sha1").update(`blob ${text.length}\0`, "utf8").update(text, "utf8").digest("hex");
|
|
1799
1949
|
}
|
|
1800
|
-
function validatePlannedIntent(planned, base, local, live) {
|
|
1950
|
+
function validatePlannedIntent(planned, base, local, live, grouping = reviewGroupingOptions(base, live, {})) {
|
|
1801
1951
|
if (sha256(base) !== planned.baseHash) {
|
|
1802
1952
|
throw new PushPlanValidationError(`Synchronization base changed for ${planned.docPath}.`);
|
|
1803
1953
|
}
|
|
@@ -1814,14 +1964,14 @@ function validatePlannedIntent(planned, base, local, live) {
|
|
|
1814
1964
|
);
|
|
1815
1965
|
}
|
|
1816
1966
|
const expected = merge.text;
|
|
1817
|
-
if (stableJson(buildOps(live, expected)) !== stableJson(planned.ops) || sha256(expected) !== planned.expectedHash) {
|
|
1967
|
+
if (stableJson(buildOps(live, expected, grouping)) !== stableJson(planned.ops) || sha256(expected) !== planned.expectedHash) {
|
|
1818
1968
|
throw new PushPlanValidationError(
|
|
1819
1969
|
`Operations in ${planned.docPath} do not match its saved Base\u2192Local intent.`
|
|
1820
1970
|
);
|
|
1821
1971
|
}
|
|
1822
1972
|
return expected;
|
|
1823
1973
|
}
|
|
1824
|
-
function validateReviewBinding(planned, state, live, expected, allowOverlap) {
|
|
1974
|
+
function validateReviewBinding(planned, state, live, expected, allowOverlap, grouping) {
|
|
1825
1975
|
if (state.version !== planned.liveVersion) {
|
|
1826
1976
|
throw new PushPlanValidationError(
|
|
1827
1977
|
`${planned.docPath} version changed from ${planned.liveVersion} to ${state.version}; create a new plan.`
|
|
@@ -1832,7 +1982,7 @@ function validateReviewBinding(planned, state, live, expected, allowOverlap) {
|
|
|
1832
1982
|
`${planned.docPath} comments or tracked ranges changed after planning; create a new plan.`
|
|
1833
1983
|
);
|
|
1834
1984
|
}
|
|
1835
|
-
const footprint = buildOperationFootprint(live, expected);
|
|
1985
|
+
const footprint = buildOperationFootprint(live, expected, grouping);
|
|
1836
1986
|
const activeTrackedRanges = serializeActiveTrackedRanges(state.ranges.changes);
|
|
1837
1987
|
const trackedOverlaps = serializeOverlaps(
|
|
1838
1988
|
findTrackedChangeOverlaps(state.ranges.changes, footprint)
|
|
@@ -1889,10 +2039,22 @@ async function bindPlanDocument(plan, planned, socket, basePath) {
|
|
|
1889
2039
|
const state = await joinDoc(socket, planned.docId);
|
|
1890
2040
|
const live = state.lines.join("\n");
|
|
1891
2041
|
const base = baseTextForPlan(plan, planned, live, basePath);
|
|
1892
|
-
|
|
1893
|
-
|
|
2042
|
+
let grouping = reviewGroupingOptions(base, live, state.ranges, plan.direct);
|
|
2043
|
+
if (planned.explicitEdits) grouping = bindExplicitEdits(base, local, live, planned.explicitEdits, grouping);
|
|
2044
|
+
const expected = validatePlannedIntent(planned, base, local, live, grouping);
|
|
2045
|
+
validateReviewBinding(planned, state, live, expected, plan.allowOverlap, grouping);
|
|
2046
|
+
assertTrackedRangeBudget(state.ranges.changes?.length ?? 0, planned.ops.length, plan.direct, planned.docPath);
|
|
1894
2047
|
return { plan: planned, state, expected };
|
|
1895
2048
|
}
|
|
2049
|
+
var MAX_TRACKED_RANGES = 2e3;
|
|
2050
|
+
function assertTrackedRangeBudget(activeCount, operationCount, direct, docPath) {
|
|
2051
|
+
if (direct || operationCount === 0) return;
|
|
2052
|
+
if (activeCount + operationCount > MAX_TRACKED_RANGES) {
|
|
2053
|
+
throw new PushPlanValidationError(
|
|
2054
|
+
`${docPath}: ${activeCount} active tracked ranges + ${operationCount} proposed operations exceeds the conservative ${MAX_TRACKED_RANGES}-range budget. Overleaf rejects documents with too many tracked changes. Reduce the revision or arrange review of existing suggestions before submitting; smaller batches do not remove accumulated ranges.`
|
|
2055
|
+
);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
1896
2058
|
function verifiedTrackedIds(plan, planned, after) {
|
|
1897
2059
|
if (plan.direct) return [];
|
|
1898
2060
|
const actualIds = new Set((after.ranges.changes ?? []).map((change) => String(change.id)));
|
|
@@ -2268,6 +2430,11 @@ function printPlan(plan) {
|
|
|
2268
2430
|
`
|
|
2269
2431
|
${doc.localPath} \u2192 ${doc.docPath} (v${doc.liveVersion}): ${doc.ops.length} op(s), ${ins} ins / ${del} del`
|
|
2270
2432
|
);
|
|
2433
|
+
if (!plan.direct) {
|
|
2434
|
+
console.log(
|
|
2435
|
+
` Tracked-range budget: ${doc.activeTrackedRanges.length} existing + ${doc.ops.length} proposed operations / ${MAX_TRACKED_RANGES}. A phrase replacement uses one deletion and one insertion.`
|
|
2436
|
+
);
|
|
2437
|
+
}
|
|
2271
2438
|
for (const op of doc.ops.slice(0, 12)) console.log(preview(op));
|
|
2272
2439
|
if (doc.ops.length > 12) console.log(` \u2026 and ${doc.ops.length - 12} more`);
|
|
2273
2440
|
for (const overlap of doc.commentOverlaps) {
|
|
@@ -2468,6 +2635,330 @@ async function upload(paths, folderName) {
|
|
|
2468
2635
|
}
|
|
2469
2636
|
}
|
|
2470
2637
|
|
|
2638
|
+
// src/commands/consolidate.ts
|
|
2639
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
2640
|
+
|
|
2641
|
+
// src/lib/consolidation.ts
|
|
2642
|
+
function checkedSnapshot(value) {
|
|
2643
|
+
const snapshot = value;
|
|
2644
|
+
if (!snapshot || typeof snapshot.projectId !== "string" || !snapshot.projectId || typeof snapshot.docId !== "string" || !snapshot.docId || typeof snapshot.docPath !== "string" || !Number.isSafeInteger(snapshot.version) || snapshot.version < 0 || typeof snapshot.text !== "string" || !Array.isArray(snapshot.ranges?.changes) || !Array.isArray(snapshot.ranges?.comments) || !snapshot.threads || typeof snapshot.threads !== "object" || Array.isArray(snapshot.threads)) throw new Error("Consolidation requires a full document snapshot: projectId, docId, docPath, version, text, ranges and threads.");
|
|
2645
|
+
for (const range of snapshot.ranges.changes) {
|
|
2646
|
+
const op = range?.op;
|
|
2647
|
+
if (typeof range?.id !== "string" || !range.id || !op || !Number.isSafeInteger(op.p) || op.p < 0 || op.p > snapshot.text.length || typeof op.i === "string" === (typeof op.d === "string") || !(op.i ?? op.d)?.length || typeof op.i === "string" && snapshot.text.slice(op.p, op.p + op.i.length) !== op.i) throw new Error("Snapshot contains an invalid or stale tracked range.");
|
|
2648
|
+
}
|
|
2649
|
+
for (const range of snapshot.ranges.comments) {
|
|
2650
|
+
const op = range?.op;
|
|
2651
|
+
if (!op || !Number.isSafeInteger(op.p) || op.p < 0 || op.p > snapshot.text.length || typeof op.c !== "string" || typeof op.t !== "string" || snapshot.text.slice(op.p, op.p + op.c.length) !== op.c) throw new Error("Snapshot contains an invalid or stale comment anchor.");
|
|
2652
|
+
}
|
|
2653
|
+
return snapshot;
|
|
2654
|
+
}
|
|
2655
|
+
function touches(start, end, range) {
|
|
2656
|
+
return start <= range.end && range.start <= end;
|
|
2657
|
+
}
|
|
2658
|
+
function protectedRanges(snapshot, selected) {
|
|
2659
|
+
const ranges = [
|
|
2660
|
+
...snapshot.ranges.changes.filter((range) => !selected.has(range.id)).map((range) => ({
|
|
2661
|
+
start: range.op.p,
|
|
2662
|
+
end: range.op.p + (range.op.i?.length ?? 0),
|
|
2663
|
+
kind: "unselected-change",
|
|
2664
|
+
id: range.id
|
|
2665
|
+
})),
|
|
2666
|
+
...snapshot.ranges.comments.map((range) => ({
|
|
2667
|
+
start: range.op.p,
|
|
2668
|
+
end: range.op.p + range.op.c.length,
|
|
2669
|
+
kind: "comment",
|
|
2670
|
+
id: range.op.t
|
|
2671
|
+
}))
|
|
2672
|
+
];
|
|
2673
|
+
return ranges.map((range) => ({ ...range, originalStart: range.start, originalEnd: range.end }));
|
|
2674
|
+
}
|
|
2675
|
+
function planConsolidation(value, authorId, requestedIds) {
|
|
2676
|
+
const snapshot = structuredClone(checkedSnapshot(value));
|
|
2677
|
+
if (!authorId) throw new Error("Choose the author id whose suggestions should be consolidated.");
|
|
2678
|
+
const selectedIds = [...new Set(requestedIds ?? snapshot.ranges.changes.filter((range) => range.metadata?.user_id === authorId).map((range) => range.id))];
|
|
2679
|
+
if (!selectedIds.length) throw new Error("No tracked changes match the selected author.");
|
|
2680
|
+
const selected = new Set(selectedIds);
|
|
2681
|
+
for (const id of selected) {
|
|
2682
|
+
const fragments = snapshot.ranges.changes.filter((range) => range.id === id);
|
|
2683
|
+
if (!fragments.length) throw new Error(`Tracked change ${id} is absent from the snapshot.`);
|
|
2684
|
+
if (fragments.some((range) => range.metadata?.user_id !== authorId)) {
|
|
2685
|
+
throw new Error(`Tracked change ${id} includes a different or unknown author.`);
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
const undo = buildRejectionPlan(snapshot.text, snapshot.ranges.changes, selectedIds);
|
|
2689
|
+
const protectedState = protectedRanges(snapshot, selected);
|
|
2690
|
+
const blockers = [];
|
|
2691
|
+
for (const op of undo.operations) {
|
|
2692
|
+
for (const range of protectedState) {
|
|
2693
|
+
if (touches(op.p, op.p + ("d" in op ? op.d.length : 0), range)) {
|
|
2694
|
+
blockers.push({ kind: range.kind, id: range.id, phase: "undo" });
|
|
2695
|
+
}
|
|
2696
|
+
const offset = "i" in op ? op.i.length : -op.d.length;
|
|
2697
|
+
if (op.p < range.start) {
|
|
2698
|
+
range.start += offset;
|
|
2699
|
+
range.end += offset;
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
const grouping = { protectedSpans: protectedState };
|
|
2704
|
+
const reapply = buildOps(undo.expectedText, snapshot.text, grouping);
|
|
2705
|
+
const footprint = buildOperationFootprint(undo.expectedText, snapshot.text, grouping);
|
|
2706
|
+
for (const edit of footprint) {
|
|
2707
|
+
for (const range of protectedState) {
|
|
2708
|
+
if (touches(edit.start, edit.end, range)) {
|
|
2709
|
+
blockers.push({ kind: range.kind, id: range.id, phase: "reapply" });
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
if (applyOps(undo.expectedText, reapply) !== snapshot.text) {
|
|
2714
|
+
throw new Error("Grouped revisions do not reconstruct the proposed text.");
|
|
2715
|
+
}
|
|
2716
|
+
const newRanges = [];
|
|
2717
|
+
let delta = 0;
|
|
2718
|
+
for (const [index, edit] of footprint.entries()) {
|
|
2719
|
+
const p = edit.start + delta;
|
|
2720
|
+
const deleted = undo.expectedText.slice(edit.start, edit.end);
|
|
2721
|
+
if (edit.text) newRanges.push({ id: `preview-insert-${index}`, op: { p, i: edit.text } });
|
|
2722
|
+
if (deleted) newRanges.push({ id: `preview-delete-${index}`, op: { p: p + edit.text.length, d: deleted } });
|
|
2723
|
+
delta += edit.text.length - deleted.length;
|
|
2724
|
+
}
|
|
2725
|
+
const reconstructed = buildRejectionPlan(snapshot.text, newRanges, newRanges.map((range) => range.id));
|
|
2726
|
+
if (reconstructed.expectedText !== undo.expectedText) {
|
|
2727
|
+
throw new Error("Grouped revisions do not preserve the text beneath the selected suggestions.");
|
|
2728
|
+
}
|
|
2729
|
+
const beforeCount = snapshot.ranges.changes.length;
|
|
2730
|
+
const projectedCount = beforeCount - undo.fragmentCount + reapply.length;
|
|
2731
|
+
const uniqueBlockers = [...new Map(blockers.map((blocker) => [JSON.stringify(blocker), blocker])).values()];
|
|
2732
|
+
return {
|
|
2733
|
+
kind: "overleaf-review-consolidation-plan",
|
|
2734
|
+
schemaVersion: 2,
|
|
2735
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2736
|
+
status: uniqueBlockers.length ? "blocked" : projectedCount >= beforeCount ? "no-reduction" : "ready",
|
|
2737
|
+
authorId,
|
|
2738
|
+
selectedIds,
|
|
2739
|
+
beforeCount,
|
|
2740
|
+
selectedFragmentCount: undo.fragmentCount,
|
|
2741
|
+
projectedCount,
|
|
2742
|
+
projectedReduction: beforeCount - projectedCount,
|
|
2743
|
+
blockers: uniqueBlockers,
|
|
2744
|
+
binding: {
|
|
2745
|
+
projectId: snapshot.projectId,
|
|
2746
|
+
docId: snapshot.docId,
|
|
2747
|
+
version: snapshot.version,
|
|
2748
|
+
textHash: sha256(snapshot.text),
|
|
2749
|
+
rangeFingerprint: fingerprintRanges(snapshot.ranges),
|
|
2750
|
+
threadsHash: sha256(stableJson(snapshot.threads))
|
|
2751
|
+
},
|
|
2752
|
+
textProof: {
|
|
2753
|
+
proposedHash: sha256(snapshot.text),
|
|
2754
|
+
selectedRejectedHash: sha256(undo.expectedText),
|
|
2755
|
+
selectedRejectedText: undo.expectedText,
|
|
2756
|
+
forwardVerified: true,
|
|
2757
|
+
reverseVerified: true
|
|
2758
|
+
},
|
|
2759
|
+
candidate: uniqueBlockers.length ? null : { undo: undo.operations, reapply },
|
|
2760
|
+
backup: snapshot,
|
|
2761
|
+
limitations: [
|
|
2762
|
+
"Only review consolidate --apply accepts this artifact; review submit does not.",
|
|
2763
|
+
"Preserves the current pending proposal, not an earlier historical review state.",
|
|
2764
|
+
"Projected count assumes isolated ranges; server transformations need sandbox verification.",
|
|
2765
|
+
"Consolidation would create new change IDs and timestamps for the selected author."
|
|
2766
|
+
]
|
|
2767
|
+
};
|
|
2768
|
+
}
|
|
2769
|
+
function validateConsolidationPlan(value) {
|
|
2770
|
+
const plan = value;
|
|
2771
|
+
if (!plan || plan.kind !== "overleaf-review-consolidation-plan" || plan.schemaVersion !== 2 || !Array.isArray(plan.selectedIds) || !plan.selectedIds.every((id) => typeof id === "string") || typeof plan.authorId !== "string" || typeof plan.createdAt !== "string") {
|
|
2772
|
+
throw new Error("Unsupported consolidation plan; create a new plan.");
|
|
2773
|
+
}
|
|
2774
|
+
const expected = planConsolidation(plan.backup, plan.authorId, plan.selectedIds);
|
|
2775
|
+
if (stableJson({ ...plan, createdAt: "" }) !== stableJson({ ...expected, createdAt: "" })) {
|
|
2776
|
+
throw new Error("Consolidation plan was altered or cannot be reproduced from its backup.");
|
|
2777
|
+
}
|
|
2778
|
+
if (plan.status !== "ready" || !plan.candidate || plan.projectedCount > MAX_TRACKED_RANGES) {
|
|
2779
|
+
throw new Error("Consolidation is blocked, does not reduce ranges, or exceeds the range budget.");
|
|
2780
|
+
}
|
|
2781
|
+
rejectedText(plan.backup);
|
|
2782
|
+
return plan;
|
|
2783
|
+
}
|
|
2784
|
+
function rejectedText(snapshot) {
|
|
2785
|
+
return buildRejectionPlan(
|
|
2786
|
+
snapshot.text,
|
|
2787
|
+
snapshot.ranges.changes,
|
|
2788
|
+
snapshot.ranges.changes.map((range) => range.id)
|
|
2789
|
+
).expectedText;
|
|
2790
|
+
}
|
|
2791
|
+
function assertConsolidationBinding(plan, live) {
|
|
2792
|
+
checkedSnapshot(live);
|
|
2793
|
+
if (live.projectId !== plan.backup.projectId || live.docId !== plan.backup.docId || live.docPath !== plan.backup.docPath || live.version !== plan.backup.version || live.text !== plan.backup.text || stableJson(live.ranges) !== stableJson(plan.backup.ranges) || stableJson(live.threads) !== stableJson(plan.backup.threads)) {
|
|
2794
|
+
throw new Error("Document, version, review ranges or threads changed after consolidation planning; re-plan.");
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
function verifyConsolidation(plan, value, seed) {
|
|
2798
|
+
const after = checkedSnapshot(value);
|
|
2799
|
+
const before = plan.backup;
|
|
2800
|
+
if (!/^[0-9a-f]{18}$/.test(seed) || after.projectId !== before.projectId || after.docId !== before.docId || after.docPath !== before.docPath || after.version !== before.version + 1 || after.text !== before.text) {
|
|
2801
|
+
throw new Error("Consolidation text, identity or document version failed verification.");
|
|
2802
|
+
}
|
|
2803
|
+
const selected = new Set(plan.selectedIds);
|
|
2804
|
+
if (after.ranges.changes.some((range) => selected.has(range.id))) throw new Error("Old tracked-change IDs remain after consolidation.");
|
|
2805
|
+
const originalOther = before.ranges.changes.filter((range) => !selected.has(range.id));
|
|
2806
|
+
const otherIds = new Set(originalOther.map((range) => range.id));
|
|
2807
|
+
const actualOther = after.ranges.changes.filter((range) => otherIds.has(range.id));
|
|
2808
|
+
const fresh = after.ranges.changes.filter((range) => !otherIds.has(range.id));
|
|
2809
|
+
const canonical = (ranges) => stableJson(ranges.map((range) => stableJson(range)).sort());
|
|
2810
|
+
if (canonical(originalOther) !== canonical(actualOther) || canonical(before.ranges.comments) !== canonical(after.ranges.comments) || stableJson(before.threads) !== stableJson(after.threads)) {
|
|
2811
|
+
throw new Error("Comments, threads or unselected suggestions changed during consolidation.");
|
|
2812
|
+
}
|
|
2813
|
+
if (fresh.some((range) => !new RegExp(`^${seed}[0-9a-f]{6}$`).test(range.id) || range.metadata?.user_id !== plan.authorId) || after.ranges.changes.length > plan.projectedCount || after.ranges.changes.length >= plan.beforeCount) {
|
|
2814
|
+
throw new Error("Consolidated range count or author attribution failed verification.");
|
|
2815
|
+
}
|
|
2816
|
+
const changeIds = [...new Set(fresh.map((range) => range.id))];
|
|
2817
|
+
if (buildRejectionPlan(after.text, after.ranges.changes, changeIds).expectedText !== plan.textProof.selectedRejectedText || rejectedText(after) !== rejectedText(before)) {
|
|
2818
|
+
throw new Error("Consolidation changed the text beneath pending suggestions.");
|
|
2819
|
+
}
|
|
2820
|
+
return { changeIds, rangeCount: after.ranges.changes.length };
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
// src/lib/consolidation-submit.ts
|
|
2824
|
+
async function submitConsolidation(value, transport) {
|
|
2825
|
+
const plan = validateConsolidationPlan(value);
|
|
2826
|
+
const planHash = sha256(stableJson(plan));
|
|
2827
|
+
const options = { receiptsDir: transport.receiptsDir };
|
|
2828
|
+
const relevant = readReceipts(transport.receiptsDir).filter((handle) => handle.receipt.operation === "consolidate" && handle.receipt.details.projectId === plan.binding.projectId && handle.receipt.details.docId === plan.binding.docId);
|
|
2829
|
+
const uncertain = relevant.find((handle) => handle.receipt.status === "ambiguous" || handle.receipt.status === "in_progress");
|
|
2830
|
+
if (uncertain) {
|
|
2831
|
+
throw new Error(`Earlier consolidation has an uncertain outcome. Inspect and reconcile ${uncertain.path}; no automatic retry was sent.`);
|
|
2832
|
+
}
|
|
2833
|
+
const prior = relevant.find((handle) => handle.receipt.status === "succeeded" && handle.receipt.details.planHash === planHash);
|
|
2834
|
+
if (prior) return { receiptPath: prior.path, alreadyApplied: true };
|
|
2835
|
+
let receipt = beginReceipt("consolidate", {
|
|
2836
|
+
projectId: plan.binding.projectId,
|
|
2837
|
+
docId: plan.binding.docId,
|
|
2838
|
+
docPath: plan.backup.docPath,
|
|
2839
|
+
planHash,
|
|
2840
|
+
plan,
|
|
2841
|
+
phase: "preflight"
|
|
2842
|
+
}, options);
|
|
2843
|
+
let attempted = false;
|
|
2844
|
+
try {
|
|
2845
|
+
if (await transport.accountId() !== plan.authorId) {
|
|
2846
|
+
throw new Error("Consolidation can only reapply suggestions belonging to the authenticated account.");
|
|
2847
|
+
}
|
|
2848
|
+
const before = await transport.snapshot();
|
|
2849
|
+
assertConsolidationBinding(plan, before);
|
|
2850
|
+
const seed = createTrackedChangeSeed();
|
|
2851
|
+
if (before.ranges.changes.some((range) => range.id.startsWith(seed))) {
|
|
2852
|
+
throw new Error("Tracked-change seed collision; create a fresh consolidation attempt.");
|
|
2853
|
+
}
|
|
2854
|
+
const update = {
|
|
2855
|
+
doc: before.docId,
|
|
2856
|
+
v: before.version,
|
|
2857
|
+
op: [...plan.candidate.undo, ...plan.candidate.reapply],
|
|
2858
|
+
meta: { tc: seed },
|
|
2859
|
+
hash: overleafSnapshotHash(before.text)
|
|
2860
|
+
};
|
|
2861
|
+
receipt = updateReceipt(receipt, "in_progress", {
|
|
2862
|
+
phase: "sending",
|
|
2863
|
+
before,
|
|
2864
|
+
seed,
|
|
2865
|
+
update,
|
|
2866
|
+
mutationStartedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2867
|
+
});
|
|
2868
|
+
attempted = true;
|
|
2869
|
+
await transport.send(update);
|
|
2870
|
+
const after = await transport.snapshot();
|
|
2871
|
+
receipt = updateReceipt(receipt, "in_progress", { phase: "verifying", after });
|
|
2872
|
+
const result = verifyConsolidation(plan, after, seed);
|
|
2873
|
+
receipt = updateReceipt(receipt, "succeeded", { phase: "complete", result });
|
|
2874
|
+
return { receiptPath: receipt.path, alreadyApplied: false, ...result };
|
|
2875
|
+
} catch (error) {
|
|
2876
|
+
receipt = updateReceipt(receipt, attempted ? "ambiguous" : "failed", {
|
|
2877
|
+
phase: attempted ? "outcome_unknown" : "preflight_failed",
|
|
2878
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2879
|
+
});
|
|
2880
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}. Receipt: ${receipt.path}. ` + (attempted ? "Do not retry or auto-rollback; inspect live review state and the saved backup first." : "Nothing sent."), { cause: error });
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
// src/commands/consolidate.ts
|
|
2885
|
+
async function readConsolidationSnapshot(opened, doc) {
|
|
2886
|
+
if (String(opened.project?._id) !== config.projectId) throw new Error("Connected project identity could not be verified.");
|
|
2887
|
+
const before = await joinDoc(opened.socket, doc._id);
|
|
2888
|
+
const threads = await getThreads();
|
|
2889
|
+
const after = await joinDoc(opened.socket, doc._id);
|
|
2890
|
+
const afterThreads = await getThreads();
|
|
2891
|
+
if (before.version !== after.version || before.lines.join("\n") !== after.lines.join("\n") || stableJson(before.ranges) !== stableJson(after.ranges) || stableJson(threads) !== stableJson(afterThreads)) {
|
|
2892
|
+
throw new Error("The document or review state changed during snapshot capture.");
|
|
2893
|
+
}
|
|
2894
|
+
return {
|
|
2895
|
+
projectId: config.projectId,
|
|
2896
|
+
docId: doc._id,
|
|
2897
|
+
docPath: doc.path,
|
|
2898
|
+
version: after.version,
|
|
2899
|
+
text: after.lines.join("\n"),
|
|
2900
|
+
ranges: { changes: after.ranges.changes ?? [], comments: after.ranges.comments ?? [] },
|
|
2901
|
+
threads: afterThreads
|
|
2902
|
+
};
|
|
2903
|
+
}
|
|
2904
|
+
async function consolidateApply(path) {
|
|
2905
|
+
const plan = validateConsolidationPlan(JSON.parse(readFileSync9(workspaceReadPath(path), "utf8")));
|
|
2906
|
+
if (config.projectId !== plan.binding.projectId) throw new Error("Consolidation plan belongs to a different project.");
|
|
2907
|
+
const lock = acquireMutationLock(config.projectId);
|
|
2908
|
+
let opened;
|
|
2909
|
+
try {
|
|
2910
|
+
opened = await openProject();
|
|
2911
|
+
const project = opened;
|
|
2912
|
+
const doc = project.docs.find((doc2) => doc2._id === plan.binding.docId && doc2.path === plan.backup.docPath);
|
|
2913
|
+
if (!doc) throw new Error("Planned consolidation document is absent or renamed.");
|
|
2914
|
+
const result = await submitConsolidation(plan, {
|
|
2915
|
+
accountId: getAuthenticatedUserId,
|
|
2916
|
+
snapshot: () => readConsolidationSnapshot(project, doc),
|
|
2917
|
+
send: (update) => applyOtUpdateAndWait(project.socket, doc._id, update)
|
|
2918
|
+
});
|
|
2919
|
+
console.log(result.alreadyApplied ? "This consolidation plan was already applied; nothing resent." : `Verified consolidation: ${plan.beforeCount} \u2192 ${result.rangeCount} tracked ranges. Both text views and protected review state preserved.`);
|
|
2920
|
+
console.log(`Audit receipt and backup: ${result.receiptPath}`);
|
|
2921
|
+
} finally {
|
|
2922
|
+
try {
|
|
2923
|
+
opened?.socket.close();
|
|
2924
|
+
} finally {
|
|
2925
|
+
lock.release();
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
async function consolidatePreview(options) {
|
|
2930
|
+
let snapshot;
|
|
2931
|
+
if (options.snapshot) {
|
|
2932
|
+
snapshot = JSON.parse(readFileSync9(workspaceReadPath(options.snapshot), "utf8"));
|
|
2933
|
+
} else {
|
|
2934
|
+
if (!options.doc) throw new Error("Consolidation requires --doc or --snapshot.");
|
|
2935
|
+
const projectId = config.projectId;
|
|
2936
|
+
const lock = acquireMutationLock(projectId);
|
|
2937
|
+
let opened;
|
|
2938
|
+
try {
|
|
2939
|
+
opened = await openProject();
|
|
2940
|
+
const doc = matchDocument(options.doc, opened.docs);
|
|
2941
|
+
if (!doc) throw new Error(`Document not found: ${options.doc}`);
|
|
2942
|
+
snapshot = await readConsolidationSnapshot(opened, doc);
|
|
2943
|
+
} finally {
|
|
2944
|
+
try {
|
|
2945
|
+
opened?.socket.close();
|
|
2946
|
+
} finally {
|
|
2947
|
+
lock.release();
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
const plan = planConsolidation(snapshot, options.author, options.changeIds);
|
|
2952
|
+
writeJsonAtomic(workspaceWritePath(options.out), plan);
|
|
2953
|
+
console.log(`Consolidation dry run for ${snapshot.docPath}: ${plan.status}`);
|
|
2954
|
+
console.log(`Tracked ranges: ${plan.beforeCount} \u2192 ${plan.projectedCount} projected (${plan.selectedFragmentCount} selected).`);
|
|
2955
|
+
for (const blocker of plan.blockers) {
|
|
2956
|
+
console.log(` Blocked by ${blocker.kind} ${blocker.id} during ${blocker.phase}.`);
|
|
2957
|
+
}
|
|
2958
|
+
console.log(`Full backup and text proofs saved to ${options.out}.`);
|
|
2959
|
+
console.log("Nothing sent to Overleaf. Inspect the plan before review consolidate --apply --plan <file>.");
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2471
2962
|
// src/commands/comment.ts
|
|
2472
2963
|
import { createHash as createHash4, randomBytes as randomBytes2 } from "crypto";
|
|
2473
2964
|
var DEFAULT_DUPLICATE_WINDOW_MS = 5 * 60 * 1e3;
|
|
@@ -3597,6 +4088,9 @@ async function login(opts) {
|
|
|
3597
4088
|
const path = saveCredentials({ baseUrl, session2: cookie });
|
|
3598
4089
|
console.log(`
|
|
3599
4090
|
\u2705 Logged in as ${account}. Saved to ${path} (chmod 600).`);
|
|
4091
|
+
if (process.env.OVERLEAF_SESSION2 && process.env.OVERLEAF_SESSION2 !== cookie) {
|
|
4092
|
+
console.warn("An OVERLEAF_SESSION2 override is still set (possibly in .env). Remove or update it to use this saved login.");
|
|
4093
|
+
}
|
|
3600
4094
|
}
|
|
3601
4095
|
async function captureCookieViaBrowser(baseUrl) {
|
|
3602
4096
|
let chromium;
|
|
@@ -3660,8 +4154,12 @@ function usage() {
|
|
|
3660
4154
|
console.log("Safe review workflow:");
|
|
3661
4155
|
console.log(" review start [--file <f>] [--out <dir>] Fetch text/base, then pull review data");
|
|
3662
4156
|
console.log(" review plan --out <plan.json> [options] Save a complete binding push plan");
|
|
4157
|
+
console.log(" --file <f> --edits <blocks.json> Preserve explicit before/after blocks");
|
|
3663
4158
|
console.log(" review submit --plan <plan.json> Validate, apply, and verify that plan");
|
|
3664
4159
|
console.log(" [--acknowledge-ambiguous] Continue only after manual reconciliation\n");
|
|
4160
|
+
console.log(" review consolidate --doc <path> --author <user-id> --out <preview.json>");
|
|
4161
|
+
console.log(" [--snapshot <snapshot.json>] [--change <id> \u2026] Plan only");
|
|
4162
|
+
console.log(" review consolidate --apply --plan <plan.json> Apply a checked consolidation\n");
|
|
3665
4163
|
console.log("Content (replaces the git bridge):");
|
|
3666
4164
|
console.log(" fetch [--file <f>] [--dry-run] Overleaf text \u2192 local files + saved base");
|
|
3667
4165
|
console.log(" upload <path\u2026> [--folder <name>] Upload figures / new files to Overleaf\n");
|
|
@@ -3689,6 +4187,7 @@ async function pullAndReport(out, options = {}) {
|
|
|
3689
4187
|
}
|
|
3690
4188
|
function pushOptions() {
|
|
3691
4189
|
return {
|
|
4190
|
+
edits: getFlag("edits"),
|
|
3692
4191
|
file: getFlag("file"),
|
|
3693
4192
|
docName: getFlag("doc"),
|
|
3694
4193
|
direct: process.argv.includes("--direct"),
|
|
@@ -3771,6 +4270,28 @@ async function main() {
|
|
|
3771
4270
|
} finally {
|
|
3772
4271
|
mutationLock.release();
|
|
3773
4272
|
}
|
|
4273
|
+
} else if (reviewCommand === "consolidate") {
|
|
4274
|
+
if (process.argv.includes("--submit")) fail("Use review consolidate --apply --plan <file>.");
|
|
4275
|
+
if (process.argv.includes("--apply")) {
|
|
4276
|
+
const plan = getFlag("plan");
|
|
4277
|
+
if (!plan || process.argv.slice(4).some((arg) => arg.startsWith("--") && !["--apply", "--plan"].includes(arg))) {
|
|
4278
|
+
fail("Consolidation apply requires only --apply --plan <file>; create a new plan to change its scope.");
|
|
4279
|
+
}
|
|
4280
|
+
await consolidateApply(plan);
|
|
4281
|
+
break;
|
|
4282
|
+
}
|
|
4283
|
+
if (getFlag("plan")) fail("--plan requires --apply for consolidation.");
|
|
4284
|
+
const author = getFlag("author");
|
|
4285
|
+
const out = getFlag("out");
|
|
4286
|
+
if (!author || !out) fail("review consolidate requires --author <user-id> and --out <preview.json>");
|
|
4287
|
+
const changeIds = getAll("change");
|
|
4288
|
+
await consolidatePreview({
|
|
4289
|
+
author,
|
|
4290
|
+
out,
|
|
4291
|
+
doc: getFlag("doc"),
|
|
4292
|
+
snapshot: getFlag("snapshot"),
|
|
4293
|
+
changeIds: changeIds.length ? changeIds : void 0
|
|
4294
|
+
});
|
|
3774
4295
|
} else if (reviewCommand === "plan") {
|
|
3775
4296
|
const out = getFlag("out");
|
|
3776
4297
|
if (!out) fail("review plan requires --out <plan.json>");
|
|
@@ -3784,7 +4305,7 @@ async function main() {
|
|
|
3784
4305
|
});
|
|
3785
4306
|
} else {
|
|
3786
4307
|
usage();
|
|
3787
|
-
fail("review requires start, plan, or
|
|
4308
|
+
fail("review requires start, plan, submit, or consolidate");
|
|
3788
4309
|
}
|
|
3789
4310
|
break;
|
|
3790
4311
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "overleaf-review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "The missing review layer for Overleaf's Git bridge — sync comments and tracked changes between Overleaf and your local repo.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
"probe:read": "tsx src/probes/01-read-ranges.ts",
|
|
41
41
|
"probe:comment": "tsx src/probes/02-write-comment.ts",
|
|
42
42
|
"probe:track": "tsx src/probes/03-write-track-change.ts",
|
|
43
|
+
"probe:consolidation-model": "tsx src/probes/04-consolidation-model.ts",
|
|
44
|
+
"probe:review-live": "tsx src/probes/05-review-live.ts",
|
|
43
45
|
"typecheck": "tsc --noEmit",
|
|
44
46
|
"test": "tsx --test test/*.test.ts",
|
|
45
47
|
"check": "npm run typecheck && npm test && npm run build"
|