overleaf-review 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,431 @@
1
+ import {
2
+ BASE_STATE_PATH,
3
+ MAX_TRACKED_RANGES,
4
+ RestRequestError,
5
+ acquireMutationLock,
6
+ applyOps,
7
+ applyOtUpdateAndWait,
8
+ beginReceipt,
9
+ bindExplicitEdits,
10
+ buildRejectionPlan,
11
+ checkedSnapshot,
12
+ config,
13
+ createTrackedChangeSeed,
14
+ fingerprintRanges,
15
+ getAuthenticatedUserId,
16
+ getCsrfToken,
17
+ getThreads,
18
+ loadBaseState,
19
+ matchDocument,
20
+ mergeBaseDocuments,
21
+ openProject,
22
+ overleafSnapshotHash,
23
+ parseReplacementManifest,
24
+ postThreadMessageDetailed,
25
+ readConsolidationSnapshot,
26
+ readReceipts,
27
+ sha256,
28
+ stableJson,
29
+ synchronizeLocalAfterRemote,
30
+ threadMessageId,
31
+ threadMessages,
32
+ updateReceipt,
33
+ workspaceReadPath,
34
+ workspaceRelativePath,
35
+ workspaceWritePath,
36
+ writeJsonAtomic
37
+ } from "./chunk-TWIDV42A.js";
38
+
39
+ // src/commands/reviewed-replace.ts
40
+ import { readFileSync } from "fs";
41
+
42
+ // src/lib/reviewed-rewrite.ts
43
+ var REVIEWED_REWRITE_KIND = "overleaf-review-reviewed-rewrite-plan";
44
+ function locateAnchor(text, anchor, occurrence) {
45
+ if (typeof anchor !== "string" || !anchor.length || occurrence !== void 0 && (!Number.isSafeInteger(occurrence) || occurrence < 1)) {
46
+ throw new Error("Comment anchorAfter must be nonempty, with an optional positive occurrence.");
47
+ }
48
+ const matches = [];
49
+ for (let p = text.indexOf(anchor); p !== -1; p = text.indexOf(anchor, p + 1)) matches.push(p);
50
+ if (!matches.length || matches.length > 1 && occurrence === void 0 || matches[(occurrence ?? 1) - 1] === void 0) {
51
+ throw new Error("Comment anchorAfter is absent or ambiguous in the revised block; specify occurrence.");
52
+ }
53
+ return matches[(occurrence ?? 1) - 1];
54
+ }
55
+ var canonical = (ranges) => stableJson(ranges.map((range) => stableJson(range)).sort());
56
+ function planReviewedRewrite(value, authorId, input) {
57
+ const backup = structuredClone(checkedSnapshot(value));
58
+ if (typeof authorId !== "string" || !authorId || !Array.isArray(input) || !input.length) {
59
+ throw new Error("Reviewed rewrite requires an authenticated author and explicit blocks.");
60
+ }
61
+ const blocks = structuredClone(input);
62
+ const selected = /* @__PURE__ */ new Set();
63
+ const movedThreads = /* @__PURE__ */ new Set();
64
+ let end = -1;
65
+ for (const block of blocks) {
66
+ if (!block || !Number.isSafeInteger(block.start) || !Number.isSafeInteger(block.end) || block.start <= end || block.start < 0 || block.end <= block.start || block.end > backup.text.length || typeof block.text !== "string" || typeof block.reason !== "string" || !block.reason.trim() || !Array.isArray(block.supersedes) || !Array.isArray(block.comments)) {
67
+ throw new Error("Reviewed blocks require separated valid spans, after text, a reason, supersedes and comments arrays.");
68
+ }
69
+ end = block.end;
70
+ const touches = (p, length) => p <= block.end && p + length >= block.start;
71
+ const contained = (p, length) => p >= block.start && p + length <= block.end;
72
+ for (const id of block.supersedes) {
73
+ if (typeof id !== "string" || selected.has(id)) throw new Error("Superseded IDs must be unique across the plan.");
74
+ const fragments = backup.ranges.changes.filter((range) => range.id === id);
75
+ if (!fragments.length || fragments.some((range) => !contained(range.op.p, range.op.i?.length ?? 0))) {
76
+ throw new Error(`Suggestion ${id} is absent or has fragments outside its approved block.`);
77
+ }
78
+ selected.add(id);
79
+ }
80
+ const unapproved = backup.ranges.changes.filter((range) => touches(range.op.p, range.op.i?.length ?? 0) && !block.supersedes.includes(range.id));
81
+ if (unapproved.length) throw new Error(`Unapproved suggestion IDs touching the block: ${[...new Set(unapproved.map((range) => range.id))].join(", ")}. Inspect and include their IDs or narrow the block.`);
82
+ for (const comment of block.comments) {
83
+ if (!comment || typeof comment.thread !== "string" || movedThreads.has(comment.thread) || comment.reply !== void 0 && (typeof comment.reply !== "string" || !comment.reply.trim())) {
84
+ throw new Error("Comment mappings require unique thread IDs and nonempty optional replies.");
85
+ }
86
+ const ranges = backup.ranges.comments.filter((range) => range.op?.t === comment.thread);
87
+ if (ranges.length !== 1 || !contained(ranges[0].op.p, ranges[0].op.c.length) || !backup.threads[comment.thread]) {
88
+ throw new Error(`Thread ${comment.thread} is absent, duplicated, or extends outside its approved block.`);
89
+ }
90
+ locateAnchor(block.text, comment.anchorAfter, comment.occurrence);
91
+ movedThreads.add(comment.thread);
92
+ }
93
+ const unapprovedComments = backup.ranges.comments.filter((range) => touches(range.op.p, range.op.c.length) && !block.comments.some((comment) => comment.thread === range.op.t));
94
+ if (unapprovedComments.length) throw new Error(`Unapproved comment threads touching the block: ${unapprovedComments.map((range) => range.op.t).join(", ")}. Specify their new anchors.`);
95
+ }
96
+ const selectedIds = [...selected];
97
+ const undo = buildRejectionPlan(backup.text, backup.ranges.changes, selectedIds);
98
+ const selectedRanges = backup.ranges.changes.filter((range) => selected.has(range.id));
99
+ const deltaFor = (ranges) => ranges.reduce((sum, range) => sum + (range.op.d?.length ?? 0) - (range.op.i?.length ?? 0), 0);
100
+ let baselineOffset = 0;
101
+ let finalOffset = 0;
102
+ const expectedNewOps = [];
103
+ const anchorOps = [];
104
+ const decisions = blocks.map((block) => {
105
+ const ranges = selectedRanges.filter((range) => block.supersedes.includes(range.id));
106
+ const baselineStart = block.start + baselineOffset;
107
+ const baselineLength = block.end - block.start + deltaFor(ranges);
108
+ const baseline = undo.expectedText.slice(baselineStart, baselineStart + baselineLength);
109
+ if (baseline === block.text) throw new Error("The revised block equals its underlying text; reject the old suggestions instead of creating an empty revision.");
110
+ const finalStart = block.start + finalOffset;
111
+ if (block.text) expectedNewOps.push({ p: finalStart, i: block.text });
112
+ if (baseline) expectedNewOps.push({ p: finalStart + block.text.length, d: baseline });
113
+ for (const comment of block.comments) {
114
+ anchorOps.push({ p: finalStart + locateAnchor(block.text, comment.anchorAfter, comment.occurrence), c: comment.anchorAfter, t: comment.thread });
115
+ }
116
+ baselineOffset += deltaFor(ranges);
117
+ finalOffset += block.text.length - (block.end - block.start);
118
+ return { ...block, before: backup.text.slice(block.start, block.end), baseline, baselineStart, finalStart, supersededRanges: ranges };
119
+ });
120
+ const replace = [];
121
+ for (const block of [...decisions].reverse()) {
122
+ if (block.baseline) replace.push({ p: block.baselineStart, d: block.baseline });
123
+ if (block.text) replace.push({ p: block.baselineStart, i: block.text });
124
+ }
125
+ const expectedText = applyOps(undo.expectedText, replace);
126
+ let directText = backup.text;
127
+ for (const block of [...blocks].reverse()) directText = directText.slice(0, block.start) + block.text + directText.slice(block.end);
128
+ if (expectedText !== directText) throw new Error("Rewrite does not reconstruct the exact approved text.");
129
+ const shiftOutside = (p) => p + blocks.filter((block) => block.end < p).reduce((sum, block) => sum + block.text.length - (block.end - block.start), 0);
130
+ const expectedUnselected = backup.ranges.changes.filter((range) => !selected.has(range.id)).map((range) => ({ ...range, op: { ...range.op, p: shiftOutside(range.op.p) } }));
131
+ const expectedComments = backup.ranges.comments.map((range) => {
132
+ const moved = anchorOps.find((op) => op.t === range.op.t);
133
+ return { ...range, op: { ...range.op, p: moved ? moved.p : shiftOutside(range.op.p), ...moved ? { c: moved.c } : {} } };
134
+ });
135
+ const virtualRanges = expectedNewOps.map((op, index) => ({ id: `new-${index}`, op }));
136
+ if (buildRejectionPlan(expectedText, virtualRanges, virtualRanges.map((range) => range.id)).expectedText !== undo.expectedText) {
137
+ throw new Error("Rewrite does not preserve its rejection baseline.");
138
+ }
139
+ const projectedCount = expectedUnselected.length + expectedNewOps.length;
140
+ if (projectedCount > MAX_TRACKED_RANGES) throw new Error(`Rewrite exceeds the ${MAX_TRACKED_RANGES}-range budget.`);
141
+ return {
142
+ kind: REVIEWED_REWRITE_KIND,
143
+ schemaVersion: 1,
144
+ authorId,
145
+ backup,
146
+ blocks,
147
+ selectedIds,
148
+ decisions,
149
+ ops: [...undo.operations, ...replace, ...anchorOps],
150
+ expectedText,
151
+ rejectionText: undo.expectedText,
152
+ allRejectedText: buildRejectionPlan(backup.text, backup.ranges.changes, backup.ranges.changes.map((range) => range.id)).expectedText,
153
+ expectedNewOps,
154
+ expectedUnselected,
155
+ expectedComments,
156
+ projectedCount,
157
+ replies: blocks.flatMap((block) => block.comments.filter((comment) => comment.reply !== void 0).map((comment) => ({ thread: comment.thread, message: comment.reply })))
158
+ };
159
+ }
160
+ function validateReviewedRewrite(value) {
161
+ const plan = value;
162
+ if (!plan || plan.kind !== REVIEWED_REWRITE_KIND || plan.schemaVersion !== 1) throw new Error("Unsupported reviewed-rewrite plan.");
163
+ const derived = planReviewedRewrite(plan.backup, plan.authorId, plan.blocks);
164
+ if (stableJson(plan) !== stableJson(derived)) throw new Error("Reviewed-rewrite plan was altered or cannot be reproduced.");
165
+ return plan;
166
+ }
167
+ function verifyReviewedRewrite(plan, value, seed) {
168
+ const after = checkedSnapshot(value);
169
+ if (!/^[0-9a-f]{18}$/.test(seed) || after.projectId !== plan.backup.projectId || after.docId !== plan.backup.docId || after.docPath !== plan.backup.docPath || after.version !== plan.backup.version + 1 || after.text !== plan.expectedText) {
170
+ throw new Error("Rewritten document identity, version or text failed verification.");
171
+ }
172
+ const originalIds = new Set(plan.expectedUnselected.map((range) => range.id));
173
+ const fresh = after.ranges.changes.filter((range) => !originalIds.has(range.id));
174
+ if (after.ranges.changes.some((range) => plan.selectedIds.includes(range.id)) || fresh.some((range) => !new RegExp(`^${seed}[0-9a-f]{6}$`).test(range.id) || range.metadata?.user_id !== plan.authorId) || canonical(fresh.map((range) => range.op)) !== canonical(plan.expectedNewOps) || canonical(after.ranges.changes.filter((range) => originalIds.has(range.id))) !== canonical(plan.expectedUnselected) || canonical(after.ranges.comments) !== canonical(plan.expectedComments) || stableJson(after.threads) !== stableJson(plan.backup.threads)) {
175
+ throw new Error("Rewritten ranges, author attribution, comment anchors or threads failed verification.");
176
+ }
177
+ if (buildRejectionPlan(after.text, after.ranges.changes, fresh.map((range) => range.id)).expectedText !== plan.rejectionText || buildRejectionPlan(after.text, after.ranges.changes, after.ranges.changes.map((range) => range.id)).expectedText !== plan.allRejectedText) {
178
+ throw new Error("Rewrite changed the intended rejection baseline.");
179
+ }
180
+ }
181
+
182
+ // src/lib/reviewed-submit.ts
183
+ function checkOriginalThreads(plan, threads) {
184
+ for (const [id, original] of Object.entries(plan.backup.threads)) {
185
+ const before = original;
186
+ const after = threads[id];
187
+ if (!after || stableJson({ ...before, messages: void 0 }) !== stableJson({ ...after, messages: void 0 })) {
188
+ throw new Error(`Thread ${id} state changed since rewriting; review it before posting replies.`);
189
+ }
190
+ for (const message of threadMessages(before)) {
191
+ if (!threadMessages(after).some((candidate) => stableJson(candidate) === stableJson(message))) {
192
+ throw new Error(`An existing message in thread ${id} changed or disappeared.`);
193
+ }
194
+ }
195
+ }
196
+ }
197
+ function recoverMessage(reply, thread, authorId) {
198
+ const oldIds = new Set(threadMessages(reply.beforeThread).map(threadMessageId));
199
+ const matches = threadMessages(thread).filter((message) => {
200
+ const id = threadMessageId(message);
201
+ const user = message.user;
202
+ const author = message.user_id ?? user?.id ?? user?._id;
203
+ return id && !oldIds.has(id) && message.content === reply.message && (reply.messageId ? id === reply.messageId : author === authorId);
204
+ });
205
+ return matches.length === 1 ? threadMessageId(matches[0]) : void 0;
206
+ }
207
+ async function submitReviewedRewrite(value, transport) {
208
+ const plan = validateReviewedRewrite(value);
209
+ const planHash = sha256(stableJson(plan));
210
+ const history = readReceipts(transport.receiptsDir).filter((handle) => handle.receipt.operation === "reviewed-rewrite" && handle.receipt.details.projectId === plan.backup.projectId && handle.receipt.details.docId === plan.backup.docId);
211
+ const uncertain = history.find((handle) => handle.receipt.details.textStatus === "sending");
212
+ if (uncertain) throw new Error(`An earlier text update has an uncertain outcome: ${uncertain.path}. No automatic retry.`);
213
+ const prior = history.find((handle) => handle.receipt.details.planHash === planHash && (handle.receipt.details.textStatus === "verified" || handle.receipt.status === "succeeded"));
214
+ if (prior?.receipt.status === "succeeded") return { receiptPath: prior.path, alreadyApplied: true };
215
+ let receipt = prior ?? beginReceipt("reviewed-rewrite", {
216
+ projectId: plan.backup.projectId,
217
+ docId: plan.backup.docId,
218
+ planHash,
219
+ plan,
220
+ textStatus: "pending",
221
+ replies: plan.replies.map((reply) => ({ ...reply, status: "pending" }))
222
+ }, { receiptsDir: transport.receiptsDir });
223
+ let textStatus = String(receipt.receipt.details.textStatus);
224
+ const replies = structuredClone(receipt.receipt.details.replies);
225
+ let seed = receipt.receipt.details.seed;
226
+ try {
227
+ if (await transport.accountId() !== plan.authorId) throw new Error("Authenticated account differs from the approved new revision author.");
228
+ if (textStatus !== "verified") {
229
+ const before = await transport.snapshot();
230
+ if (stableJson(before) !== stableJson(plan.backup)) throw new Error("Document or review state changed after planning; re-plan.");
231
+ seed = createTrackedChangeSeed();
232
+ if (before.ranges.changes.some((range) => range.id.startsWith(seed))) throw new Error("Tracked seed collision.");
233
+ const update = {
234
+ doc: before.docId,
235
+ v: before.version,
236
+ op: plan.ops,
237
+ meta: { tc: seed },
238
+ hash: overleafSnapshotHash(plan.expectedText)
239
+ };
240
+ receipt = updateReceipt(receipt, "in_progress", { textStatus: "sending", seed, before, update });
241
+ textStatus = "sending";
242
+ await transport.send(update);
243
+ const after = await transport.snapshot();
244
+ receipt = updateReceipt(receipt, "in_progress", { after });
245
+ verifyReviewedRewrite(plan, after, seed);
246
+ receipt = updateReceipt(receipt, "in_progress", { textStatus: "verified" });
247
+ textStatus = "verified";
248
+ }
249
+ const live = await transport.snapshot();
250
+ verifyReviewedRewrite(plan, { ...live, threads: plan.backup.threads }, seed);
251
+ checkOriginalThreads(plan, live.threads);
252
+ await transport.syncLocal?.(live);
253
+ for (const reply of replies) {
254
+ let threads = await transport.threads();
255
+ checkOriginalThreads(plan, threads);
256
+ if (reply.status === "verified") {
257
+ if (!threadMessages(threads[reply.thread]).some((message) => threadMessageId(message) === reply.messageId && message.content === reply.message)) {
258
+ throw new Error("A previously verified reply is missing or edited; no automatic repost.");
259
+ }
260
+ continue;
261
+ }
262
+ if (reply.status === "sending") {
263
+ const recovered = recoverMessage(reply, threads[reply.thread], plan.authorId);
264
+ if (!recovered) throw new Error(`Reply in thread ${reply.thread} has an uncertain outcome; inspect the receipt before any retry.`);
265
+ reply.messageId = recovered;
266
+ reply.status = "verified";
267
+ receipt = updateReceipt(receipt, "in_progress", { replies });
268
+ continue;
269
+ }
270
+ const current = await transport.snapshot();
271
+ verifyReviewedRewrite(plan, { ...current, threads: plan.backup.threads }, seed);
272
+ checkOriginalThreads(plan, current.threads);
273
+ reply.beforeThread = current.threads[reply.thread];
274
+ reply.status = "sending";
275
+ receipt = updateReceipt(receipt, "in_progress", { replies });
276
+ try {
277
+ const result = await transport.postReply(reply.thread, reply.message);
278
+ reply.messageId = result.messageId;
279
+ receipt = updateReceipt(receipt, "in_progress", { replies });
280
+ } catch (error) {
281
+ if (error instanceof RestRequestError && error.status >= 400 && error.status < 500 && error.status !== 408) {
282
+ reply.status = "failed";
283
+ receipt = updateReceipt(receipt, "in_progress", { replies });
284
+ }
285
+ throw error;
286
+ }
287
+ threads = await transport.threads();
288
+ checkOriginalThreads(plan, threads);
289
+ const id = recoverMessage(reply, threads[reply.thread], plan.authorId);
290
+ if (!id) throw new Error(`Reply in thread ${reply.thread} was not verified; retry only to reconcile, not repost.`);
291
+ reply.messageId = id;
292
+ reply.status = "verified";
293
+ receipt = updateReceipt(receipt, "in_progress", { replies });
294
+ }
295
+ receipt = updateReceipt(receipt, "succeeded", { replies, phase: "complete" });
296
+ return { receiptPath: receipt.path, alreadyApplied: false };
297
+ } catch (error) {
298
+ const ambiguous = textStatus === "sending" || replies.some((reply) => reply.status === "sending");
299
+ receipt = updateReceipt(receipt, ambiguous ? "ambiguous" : "failed", {
300
+ textStatus,
301
+ replies,
302
+ error: error instanceof Error ? error.message : String(error)
303
+ });
304
+ throw new Error(`${textStatus === "verified" ? "Manuscript verified; follow-up incomplete. " : ""}${error instanceof Error ? error.message : String(error)} Receipt: ${receipt.path}`, { cause: error });
305
+ }
306
+ }
307
+
308
+ // src/commands/reviewed-replace.ts
309
+ var REVIEWED_FILE_KIND = "overleaf-review-reviewed-file-plan";
310
+ function isReviewedManifest(value) {
311
+ const manifest = value;
312
+ return Array.isArray(manifest?.replacements) && manifest.replacements.some((block) => block && ["supersedes", "reason", "comments"].some((key) => Object.hasOwn(block, key)));
313
+ }
314
+ function resolveReviewedBlocks(base, local, live, value) {
315
+ const manifest = value;
316
+ if (!Array.isArray(manifest?.replacements) || !manifest.replacements.length) throw new Error("Reviewed manifest requires replacements.");
317
+ const resolved = manifest.replacements.map((block) => {
318
+ const [edit] = parseReplacementManifest(base, { replacements: [{ before: block.before, after: block.after, occurrence: block.occurrence }] });
319
+ return { ...edit, supersedes: block.supersedes ?? [], reason: block.reason, comments: block.comments ?? [] };
320
+ }).sort((a, b) => a.start - b.start);
321
+ const mapped = bindExplicitEdits(base, local, live, resolved, {}).explicitEdits;
322
+ return resolved.map((block, index) => ({ ...block, ...mapped[index] }));
323
+ }
324
+ async function createReviewedFilePlan(opts) {
325
+ if (!opts.edits || !opts.file || !opts.planOut || opts.direct || opts.unsafeNoBase || opts.allowOverlap || opts.plan) {
326
+ throw new Error("Reviewed overlaps require --file, --edits and --out/--plan-out, with a saved base. Broad overlap/direct overrides are not supported.");
327
+ }
328
+ const localPath = workspaceRelativePath(opts.file);
329
+ const manifest = JSON.parse(readFileSync(workspaceReadPath(opts.edits), "utf8"));
330
+ const localText = readFileSync(workspaceReadPath(localPath), "utf8");
331
+ const saved = loadBaseState(opts.basePath ?? BASE_STATE_PATH);
332
+ const lock = acquireMutationLock(config.projectId);
333
+ let opened;
334
+ try {
335
+ const authorId = await getAuthenticatedUserId();
336
+ opened = await openProject();
337
+ const doc = matchDocument(opts.docName ?? localPath, opened.docs);
338
+ if (!doc) throw new Error("Reviewed rewrite document not found.");
339
+ const base = saved?.projectId === config.projectId ? saved.documents[doc._id] : void 0;
340
+ if (!base) throw new Error("Fetch this document first to save its synchronization base.");
341
+ const snapshot = await readConsolidationSnapshot(opened, doc);
342
+ const blocks = resolveReviewedBlocks(base.text, localText, snapshot.text, manifest);
343
+ const rewrite = planReviewedRewrite(snapshot, authorId, blocks);
344
+ const plan = { kind: REVIEWED_FILE_KIND, schemaVersion: 1, localPath, baseText: base.text, localText, manifest, rewrite };
345
+ writeJsonAtomic(workspaceWritePath(opts.planOut), plan);
346
+ console.log(`Reviewed paragraph plan saved to ${opts.planOut}; nothing sent.`);
347
+ for (const block of rewrite.decisions) {
348
+ console.log(`
349
+ Block [${block.start},${block.end}): ${block.reason}`);
350
+ console.log(` Underlying: ${JSON.stringify(block.baseline)}
351
+ Current: ${JSON.stringify(block.before)}
352
+ Proposed: ${JSON.stringify(block.text)}`);
353
+ console.log(` Supersedes: ${block.supersedes.join(", ") || "(none)"}; moves ${block.comments.length} comment anchor(s).`);
354
+ }
355
+ console.log(`Projected tracked ranges: ${snapshot.ranges.changes.length} \u2192 ${rewrite.projectedCount}; planned replies: ${rewrite.replies.length}.`);
356
+ console.log("Rejecting the new revision restores the underlying text, not the old suggestion cards. Submit only during a quiet editing window.");
357
+ return plan;
358
+ } finally {
359
+ try {
360
+ opened?.socket.close();
361
+ } finally {
362
+ lock.release();
363
+ }
364
+ }
365
+ }
366
+ function validateReviewedFilePlan(value) {
367
+ const plan = value;
368
+ if (!plan || plan.kind !== REVIEWED_FILE_KIND || plan.schemaVersion !== 1 || typeof plan.baseText !== "string" || typeof plan.localText !== "string" || workspaceRelativePath(plan.localPath) !== plan.localPath) throw new Error("Invalid reviewed-file plan.");
369
+ validateReviewedRewrite(plan.rewrite);
370
+ const blocks = resolveReviewedBlocks(plan.baseText, plan.localText, plan.rewrite.backup.text, plan.manifest);
371
+ if (stableJson(blocks) !== stableJson(plan.rewrite.blocks)) throw new Error("Reviewed blocks do not match the saved Base\u2192Local intent.");
372
+ return plan;
373
+ }
374
+ async function applyReviewedFilePlan(value, opts) {
375
+ if (opts.direct || opts.allowOverlap || opts.unsafeNoBase || opts.allowAmbiguousRetry || opts.dryRun || opts.edits || opts.file || opts.docName || opts.planOut) {
376
+ throw new Error("Reviewed submission accepts only its saved plan; overrides and ambiguous replay are not allowed.");
377
+ }
378
+ const plan = validateReviewedFilePlan(value);
379
+ if (config.projectId !== plan.rewrite.backup.projectId) throw new Error("Reviewed plan belongs to another project.");
380
+ const basePath = opts.basePath ?? BASE_STATE_PATH;
381
+ const lock = acquireMutationLock(config.projectId);
382
+ let opened;
383
+ try {
384
+ const local = readFileSync(workspaceReadPath(plan.localPath), "utf8");
385
+ const base = loadBaseState(basePath);
386
+ const baseText = base?.projectId === config.projectId ? base.documents[plan.rewrite.backup.docId]?.text : void 0;
387
+ if (![plan.localText, plan.rewrite.expectedText].includes(local) || baseText !== plan.baseText && baseText !== plan.rewrite.expectedText) {
388
+ throw new Error("Local file or synchronization base changed after planning.");
389
+ }
390
+ opened = await openProject();
391
+ const project = opened;
392
+ const doc = project.docs.find((doc2) => doc2._id === plan.rewrite.backup.docId && doc2.path === plan.rewrite.backup.docPath);
393
+ if (!doc) throw new Error("Reviewed document is absent or renamed.");
394
+ const result = await submitReviewedRewrite(plan.rewrite, {
395
+ receiptsDir: opts.receiptsDir,
396
+ accountId: getAuthenticatedUserId,
397
+ threads: getThreads,
398
+ snapshot: () => readConsolidationSnapshot(project, doc),
399
+ send: (update) => applyOtUpdateAndWait(project.socket, doc._id, update),
400
+ postReply: async (thread, message) => postThreadMessageDetailed(thread, message, await getCsrfToken()),
401
+ syncLocal: async (after) => {
402
+ synchronizeLocalAfterRemote(plan.localPath, sha256(plan.localText), after.text);
403
+ mergeBaseDocuments(config.projectId, [{
404
+ docId: doc._id,
405
+ path: doc.path,
406
+ text: after.text,
407
+ hash: sha256(after.text),
408
+ version: after.version,
409
+ rangeFingerprint: fingerprintRanges(after.ranges),
410
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
411
+ }], basePath);
412
+ }
413
+ });
414
+ console.log(result.alreadyApplied ? "This reviewed rewrite was already completed; nothing resent." : "Verified paragraph rewrite, comment anchors, and planned replies.");
415
+ console.log(`Audit receipt and full review backup: ${result.receiptPath}`);
416
+ } finally {
417
+ try {
418
+ opened?.socket.close();
419
+ } finally {
420
+ lock.release();
421
+ }
422
+ }
423
+ }
424
+ export {
425
+ REVIEWED_FILE_KIND,
426
+ applyReviewedFilePlan,
427
+ createReviewedFilePlan,
428
+ isReviewedManifest,
429
+ resolveReviewedBlocks,
430
+ validateReviewedFilePlan
431
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "overleaf-review",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
@@ -42,6 +42,8 @@
42
42
  "probe:track": "tsx src/probes/03-write-track-change.ts",
43
43
  "probe:consolidation-model": "tsx src/probes/04-consolidation-model.ts",
44
44
  "probe:review-live": "tsx src/probes/05-review-live.ts",
45
+ "probe:reviewed-model": "tsx src/probes/06-reviewed-model.ts",
46
+ "probe:reviewed-live": "tsx src/probes/07-reviewed-live.ts",
45
47
  "typecheck": "tsc --noEmit",
46
48
  "test": "tsx --test test/*.test.ts",
47
49
  "check": "npm run typecheck && npm test && npm run build"