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.
Files changed (3) hide show
  1. package/README.md +105 -1
  2. package/dist/cli.js +714 -193
  3. 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 = { "&quot;": '"', "&apos;": "'", "&amp;": "&", "&lt;": "<", "&gt;": ">" };
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 = 2;
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 p = 0;
1402
- for (const part of diffWordsWithSpace(source, target)) {
1403
- if (part.added) {
1404
- ops.push({ p, i: part.value });
1405
- p += part.value.length;
1406
- } else if (part.removed) {
1407
- ops.push({ p, d: part.value });
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
- const edits = [];
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 ops = buildOps(live, expected);
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
- const expected = validatePlannedIntent(planned, base, local, live);
1893
- validateReviewBinding(planned, state, live, expected, plan.allowOverlap);
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 submit");
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.4.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"