overleaf-review 0.3.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 +198 -15
  2. package/dist/cli.js +3725 -371
  3. package/package.json +6 -2
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/commands/pull.ts
4
- import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "fs";
5
- import { join as join3 } from "path";
4
+ import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync4 } from "fs";
5
+ import { join as join4 } from "path";
6
6
 
7
7
  // src/config.ts
8
8
  import "dotenv/config";
@@ -33,7 +33,7 @@ function saveCredentials(creds) {
33
33
  }
34
34
 
35
35
  // src/lib/project-config.ts
36
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
36
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, renameSync, unlinkSync } from "fs";
37
37
  import { join as join2 } from "path";
38
38
  var REPO_CONFIG_PATH = join2(".overleaf", "config.json");
39
39
  function loadProjectConfig() {
@@ -45,7 +45,17 @@ function loadProjectConfig() {
45
45
  }
46
46
  function saveProjectConfig(cfg) {
47
47
  mkdirSync2(".overleaf", { recursive: true });
48
- writeFileSync2(REPO_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
48
+ const temp = `${REPO_CONFIG_PATH}.tmp-${process.pid}-${Date.now()}`;
49
+ try {
50
+ writeFileSync2(temp, JSON.stringify(cfg, null, 2) + "\n", { mode: 384 });
51
+ renameSync(temp, REPO_CONFIG_PATH);
52
+ } catch (error) {
53
+ try {
54
+ unlinkSync(temp);
55
+ } catch {
56
+ }
57
+ throw error;
58
+ }
49
59
  return REPO_CONFIG_PATH;
50
60
  }
51
61
 
@@ -131,6 +141,15 @@ var OverleafSocket = class {
131
141
  const list = this.handlers.get(event) ?? [];
132
142
  list.push(handler);
133
143
  this.handlers.set(event, list);
144
+ return () => this.off(event, handler);
145
+ }
146
+ /** Remove a previously registered server-pushed event handler. */
147
+ off(event, handler) {
148
+ const list = this.handlers.get(event);
149
+ if (!list) return;
150
+ const next = list.filter((candidate) => candidate !== handler);
151
+ if (next.length) this.handlers.set(event, next);
152
+ else this.handlers.delete(event);
134
153
  }
135
154
  async connect(projectId) {
136
155
  const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${projectId}&t=${Date.now()}`;
@@ -149,8 +168,8 @@ var OverleafSocket = class {
149
168
  const wsUrl = `${this.baseUrl.replace(/^http/, "ws")}/socket.io/1/websocket/${sid}`;
150
169
  if (this.debug) console.log(`[socket] transports=${transports}; ws=${wsUrl}`);
151
170
  this.ws = new WebSocket(wsUrl, { headers: this.browserHeaders });
152
- await new Promise((resolve2, reject2) => {
153
- this.ws.once("open", () => resolve2());
171
+ await new Promise((resolve3, reject2) => {
172
+ this.ws.once("open", () => resolve3());
154
173
  this.ws.once("error", reject2);
155
174
  this.ws.once("unexpected-response", (_req, response) => {
156
175
  let errBody = "";
@@ -175,14 +194,14 @@ var OverleafSocket = class {
175
194
  /** Emit an event and resolve with the server's ack payload (array of args). */
176
195
  emit(name, args, timeoutMs = 15e3) {
177
196
  const id = this.ackId++;
178
- return new Promise((resolve2, reject2) => {
197
+ return new Promise((resolve3, reject2) => {
179
198
  const timer = setTimeout(() => {
180
199
  this.pendingAcks.delete(id);
181
200
  reject2(new Error(`emit('${name}') timed out after ${timeoutMs}ms with no ack`));
182
201
  }, timeoutMs);
183
202
  this.pendingAcks.set(id, (payload) => {
184
203
  clearTimeout(timer);
185
- resolve2(payload);
204
+ resolve3(payload);
186
205
  });
187
206
  this.send(`5:${id}+::${JSON.stringify({ name, args })}`);
188
207
  });
@@ -243,7 +262,7 @@ function collectDocs(rootFolder) {
243
262
  }
244
263
  async function openProject(opts = {}) {
245
264
  const socket = new OverleafSocket(config.baseUrl, config.cookie, { debug: opts.debug ?? false });
246
- const pushed = new Promise((resolve2) => socket.on("joinProjectResponse", resolve2));
265
+ const pushed = new Promise((resolve3) => socket.on("joinProjectResponse", resolve3));
247
266
  await socket.connect(config.projectId);
248
267
  const first = await Promise.race([
249
268
  pushed,
@@ -266,6 +285,41 @@ async function joinDoc(socket, docId) {
266
285
  if (err) throw new Error(`joinDoc error: ${JSON.stringify(err)}`);
267
286
  return { version, lines: lines ?? [], ranges: ranges ?? {} };
268
287
  }
288
+ async function applyOtUpdateAndWait(socket, docId, update, timeoutMs = 3e4) {
289
+ let timer;
290
+ let stopApplied = () => {
291
+ };
292
+ let stopError = () => {
293
+ };
294
+ const applied = new Promise((resolve3, reject2) => {
295
+ stopApplied = socket.on("otUpdateApplied", (args) => {
296
+ const event = args[0];
297
+ if (event?.doc === docId && Number.isSafeInteger(event.v) && event.v >= update.v && !Object.prototype.hasOwnProperty.call(event, "op")) {
298
+ resolve3();
299
+ }
300
+ });
301
+ stopError = socket.on("otUpdateError", (args) => {
302
+ const metadata = args[1];
303
+ if (metadata?.doc_id && metadata.doc_id !== docId) return;
304
+ reject2(new Error(`Overleaf failed to apply the OT update: ${JSON.stringify(args)}`));
305
+ });
306
+ timer = setTimeout(
307
+ () => reject2(new Error(`OT update for ${docId} was queued but not confirmed within ${timeoutMs}ms`)),
308
+ timeoutMs
309
+ );
310
+ });
311
+ void applied.catch(() => {
312
+ });
313
+ try {
314
+ const ack = await socket.emit("applyOtUpdate", [docId, update], timeoutMs);
315
+ if (ack?.[0]) throw new Error(`Overleaf rejected the OT update: ${JSON.stringify(ack[0])}`);
316
+ await applied;
317
+ } finally {
318
+ if (timer) clearTimeout(timer);
319
+ stopApplied();
320
+ stopError();
321
+ }
322
+ }
269
323
 
270
324
  // src/lib/rest.ts
271
325
  var UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
@@ -290,7 +344,111 @@ async function getThreads() {
290
344
  if (!res.ok) throw new Error(`getThreads ${res.status}: ${(await res.text()).slice(0, 200)}`);
291
345
  return res.json();
292
346
  }
293
- async function postThreadMessage(threadId, content, csrf) {
347
+ var RestRequestError = class extends Error {
348
+ constructor(message, status, responseBody) {
349
+ super(message);
350
+ this.status = status;
351
+ this.responseBody = responseBody;
352
+ this.name = "RestRequestError";
353
+ }
354
+ status;
355
+ responseBody;
356
+ };
357
+ function threadMessages(thread) {
358
+ if (!thread || typeof thread !== "object") return [];
359
+ const messages = thread.messages;
360
+ return Array.isArray(messages) ? messages.filter((message) => Boolean(message && typeof message === "object")) : [];
361
+ }
362
+ function threadMessageId(message) {
363
+ const value = message?.id ?? message?._id;
364
+ return typeof value === "string" && value ? value : void 0;
365
+ }
366
+ function timestampMs(message) {
367
+ const raw = message.timestamp ?? message.createdAt ?? message.created_at;
368
+ if (typeof raw === "number" && Number.isFinite(raw)) {
369
+ return raw < 1e11 ? raw * 1e3 : raw;
370
+ }
371
+ if (typeof raw === "string") {
372
+ const numeric = Number(raw);
373
+ if (Number.isFinite(numeric) && raw.trim()) {
374
+ return numeric < 1e11 ? numeric * 1e3 : numeric;
375
+ }
376
+ const parsed = Date.parse(raw);
377
+ if (Number.isFinite(parsed)) return parsed;
378
+ }
379
+ return void 0;
380
+ }
381
+ function findRecentIdenticalMessage(thread, content, nowMs = Date.now(), windowMs = 5 * 60 * 1e3) {
382
+ const earliest = nowMs - Math.max(0, windowMs);
383
+ return threadMessages(thread).filter((message) => {
384
+ if (message.content !== content) return false;
385
+ const timestamp = timestampMs(message);
386
+ return timestamp !== void 0 && timestamp >= earliest && timestamp <= nowMs + 6e4;
387
+ }).sort((a, b) => (timestampMs(b) ?? 0) - (timestampMs(a) ?? 0))[0];
388
+ }
389
+ function findNewIdenticalMessage(beforeThread, afterThread, content, returnedMessageId) {
390
+ const after = threadMessages(afterThread);
391
+ if (returnedMessageId) {
392
+ return after.find(
393
+ (message) => threadMessageId(message) === returnedMessageId && message.content === content
394
+ );
395
+ }
396
+ const beforeIds = new Set(
397
+ threadMessages(beforeThread).map(threadMessageId).filter((id) => Boolean(id))
398
+ );
399
+ const candidates = [...after].reverse().filter((message) => {
400
+ if (message.content !== content) return false;
401
+ const id = threadMessageId(message);
402
+ return Boolean(id && !beforeIds.has(id));
403
+ });
404
+ return candidates.length === 1 ? candidates[0] : void 0;
405
+ }
406
+ function extractPostedMessageId(value) {
407
+ if (!value || typeof value !== "object") return void 0;
408
+ const object = value;
409
+ for (const key of ["message_id", "messageId"]) {
410
+ if (typeof object[key] === "string" && object[key]) return object[key];
411
+ }
412
+ for (const key of ["message", "data"]) {
413
+ const nested = object[key];
414
+ if (nested && typeof nested === "object") {
415
+ const nestedId = threadMessageId(nested) ?? extractPostedMessageId(nested);
416
+ if (nestedId) return nestedId;
417
+ }
418
+ }
419
+ return threadMessageId(object);
420
+ }
421
+ async function observePostedThreadMessage(threadId, beforeThread, content, returnedMessageId, options = {}) {
422
+ const timeoutMs = Math.max(0, options.timeoutMs ?? 5e3);
423
+ const intervalMs = Math.max(10, options.intervalMs ?? 250);
424
+ const deadline = Date.now() + timeoutMs;
425
+ let attempts = 0;
426
+ let lastThread;
427
+ let lastError;
428
+ do {
429
+ attempts += 1;
430
+ try {
431
+ lastThread = (await getThreads())[threadId];
432
+ const message = findNewIdenticalMessage(
433
+ beforeThread,
434
+ lastThread,
435
+ content,
436
+ returnedMessageId
437
+ );
438
+ if (message) return { message, thread: lastThread, attempts };
439
+ } catch (error) {
440
+ lastError = error instanceof Error ? error.message : String(error);
441
+ }
442
+ if (Date.now() >= deadline) break;
443
+ await new Promise((resolve3) => setTimeout(resolve3, Math.min(intervalMs, deadline - Date.now())));
444
+ } while (Date.now() <= deadline);
445
+ return {
446
+ thread: lastThread,
447
+ attempts,
448
+ ...lastError ? { lastError } : {}
449
+ };
450
+ }
451
+ async function postThreadMessageDetailed(threadId, content, csrf) {
294
452
  const res = await fetch(
295
453
  `${config.baseUrl}/project/${config.projectId}/thread/${threadId}/messages`,
296
454
  {
@@ -299,10 +457,27 @@ async function postThreadMessage(threadId, content, csrf) {
299
457
  body: JSON.stringify({ content })
300
458
  }
301
459
  );
460
+ const rawBody = await res.text();
302
461
  if (!res.ok) {
303
- throw new Error(`postThreadMessage ${res.status}: ${(await res.text()).slice(0, 300)}`);
462
+ throw new RestRequestError(
463
+ `postThreadMessage ${res.status}: ${rawBody.slice(0, 300)}`,
464
+ res.status,
465
+ rawBody.slice(0, 1e3)
466
+ );
304
467
  }
305
- return res.status;
468
+ let responseBody;
469
+ if (rawBody) {
470
+ try {
471
+ responseBody = JSON.parse(rawBody);
472
+ } catch {
473
+ responseBody = rawBody.slice(0, 1e3);
474
+ }
475
+ }
476
+ return {
477
+ status: res.status,
478
+ messageId: extractPostedMessageId(responseBody),
479
+ ...responseBody === void 0 ? {} : { responseBody }
480
+ };
306
481
  }
307
482
  async function setThreadResolved(docId, threadId, reopen, csrf) {
308
483
  const action = reopen ? "reopen" : "resolve";
@@ -362,6 +537,7 @@ async function validateSession(baseUrl, session2) {
362
537
  redirect: "follow"
363
538
  });
364
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.`);
365
541
  const looksLikeLogin = res.url.includes("/login") || /name="ol-page"\s+content="login"/.test(html) || html.includes('id="loginForm"');
366
542
  if (looksLikeLogin) {
367
543
  throw new Error("Session cookie is invalid or expired (got the login page).");
@@ -369,6 +545,33 @@ async function validateSession(baseUrl, session2) {
369
545
  const m = html.match(/name="ol-usersEmail"\s+content="([^"]+)"/) ?? html.match(/"email":"([^"@]+@[^"]+)"/);
370
546
  return m ? m[1] : "your Overleaf account";
371
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
+ }
372
575
 
373
576
  // src/lib/anchors.ts
374
577
  function offsetToLine(lines, offset) {
@@ -390,6 +593,156 @@ function lineContext(lines, line, radius = 1) {
390
593
  return out.join("\n");
391
594
  }
392
595
 
596
+ // src/lib/submission-lock.ts
597
+ import { createHash, randomUUID } from "crypto";
598
+ import {
599
+ closeSync,
600
+ existsSync as existsSync2,
601
+ fsyncSync,
602
+ mkdirSync as mkdirSync3,
603
+ openSync,
604
+ readFileSync as readFileSync3,
605
+ realpathSync as realpathSync2,
606
+ unlinkSync as unlinkSync2,
607
+ writeFileSync as writeFileSync3
608
+ } from "fs";
609
+ import { tmpdir } from "os";
610
+ import { dirname as dirname2, join as join3 } from "path";
611
+
612
+ // src/lib/workspace-path.ts
613
+ import { existsSync, realpathSync } from "fs";
614
+ import { dirname, isAbsolute, relative, resolve, sep } from "path";
615
+ function isOutside(root, candidate) {
616
+ const rel = relative(root, candidate);
617
+ return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
618
+ }
619
+ function workspaceRelativePath(input, root = process.cwd()) {
620
+ if (typeof input !== "string" || !input.length) throw new Error("workspace path is empty");
621
+ if (isAbsolute(input) || /^[\\/]/.test(input) || /^[a-zA-Z]:[\\/]/.test(input)) {
622
+ throw new Error(`Absolute paths are not allowed: ${input}`);
623
+ }
624
+ const portable = input.replace(/\\/g, "/");
625
+ if (portable.split("/").includes("..")) {
626
+ throw new Error(`Parent path traversal is not allowed: ${input}`);
627
+ }
628
+ const target = resolve(root, ...portable.split("/"));
629
+ const absoluteRoot = resolve(root);
630
+ if (isOutside(absoluteRoot, target) || target === absoluteRoot) {
631
+ throw new Error(`Path must identify a file inside the working tree: ${input}`);
632
+ }
633
+ return relative(absoluteRoot, target).split(sep).join("/");
634
+ }
635
+ function workspaceReadPath(input, root = process.cwd()) {
636
+ const rel = workspaceRelativePath(input, root);
637
+ const realRoot = realpathSync(root);
638
+ const lexicalTarget = resolve(root, ...rel.split("/"));
639
+ const realTarget = realpathSync(lexicalTarget);
640
+ if (isOutside(realRoot, realTarget) || realTarget === realRoot) {
641
+ throw new Error(`Path resolves outside the working tree: ${input}`);
642
+ }
643
+ return lexicalTarget;
644
+ }
645
+ function workspaceWritePath(input, root = process.cwd()) {
646
+ const rel = workspaceRelativePath(input, root);
647
+ const lexicalTarget = resolve(root, ...rel.split("/"));
648
+ const realRoot = realpathSync(root);
649
+ if (existsSync(lexicalTarget)) {
650
+ const target = realpathSync(lexicalTarget);
651
+ if (isOutside(realRoot, target) || target === realRoot) {
652
+ throw new Error(`Path resolves outside the working tree: ${input}`);
653
+ }
654
+ return lexicalTarget;
655
+ }
656
+ let ancestor = dirname(lexicalTarget);
657
+ while (!existsSync(ancestor)) {
658
+ const parent = dirname(ancestor);
659
+ if (parent === ancestor) throw new Error(`Cannot resolve a safe parent for ${input}`);
660
+ ancestor = parent;
661
+ }
662
+ const realAncestor = realpathSync(ancestor);
663
+ if (isOutside(realRoot, realAncestor)) {
664
+ throw new Error(`Path has an ancestor outside the working tree: ${input}`);
665
+ }
666
+ return lexicalTarget;
667
+ }
668
+
669
+ // src/lib/submission-lock.ts
670
+ var MUTATION_LOCK_PATH = join3(".overleaf", "mutation.lock");
671
+ function mutationLockPath(root = process.cwd()) {
672
+ const key = createHash("sha256").update(realpathSync2(root), "utf8").digest("hex").slice(0, 32);
673
+ return join3(tmpdir(), `overleaf-review-${key}.lock`);
674
+ }
675
+ function acquireMutationLock(projectId, options = {}) {
676
+ const root = options.root ?? process.cwd();
677
+ const path = options.path ? workspaceWritePath(options.path, root) : mutationLockPath(root);
678
+ const displayPath = options.path ?? path;
679
+ mkdirSync3(dirname2(path), { recursive: true });
680
+ const token = randomUUID();
681
+ let fd;
682
+ try {
683
+ fd = openSync(path, "wx", 384);
684
+ } catch (error) {
685
+ if (error.code !== "EEXIST") throw error;
686
+ let owner = "";
687
+ try {
688
+ const parsed = JSON.parse(readFileSync3(path, "utf8"));
689
+ const details = [
690
+ typeof parsed.pid === "number" ? `pid ${parsed.pid}` : void 0,
691
+ typeof parsed.startedAt === "string" ? `since ${parsed.startedAt}` : void 0,
692
+ typeof parsed.projectId === "string" ? `project ${parsed.projectId}` : void 0
693
+ ].filter(Boolean);
694
+ if (details.length) owner = ` (${details.join(", ")})`;
695
+ } catch {
696
+ }
697
+ throw new Error(
698
+ `Another overleaf-review mutation holds ${displayPath}${owner}. Wait for it to finish. If its process crashed, inspect any relevant receipt and live project before removing the lock manually.`,
699
+ { cause: error }
700
+ );
701
+ }
702
+ try {
703
+ writeFileSync3(
704
+ fd,
705
+ `${JSON.stringify(
706
+ {
707
+ token,
708
+ pid: process.pid,
709
+ projectId,
710
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
711
+ },
712
+ null,
713
+ 2
714
+ )}
715
+ `,
716
+ "utf8"
717
+ );
718
+ fsyncSync(fd);
719
+ } catch (error) {
720
+ closeSync(fd);
721
+ if (existsSync2(path)) unlinkSync2(path);
722
+ throw error;
723
+ }
724
+ closeSync(fd);
725
+ let released = false;
726
+ return {
727
+ path,
728
+ token,
729
+ release() {
730
+ if (released) return;
731
+ try {
732
+ const current = JSON.parse(readFileSync3(path, "utf8"));
733
+ if (current.token === token) unlinkSync2(path);
734
+ released = true;
735
+ } catch (error) {
736
+ if (error.code === "ENOENT") {
737
+ released = true;
738
+ return;
739
+ }
740
+ throw error;
741
+ }
742
+ }
743
+ };
744
+ }
745
+
393
746
  // src/commands/pull.ts
394
747
  function buildMemberMap(project) {
395
748
  const map = {};
@@ -401,60 +754,71 @@ function buildMemberMap(project) {
401
754
  for (const m of project?.members ?? []) add(m);
402
755
  return map;
403
756
  }
404
- async function pull(outDir = ".overleaf") {
405
- const { socket, project, docs } = await openProject();
406
- const members = buildMemberMap(project);
407
- const threads = await getThreads();
408
- const comments = [];
409
- const changes = [];
410
- for (const doc of docs) {
411
- const state = await joinDoc(socket, doc._id);
412
- for (const c of state.ranges.comments ?? []) {
413
- const line = offsetToLine(state.lines, c.op.p);
414
- const thread = threads[c.op.t] ?? {};
415
- comments.push({
416
- doc: doc.path,
417
- threadId: c.op.t,
418
- anchor: c.op.c,
419
- line: line + 1,
420
- context: lineContext(state.lines, line),
421
- resolved: Boolean(thread.resolved),
422
- messages: (thread.messages ?? []).map((m) => ({
423
- id: m.id,
424
- author: m.user?.first_name ?? members[m.user_id] ?? m.user_id,
425
- email: m.user?.email,
426
- content: m.content,
427
- timestamp: m.timestamp
428
- }))
429
- });
757
+ async function pull(outDir = ".overleaf", options = {}) {
758
+ let mutationLock;
759
+ let opened;
760
+ try {
761
+ if (options.acquireLock !== false) mutationLock = acquireMutationLock(config.projectId);
762
+ opened = await openProject();
763
+ const { socket, project, docs } = opened;
764
+ const members = buildMemberMap(project);
765
+ const threads = await getThreads();
766
+ const comments = [];
767
+ const changes = [];
768
+ for (const doc of docs) {
769
+ const state = await joinDoc(socket, doc._id);
770
+ for (const c of state.ranges.comments ?? []) {
771
+ const line = offsetToLine(state.lines, c.op.p);
772
+ const thread = threads[c.op.t] ?? {};
773
+ comments.push({
774
+ doc: doc.path,
775
+ threadId: c.op.t,
776
+ anchor: c.op.c,
777
+ line: line + 1,
778
+ context: lineContext(state.lines, line),
779
+ resolved: Boolean(thread.resolved),
780
+ messages: (thread.messages ?? []).map((m) => ({
781
+ id: m.id,
782
+ author: m.user?.first_name ?? members[m.user_id] ?? m.user_id,
783
+ email: m.user?.email,
784
+ content: m.content,
785
+ timestamp: m.timestamp
786
+ }))
787
+ });
788
+ }
789
+ for (const ch of state.ranges.changes ?? []) {
790
+ const isInsert = typeof ch.op.i === "string";
791
+ const line = offsetToLine(state.lines, ch.op.p);
792
+ changes.push({
793
+ doc: doc.path,
794
+ id: ch.id,
795
+ type: isInsert ? "insert" : "delete",
796
+ text: isInsert ? ch.op.i : ch.op.d,
797
+ line: line + 1,
798
+ context: lineContext(state.lines, line),
799
+ author: members[ch.metadata?.user_id] ?? ch.metadata?.user_id ?? "unknown",
800
+ ts: ch.metadata?.ts
801
+ });
802
+ }
430
803
  }
431
- for (const ch of state.ranges.changes ?? []) {
432
- const isInsert = typeof ch.op.i === "string";
433
- const line = offsetToLine(state.lines, ch.op.p);
434
- changes.push({
435
- doc: doc.path,
436
- id: ch.id,
437
- type: isInsert ? "insert" : "delete",
438
- text: isInsert ? ch.op.i : ch.op.d,
439
- line: line + 1,
440
- context: lineContext(state.lines, line),
441
- author: members[ch.metadata?.user_id] ?? ch.metadata?.user_id ?? "unknown",
442
- ts: ch.metadata?.ts
443
- });
804
+ const data = {
805
+ project: project?.name ?? "(unknown)",
806
+ projectId: config.projectId,
807
+ pulledAt: (/* @__PURE__ */ new Date()).toISOString(),
808
+ comments,
809
+ changes
810
+ };
811
+ mkdirSync4(outDir, { recursive: true });
812
+ writeFileSync4(join4(outDir, "reviews.json"), JSON.stringify(data, null, 2) + "\n");
813
+ writeFileSync4(join4(outDir, "reviews.md"), renderMarkdown(data));
814
+ return data;
815
+ } finally {
816
+ try {
817
+ opened?.socket.close();
818
+ } finally {
819
+ mutationLock?.release();
444
820
  }
445
821
  }
446
- socket.close();
447
- const data = {
448
- project: project?.name ?? "(unknown)",
449
- projectId: config.projectId,
450
- pulledAt: (/* @__PURE__ */ new Date()).toISOString(),
451
- comments,
452
- changes
453
- };
454
- mkdirSync3(outDir, { recursive: true });
455
- writeFileSync3(join3(outDir, "reviews.json"), JSON.stringify(data, null, 2) + "\n");
456
- writeFileSync3(join3(outDir, "reviews.md"), renderMarkdown(data));
457
- return data;
458
822
  }
459
823
  function renderMarkdown(d) {
460
824
  const L = [];
@@ -505,343 +869,3203 @@ function renderMarkdown(d) {
505
869
  }
506
870
 
507
871
  // src/commands/push.ts
508
- import { readFileSync as readFileSync3, readdirSync } from "fs";
509
- import { relative, resolve as resolvePath, sep } from "path";
510
- import { randomBytes } from "crypto";
872
+ import {
873
+ mkdirSync as mkdirSync7,
874
+ readFileSync as readFileSync6,
875
+ readdirSync as readdirSync2,
876
+ renameSync as renameSync4,
877
+ statSync,
878
+ unlinkSync as unlinkSync5,
879
+ writeFileSync as writeFileSync7
880
+ } from "fs";
881
+ import { createHash as createHash3 } from "crypto";
882
+ import { dirname as dirname5, relative as relative2, resolve as resolvePath, sep as sep2 } from "path";
883
+
884
+ // src/lib/review-edits.ts
511
885
  import { diffWordsWithSpace } from "diff";
512
- function buildOps(remote, local) {
513
- const ops = [];
514
- let p = 0;
515
- for (const part of diffWordsWithSpace(remote, local)) {
516
- if (part.added) {
517
- ops.push({ p, i: part.value });
518
- p += part.value.length;
519
- } else if (part.removed) {
520
- ops.push({ p, d: part.value });
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;
521
908
  } else {
522
- p += part.value.length;
909
+ pending.text += part.value;
523
910
  }
524
911
  }
525
- return ops;
912
+ flush();
913
+ return edits;
526
914
  }
527
- function preview(op) {
528
- const kind = op.i != null ? "insert" : "delete";
529
- const text = (op.i ?? op.d ?? "").replace(/\n/g, "\u23CE");
530
- const clip = text.length > 60 ? text.slice(0, 60) + "\u2026" : text;
531
- return ` ${kind.padEnd(6)} @ ${String(op.p).padStart(5)} "${clip}"`;
915
+ function sameEdit(a, b) {
916
+ return a.start === b.start && a.end === b.end && a.text === b.text;
532
917
  }
533
- function toOverleafPath(file) {
534
- return relative(process.cwd(), resolvePath(file)).split(sep).join("/");
918
+ function reverseCodePoints(text) {
919
+ return Array.from(text).reverse().join("");
535
920
  }
536
- var IGNORE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".overleaf", "tmp", "dist"]);
537
- function discoverLocalTex(dir = process.cwd(), acc = []) {
538
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
539
- if (entry.name.startsWith(".") || IGNORE_DIRS.has(entry.name)) continue;
540
- const full = resolvePath(dir, entry.name);
541
- if (entry.isDirectory()) discoverLocalTex(full, acc);
542
- else if (entry.name.endsWith(".tex")) acc.push(toOverleafPath(full));
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
+ });
543
945
  }
544
- return acc;
946
+ return ambiguities;
545
947
  }
546
- function pickDoc(file, docName, docs, rootDocId, allowRootFallback) {
547
- if (docName) return docs.find((d) => d.path === docName || d.name === docName);
548
- const rel = toOverleafPath(file);
549
- const base = rel.split("/").pop();
550
- return docs.find((d) => d.path === rel) ?? docs.find((d) => d.name === base) ?? (allowRootFallback ? docs.find((d) => d._id === rootDocId) ?? docs[0] : void 0);
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;
551
951
  }
552
- async function push(opts) {
553
- const { socket, project, docs } = await openProject();
554
- const files = opts.file ? [opts.file] : discoverLocalTex();
555
- if (!files.length) {
556
- console.log("No local .tex files found to push.");
557
- socket.close();
558
- return;
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);
559
970
  }
560
- const plans = [];
561
- for (const file of files) {
562
- let local;
563
- try {
564
- local = readFileSync3(file, "utf8");
565
- } catch {
566
- console.log(`- ${file}: cannot read, skipped`);
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);
567
993
  continue;
568
994
  }
569
- const doc = pickDoc(file, opts.docName, docs, project.rootDoc_id, Boolean(opts.file));
570
- if (!doc) {
571
- console.log(`- ${file}: no matching Overleaf doc, skipped`);
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
+ }
572
1012
  continue;
573
1013
  }
574
- const state = await joinDoc(socket, doc._id);
575
- const ops = buildOps(state.lines.join("\n"), local);
576
- if (ops.length) plans.push({ file, doc, ops, version: state.version });
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
+ }
577
1022
  }
578
- if (!plans.length) {
579
- console.log("Nothing to push \u2014 local files already match Overleaf.");
580
- socket.close();
581
- return;
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;
582
1048
  }
583
- console.log(
584
- opts.direct ? "Mode: DIRECT \u2014 plain edits (not marked as suggestions)" : "Mode: SUGGESTIONS \u2014 tracked changes for co-authors to accept/reject"
585
- );
586
- for (const pl of plans) {
587
- const ins = pl.ops.filter((o) => o.i != null).length;
588
- const del = pl.ops.filter((o) => o.d != null).length;
589
- console.log(`
590
- ${pl.file} \u2192 ${pl.doc.path} (v${pl.version}): ${pl.ops.length} op(s), ${ins} ins / ${del} del`);
591
- for (const op of pl.ops.slice(0, 12)) console.log(preview(op));
592
- if (pl.ops.length > 12) console.log(` \u2026 and ${pl.ops.length - 12} more`);
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
+ }
593
1069
  }
594
- if (opts.dryRun) {
595
- console.log("\n(dry run \u2014 nothing sent to Overleaf)");
596
- socket.close();
597
- return;
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
+ }
598
1090
  }
599
- socket.on("otUpdateError", (a) => console.log("!! otUpdateError:", JSON.stringify(a)));
600
- for (const pl of plans) {
601
- const meta = opts.direct ? {} : { tc: randomBytes(12).toString("hex") };
602
- const update = { doc: pl.doc._id, op: pl.ops, v: pl.version, meta };
603
- const ack = await socket.emit("applyOtUpdate", [pl.doc._id, update], 2e4);
604
- if (ack?.[0]) {
605
- socket.close();
606
- throw new Error(`Overleaf rejected ${pl.file}: ${JSON.stringify(ack[0])}`);
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.");
607
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);
608
1107
  }
609
- let allMatch = true;
610
- for (const pl of plans) {
611
- const after = await joinDoc(socket, pl.doc._id);
612
- if (after.lines.join("\n") !== readFileSync3(pl.file, "utf8")) allMatch = false;
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.");
613
1114
  }
614
- socket.close();
615
- const totalOps = plans.reduce((n, p) => n + p.ops.length, 0);
616
- const mode = opts.direct ? "direct edit(s)" : "tracked suggestion(s)";
617
- console.log(
618
- `
619
- \u2705 Pushed ${totalOps} ${mode} across ${plans.length} file(s) \u2014 verified match: ${allMatch ? "yes" : "\u26A0\uFE0F NO, inspect"}`
620
- );
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);
621
1129
  }
622
-
623
- // src/commands/fetch.ts
624
- import { writeFileSync as writeFileSync4, readFileSync as readFileSync4, mkdirSync as mkdirSync4, existsSync } from "fs";
625
- import { dirname } from "path";
626
- async function fetchDocs(opts) {
627
- const { socket, docs } = await openProject();
628
- const targets = opts.file ? docs.filter((d) => d.path === opts.file || d.name === opts.file) : docs;
629
- if (!targets.length) {
630
- console.log(`No matching doc for "${opts.file}".`);
631
- socket.close();
632
- return;
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.");
633
1134
  }
634
- let changed = 0;
635
- for (const doc of targets) {
636
- const state = await joinDoc(socket, doc._id);
637
- const remote = state.lines.join("\n");
638
- const local = existsSync(doc.path) ? readFileSync4(doc.path, "utf8") : null;
639
- if (local === remote) continue;
640
- changed++;
641
- const delta = local === null ? "(new file)" : `${local.length} \u2192 ${remote.length} chars`;
642
- console.log(` ${doc.path} ${delta}`);
643
- if (!opts.dryRun) {
644
- mkdirSync4(dirname(doc.path), { recursive: true });
645
- writeFileSync4(doc.path, remote);
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) });
646
1153
  }
647
1154
  }
648
- socket.close();
649
- if (!changed) {
650
- console.log("Already up to date \u2014 local files match Overleaf.");
651
- return;
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
+ }
652
1159
  }
653
- console.log(
654
- opts.dryRun ? `
655
- (dry run \u2014 ${changed} local file(s) would be overwritten)` : `
656
- \u2705 Fetched ${changed} file(s) from Overleaf.`
657
- );
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 };
658
1167
  }
659
1168
 
660
- // src/commands/upload.ts
661
- import { readFileSync as readFileSync5 } from "fs";
662
- import { basename } from "path";
663
- function findFolder(folder, wanted, prefix = "") {
664
- for (const f of folder?.folders ?? []) {
665
- const path = prefix ? `${prefix}/${f.name}` : f.name;
666
- if (f.name === wanted || path === wanted) return f;
667
- const deeper = findFolder(f, wanted, path);
668
- if (deeper) return deeper;
1169
+ // src/lib/document-match.ts
1170
+ var AmbiguousDocumentError = class extends Error {
1171
+ constructor(identifier, matches) {
1172
+ super(
1173
+ `Document name "${identifier}" is ambiguous (${matches.map((doc) => doc.path).join(", ")}); use the exact project path.`
1174
+ );
1175
+ this.name = "AmbiguousDocumentError";
669
1176
  }
670
- return void 0;
1177
+ };
1178
+ function matchDocument(identifier, docs) {
1179
+ const exact = docs.filter((doc) => doc.path === identifier);
1180
+ if (exact.length > 1) throw new AmbiguousDocumentError(identifier, exact);
1181
+ if (exact.length === 1) return exact[0];
1182
+ if (identifier.includes("/") || identifier.includes("\\")) return void 0;
1183
+ const basename3 = identifier.replace(/\\/g, "/").split("/").pop() ?? identifier;
1184
+ const matches = docs.filter((doc) => doc.name === basename3);
1185
+ if (matches.length > 1) throw new AmbiguousDocumentError(identifier, matches);
1186
+ return matches[0];
671
1187
  }
672
- async function upload(paths, folderName) {
673
- const { socket, project } = await openProject();
674
- socket.close();
675
- const root = project?.rootFolder?.[0];
676
- if (!root?._id) throw new Error("could not resolve the project root folder");
677
- let folderId = root._id;
678
- if (folderName) {
679
- const found = findFolder(root, folderName);
680
- if (!found) throw new Error(`folder not found in project: ${folderName}`);
681
- folderId = found._id;
682
- }
683
- const csrf = await getCsrfToken();
684
- for (const path of paths) {
685
- const bytes = readFileSync5(path);
686
- const res = await uploadFile(folderId, basename(path), bytes, csrf);
687
- console.log(`\u2705 Uploaded ${path} \u2192 ${res.entity_type} ${res.entity_id}`);
1188
+
1189
+ // src/lib/receipts.ts
1190
+ import {
1191
+ closeSync as closeSync2,
1192
+ existsSync as existsSync3,
1193
+ fsyncSync as fsyncSync2,
1194
+ mkdirSync as mkdirSync5,
1195
+ openSync as openSync2,
1196
+ readFileSync as readFileSync4,
1197
+ readdirSync,
1198
+ renameSync as renameSync2,
1199
+ unlinkSync as unlinkSync3,
1200
+ writeFileSync as writeFileSync5
1201
+ } from "fs";
1202
+ import { randomUUID as randomUUID2 } from "crypto";
1203
+ import { basename, dirname as dirname3, join as join5 } from "path";
1204
+ var RECEIPT_SCHEMA_VERSION = 1;
1205
+ var DEFAULT_RECEIPTS_DIR = join5(".overleaf", "receipts");
1206
+ function safeFilenamePart(value) {
1207
+ const safe = value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
1208
+ return safe || "operation";
1209
+ }
1210
+ function writeJsonAtomic(path, value) {
1211
+ const targetDir = dirname3(path);
1212
+ mkdirSync5(targetDir, { recursive: true });
1213
+ const tempPath = join5(
1214
+ targetDir,
1215
+ `.${basename(path)}.${process.pid}.${randomUUID2()}.tmp`
1216
+ );
1217
+ let fd;
1218
+ try {
1219
+ fd = openSync2(tempPath, "wx", 384);
1220
+ writeFileSync5(fd, `${JSON.stringify(value, null, 2)}
1221
+ `, "utf8");
1222
+ fsyncSync2(fd);
1223
+ closeSync2(fd);
1224
+ fd = void 0;
1225
+ renameSync2(tempPath, path);
1226
+ let dirFd;
1227
+ try {
1228
+ dirFd = openSync2(targetDir, "r");
1229
+ fsyncSync2(dirFd);
1230
+ } catch {
1231
+ } finally {
1232
+ if (dirFd !== void 0) closeSync2(dirFd);
1233
+ }
1234
+ } finally {
1235
+ if (fd !== void 0) closeSync2(fd);
1236
+ if (existsSync3(tempPath)) unlinkSync3(tempPath);
688
1237
  }
689
1238
  }
690
-
691
- // src/commands/comment.ts
692
- import { randomBytes as randomBytes2 } from "crypto";
693
- async function comment(opts) {
694
- const { socket, project, docs } = await openProject();
695
- const doc = opts.docName ? docs.find((d) => d.path === opts.docName || d.name === opts.docName) : docs.find((d) => d._id === project.rootDoc_id) ?? docs[0];
696
- if (!doc) {
697
- socket.close();
698
- throw new Error(`doc not found: ${opts.docName ?? "(root)"}`);
699
- }
700
- const state = await joinDoc(socket, doc._id);
701
- const flat = state.lines.join("\n");
702
- const nth = Math.max(1, opts.occurrence ?? 1);
703
- let p = -1;
704
- let from = 0;
705
- for (let i = 0; i < nth; i++) {
706
- p = flat.indexOf(opts.anchor, from);
707
- if (p < 0) break;
708
- from = p + 1;
709
- }
710
- if (p < 0) {
711
- socket.close();
712
- throw new Error(`anchor text not found in ${doc.name}: "${opts.anchor}"`);
713
- }
714
- const threadId = randomBytes2(12).toString("hex");
715
- const update = {
716
- doc: doc._id,
717
- op: [{ p, c: opts.anchor, t: threadId }],
718
- v: state.version,
719
- meta: {}
1239
+ function beginReceipt(operation, details, options = {}) {
1240
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
1241
+ const operationId = options.operationId ?? randomUUID2();
1242
+ const receipt = {
1243
+ schemaVersion: RECEIPT_SCHEMA_VERSION,
1244
+ operationId,
1245
+ operation,
1246
+ status: "started",
1247
+ startedAt: now,
1248
+ updatedAt: now,
1249
+ details
720
1250
  };
721
- const ack = await socket.emit("applyOtUpdate", [doc._id, update], 15e3);
722
- socket.close();
723
- if (ack?.[0]) throw new Error(`Overleaf rejected the comment op: ${JSON.stringify(ack[0])}`);
724
- const csrf = await getCsrfToken();
725
- await postThreadMessage(threadId, opts.message, csrf);
726
- console.log(`\u2705 Commented on "${opts.anchor}" in ${doc.name} (thread ${threadId})`);
1251
+ const dir = options.receiptsDir ?? DEFAULT_RECEIPTS_DIR;
1252
+ const timestamp = now.replace(/[:.]/g, "-");
1253
+ const path = join5(dir, `${timestamp}-${safeFilenamePart(operation)}-${operationId}.json`);
1254
+ writeJsonAtomic(path, receipt);
1255
+ return { path, receipt };
727
1256
  }
728
-
729
- // src/commands/reply.ts
730
- async function reply(threadId, message) {
731
- const csrf = await getCsrfToken();
732
- await postThreadMessage(threadId, message, csrf);
733
- console.log(`\u2705 Replied to thread ${threadId}`);
1257
+ function updateReceipt(handle, status, details, options = {}) {
1258
+ const receipt = {
1259
+ ...handle.receipt,
1260
+ status,
1261
+ updatedAt: (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
1262
+ details: { ...handle.receipt.details, ...details }
1263
+ };
1264
+ writeJsonAtomic(handle.path, receipt);
1265
+ return { path: handle.path, receipt };
734
1266
  }
735
-
736
- // src/commands/resolve.ts
737
- async function resolve(threadId, reopen = false) {
738
- const { socket, docs } = await openProject();
739
- let docId;
740
- for (const doc of docs) {
741
- const state = await joinDoc(socket, doc._id);
742
- if ((state.ranges.comments ?? []).some((c) => c.op?.t === threadId)) {
743
- docId = doc._id;
744
- break;
745
- }
746
- }
747
- socket.close();
748
- if (!docId) {
749
- throw new Error(
750
- `thread ${threadId} not found in any doc's active comments (already resolved threads may not be locatable this way)`
751
- );
1267
+ function readReceipts(receiptsDir = DEFAULT_RECEIPTS_DIR) {
1268
+ let names;
1269
+ try {
1270
+ names = readdirSync(receiptsDir).filter((name) => name.endsWith(".json"));
1271
+ } catch {
1272
+ return [];
752
1273
  }
753
- const csrf = await getCsrfToken();
754
- await setThreadResolved(docId, threadId, reopen, csrf);
755
- console.log(`\u2705 Thread ${threadId} ${reopen ? "reopened" : "resolved"}`);
756
- }
757
-
758
- // src/commands/delete-comment.ts
759
- async function deleteComment(threadId) {
760
- const { socket, docs } = await openProject();
761
- let docId;
762
- for (const doc of docs) {
763
- const state = await joinDoc(socket, doc._id);
764
- if ((state.ranges.comments ?? []).some((c) => c.op?.t === threadId)) {
765
- docId = doc._id;
766
- break;
1274
+ const receipts = [];
1275
+ for (const name of names) {
1276
+ const path = join5(receiptsDir, name);
1277
+ try {
1278
+ const receipt = JSON.parse(readFileSync4(path, "utf8"));
1279
+ if (receipt?.schemaVersion === RECEIPT_SCHEMA_VERSION && typeof receipt.operationId === "string" && typeof receipt.operation === "string" && typeof receipt.updatedAt === "string" && receipt.details && typeof receipt.details === "object") {
1280
+ receipts.push({ path, receipt });
1281
+ }
1282
+ } catch {
767
1283
  }
768
1284
  }
769
- socket.close();
770
- if (!docId) throw new Error(`thread ${threadId} not found in any doc's comments`);
771
- const csrf = await getCsrfToken();
772
- await deleteThread(docId, threadId, csrf);
773
- console.log(`\u2705 Deleted comment thread ${threadId}`);
1285
+ return receipts.sort((a, b) => b.receipt.updatedAt.localeCompare(a.receipt.updatedAt));
774
1286
  }
775
1287
 
776
- // src/commands/delete-message.ts
777
- async function deleteThreadMessage(messageId, threadId) {
778
- const csrf = await getCsrfToken();
779
- let tid = threadId;
780
- if (!tid) {
781
- const threads = await getThreads();
782
- for (const [t, v] of Object.entries(threads)) {
783
- if (v.messages?.some((m) => m.id === messageId)) {
784
- tid = t;
785
- break;
786
- }
1288
+ // src/lib/sync-state.ts
1289
+ import { createHash as createHash2 } from "crypto";
1290
+ import {
1291
+ mkdirSync as mkdirSync6,
1292
+ readFileSync as readFileSync5,
1293
+ renameSync as renameSync3,
1294
+ unlinkSync as unlinkSync4,
1295
+ writeFileSync as writeFileSync6
1296
+ } from "fs";
1297
+ import { dirname as dirname4, join as join6 } from "path";
1298
+ var BASE_STATE_SCHEMA_VERSION = 1;
1299
+ var BASE_STATE_PATH = join6(".overleaf", "base.json");
1300
+ function sha256(text) {
1301
+ return createHash2("sha256").update(text, "utf8").digest("hex");
1302
+ }
1303
+ function canonicalize(value) {
1304
+ if (Array.isArray(value)) return value.map(canonicalize);
1305
+ if (value && typeof value === "object") {
1306
+ const out = {};
1307
+ for (const key of Object.keys(value).sort()) {
1308
+ const item = value[key];
1309
+ if (item !== void 0) out[key] = canonicalize(item);
787
1310
  }
1311
+ return out;
788
1312
  }
789
- if (!tid) throw new Error(`message ${messageId} not found in any thread`);
790
- await deleteMessage(tid, messageId, csrf);
791
- console.log(`\u2705 Deleted message ${messageId} from thread ${tid}`);
1313
+ return value;
792
1314
  }
793
-
794
- // src/commands/accept.ts
795
- async function accept(changeIds) {
796
- const { socket, docs } = await openProject();
797
- const byDoc = /* @__PURE__ */ new Map();
798
- for (const doc of docs) {
799
- const state = await joinDoc(socket, doc._id);
800
- const here = (state.ranges.changes ?? []).filter((c) => changeIds.includes(c.id)).map((c) => c.id);
801
- if (here.length) byDoc.set(doc._id, here);
802
- }
803
- socket.close();
804
- const found = [...byDoc.values()].flat();
805
- const missing = changeIds.filter((id) => !found.includes(id));
806
- if (missing.length) console.log(`\u26A0\uFE0F not found (already accepted/rejected?): ${missing.join(", ")}`);
807
- if (!found.length) throw new Error("no matching tracked changes found");
808
- const csrf = await getCsrfToken();
809
- for (const [docId, ids] of byDoc) await acceptChanges(docId, ids, csrf);
810
- console.log(`\u2705 Accepted ${found.length} tracked change(s)`);
1315
+ function stableJson(value) {
1316
+ return JSON.stringify(canonicalize(value));
811
1317
  }
812
-
1318
+ function sortedRanges(values) {
1319
+ return (values ?? []).map(canonicalize).sort((a, b) => {
1320
+ const left = stableJson(a);
1321
+ const right = stableJson(b);
1322
+ return left < right ? -1 : left > right ? 1 : 0;
1323
+ });
1324
+ }
1325
+ function fingerprintRanges(ranges) {
1326
+ return sha256(
1327
+ stableJson({
1328
+ comments: sortedRanges(ranges.comments),
1329
+ changes: sortedRanges(ranges.changes)
1330
+ })
1331
+ );
1332
+ }
1333
+ function assertBaseState(value, path) {
1334
+ if (!value || typeof value !== "object") throw new Error(`Invalid base state in ${path}`);
1335
+ const state = value;
1336
+ if (state.schemaVersion !== BASE_STATE_SCHEMA_VERSION || typeof state.projectId !== "string" || !state.documents || typeof state.documents !== "object") {
1337
+ throw new Error(
1338
+ `Unsupported or invalid base state in ${path}; run fetch to create a new synchronization base.`
1339
+ );
1340
+ }
1341
+ for (const [docId, raw] of Object.entries(state.documents)) {
1342
+ const doc = raw;
1343
+ if (doc.docId !== docId || typeof doc.path !== "string" || typeof doc.text !== "string" || typeof doc.hash !== "string" || doc.hash !== sha256(doc.text)) {
1344
+ throw new Error(`Invalid document ${docId} in ${path}`);
1345
+ }
1346
+ }
1347
+ }
1348
+ function loadBaseState(path = BASE_STATE_PATH) {
1349
+ let raw;
1350
+ try {
1351
+ raw = readFileSync5(path, "utf8");
1352
+ } catch (error) {
1353
+ if (error.code === "ENOENT") return void 0;
1354
+ throw error;
1355
+ }
1356
+ let parsed;
1357
+ try {
1358
+ parsed = JSON.parse(raw);
1359
+ } catch {
1360
+ throw new Error(`Invalid JSON in ${path}; run fetch to recreate the synchronization base.`);
1361
+ }
1362
+ assertBaseState(parsed, path);
1363
+ return parsed;
1364
+ }
1365
+ function saveBaseState(state, path = BASE_STATE_PATH) {
1366
+ mkdirSync6(dirname4(path), { recursive: true });
1367
+ const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
1368
+ try {
1369
+ writeFileSync6(temp, JSON.stringify(state, null, 2) + "\n", { mode: 384 });
1370
+ renameSync3(temp, path);
1371
+ } catch (error) {
1372
+ try {
1373
+ unlinkSync4(temp);
1374
+ } catch {
1375
+ }
1376
+ throw error;
1377
+ }
1378
+ }
1379
+ function mergeBaseDocuments(projectId, documents, path = BASE_STATE_PATH) {
1380
+ const previous = loadBaseState(path);
1381
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1382
+ const state = {
1383
+ schemaVersion: BASE_STATE_SCHEMA_VERSION,
1384
+ projectId,
1385
+ updatedAt: now,
1386
+ documents: previous?.projectId === projectId ? { ...previous.documents } : {}
1387
+ };
1388
+ for (const doc of documents) state.documents[doc.docId] = doc;
1389
+ saveBaseState(state, path);
1390
+ return state;
1391
+ }
1392
+
1393
+ // src/lib/tracked-overlap.ts
1394
+ function spanOverlapsEdit(start, end, edit) {
1395
+ const editIsPoint = edit.start === edit.end;
1396
+ const rangeIsPoint = start === end;
1397
+ if (editIsPoint && rangeIsPoint) return edit.start === start;
1398
+ if (editIsPoint) return edit.start > start && edit.start < end;
1399
+ if (rangeIsPoint) return start >= edit.start && start < edit.end;
1400
+ return edit.start < end && start < edit.end;
1401
+ }
1402
+ function overlapsEdit(change, edit) {
1403
+ const p = change.op?.p;
1404
+ if (typeof p !== "number") return false;
1405
+ const inserted = typeof change.op?.i === "string" ? change.op.i : void 0;
1406
+ const changeStart = p;
1407
+ const changeEnd = p + (inserted?.length ?? 0);
1408
+ return spanOverlapsEdit(changeStart, changeEnd, edit);
1409
+ }
1410
+ function findTrackedChangeOverlaps(changes, proposedEdits) {
1411
+ const out = [];
1412
+ for (const change of changes ?? []) {
1413
+ for (const proposedEdit of proposedEdits) {
1414
+ if (overlapsEdit(change, proposedEdit)) {
1415
+ out.push({ changeId: change.id ?? "(unknown)", change, proposedEdit });
1416
+ }
1417
+ }
1418
+ }
1419
+ return out;
1420
+ }
1421
+ function findCommentOverlaps(comments, proposedEdits) {
1422
+ const out = [];
1423
+ for (const comment2 of comments ?? []) {
1424
+ const p = comment2.op?.p;
1425
+ const anchor = comment2.op?.c;
1426
+ if (typeof p !== "number" || typeof anchor !== "string") continue;
1427
+ for (const proposedEdit of proposedEdits) {
1428
+ if (spanOverlapsEdit(p, p + anchor.length, proposedEdit)) {
1429
+ out.push({
1430
+ threadId: comment2.op?.t ?? comment2.id ?? "(unknown)",
1431
+ position: p,
1432
+ anchor,
1433
+ comment: comment2,
1434
+ proposedEdit
1435
+ });
1436
+ }
1437
+ }
1438
+ }
1439
+ return out;
1440
+ }
1441
+
1442
+ // src/lib/tracked-changes.ts
1443
+ import { randomBytes } from "crypto";
1444
+ var TRACKED_CHANGE_SEED_BYTES = 9;
1445
+ function createTrackedChangeSeed(bytes = randomBytes) {
1446
+ const entropy = bytes(TRACKED_CHANGE_SEED_BYTES);
1447
+ if (entropy.byteLength !== TRACKED_CHANGE_SEED_BYTES) {
1448
+ throw new Error(
1449
+ `tracked-change seed source returned ${entropy.byteLength} bytes; expected ${TRACKED_CHANGE_SEED_BYTES}`
1450
+ );
1451
+ }
1452
+ return Buffer.from(entropy).toString("hex");
1453
+ }
1454
+ var TrackedChangeMutationError = class extends Error {
1455
+ constructor(message, result, cause) {
1456
+ super(message, cause === void 0 ? void 0 : { cause });
1457
+ this.result = result;
1458
+ this.name = "TrackedChangeMutationError";
1459
+ }
1460
+ result;
1461
+ };
1462
+ function uniqueChangeIds(changeIds) {
1463
+ return [...new Set(changeIds)];
1464
+ }
1465
+ function changeIdsInRanges(ranges) {
1466
+ return [...new Set(ranges.map((range) => range.id))];
1467
+ }
1468
+ function remainingChangeIds(ranges, requestedIds) {
1469
+ const present = new Set(changeIdsInRanges(ranges));
1470
+ return uniqueChangeIds(requestedIds).filter((id) => present.has(id));
1471
+ }
1472
+ function inverseOf(range) {
1473
+ const { p, i, d } = range.op ?? {};
1474
+ if (!Number.isSafeInteger(p) || p < 0) {
1475
+ throw new Error(`tracked change ${range.id} has an invalid position: ${String(p)}`);
1476
+ }
1477
+ if (typeof i === "string" && d === void 0) return { p, d: i, u: true };
1478
+ if (typeof d === "string" && i === void 0) return { p, i: d, u: true };
1479
+ throw new Error(`tracked change ${range.id} does not contain exactly one insert/delete op`);
1480
+ }
1481
+ function applyUndo(text, op, changeId) {
1482
+ if (op.p > text.length) {
1483
+ throw new Error(
1484
+ `tracked change ${changeId} starts at ${op.p}, beyond document length ${text.length}`
1485
+ );
1486
+ }
1487
+ if ("d" in op) {
1488
+ const actual = text.slice(op.p, op.p + op.d.length);
1489
+ if (actual !== op.d) {
1490
+ throw new Error(
1491
+ `tracked insertion ${changeId} no longer matches document text at ${op.p}: expected ${JSON.stringify(op.d)}, found ${JSON.stringify(actual)}`
1492
+ );
1493
+ }
1494
+ return text.slice(0, op.p) + text.slice(op.p + op.d.length);
1495
+ }
1496
+ return text.slice(0, op.p) + op.i + text.slice(op.p);
1497
+ }
1498
+ function buildRejectionPlan(currentText, ranges, requestedIds) {
1499
+ const requested = new Set(uniqueChangeIds(requestedIds));
1500
+ const fragments = ranges.filter((range) => requested.has(range.id));
1501
+ fragments.sort((a, b) => b.op.p - a.op.p);
1502
+ const operations = [];
1503
+ let expectedText = currentText;
1504
+ for (const range of fragments) {
1505
+ const inverse = inverseOf(range);
1506
+ expectedText = applyUndo(expectedText, inverse, range.id);
1507
+ operations.push(inverse);
1508
+ }
1509
+ return {
1510
+ changeIds: changeIdsInRanges(fragments),
1511
+ fragmentCount: fragments.length,
1512
+ operations,
1513
+ expectedText
1514
+ };
1515
+ }
1516
+
1517
+ // src/lib/snapshots.ts
1518
+ import { join as join7 } from "path";
1519
+ var SNAPSHOTS_DIR = join7(".overleaf", "snapshots");
1520
+ function snapshotTimestamp(date = /* @__PURE__ */ new Date()) {
1521
+ return date.toISOString().replace(/[:.]/g, "-");
1522
+ }
1523
+ function snapshotRelativePath(timestamp, projectPath, root = process.cwd()) {
1524
+ if (!/^[0-9TZ-]+$/.test(timestamp)) throw new Error(`Invalid snapshot timestamp: ${timestamp}`);
1525
+ const safeProjectPath = workspaceRelativePath(projectPath, root);
1526
+ return workspaceRelativePath(join7(SNAPSHOTS_DIR, timestamp, safeProjectPath), root);
1527
+ }
1528
+
1529
+ // src/commands/push.ts
1530
+ var PUSH_PLAN_SCHEMA_VERSION = 4;
1531
+ var PUSH_PLAN_KIND = "overleaf-review-push-plan";
1532
+ var PushSubmissionError = class extends Error {
1533
+ constructor(message, receiptPath, status, documents, cause) {
1534
+ super(message, cause === void 0 ? void 0 : { cause });
1535
+ this.receiptPath = receiptPath;
1536
+ this.status = status;
1537
+ this.documents = documents;
1538
+ this.name = "PushSubmissionError";
1539
+ }
1540
+ receiptPath;
1541
+ status;
1542
+ documents;
1543
+ };
1544
+ var PushPlanningError = class extends Error {
1545
+ constructor(message, conflicts = [], overlaps = []) {
1546
+ super(message);
1547
+ this.conflicts = conflicts;
1548
+ this.overlaps = overlaps;
1549
+ this.name = "PushPlanningError";
1550
+ }
1551
+ conflicts;
1552
+ overlaps;
1553
+ };
1554
+ var PushPlanValidationError = class extends Error {
1555
+ constructor(message) {
1556
+ super(message);
1557
+ this.name = "PushPlanValidationError";
1558
+ }
1559
+ };
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
+ }
1564
+ if (opts.docName && !opts.file) {
1565
+ throw new Error("--doc requires --file; bulk pushes cannot map multiple local files to one document.");
1566
+ }
1567
+ }
1568
+ function buildOps(source, target, options = {}) {
1569
+ const ops = [];
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;
1577
+ }
1578
+ const rebuilt = applyOps(source, ops);
1579
+ if (rebuilt !== target) {
1580
+ throw new Error("internal error: generated OT operations do not reconstruct target text");
1581
+ }
1582
+ return ops;
1583
+ }
1584
+ function buildOperationFootprint(source, target, options = {}) {
1585
+ return buildReviewEdits(source, target, options);
1586
+ }
1587
+ function applyOps(source, ops) {
1588
+ let text = source;
1589
+ for (const op of ops) {
1590
+ if (!Number.isSafeInteger(op.p) || op.p < 0 || op.p > text.length) {
1591
+ throw new Error(`invalid operation position ${String(op.p)} for ${text.length}-character text`);
1592
+ }
1593
+ const hasInsert = typeof op.i === "string";
1594
+ const hasDelete = typeof op.d === "string";
1595
+ if (hasInsert === hasDelete) throw new Error("operation must contain exactly one of i or d");
1596
+ if (hasInsert) {
1597
+ text = text.slice(0, op.p) + op.i + text.slice(op.p);
1598
+ } else {
1599
+ const deletion = op.d;
1600
+ const actual = text.slice(op.p, op.p + deletion.length);
1601
+ if (actual !== deletion) {
1602
+ throw new Error(
1603
+ `delete operation mismatch at ${op.p}: expected ${JSON.stringify(deletion)}, found ${JSON.stringify(actual)}`
1604
+ );
1605
+ }
1606
+ text = text.slice(0, op.p) + text.slice(op.p + deletion.length);
1607
+ }
1608
+ }
1609
+ return text;
1610
+ }
1611
+ function preview(op) {
1612
+ const kind = op.i != null ? "insert" : "delete";
1613
+ const text = (op.i ?? op.d ?? "").replace(/\n/g, "\u23CE");
1614
+ const clip = text.length > 60 ? text.slice(0, 60) + "\u2026" : text;
1615
+ return ` ${kind.padEnd(6)} @ ${String(op.p).padStart(5)} "${clip}"`;
1616
+ }
1617
+ function toOverleafPath(file) {
1618
+ return workspaceRelativePath(file);
1619
+ }
1620
+ var IGNORE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".overleaf", "tmp", "dist"]);
1621
+ function discoverLocalTex(dir = process.cwd(), acc = []) {
1622
+ for (const entry of readdirSync2(dir, { withFileTypes: true })) {
1623
+ if (entry.name.startsWith(".") || IGNORE_DIRS.has(entry.name)) continue;
1624
+ const full = resolvePath(dir, entry.name);
1625
+ if (entry.isDirectory()) discoverLocalTex(full, acc);
1626
+ else if (entry.name.endsWith(".tex")) {
1627
+ acc.push(relative2(process.cwd(), full).split(sep2).join("/"));
1628
+ }
1629
+ }
1630
+ return acc;
1631
+ }
1632
+ function pickDoc(file, docName, docs) {
1633
+ return matchDocument(docName?.replace(/\\/g, "/") ?? toOverleafPath(file), docs);
1634
+ }
1635
+ function formatConflicts(conflicts) {
1636
+ return conflicts.flatMap(
1637
+ ({ docPath, conflicts: items }) => items.map(
1638
+ ({ local, live, reason }) => reason === "ambiguous-local-anchor" ? `${docPath}: repeated-text anchor for local [${local.start},${local.end}) is ambiguous and its envelope is touched by live [${live.start},${live.end}); refresh and reapply this edit explicitly` : `${docPath}: local [${local.start},${local.end}) overlaps live [${live.start},${live.end})`
1639
+ )
1640
+ ).join("\n ");
1641
+ }
1642
+ function serializeOverlaps(overlaps) {
1643
+ return overlaps.map(({ changeId, proposedEdit, change }) => ({
1644
+ changeId,
1645
+ proposedEdit,
1646
+ trackedOp: {
1647
+ p: change.op?.p,
1648
+ i: change.op?.i,
1649
+ d: change.op?.d
1650
+ }
1651
+ })).sort((a, b) => {
1652
+ const left = stableJson(a);
1653
+ const right = stableJson(b);
1654
+ return left < right ? -1 : left > right ? 1 : 0;
1655
+ });
1656
+ }
1657
+ function serializeActiveTrackedRanges(changes) {
1658
+ const ranges = (changes ?? []).map((change, index) => {
1659
+ const id = change?.id;
1660
+ const p = change?.op?.p;
1661
+ const hasInsert = typeof change?.op?.i === "string";
1662
+ const hasDelete = typeof change?.op?.d === "string";
1663
+ if (typeof id !== "string" || !Number.isSafeInteger(p) || p < 0 || hasInsert === hasDelete) {
1664
+ throw new Error(`Overleaf returned an invalid tracked range at index ${index}`);
1665
+ }
1666
+ return {
1667
+ id,
1668
+ op: {
1669
+ p,
1670
+ ...hasInsert ? { i: change.op.i } : { d: change.op.d }
1671
+ },
1672
+ ...change.metadata && typeof change.metadata === "object" ? { metadata: change.metadata } : {}
1673
+ };
1674
+ });
1675
+ return ranges.sort((a, b) => {
1676
+ const left = stableJson(a);
1677
+ const right = stableJson(b);
1678
+ return left < right ? -1 : left > right ? 1 : 0;
1679
+ });
1680
+ }
1681
+ function serializeCommentOverlaps(overlaps) {
1682
+ return overlaps.map(({ threadId, position, anchor, proposedEdit }) => ({
1683
+ threadId,
1684
+ position,
1685
+ anchor,
1686
+ proposedEdit
1687
+ })).sort((a, b) => {
1688
+ const left = stableJson(a);
1689
+ const right = stableJson(b);
1690
+ return left < right ? -1 : left > right ? 1 : 0;
1691
+ });
1692
+ }
1693
+ async function createPlan(opts = {}) {
1694
+ validatePushOptions(opts);
1695
+ const manifest = opts.edits ? JSON.parse(readFileSync6(workspaceReadPath(opts.edits), "utf8")) : void 0;
1696
+ if (opts.plan) throw new Error("createPlan does not accept an existing plan");
1697
+ const basePath = opts.basePath ?? BASE_STATE_PATH;
1698
+ const baseState = loadBaseState(basePath);
1699
+ if (baseState && baseState.projectId !== config.projectId && !opts.unsafeNoBase) {
1700
+ throw new PushPlanningError(
1701
+ `Saved base belongs to project ${baseState.projectId}, not ${config.projectId}; run fetch first.`
1702
+ );
1703
+ }
1704
+ const { socket, project, docs } = await openProject();
1705
+ try {
1706
+ const projectId = String(project?._id ?? config.projectId);
1707
+ if (projectId !== config.projectId) {
1708
+ throw new PushPlanningError(
1709
+ `Connected project id ${projectId} does not match configured project ${config.projectId}.`
1710
+ );
1711
+ }
1712
+ const files = opts.file ? [workspaceRelativePath(opts.file)] : discoverLocalTex();
1713
+ if (!files.length) {
1714
+ const emptyPlan = {
1715
+ kind: PUSH_PLAN_KIND,
1716
+ schemaVersion: PUSH_PLAN_SCHEMA_VERSION,
1717
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1718
+ projectId,
1719
+ projectName: String(project?.name ?? "(unknown)"),
1720
+ direct: Boolean(opts.direct),
1721
+ unsafeNoBase: Boolean(opts.unsafeNoBase),
1722
+ allowOverlap: Boolean(opts.allowOverlap),
1723
+ documents: []
1724
+ };
1725
+ if (opts.planOut) writePushPlan(emptyPlan, opts.planOut);
1726
+ return emptyPlan;
1727
+ }
1728
+ const documents = [];
1729
+ const conflicts = [];
1730
+ const blockedOverlaps = [];
1731
+ for (const file of files) {
1732
+ let local;
1733
+ try {
1734
+ local = readFileSync6(workspaceReadPath(file), "utf8");
1735
+ } catch (error) {
1736
+ throw new Error(`Cannot safely read ${file}: ${error.message}`);
1737
+ }
1738
+ const doc = pickDoc(file, opts.docName, docs);
1739
+ if (!doc) {
1740
+ throw new Error(
1741
+ `No Overleaf document matches ${file}; push it with --file and --doc using the exact project path.`
1742
+ );
1743
+ }
1744
+ const state = await joinDoc(socket, doc._id);
1745
+ const live = state.lines.join("\n");
1746
+ const savedBase = baseState?.projectId === projectId ? baseState.documents[doc._id] : void 0;
1747
+ if (!savedBase && !opts.unsafeNoBase) {
1748
+ throw new PushPlanningError(
1749
+ `No saved synchronization base for ${doc.path}. Run fetch first, or explicitly use \`--unsafe-no-base\` to request legacy two-way behavior.`
1750
+ );
1751
+ }
1752
+ const base = savedBase?.text ?? live;
1753
+ const merge = threeWayMerge(base, local, live);
1754
+ if (merge.conflicts.length) {
1755
+ conflicts.push({ localPath: file, docPath: doc.path, conflicts: merge.conflicts });
1756
+ continue;
1757
+ }
1758
+ const expected = merge.text;
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);
1763
+ if (!ops.length) continue;
1764
+ const proposedEdits = buildOperationFootprint(live, expected, grouping);
1765
+ const activeTrackedRanges = serializeActiveTrackedRanges(state.ranges.changes);
1766
+ assertTrackedRangeBudget(activeTrackedRanges.length, ops.length, Boolean(opts.direct), doc.path);
1767
+ const overlaps = serializeOverlaps(
1768
+ findTrackedChangeOverlaps(state.ranges.changes, proposedEdits)
1769
+ );
1770
+ const commentOverlaps = serializeCommentOverlaps(
1771
+ findCommentOverlaps(state.ranges.comments, proposedEdits)
1772
+ );
1773
+ if (overlaps.length && !opts.allowOverlap) {
1774
+ blockedOverlaps.push({ localPath: file, docPath: doc.path, overlaps });
1775
+ continue;
1776
+ }
1777
+ documents.push({
1778
+ ...explicitEdits ? { explicitEdits } : {},
1779
+ localPath: toOverleafPath(file),
1780
+ docId: doc._id,
1781
+ docPath: doc.path,
1782
+ baseSource: savedBase ? "saved" : "live-unsafe",
1783
+ baseHash: sha256(base),
1784
+ localHash: sha256(local),
1785
+ liveHash: sha256(live),
1786
+ liveVersion: state.version,
1787
+ rangeFingerprint: fingerprintRanges(state.ranges),
1788
+ ops,
1789
+ expectedHash: sha256(expected),
1790
+ tcSeed: opts.direct ? null : createTrackedChangeSeed(),
1791
+ activeTrackedRanges,
1792
+ trackedChangeOverlaps: overlaps,
1793
+ commentOverlaps
1794
+ });
1795
+ }
1796
+ if (conflicts.length || blockedOverlaps.length) {
1797
+ const parts = [];
1798
+ if (conflicts.length) parts.push(`Concurrent edit conflicts:
1799
+ ${formatConflicts(conflicts)}`);
1800
+ if (blockedOverlaps.length) {
1801
+ const lines = blockedOverlaps.flatMap(
1802
+ ({ docPath, overlaps }) => overlaps.map(
1803
+ ({ changeId, proposedEdit }) => `${docPath}: proposed [${proposedEdit.start},${proposedEdit.end}) overlaps change ${changeId}`
1804
+ )
1805
+ );
1806
+ parts.push(
1807
+ `Active tracked-change overlaps:
1808
+ ${lines.join("\n ")}
1809
+ Re-plan with --allow-overlap only after inspecting these changes.`
1810
+ );
1811
+ }
1812
+ throw new PushPlanningError(parts.join("\n\n"), conflicts, blockedOverlaps);
1813
+ }
1814
+ const plan = {
1815
+ kind: PUSH_PLAN_KIND,
1816
+ schemaVersion: PUSH_PLAN_SCHEMA_VERSION,
1817
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1818
+ projectId,
1819
+ projectName: String(project?.name ?? "(unknown)"),
1820
+ direct: Boolean(opts.direct),
1821
+ unsafeNoBase: documents.some((doc) => doc.baseSource === "live-unsafe"),
1822
+ allowOverlap: Boolean(opts.allowOverlap),
1823
+ documents
1824
+ };
1825
+ if (opts.planOut) writePushPlan(plan, opts.planOut);
1826
+ return plan;
1827
+ } finally {
1828
+ socket.close();
1829
+ }
1830
+ }
1831
+ function assertHash(value, field) {
1832
+ if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) {
1833
+ throw new PushPlanValidationError(`Invalid ${field} in push plan`);
1834
+ }
1835
+ }
1836
+ function validatePushPlan(value) {
1837
+ if (!value || typeof value !== "object") throw new PushPlanValidationError("Push plan is not an object");
1838
+ const plan = value;
1839
+ if (plan.kind !== PUSH_PLAN_KIND || plan.schemaVersion !== PUSH_PLAN_SCHEMA_VERSION) {
1840
+ throw new PushPlanValidationError("Unsupported push-plan kind or schema version; create a new plan with the current tool.");
1841
+ }
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)) {
1843
+ throw new PushPlanValidationError("Push plan is missing required fields");
1844
+ }
1845
+ const seen = /* @__PURE__ */ new Set();
1846
+ const seenLocalPaths = /* @__PURE__ */ new Set();
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.");
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)) {
1850
+ throw new PushPlanValidationError("Push plan contains an invalid document");
1851
+ }
1852
+ try {
1853
+ if (workspaceRelativePath(doc.localPath) !== doc.localPath || workspaceRelativePath(doc.docPath) !== doc.docPath) {
1854
+ throw new Error("path is not normalized");
1855
+ }
1856
+ } catch (error) {
1857
+ throw new PushPlanValidationError(
1858
+ `Unsafe or invalid path in push plan: ${error.message}`
1859
+ );
1860
+ }
1861
+ if (seen.has(doc.docId)) throw new PushPlanValidationError(`Duplicate document ${doc.docId} in plan`);
1862
+ seen.add(doc.docId);
1863
+ if (seenLocalPaths.has(doc.localPath)) {
1864
+ throw new PushPlanValidationError(`Duplicate local path ${doc.localPath} in plan`);
1865
+ }
1866
+ seenLocalPaths.add(doc.localPath);
1867
+ assertHash(doc.baseHash, "baseHash");
1868
+ assertHash(doc.localHash, "localHash");
1869
+ assertHash(doc.liveHash, "liveHash");
1870
+ assertHash(doc.rangeFingerprint, "rangeFingerprint");
1871
+ assertHash(doc.expectedHash, "expectedHash");
1872
+ for (const op of doc.ops) {
1873
+ if (!op || !Number.isSafeInteger(op.p) || op.p < 0 || typeof op.i === "string" === (typeof op.d === "string") || typeof (op.i ?? op.d) === "string" && (op.i ?? op.d).length === 0) {
1874
+ throw new PushPlanValidationError(`Invalid operation in ${doc.docPath}`);
1875
+ }
1876
+ }
1877
+ for (const range of doc.activeTrackedRanges) {
1878
+ if (!range || typeof range.id !== "string" || !Number.isSafeInteger(range.op?.p) || range.op.p < 0 || typeof range.op.i === "string" === (typeof range.op.d === "string") || range.metadata !== void 0 && (!range.metadata || typeof range.metadata !== "object")) {
1879
+ throw new PushPlanValidationError(`Invalid active tracked range in ${doc.docPath}`);
1880
+ }
1881
+ }
1882
+ for (const overlap of doc.trackedChangeOverlaps) {
1883
+ if (!overlap || typeof overlap.changeId !== "string" || !validTextEdit(overlap.proposedEdit) || !Number.isSafeInteger(overlap.trackedOp?.p)) {
1884
+ throw new PushPlanValidationError(`Invalid tracked-change overlap in ${doc.docPath}`);
1885
+ }
1886
+ }
1887
+ for (const overlap of doc.commentOverlaps) {
1888
+ if (!overlap || typeof overlap.threadId !== "string" || !Number.isSafeInteger(overlap.position) || overlap.position < 0 || typeof overlap.anchor !== "string" || !validTextEdit(overlap.proposedEdit)) {
1889
+ throw new PushPlanValidationError(`Invalid comment overlap in ${doc.docPath}`);
1890
+ }
1891
+ }
1892
+ if (plan.direct) {
1893
+ if (doc.tcSeed !== null) throw new PushPlanValidationError("Direct plan must not contain tcSeed");
1894
+ } else if (typeof doc.tcSeed !== "string" || !/^[0-9a-f]{18}$/.test(doc.tcSeed)) {
1895
+ throw new PushPlanValidationError(`Invalid tracked-change seed in ${doc.docPath}`);
1896
+ }
1897
+ if (!plan.allowOverlap && doc.trackedChangeOverlaps.length) {
1898
+ throw new PushPlanValidationError("Plan contains blocked tracked-change overlaps");
1899
+ }
1900
+ if (doc.baseSource === "live-unsafe" && doc.baseHash !== doc.liveHash) {
1901
+ throw new PushPlanValidationError(`Unsafe base must equal planned live text in ${doc.docPath}`);
1902
+ }
1903
+ }
1904
+ return plan;
1905
+ }
1906
+ function validTextEdit(value) {
1907
+ if (!value || typeof value !== "object") return false;
1908
+ const edit = value;
1909
+ return Boolean(
1910
+ Number.isSafeInteger(edit.start) && Number.isSafeInteger(edit.end) && edit.start >= 0 && edit.end >= edit.start && typeof edit.text === "string"
1911
+ );
1912
+ }
1913
+ function readPushPlan(path, workspaceRoot = process.cwd()) {
1914
+ let parsed;
1915
+ try {
1916
+ parsed = JSON.parse(readFileSync6(workspaceReadPath(path, workspaceRoot), "utf8"));
1917
+ } catch (error) {
1918
+ throw new PushPlanValidationError(`Cannot read push plan ${path}: ${error.message}`);
1919
+ }
1920
+ return validatePushPlan(parsed);
1921
+ }
1922
+ function writePushPlan(plan, path, workspaceRoot = process.cwd()) {
1923
+ validatePushPlan(plan);
1924
+ const target = workspaceWritePath(path, workspaceRoot);
1925
+ mkdirSync7(dirname5(target), { recursive: true });
1926
+ const temp = `${target}.tmp-${process.pid}-${Date.now()}`;
1927
+ try {
1928
+ writeFileSync7(temp, JSON.stringify(plan, null, 2) + "\n", { mode: 384 });
1929
+ renameSync4(temp, target);
1930
+ } catch (error) {
1931
+ try {
1932
+ unlinkSync5(temp);
1933
+ } catch {
1934
+ }
1935
+ throw error;
1936
+ }
1937
+ }
1938
+ function trackedChangeIdsForSeed(seed, count) {
1939
+ return Array.from(
1940
+ { length: count },
1941
+ (_, index) => `${seed}${(index + 1).toString(16).padStart(6, "0")}`
1942
+ );
1943
+ }
1944
+ function errorMessage(error) {
1945
+ return error instanceof Error ? error.message : String(error);
1946
+ }
1947
+ function overleafSnapshotHash(text) {
1948
+ return createHash3("sha1").update(`blob ${text.length}\0`, "utf8").update(text, "utf8").digest("hex");
1949
+ }
1950
+ function validatePlannedIntent(planned, base, local, live, grouping = reviewGroupingOptions(base, live, {})) {
1951
+ if (sha256(base) !== planned.baseHash) {
1952
+ throw new PushPlanValidationError(`Synchronization base changed for ${planned.docPath}.`);
1953
+ }
1954
+ if (sha256(local) !== planned.localHash) {
1955
+ throw new PushPlanValidationError(`${planned.localPath} changed after planning.`);
1956
+ }
1957
+ if (sha256(live) !== planned.liveHash) {
1958
+ throw new PushPlanValidationError(`${planned.docPath} text changed after planning.`);
1959
+ }
1960
+ const merge = threeWayMerge(base, local, live);
1961
+ if (merge.conflicts.length || merge.text === void 0) {
1962
+ throw new PushPlanValidationError(
1963
+ `${planned.docPath} no longer has the conflict-free intent recorded by the plan.`
1964
+ );
1965
+ }
1966
+ const expected = merge.text;
1967
+ if (stableJson(buildOps(live, expected, grouping)) !== stableJson(planned.ops) || sha256(expected) !== planned.expectedHash) {
1968
+ throw new PushPlanValidationError(
1969
+ `Operations in ${planned.docPath} do not match its saved Base\u2192Local intent.`
1970
+ );
1971
+ }
1972
+ return expected;
1973
+ }
1974
+ function validateReviewBinding(planned, state, live, expected, allowOverlap, grouping) {
1975
+ if (state.version !== planned.liveVersion) {
1976
+ throw new PushPlanValidationError(
1977
+ `${planned.docPath} version changed from ${planned.liveVersion} to ${state.version}; create a new plan.`
1978
+ );
1979
+ }
1980
+ if (fingerprintRanges(state.ranges) !== planned.rangeFingerprint) {
1981
+ throw new PushPlanValidationError(
1982
+ `${planned.docPath} comments or tracked ranges changed after planning; create a new plan.`
1983
+ );
1984
+ }
1985
+ const footprint = buildOperationFootprint(live, expected, grouping);
1986
+ const activeTrackedRanges = serializeActiveTrackedRanges(state.ranges.changes);
1987
+ const trackedOverlaps = serializeOverlaps(
1988
+ findTrackedChangeOverlaps(state.ranges.changes, footprint)
1989
+ );
1990
+ const commentOverlaps = serializeCommentOverlaps(
1991
+ findCommentOverlaps(state.ranges.comments, footprint)
1992
+ );
1993
+ if (stableJson(activeTrackedRanges) !== stableJson(planned.activeTrackedRanges)) {
1994
+ throw new PushPlanValidationError(
1995
+ `Active tracked-range data is invalid for ${planned.docPath}; create a new plan.`
1996
+ );
1997
+ }
1998
+ if (stableJson(trackedOverlaps) !== stableJson(planned.trackedChangeOverlaps)) {
1999
+ throw new PushPlanValidationError(
2000
+ `Tracked-change overlap data is invalid for ${planned.docPath}; create a new plan.`
2001
+ );
2002
+ }
2003
+ if (trackedOverlaps.length && !allowOverlap) {
2004
+ throw new PushPlanValidationError(
2005
+ `${planned.docPath} operations overlap active tracked changes; create a plan with --allow-overlap only after inspecting them.`
2006
+ );
2007
+ }
2008
+ if (stableJson(commentOverlaps) !== stableJson(planned.commentOverlaps)) {
2009
+ throw new PushPlanValidationError(
2010
+ `Comment-overlap data is invalid for ${planned.docPath}; create a new plan.`
2011
+ );
2012
+ }
2013
+ }
2014
+ function baseTextForPlan(plan, planned, live, basePath) {
2015
+ if (planned.baseSource === "live-unsafe") {
2016
+ if (!plan.unsafeNoBase) {
2017
+ throw new PushPlanValidationError(`${planned.docPath} uses an unauthorized unsafe base.`);
2018
+ }
2019
+ return live;
2020
+ }
2021
+ const state = loadBaseState(basePath);
2022
+ const saved = state?.projectId === plan.projectId ? state.documents[planned.docId] : void 0;
2023
+ if (!saved || saved.hash !== planned.baseHash || sha256(saved.text) !== planned.baseHash) {
2024
+ throw new PushPlanValidationError(
2025
+ `Synchronization base for ${planned.docPath} changed after planning; create a new plan.`
2026
+ );
2027
+ }
2028
+ return saved.text;
2029
+ }
2030
+ async function bindPlanDocument(plan, planned, socket, basePath) {
2031
+ let local;
2032
+ try {
2033
+ local = readFileSync6(workspaceReadPath(planned.localPath), "utf8");
2034
+ } catch (error) {
2035
+ throw new PushPlanValidationError(
2036
+ `Cannot read planned local file ${planned.localPath}: ${errorMessage(error)}`
2037
+ );
2038
+ }
2039
+ const state = await joinDoc(socket, planned.docId);
2040
+ const live = state.lines.join("\n");
2041
+ const base = baseTextForPlan(plan, planned, live, basePath);
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);
2047
+ return { plan: planned, state, expected };
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
+ }
2058
+ function verifiedTrackedIds(plan, planned, after) {
2059
+ if (plan.direct) return [];
2060
+ const actualIds = new Set((after.ranges.changes ?? []).map((change) => String(change.id)));
2061
+ if (planned.trackedChangeOverlaps.length) {
2062
+ const ids = [...actualIds].filter(
2063
+ (id) => new RegExp(`^${planned.tcSeed}[0-9a-f]{6}$`).test(id)
2064
+ );
2065
+ if (!ids.length) {
2066
+ throw new Error(
2067
+ `Verification failed for ${planned.docPath}: no tracked ranges with seed ${planned.tcSeed} were created.`
2068
+ );
2069
+ }
2070
+ return ids;
2071
+ }
2072
+ const expectedIds = trackedChangeIdsForSeed(planned.tcSeed, planned.ops.length);
2073
+ const missing = expectedIds.filter((id) => !actualIds.has(id));
2074
+ if (missing.length) {
2075
+ throw new Error(
2076
+ `Verification failed for ${planned.docPath}: tracked ranges were not created for ${missing.join(", ")}.`
2077
+ );
2078
+ }
2079
+ return expectedIds;
2080
+ }
2081
+ function definitelyRejectedApply(error) {
2082
+ const message = errorMessage(error);
2083
+ return message.includes("Overleaf rejected the OT update") || message.includes("Overleaf failed to apply the OT update");
2084
+ }
2085
+ function initialReceiptDocuments(plan) {
2086
+ return plan.documents.map((doc) => ({
2087
+ docId: doc.docId,
2088
+ docPath: doc.docPath,
2089
+ localPath: doc.localPath,
2090
+ opCount: doc.ops.length,
2091
+ expectedHash: doc.expectedHash,
2092
+ status: "pending",
2093
+ mutationAttempted: false
2094
+ }));
2095
+ }
2096
+ function pushReceiptNeedsQuarantine(receipt) {
2097
+ if (receipt.status === "ambiguous") return true;
2098
+ if (receipt.status !== "in_progress") return false;
2099
+ const documents = receipt.details?.documents;
2100
+ return Array.isArray(documents) && documents.some(
2101
+ (document) => Boolean(document) && typeof document === "object" && (document.mutationAttempted === true || typeof document.mutationStartedAt === "string")
2102
+ );
2103
+ }
2104
+ function receiptDocumentIds(receipt) {
2105
+ const documents = receipt.details?.documents;
2106
+ if (!Array.isArray(documents)) return [];
2107
+ return documents.flatMap((document) => {
2108
+ if (!document || typeof document !== "object") return [];
2109
+ const docId = document.docId;
2110
+ return typeof docId === "string" ? [docId] : [];
2111
+ });
2112
+ }
2113
+ function quarantinedPlanDocuments(plan, receipts) {
2114
+ const plannedIds = new Set(plan.documents.map((document) => document.docId));
2115
+ const disposition = /* @__PURE__ */ new Map();
2116
+ const newestFirst = [...receipts].sort(
2117
+ (a, b) => String(b.receipt.updatedAt ?? "").localeCompare(String(a.receipt.updatedAt ?? ""))
2118
+ );
2119
+ for (const { receipt } of newestFirst) {
2120
+ if (receipt.operation !== "push" || receipt.details?.projectId !== plan.projectId) {
2121
+ continue;
2122
+ }
2123
+ const reconciles = receipt.status === "succeeded" && receipt.details.acknowledgedAmbiguousRetry === true;
2124
+ const quarantines = pushReceiptNeedsQuarantine(receipt);
2125
+ if (!reconciles && !quarantines) continue;
2126
+ for (const docId of receiptDocumentIds(receipt)) {
2127
+ if (plannedIds.has(docId) && !disposition.has(docId)) {
2128
+ disposition.set(docId, reconciles ? "reconciled" : "quarantined");
2129
+ }
2130
+ }
2131
+ }
2132
+ return plan.documents.filter((document) => disposition.get(document.docId) === "quarantined").map((document) => document.docPath);
2133
+ }
2134
+ function synchronizeLocalAfterRemote(localPath, plannedLocalHash, remoteText, workspaceRoot = process.cwd()) {
2135
+ const localFile = workspaceReadPath(localPath, workspaceRoot);
2136
+ const currentLocal = readFileSync6(localFile, "utf8");
2137
+ if (sha256(currentLocal) !== plannedLocalHash) {
2138
+ throw new Error(`${localPath} changed while the plan was being submitted; local file left untouched.`);
2139
+ }
2140
+ if (currentLocal === remoteText) return {};
2141
+ const timestamp = `${snapshotTimestamp()}-${process.pid}`;
2142
+ const snapshotPath = snapshotRelativePath(timestamp, localPath, workspaceRoot);
2143
+ const snapshotFile = workspaceWritePath(snapshotPath, workspaceRoot);
2144
+ mkdirSync7(dirname5(snapshotFile), { recursive: true });
2145
+ writeFileSync7(snapshotFile, currentLocal, { flag: "wx", mode: 384 });
2146
+ const target = workspaceWritePath(localPath, workspaceRoot);
2147
+ const temp = `${target}.tmp-${process.pid}-${Date.now()}`;
2148
+ try {
2149
+ writeFileSync7(temp, remoteText, { mode: statSync(localFile).mode & 511 });
2150
+ if (sha256(readFileSync6(localFile, "utf8")) !== plannedLocalHash) {
2151
+ throw new Error(`${localPath} changed while the plan was being submitted; local file left untouched.`);
2152
+ }
2153
+ renameSync4(temp, target);
2154
+ } catch (error) {
2155
+ try {
2156
+ unlinkSync5(temp);
2157
+ } catch {
2158
+ }
2159
+ throw error;
2160
+ }
2161
+ return { snapshotPath };
2162
+ }
2163
+ async function submitPlan(planOrPath, opts = {}) {
2164
+ const plan = typeof planOrPath === "string" ? readPushPlan(planOrPath) : validatePushPlan(planOrPath);
2165
+ const totalOps = plan.documents.reduce((sum, doc) => sum + doc.ops.length, 0);
2166
+ const planHash = sha256(stableJson(plan));
2167
+ const receiptDocuments = initialReceiptDocuments(plan);
2168
+ let receipt = beginReceipt(
2169
+ "push",
2170
+ {
2171
+ projectId: plan.projectId,
2172
+ direct: plan.direct,
2173
+ planCreatedAt: plan.createdAt,
2174
+ planHash,
2175
+ totalOps,
2176
+ phase: "preflight",
2177
+ plan,
2178
+ documents: receiptDocuments
2179
+ },
2180
+ { receiptsDir: opts.receiptsDir }
2181
+ );
2182
+ const completed = [];
2183
+ const basePath = opts.basePath ?? BASE_STATE_PATH;
2184
+ let opened;
2185
+ let mutationLock;
2186
+ let unknownMutationOutcome = false;
2187
+ let failureStatus;
2188
+ try {
2189
+ if (plan.projectId !== config.projectId) {
2190
+ throw new PushPlanValidationError(
2191
+ `Plan is for project ${plan.projectId}, but this repository is linked to ${config.projectId}.`
2192
+ );
2193
+ }
2194
+ mutationLock = acquireMutationLock(plan.projectId);
2195
+ const priorReceipts = readReceipts(opts.receiptsDir);
2196
+ const quarantinedDocuments = quarantinedPlanDocuments(plan, priorReceipts);
2197
+ if (quarantinedDocuments.length && !opts.allowAmbiguousRetry) {
2198
+ throw new PushPlanValidationError(
2199
+ `A prior push has an unresolved outcome for: ${quarantinedDocuments.join(", ")}. Wait for delayed updates, inspect Overleaf and its receipt, then create a fresh plan. Only after manual reconciliation may you submit with --acknowledge-ambiguous.`
2200
+ );
2201
+ }
2202
+ if (!plan.documents.length) {
2203
+ receipt = updateReceipt(receipt, "skipped", {
2204
+ phase: "complete",
2205
+ outcome: "empty_plan",
2206
+ documents: receiptDocuments
2207
+ });
2208
+ return {
2209
+ projectId: plan.projectId,
2210
+ direct: plan.direct,
2211
+ totalOps,
2212
+ documents: completed,
2213
+ receiptPath: receipt.path
2214
+ };
2215
+ }
2216
+ opened = await openProject();
2217
+ const { socket, project, docs } = opened;
2218
+ const connectedProjectId = String(project?._id ?? config.projectId);
2219
+ if (connectedProjectId !== plan.projectId) {
2220
+ throw new PushPlanValidationError(
2221
+ `Connected project ${connectedProjectId} does not match plan ${plan.projectId}.`
2222
+ );
2223
+ }
2224
+ const docsById = new Map(docs.map((doc) => [doc._id, doc]));
2225
+ for (const planned of plan.documents) {
2226
+ const doc = docsById.get(planned.docId);
2227
+ if (!doc || doc.path !== planned.docPath) {
2228
+ throw new PushPlanValidationError(
2229
+ `Document ${planned.docPath} (${planned.docId}) no longer exists at its planned path.`
2230
+ );
2231
+ }
2232
+ }
2233
+ for (const planned of plan.documents) {
2234
+ await bindPlanDocument(plan, planned, socket, basePath);
2235
+ }
2236
+ receipt = updateReceipt(receipt, "in_progress", {
2237
+ phase: "applying",
2238
+ preflightVerifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
2239
+ ...opts.allowAmbiguousRetry && quarantinedDocuments.length ? {
2240
+ acknowledgedAmbiguousRetry: true,
2241
+ reconciledAmbiguousDocuments: quarantinedDocuments
2242
+ } : {},
2243
+ documents: receiptDocuments
2244
+ });
2245
+ for (let index = 0; index < plan.documents.length; index++) {
2246
+ const planned = plan.documents[index];
2247
+ const { state, expected } = await bindPlanDocument(
2248
+ plan,
2249
+ planned,
2250
+ socket,
2251
+ basePath
2252
+ );
2253
+ receiptDocuments[index] = {
2254
+ ...receiptDocuments[index],
2255
+ status: "applying",
2256
+ mutationAttempted: true,
2257
+ mutationStartedAt: (/* @__PURE__ */ new Date()).toISOString()
2258
+ };
2259
+ receipt = updateReceipt(receipt, "in_progress", {
2260
+ phase: "applying",
2261
+ currentDocument: planned.docPath,
2262
+ documents: receiptDocuments
2263
+ });
2264
+ unknownMutationOutcome = true;
2265
+ let applyError;
2266
+ try {
2267
+ await applyOtUpdateAndWait(socket, planned.docId, {
2268
+ doc: planned.docId,
2269
+ op: planned.ops,
2270
+ v: state.version,
2271
+ meta: plan.direct ? {} : { tc: planned.tcSeed },
2272
+ hash: overleafSnapshotHash(expected)
2273
+ });
2274
+ } catch (error) {
2275
+ applyError = error;
2276
+ }
2277
+ let after;
2278
+ try {
2279
+ after = await joinDoc(socket, planned.docId);
2280
+ } catch (readbackError) {
2281
+ const definitelyRejected = Boolean(applyError && definitelyRejectedApply(applyError));
2282
+ failureStatus = definitelyRejected ? "failed" : "ambiguous";
2283
+ unknownMutationOutcome = !definitelyRejected;
2284
+ receiptDocuments[index] = {
2285
+ ...receiptDocuments[index],
2286
+ status: failureStatus,
2287
+ ...applyError ? { transportError: errorMessage(applyError) } : {},
2288
+ error: `Readback failed: ${errorMessage(readbackError)}`
2289
+ };
2290
+ throw new Error(
2291
+ `${planned.docPath} could not be verified after its update: ${errorMessage(readbackError)}`
2292
+ );
2293
+ }
2294
+ const afterText = after.lines.join("\n");
2295
+ if (afterText !== expected || sha256(afterText) !== planned.expectedHash) {
2296
+ const definitelyRejected = Boolean(applyError && definitelyRejectedApply(applyError));
2297
+ failureStatus = definitelyRejected ? "failed" : "ambiguous";
2298
+ unknownMutationOutcome = !definitelyRejected;
2299
+ receiptDocuments[index] = {
2300
+ ...receiptDocuments[index],
2301
+ status: failureStatus,
2302
+ afterVersion: after.version,
2303
+ ...applyError ? { transportError: errorMessage(applyError) } : {},
2304
+ error: "Overleaf text does not match the planned result"
2305
+ };
2306
+ throw new Error(
2307
+ `Verification failed for ${planned.docPath}: Overleaf text does not match the planned result.`
2308
+ );
2309
+ }
2310
+ let trackedChangeIds;
2311
+ try {
2312
+ trackedChangeIds = verifiedTrackedIds(plan, planned, after);
2313
+ } catch (error) {
2314
+ const ambiguousTransport = Boolean(applyError && !definitelyRejectedApply(applyError));
2315
+ unknownMutationOutcome = ambiguousTransport;
2316
+ failureStatus = ambiguousTransport ? "ambiguous" : "failed";
2317
+ receiptDocuments[index] = {
2318
+ ...receiptDocuments[index],
2319
+ status: failureStatus,
2320
+ afterVersion: after.version,
2321
+ ...applyError ? { transportError: errorMessage(applyError) } : {},
2322
+ error: errorMessage(error)
2323
+ };
2324
+ throw error;
2325
+ }
2326
+ unknownMutationOutcome = false;
2327
+ try {
2328
+ const localSync = synchronizeLocalAfterRemote(
2329
+ planned.localPath,
2330
+ planned.localHash,
2331
+ afterText
2332
+ );
2333
+ if (localSync.snapshotPath) {
2334
+ receiptDocuments[index] = {
2335
+ ...receiptDocuments[index],
2336
+ localSnapshotPath: localSync.snapshotPath
2337
+ };
2338
+ }
2339
+ const base = {
2340
+ docId: planned.docId,
2341
+ path: planned.docPath,
2342
+ text: afterText,
2343
+ hash: planned.expectedHash,
2344
+ version: after.version,
2345
+ rangeFingerprint: fingerprintRanges(after.ranges),
2346
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
2347
+ };
2348
+ mergeBaseDocuments(plan.projectId, [base], basePath);
2349
+ } catch (error) {
2350
+ failureStatus = "failed";
2351
+ receiptDocuments[index] = {
2352
+ ...receiptDocuments[index],
2353
+ status: "remote_verified_local_failed",
2354
+ afterVersion: after.version,
2355
+ trackedChangeIds,
2356
+ ...applyError ? { transportError: errorMessage(applyError) } : {},
2357
+ error: errorMessage(error)
2358
+ };
2359
+ throw new Error(
2360
+ `${planned.docPath} was verified on Overleaf, but local synchronization failed: ` + errorMessage(error)
2361
+ );
2362
+ }
2363
+ const result = {
2364
+ docId: planned.docId,
2365
+ docPath: planned.docPath,
2366
+ version: after.version,
2367
+ hash: planned.expectedHash,
2368
+ trackedChangeIds
2369
+ };
2370
+ completed.push(result);
2371
+ receiptDocuments[index] = {
2372
+ ...receiptDocuments[index],
2373
+ status: "verified",
2374
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
2375
+ afterVersion: after.version,
2376
+ trackedChangeIds,
2377
+ ...applyError ? { transportError: errorMessage(applyError) } : {}
2378
+ };
2379
+ receipt = updateReceipt(receipt, "in_progress", {
2380
+ phase: "applying",
2381
+ completedDocuments: completed.map((doc) => doc.docPath),
2382
+ documents: receiptDocuments
2383
+ });
2384
+ }
2385
+ receipt = updateReceipt(receipt, "succeeded", {
2386
+ phase: "complete",
2387
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
2388
+ completedDocuments: completed.map((doc) => doc.docPath),
2389
+ documents: receiptDocuments
2390
+ });
2391
+ return {
2392
+ projectId: plan.projectId,
2393
+ direct: plan.direct,
2394
+ totalOps,
2395
+ documents: completed,
2396
+ receiptPath: receipt.path
2397
+ };
2398
+ } catch (error) {
2399
+ const status = failureStatus ?? (unknownMutationOutcome ? "ambiguous" : "failed");
2400
+ receipt = updateReceipt(receipt, status, {
2401
+ phase: status === "ambiguous" ? "mutation_outcome_unknown" : "failed",
2402
+ failedAt: (/* @__PURE__ */ new Date()).toISOString(),
2403
+ error: errorMessage(error),
2404
+ completedDocuments: completed.map((doc) => doc.docPath),
2405
+ documents: receiptDocuments
2406
+ });
2407
+ throw new PushSubmissionError(
2408
+ `${errorMessage(error)} Audit receipt: ${receipt.path}`,
2409
+ receipt.path,
2410
+ status,
2411
+ receiptDocuments,
2412
+ error
2413
+ );
2414
+ } finally {
2415
+ try {
2416
+ opened?.socket.close();
2417
+ } finally {
2418
+ mutationLock?.release();
2419
+ }
2420
+ }
2421
+ }
2422
+ function printPlan(plan) {
2423
+ console.log(
2424
+ plan.direct ? "Mode: DIRECT \u2014 plain edits (not marked as suggestions)" : "Mode: SUGGESTIONS \u2014 tracked changes for co-authors to accept/reject"
2425
+ );
2426
+ for (const doc of plan.documents) {
2427
+ const ins = doc.ops.filter((op) => op.i != null).length;
2428
+ const del = doc.ops.filter((op) => op.d != null).length;
2429
+ console.log(
2430
+ `
2431
+ ${doc.localPath} \u2192 ${doc.docPath} (v${doc.liveVersion}): ${doc.ops.length} op(s), ${ins} ins / ${del} del`
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
+ }
2438
+ for (const op of doc.ops.slice(0, 12)) console.log(preview(op));
2439
+ if (doc.ops.length > 12) console.log(` \u2026 and ${doc.ops.length - 12} more`);
2440
+ for (const overlap of doc.commentOverlaps) {
2441
+ console.log(
2442
+ ` \u2139\uFE0F touches comment ${overlap.threadId} @ ${overlap.position}: ${JSON.stringify(overlap.anchor)}`
2443
+ );
2444
+ }
2445
+ }
2446
+ }
2447
+ async function push(opts) {
2448
+ validatePushOptions(opts);
2449
+ if (opts.plan) {
2450
+ const result2 = await submitPlan(opts.plan, {
2451
+ basePath: opts.basePath,
2452
+ receiptsDir: opts.receiptsDir,
2453
+ allowAmbiguousRetry: opts.allowAmbiguousRetry
2454
+ });
2455
+ console.log(
2456
+ `\u2705 Submitted and verified ${result2.totalOps} ${result2.direct ? "direct edit(s)" : "tracked suggestion(s)"} across ${result2.documents.length} file(s).`
2457
+ );
2458
+ console.log(`Audit receipt: ${result2.receiptPath}`);
2459
+ return;
2460
+ }
2461
+ const plan = await createPlan(opts);
2462
+ if (!plan.documents.length) {
2463
+ console.log("Nothing to push \u2014 no unapplied local edits were found.");
2464
+ if (opts.planOut) console.log(`Saved empty plan to ${opts.planOut}.`);
2465
+ return;
2466
+ }
2467
+ printPlan(plan);
2468
+ if (opts.planOut) {
2469
+ console.log(`
2470
+ Saved binding plan to ${opts.planOut}; nothing sent to Overleaf.`);
2471
+ return;
2472
+ }
2473
+ if (opts.dryRun) {
2474
+ console.log("\n(dry run \u2014 nothing sent to Overleaf)");
2475
+ return;
2476
+ }
2477
+ const result = await submitPlan(plan, {
2478
+ basePath: opts.basePath,
2479
+ receiptsDir: opts.receiptsDir,
2480
+ allowAmbiguousRetry: opts.allowAmbiguousRetry
2481
+ });
2482
+ console.log(
2483
+ `
2484
+ \u2705 Pushed and verified ${result.totalOps} ${result.direct ? "direct edit(s)" : "tracked suggestion(s)"} across ${result.documents.length} file(s).`
2485
+ );
2486
+ console.log(`Audit receipt: ${result.receiptPath}`);
2487
+ }
2488
+
2489
+ // src/commands/fetch.ts
2490
+ import { writeFileSync as writeFileSync8, readFileSync as readFileSync7, mkdirSync as mkdirSync8, existsSync as existsSync4 } from "fs";
2491
+ import { dirname as dirname6 } from "path";
2492
+ async function fetchDocs(opts) {
2493
+ const { socket, project, docs } = await openProject();
2494
+ let mutationLock;
2495
+ try {
2496
+ if (!opts.dryRun && opts.acquireLock !== false) {
2497
+ mutationLock = acquireMutationLock(config.projectId);
2498
+ }
2499
+ const projectId = String(project?._id ?? config.projectId);
2500
+ if (projectId !== config.projectId) {
2501
+ throw new Error(
2502
+ `Connected project id ${projectId} does not match configured project ${config.projectId}.`
2503
+ );
2504
+ }
2505
+ const requested = opts.file?.replace(/\\/g, "/");
2506
+ const match = requested ? matchDocument(requested, docs) : void 0;
2507
+ const targets = requested ? match ? [match] : [] : docs;
2508
+ if (!targets.length) {
2509
+ if (requested) {
2510
+ throw new Error(
2511
+ `No matching document for "${opts.file}"; use its exact Overleaf project path.`
2512
+ );
2513
+ }
2514
+ console.log("No Overleaf documents found.");
2515
+ return;
2516
+ }
2517
+ const localTargets = targets.map((doc) => ({
2518
+ doc,
2519
+ localPath: workspaceWritePath(doc.path)
2520
+ }));
2521
+ const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
2522
+ const entries = [];
2523
+ for (const { doc, localPath } of localTargets) {
2524
+ const state = await joinDoc(socket, doc._id);
2525
+ const remote = state.lines.join("\n");
2526
+ const local = existsSync4(localPath) ? readFileSync7(localPath, "utf8") : null;
2527
+ entries.push({
2528
+ doc,
2529
+ localPath,
2530
+ remote,
2531
+ local,
2532
+ base: {
2533
+ docId: doc._id,
2534
+ path: doc.path,
2535
+ text: remote,
2536
+ hash: sha256(remote),
2537
+ version: state.version,
2538
+ rangeFingerprint: fingerprintRanges(state.ranges),
2539
+ fetchedAt
2540
+ }
2541
+ });
2542
+ }
2543
+ const changedEntries = entries.filter((entry) => entry.local !== entry.remote);
2544
+ for (const { doc, local, remote } of changedEntries) {
2545
+ const delta = local === null ? "(new file)" : `${local.length} \u2192 ${remote.length} chars`;
2546
+ console.log(` ${doc.path} ${delta}`);
2547
+ }
2548
+ let snapshotRoot;
2549
+ if (!opts.dryRun) {
2550
+ for (const entry of changedEntries) {
2551
+ if (entry.local === null) continue;
2552
+ const current = readFileSync7(entry.localPath, "utf8");
2553
+ if (current !== entry.local) {
2554
+ throw new Error(`${entry.doc.path} changed while fetch was reading the project; retry fetch.`);
2555
+ }
2556
+ }
2557
+ const timestamp = snapshotTimestamp();
2558
+ const snapshotTargets = changedEntries.filter((entry) => entry.local !== null).map((entry) => ({
2559
+ entry,
2560
+ snapshotPath: workspaceWritePath(snapshotRelativePath(timestamp, entry.doc.path))
2561
+ }));
2562
+ for (const { entry, snapshotPath } of snapshotTargets) {
2563
+ mkdirSync8(dirname6(snapshotPath), { recursive: true });
2564
+ writeFileSync8(snapshotPath, entry.local, { mode: 384 });
2565
+ }
2566
+ if (snapshotTargets.length) {
2567
+ snapshotRoot = `${SNAPSHOTS_DIR}/${timestamp}`;
2568
+ }
2569
+ for (const { localPath, remote } of changedEntries) {
2570
+ mkdirSync8(dirname6(localPath), { recursive: true });
2571
+ writeFileSync8(localPath, remote);
2572
+ }
2573
+ }
2574
+ if (!opts.dryRun) mergeBaseDocuments(projectId, entries.map((entry) => entry.base));
2575
+ if (!changedEntries.length) {
2576
+ console.log(
2577
+ opts.dryRun ? "Already up to date \u2014 local files match Overleaf." : `Already up to date \u2014 refreshed synchronization base for ${entries.length} file(s).`
2578
+ );
2579
+ return;
2580
+ }
2581
+ if (snapshotRoot) console.log(`Recoverable local snapshot: ${snapshotRoot}`);
2582
+ console.log(
2583
+ opts.dryRun ? `
2584
+ (dry run \u2014 ${changedEntries.length} local file(s) would be overwritten; base unchanged)` : `
2585
+ \u2705 Fetched ${changedEntries.length} file(s) from Overleaf and saved their synchronization base.`
2586
+ );
2587
+ } finally {
2588
+ try {
2589
+ socket.close();
2590
+ } finally {
2591
+ mutationLock?.release();
2592
+ }
2593
+ }
2594
+ }
2595
+
2596
+ // src/commands/upload.ts
2597
+ import { readFileSync as readFileSync8 } from "fs";
2598
+ import { basename as basename2 } from "path";
2599
+ function findFolder(folder, wanted, prefix = "") {
2600
+ for (const f of folder?.folders ?? []) {
2601
+ const path = prefix ? `${prefix}/${f.name}` : f.name;
2602
+ if (f.name === wanted || path === wanted) return f;
2603
+ const deeper = findFolder(f, wanted, path);
2604
+ if (deeper) return deeper;
2605
+ }
2606
+ return void 0;
2607
+ }
2608
+ async function upload(paths, folderName) {
2609
+ const mutationLock = acquireMutationLock(config.projectId);
2610
+ let socket;
2611
+ try {
2612
+ const opened = await openProject();
2613
+ socket = opened.socket;
2614
+ const { project } = opened;
2615
+ const root = project?.rootFolder?.[0];
2616
+ if (!root?._id) throw new Error("could not resolve the project root folder");
2617
+ let folderId = root._id;
2618
+ if (folderName) {
2619
+ const found = findFolder(root, folderName);
2620
+ if (!found) throw new Error(`folder not found in project: ${folderName}`);
2621
+ folderId = found._id;
2622
+ }
2623
+ const csrf = await getCsrfToken();
2624
+ for (const path of paths) {
2625
+ const bytes = readFileSync8(path);
2626
+ const res = await uploadFile(folderId, basename2(path), bytes, csrf);
2627
+ console.log(`\u2705 Uploaded ${path} \u2192 ${res.entity_type} ${res.entity_id}`);
2628
+ }
2629
+ } finally {
2630
+ try {
2631
+ socket?.close();
2632
+ } finally {
2633
+ mutationLock.release();
2634
+ }
2635
+ }
2636
+ }
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
+
2962
+ // src/commands/comment.ts
2963
+ import { createHash as createHash4, randomBytes as randomBytes2 } from "crypto";
2964
+ var DEFAULT_DUPLICATE_WINDOW_MS = 5 * 60 * 1e3;
2965
+ function isRecentAttempt(prior, nowMs, windowMs) {
2966
+ const updatedAt = prior.updatedAt ? Date.parse(prior.updatedAt) : Number.NaN;
2967
+ return Number.isFinite(updatedAt) && updatedAt >= nowMs - Math.max(0, windowMs) && updatedAt <= nowMs + 6e4;
2968
+ }
2969
+ function shouldQuarantineCommentAnchorRetry(prior, nowMs, windowMs) {
2970
+ return (prior.status === "ambiguous" || prior.status === "in_progress" && Boolean(prior.anchorAttemptedAt)) && isRecentAttempt(prior, nowMs, windowMs);
2971
+ }
2972
+ function shouldQuarantineCommentRetry(prior, nowMs, windowMs) {
2973
+ if (!prior.postAttemptedAt || prior.status !== "ambiguous" && prior.status !== "in_progress") {
2974
+ return false;
2975
+ }
2976
+ return isRecentAttempt(prior, nowMs, windowMs);
2977
+ }
2978
+ function findCommentRangeByThreadId(ranges, threadId) {
2979
+ if (!Array.isArray(ranges)) return void 0;
2980
+ return ranges.find(
2981
+ (range) => Boolean(range && typeof range === "object" && range.op?.t === threadId)
2982
+ );
2983
+ }
2984
+ function findCommentRangesAt(ranges, position, anchor) {
2985
+ if (!Array.isArray(ranges)) return [];
2986
+ return ranges.filter(
2987
+ (range) => Boolean(
2988
+ range && typeof range === "object" && range.op?.p === position && range.op?.c === anchor
2989
+ )
2990
+ );
2991
+ }
2992
+ function commentRangeAnchorsText(range, text, anchor) {
2993
+ const position = range?.op?.p;
2994
+ return Number.isSafeInteger(position) && position >= 0 && range?.op?.c === anchor && text.slice(position, position + anchor.length) === anchor;
2995
+ }
2996
+ function commentIntentHash(intent) {
2997
+ const canonical = [
2998
+ intent.projectId,
2999
+ intent.docId,
3000
+ intent.anchor,
3001
+ intent.occurrence,
3002
+ intent.message
3003
+ ];
3004
+ return createHash4("sha256").update(JSON.stringify(canonical)).digest("hex");
3005
+ }
3006
+ function rangeThreadId(range) {
3007
+ const value = range?.op?.t;
3008
+ return typeof value === "string" && value ? value : void 0;
3009
+ }
3010
+ function rangeSnapshot(range) {
3011
+ return range ? {
3012
+ present: true,
3013
+ position: range.op?.p,
3014
+ anchor: range.op?.c,
3015
+ threadId: range.op?.t
3016
+ } : { present: false };
3017
+ }
3018
+ function threadSnapshot(thread) {
3019
+ const messages = threadMessages(thread);
3020
+ return {
3021
+ exists: Boolean(thread),
3022
+ messageCount: messages.length,
3023
+ messageIds: messages.map(threadMessageId).filter((id) => Boolean(id))
3024
+ };
3025
+ }
3026
+ function errorMessage2(error) {
3027
+ return error instanceof Error ? error.message : String(error);
3028
+ }
3029
+ function priorThreadIdForIntent(intentHash, receiptsDir) {
3030
+ const prior = readReceipts(receiptsDir).find(
3031
+ ({ receipt }) => receipt.operation === "comment" && receipt.details.intentHash === intentHash
3032
+ );
3033
+ const threadId = prior?.receipt.details.threadId;
3034
+ const anchorAttemptedAt = prior?.receipt.details.anchorAttemptedAt;
3035
+ const postAttemptedAt = prior?.receipt.details.postAttemptedAt;
3036
+ return {
3037
+ ...typeof threadId === "string" ? { threadId } : {},
3038
+ ...prior ? { receiptPath: prior.path } : {},
3039
+ ...prior ? { status: prior.receipt.status, updatedAt: prior.receipt.updatedAt } : {},
3040
+ ...typeof anchorAttemptedAt === "string" ? { anchorAttemptedAt } : {},
3041
+ ...typeof postAttemptedAt === "string" ? { postAttemptedAt } : {}
3042
+ };
3043
+ }
3044
+ async function commentWithResult(opts) {
3045
+ const { socket, project, docs } = await openProject();
3046
+ let mutationLock;
3047
+ let receipt;
3048
+ try {
3049
+ mutationLock = acquireMutationLock(config.projectId);
3050
+ const doc = opts.docName ? matchDocument(opts.docName.replace(/\\/g, "/"), docs) : docs.find((d) => d._id === project.rootDoc_id) ?? docs[0];
3051
+ if (!doc) throw new Error(`doc not found: ${opts.docName ?? "(root)"}`);
3052
+ const state = await joinDoc(socket, doc._id);
3053
+ const flat = state.lines.join("\n");
3054
+ const nth = Math.max(1, opts.occurrence ?? 1);
3055
+ let p = -1;
3056
+ let from = 0;
3057
+ for (let i = 0; i < nth; i++) {
3058
+ p = flat.indexOf(opts.anchor, from);
3059
+ if (p < 0) break;
3060
+ from = p + 1;
3061
+ }
3062
+ if (p < 0) {
3063
+ throw new Error(`anchor text not found in ${doc.name}: "${opts.anchor}"`);
3064
+ }
3065
+ const duplicateWindowMs = Math.max(
3066
+ 0,
3067
+ opts.duplicateWindowMs ?? DEFAULT_DUPLICATE_WINDOW_MS
3068
+ );
3069
+ const intentHash = commentIntentHash({
3070
+ projectId: config.projectId,
3071
+ docId: doc._id,
3072
+ anchor: opts.anchor,
3073
+ occurrence: nth,
3074
+ message: opts.message
3075
+ });
3076
+ const prior = priorThreadIdForIntent(intentHash, opts.receiptsDir);
3077
+ const threads = await getThreads();
3078
+ const commentRanges = state.ranges.comments ?? [];
3079
+ let duplicateRange;
3080
+ if (!opts.force) {
3081
+ const candidates = findCommentRangesAt(commentRanges, p, opts.anchor).filter(
3082
+ (range) => commentRangeAnchorsText(range, flat, opts.anchor)
3083
+ );
3084
+ const priorRange2 = prior.threadId ? findCommentRangeByThreadId(commentRanges, prior.threadId) : void 0;
3085
+ if (priorRange2 && !candidates.includes(priorRange2)) candidates.push(priorRange2);
3086
+ duplicateRange = candidates.find((range) => {
3087
+ const candidateThreadId = rangeThreadId(range);
3088
+ return Boolean(
3089
+ candidateThreadId && findRecentIdenticalMessage(
3090
+ threads[candidateThreadId],
3091
+ opts.message,
3092
+ Date.now(),
3093
+ duplicateWindowMs
3094
+ )
3095
+ );
3096
+ });
3097
+ }
3098
+ if (duplicateRange) {
3099
+ const threadId2 = rangeThreadId(duplicateRange);
3100
+ const duplicateMessage = findRecentIdenticalMessage(
3101
+ threads[threadId2],
3102
+ opts.message,
3103
+ Date.now(),
3104
+ duplicateWindowMs
3105
+ );
3106
+ const messageId = threadMessageId(duplicateMessage);
3107
+ receipt = beginReceipt(
3108
+ "comment",
3109
+ {
3110
+ projectId: config.projectId,
3111
+ docId: doc._id,
3112
+ doc: doc.path,
3113
+ threadId: threadId2,
3114
+ message: opts.message,
3115
+ anchor: opts.anchor,
3116
+ occurrence: nth,
3117
+ position: p,
3118
+ intentHash,
3119
+ force: false,
3120
+ phase: "preflight"
3121
+ },
3122
+ { receiptsDir: opts.receiptsDir }
3123
+ );
3124
+ receipt = updateReceipt(receipt, "skipped", {
3125
+ phase: "complete",
3126
+ outcome: "recent_identical_comment",
3127
+ anchorRange: rangeSnapshot(duplicateRange),
3128
+ ...messageId ? { messageId } : {},
3129
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
3130
+ });
3131
+ return {
3132
+ doc: doc.path,
3133
+ threadId: threadId2,
3134
+ anchorCreated: false,
3135
+ messagePosted: false,
3136
+ duplicate: true,
3137
+ ...messageId ? { messageId } : {},
3138
+ receiptPath: receipt.path
3139
+ };
3140
+ }
3141
+ const priorRangeCandidate = !opts.force && prior.threadId ? findCommentRangeByThreadId(commentRanges, prior.threadId) : void 0;
3142
+ const priorRange = commentRangeAnchorsText(priorRangeCandidate, flat, opts.anchor) ? priorRangeCandidate : void 0;
3143
+ const now = Date.now();
3144
+ if (!opts.force && !priorRange && shouldQuarantineCommentAnchorRetry(prior, now, duplicateWindowMs)) {
3145
+ throw new Error(
3146
+ `A recent comment attempt has an ambiguous anchor outcome (${prior.receiptPath}). Inspect Overleaf before retrying, or use force: true explicitly`
3147
+ );
3148
+ }
3149
+ if (!opts.force && priorRange && shouldQuarantineCommentRetry(prior, now, duplicateWindowMs)) {
3150
+ throw new Error(
3151
+ `A recent comment message attempt has an unverified outcome (${prior.receiptPath}). Inspect the thread before retrying, or use force: true explicitly`
3152
+ );
3153
+ }
3154
+ const canResume = Boolean(
3155
+ priorRange && prior.threadId && threadMessages(threads[prior.threadId]).length === 0
3156
+ );
3157
+ const threadId = canResume ? prior.threadId : randomBytes2(12).toString("hex");
3158
+ let anchorCreated = false;
3159
+ let beforeThread = threads[threadId];
3160
+ receipt = beginReceipt(
3161
+ "comment",
3162
+ {
3163
+ projectId: config.projectId,
3164
+ docId: doc._id,
3165
+ doc: doc.path,
3166
+ threadId,
3167
+ message: opts.message,
3168
+ anchor: opts.anchor,
3169
+ occurrence: nth,
3170
+ position: p,
3171
+ sourceVersion: state.version,
3172
+ intentHash,
3173
+ force: opts.force ?? false,
3174
+ phase: "preflight",
3175
+ beforeThread: threadSnapshot(beforeThread),
3176
+ ...canResume && prior.receiptPath ? { resumedFromReceipt: prior.receiptPath } : {}
3177
+ },
3178
+ { receiptsDir: opts.receiptsDir }
3179
+ );
3180
+ let verifiedRange = canResume ? priorRange : void 0;
3181
+ if (!verifiedRange) {
3182
+ receipt = updateReceipt(receipt, "in_progress", {
3183
+ phase: "creating_anchor",
3184
+ anchorAttemptedAt: (/* @__PURE__ */ new Date()).toISOString()
3185
+ });
3186
+ const update = {
3187
+ doc: doc._id,
3188
+ op: [{ p, c: opts.anchor, t: threadId }],
3189
+ v: state.version,
3190
+ meta: {}
3191
+ };
3192
+ let anchorError;
3193
+ try {
3194
+ await applyOtUpdateAndWait(socket, doc._id, update);
3195
+ } catch (error) {
3196
+ anchorError = error;
3197
+ }
3198
+ let afterAnchorText;
3199
+ try {
3200
+ const afterAnchor = await joinDoc(socket, doc._id);
3201
+ afterAnchorText = afterAnchor.lines.join("\n");
3202
+ verifiedRange = findCommentRangeByThreadId(afterAnchor.ranges.comments, threadId);
3203
+ } catch (readbackError) {
3204
+ receipt = updateReceipt(receipt, "ambiguous", {
3205
+ phase: "anchor_outcome_unknown",
3206
+ ...anchorError ? { transportError: errorMessage2(anchorError) } : {},
3207
+ verificationError: errorMessage2(readbackError)
3208
+ });
3209
+ throw new Error(
3210
+ `Comment anchor outcome is ambiguous; inspect ${receipt.path} before retrying`
3211
+ );
3212
+ }
3213
+ if (!commentRangeAnchorsText(verifiedRange, afterAnchorText ?? "", opts.anchor)) {
3214
+ receipt = updateReceipt(receipt, anchorError ? "ambiguous" : "failed", {
3215
+ phase: anchorError ? "anchor_outcome_unknown" : "anchor_unverified",
3216
+ ...anchorError ? { transportError: errorMessage2(anchorError) } : {},
3217
+ anchorRange: rangeSnapshot(verifiedRange)
3218
+ });
3219
+ throw new Error(
3220
+ `Comment anchor was not visible on readback; inspect ${receipt.path} before retrying`
3221
+ );
3222
+ }
3223
+ anchorCreated = true;
3224
+ receipt = updateReceipt(receipt, "in_progress", {
3225
+ phase: "anchor_verified",
3226
+ anchorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
3227
+ anchorRange: rangeSnapshot(verifiedRange),
3228
+ ...anchorError ? { transportError: errorMessage2(anchorError) } : {}
3229
+ });
3230
+ } else {
3231
+ receipt = updateReceipt(receipt, "in_progress", {
3232
+ phase: "anchor_verified",
3233
+ outcome: "resumed_anchor_only_operation",
3234
+ anchorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
3235
+ anchorRange: rangeSnapshot(verifiedRange)
3236
+ });
3237
+ }
3238
+ let postError;
3239
+ let postAttempted = false;
3240
+ let responseStatus;
3241
+ let returnedMessageId;
3242
+ try {
3243
+ const csrf = await getCsrfToken();
3244
+ receipt = updateReceipt(receipt, "in_progress", {
3245
+ phase: "posting_message",
3246
+ postAttemptedAt: (/* @__PURE__ */ new Date()).toISOString()
3247
+ });
3248
+ postAttempted = true;
3249
+ const response = await postThreadMessageDetailed(threadId, opts.message, csrf);
3250
+ responseStatus = response.status;
3251
+ returnedMessageId = response.messageId;
3252
+ receipt = updateReceipt(receipt, "in_progress", {
3253
+ phase: "verifying_message",
3254
+ responseStatus,
3255
+ ...returnedMessageId ? { returnedMessageId } : {},
3256
+ ...response.responseBody === void 0 ? {} : { responseBody: response.responseBody }
3257
+ });
3258
+ } catch (error) {
3259
+ postError = error;
3260
+ }
3261
+ const observed = postAttempted ? await observePostedThreadMessage(
3262
+ threadId,
3263
+ beforeThread,
3264
+ opts.message,
3265
+ returnedMessageId,
3266
+ {
3267
+ timeoutMs: opts.verificationTimeoutMs,
3268
+ intervalMs: opts.verificationIntervalMs
3269
+ }
3270
+ ) : { attempts: 0, thread: beforeThread };
3271
+ const observedMessageId = threadMessageId(observed.message);
3272
+ if (!observed.message) {
3273
+ const definitelyRejected = !postAttempted || postError instanceof RestRequestError && postError.status < 500;
3274
+ receipt = updateReceipt(receipt, definitelyRejected ? "failed" : "ambiguous", {
3275
+ phase: !postAttempted ? "message_preflight_failed" : definitelyRejected ? "message_rejected" : "message_outcome_unknown",
3276
+ anchorRange: rangeSnapshot(verifiedRange),
3277
+ ...postError ? { error: errorMessage2(postError) } : {},
3278
+ ...postError instanceof RestRequestError ? { responseStatus: postError.status, responseBody: postError.responseBody } : responseStatus ? { responseStatus } : {},
3279
+ verificationAttempts: observed.attempts,
3280
+ ...observed.lastError ? { verificationError: observed.lastError } : {},
3281
+ afterThread: threadSnapshot(observed.thread)
3282
+ });
3283
+ const outcome = !postAttempted ? "could not be attempted" : definitelyRejected ? "was rejected" : "has an ambiguous outcome";
3284
+ throw new Error(
3285
+ `Comment message ${outcome}; the anchor may remain. Inspect ${receipt.path} before retrying`
3286
+ );
3287
+ }
3288
+ let finalRange;
3289
+ let finalStateText;
3290
+ try {
3291
+ const finalState = await joinDoc(socket, doc._id);
3292
+ finalStateText = finalState.lines.join("\n");
3293
+ finalRange = findCommentRangeByThreadId(finalState.ranges.comments, threadId);
3294
+ } catch (error) {
3295
+ receipt = updateReceipt(receipt, "ambiguous", {
3296
+ phase: "final_anchor_verification_failed",
3297
+ messageId: observedMessageId,
3298
+ messageVerifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
3299
+ verificationError: errorMessage2(error)
3300
+ });
3301
+ throw new Error(
3302
+ `Comment message exists, but its anchor could not be rechecked. Inspect ${receipt.path}`
3303
+ );
3304
+ }
3305
+ if (!finalRange || !commentRangeAnchorsText(finalRange, finalStateText ?? "", opts.anchor)) {
3306
+ receipt = updateReceipt(receipt, "failed", {
3307
+ phase: "partial_message_without_anchor",
3308
+ ...observedMessageId ? { messageId: observedMessageId } : {},
3309
+ finalAnchorRange: rangeSnapshot(finalRange)
3310
+ });
3311
+ throw new Error(
3312
+ `Comment message exists but anchor ${threadId} is missing; see ${receipt.path}`
3313
+ );
3314
+ }
3315
+ receipt = updateReceipt(receipt, "succeeded", {
3316
+ phase: "complete",
3317
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
3318
+ verificationAttempts: observed.attempts,
3319
+ finalAnchorRange: rangeSnapshot(finalRange),
3320
+ afterThread: threadSnapshot(observed.thread),
3321
+ ...postError ? { transportError: errorMessage2(postError) } : {},
3322
+ ...observedMessageId ? { messageId: observedMessageId } : {}
3323
+ });
3324
+ return {
3325
+ doc: doc.path,
3326
+ threadId,
3327
+ anchorCreated,
3328
+ messagePosted: true,
3329
+ duplicate: false,
3330
+ ...observedMessageId ? { messageId: observedMessageId } : {},
3331
+ receiptPath: receipt.path
3332
+ };
3333
+ } catch (error) {
3334
+ if (receipt && receipt.receipt.status !== "failed" && receipt.receipt.status !== "ambiguous" && receipt.receipt.status !== "skipped" && receipt.receipt.status !== "succeeded") {
3335
+ receipt = updateReceipt(receipt, "failed", {
3336
+ phase: "command_failed",
3337
+ error: errorMessage2(error)
3338
+ });
3339
+ }
3340
+ throw error;
3341
+ } finally {
3342
+ try {
3343
+ socket.close();
3344
+ } finally {
3345
+ mutationLock?.release();
3346
+ }
3347
+ }
3348
+ }
3349
+ async function comment(opts) {
3350
+ const result = await commentWithResult(opts);
3351
+ if (result.duplicate) {
3352
+ console.log(
3353
+ `\u21AA\uFE0F Skipped duplicate comment on "${opts.anchor}" (thread ${result.threadId})`
3354
+ );
3355
+ } else {
3356
+ console.log(
3357
+ `\u2705 Commented on "${opts.anchor}" in ${result.doc} (thread ${result.threadId}` + (result.messageId ? `, message ${result.messageId}` : "") + ")"
3358
+ );
3359
+ }
3360
+ }
3361
+
3362
+ // src/commands/reply.ts
3363
+ var DEFAULT_DUPLICATE_WINDOW_MS2 = 5 * 60 * 1e3;
3364
+ function errorMessage3(error) {
3365
+ return error instanceof Error ? error.message : String(error);
3366
+ }
3367
+ function threadSnapshot2(thread) {
3368
+ const messages = threadMessages(thread);
3369
+ return {
3370
+ exists: Boolean(thread),
3371
+ messageCount: messages.length,
3372
+ messageIds: messages.map(threadMessageId).filter((id) => Boolean(id))
3373
+ };
3374
+ }
3375
+ function recentUnverifiedReply(projectId, threadId, message, windowMs, receiptsDir) {
3376
+ const now = Date.now();
3377
+ const earliest = now - windowMs;
3378
+ const prior = readReceipts(receiptsDir).find(({ receipt }) => {
3379
+ const updatedAt = Date.parse(receipt.updatedAt);
3380
+ return receipt.operation === "reply" && (receipt.status === "ambiguous" || receipt.status === "in_progress" && typeof receipt.details.postAttemptedAt === "string") && receipt.details.projectId === projectId && receipt.details.threadId === threadId && receipt.details.message === message && Number.isFinite(updatedAt) && updatedAt >= earliest && updatedAt <= now + 6e4;
3381
+ });
3382
+ return prior ? { path: prior.path, updatedAt: prior.receipt.updatedAt } : void 0;
3383
+ }
3384
+ async function replyWithResult(threadId, message, options = {}) {
3385
+ const duplicateWindowMs = Math.max(
3386
+ 0,
3387
+ options.duplicateWindowMs ?? DEFAULT_DUPLICATE_WINDOW_MS2
3388
+ );
3389
+ let receipt = beginReceipt(
3390
+ "reply",
3391
+ {
3392
+ projectId: config.projectId,
3393
+ threadId,
3394
+ message,
3395
+ force: options.force ?? false,
3396
+ duplicateWindowMs,
3397
+ phase: "preflight"
3398
+ },
3399
+ { receiptsDir: options.receiptsDir }
3400
+ );
3401
+ let beforeThread;
3402
+ let postAttempted = false;
3403
+ let returnedMessageId;
3404
+ let responseStatus;
3405
+ let mutationLock;
3406
+ try {
3407
+ mutationLock = acquireMutationLock(config.projectId);
3408
+ const threads = await getThreads();
3409
+ beforeThread = threads[threadId];
3410
+ receipt = updateReceipt(receipt, "in_progress", {
3411
+ preflightAt: (/* @__PURE__ */ new Date()).toISOString(),
3412
+ before: threadSnapshot2(beforeThread)
3413
+ });
3414
+ if (!beforeThread) throw new Error(`thread ${threadId} not found`);
3415
+ const duplicate = !options.force ? findRecentIdenticalMessage(beforeThread, message, Date.now(), duplicateWindowMs) : void 0;
3416
+ if (duplicate) {
3417
+ const messageId = threadMessageId(duplicate);
3418
+ receipt = updateReceipt(receipt, "skipped", {
3419
+ phase: "complete",
3420
+ outcome: "recent_identical_message",
3421
+ ...messageId ? { messageId } : {},
3422
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
3423
+ });
3424
+ return {
3425
+ threadId,
3426
+ posted: false,
3427
+ duplicate: true,
3428
+ ...messageId ? { messageId } : {},
3429
+ receiptPath: receipt.path
3430
+ };
3431
+ }
3432
+ const priorUnverified = !options.force ? recentUnverifiedReply(
3433
+ config.projectId,
3434
+ threadId,
3435
+ message,
3436
+ duplicateWindowMs,
3437
+ options.receiptsDir
3438
+ ) : void 0;
3439
+ if (priorUnverified) {
3440
+ receipt = updateReceipt(receipt, "skipped", {
3441
+ phase: "blocked_by_prior_ambiguity",
3442
+ outcome: "retry_not_sent",
3443
+ priorReceipt: priorUnverified.path,
3444
+ priorUpdatedAt: priorUnverified.updatedAt
3445
+ });
3446
+ throw new Error(
3447
+ `A recent reply attempt has an unverified outcome (${priorUnverified.path}). Inspect the thread before retrying, or use force: true explicitly`
3448
+ );
3449
+ }
3450
+ const csrf = await getCsrfToken();
3451
+ postAttempted = true;
3452
+ receipt = updateReceipt(receipt, "in_progress", {
3453
+ phase: "posting_message",
3454
+ postAttemptedAt: (/* @__PURE__ */ new Date()).toISOString()
3455
+ });
3456
+ const response = await postThreadMessageDetailed(threadId, message, csrf);
3457
+ responseStatus = response.status;
3458
+ returnedMessageId = response.messageId;
3459
+ receipt = updateReceipt(receipt, "in_progress", {
3460
+ phase: "verifying_message",
3461
+ responseStatus,
3462
+ ...returnedMessageId ? { returnedMessageId } : {},
3463
+ ...response.responseBody === void 0 ? {} : { responseBody: response.responseBody }
3464
+ });
3465
+ const observed = await observePostedThreadMessage(
3466
+ threadId,
3467
+ beforeThread,
3468
+ message,
3469
+ returnedMessageId,
3470
+ {
3471
+ timeoutMs: options.verificationTimeoutMs,
3472
+ intervalMs: options.verificationIntervalMs
3473
+ }
3474
+ );
3475
+ const observedMessageId = threadMessageId(observed.message);
3476
+ if (!observed.message) {
3477
+ receipt = updateReceipt(receipt, "ambiguous", {
3478
+ phase: "message_unverified",
3479
+ verificationAttempts: observed.attempts,
3480
+ ...observed.lastError ? { verificationError: observed.lastError } : {},
3481
+ after: threadSnapshot2(observed.thread)
3482
+ });
3483
+ throw new Error(
3484
+ `Overleaf accepted the reply request, but the message was not visible on readback. Outcome is ambiguous; inspect ${receipt.path} before retrying`
3485
+ );
3486
+ }
3487
+ receipt = updateReceipt(receipt, "succeeded", {
3488
+ phase: "complete",
3489
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
3490
+ verificationAttempts: observed.attempts,
3491
+ after: threadSnapshot2(observed.thread),
3492
+ ...observedMessageId ? { messageId: observedMessageId } : {}
3493
+ });
3494
+ return {
3495
+ threadId,
3496
+ posted: true,
3497
+ duplicate: false,
3498
+ ...observedMessageId ? { messageId: observedMessageId } : {},
3499
+ receiptPath: receipt.path
3500
+ };
3501
+ } catch (error) {
3502
+ if (postAttempted && receipt.receipt.status !== "ambiguous") {
3503
+ const observed = await observePostedThreadMessage(
3504
+ threadId,
3505
+ beforeThread,
3506
+ message,
3507
+ returnedMessageId,
3508
+ {
3509
+ timeoutMs: options.verificationTimeoutMs,
3510
+ intervalMs: options.verificationIntervalMs
3511
+ }
3512
+ );
3513
+ const observedMessageId = threadMessageId(observed.message);
3514
+ if (observed.message) {
3515
+ receipt = updateReceipt(receipt, "succeeded", {
3516
+ phase: "complete",
3517
+ outcome: "verified_after_transport_error",
3518
+ transportError: errorMessage3(error),
3519
+ ...responseStatus ? { responseStatus } : {},
3520
+ verificationAttempts: observed.attempts,
3521
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
3522
+ after: threadSnapshot2(observed.thread),
3523
+ ...observedMessageId ? { messageId: observedMessageId } : {}
3524
+ });
3525
+ return {
3526
+ threadId,
3527
+ posted: true,
3528
+ duplicate: false,
3529
+ ...observedMessageId ? { messageId: observedMessageId } : {},
3530
+ receiptPath: receipt.path
3531
+ };
3532
+ }
3533
+ const definitelyRejected = error instanceof RestRequestError && error.status < 500;
3534
+ receipt = updateReceipt(receipt, definitelyRejected ? "failed" : "ambiguous", {
3535
+ phase: definitelyRejected ? "message_rejected" : "message_outcome_unknown",
3536
+ error: errorMessage3(error),
3537
+ ...error instanceof RestRequestError ? { responseStatus: error.status, responseBody: error.responseBody } : {},
3538
+ verificationAttempts: observed.attempts,
3539
+ ...observed.lastError ? { verificationError: observed.lastError } : {},
3540
+ after: threadSnapshot2(observed.thread)
3541
+ });
3542
+ const qualification = definitelyRejected ? "failed" : "has an ambiguous outcome";
3543
+ throw new Error(
3544
+ `Reply to thread ${threadId} ${qualification}: ${errorMessage3(error)}. Receipt: ${receipt.path}`
3545
+ );
3546
+ }
3547
+ if (receipt.receipt.status !== "ambiguous" && receipt.receipt.status !== "failed" && receipt.receipt.status !== "skipped" && receipt.receipt.status !== "succeeded") {
3548
+ receipt = updateReceipt(receipt, "failed", {
3549
+ phase: "preflight_failed",
3550
+ error: errorMessage3(error)
3551
+ });
3552
+ }
3553
+ throw error;
3554
+ } finally {
3555
+ mutationLock?.release();
3556
+ }
3557
+ }
3558
+ async function reply(threadId, message, options = {}) {
3559
+ const result = await replyWithResult(threadId, message, options);
3560
+ if (result.duplicate) {
3561
+ console.log(
3562
+ `\u21AA\uFE0F Skipped duplicate reply in thread ${threadId}` + (result.messageId ? ` (message ${result.messageId})` : "")
3563
+ );
3564
+ } else {
3565
+ console.log(
3566
+ `\u2705 Replied to thread ${threadId}` + (result.messageId ? ` (message ${result.messageId})` : "")
3567
+ );
3568
+ }
3569
+ }
3570
+
3571
+ // src/commands/resolve.ts
3572
+ async function resolve2(threadId, reopen = false) {
3573
+ const mutationLock = acquireMutationLock(config.projectId);
3574
+ let socket;
3575
+ try {
3576
+ const opened = await openProject();
3577
+ socket = opened.socket;
3578
+ const { docs } = opened;
3579
+ let docId;
3580
+ for (const doc of docs) {
3581
+ const state = await joinDoc(socket, doc._id);
3582
+ if ((state.ranges.comments ?? []).some((c) => c.op?.t === threadId)) {
3583
+ docId = doc._id;
3584
+ break;
3585
+ }
3586
+ }
3587
+ if (!docId) {
3588
+ throw new Error(
3589
+ `thread ${threadId} not found in any doc's active comments (already resolved threads may not be locatable this way)`
3590
+ );
3591
+ }
3592
+ const csrf = await getCsrfToken();
3593
+ await setThreadResolved(docId, threadId, reopen, csrf);
3594
+ console.log(`\u2705 Thread ${threadId} ${reopen ? "reopened" : "resolved"}`);
3595
+ } finally {
3596
+ try {
3597
+ socket?.close();
3598
+ } finally {
3599
+ mutationLock.release();
3600
+ }
3601
+ }
3602
+ }
3603
+
3604
+ // src/commands/delete-comment.ts
3605
+ async function deleteComment(threadId) {
3606
+ const mutationLock = acquireMutationLock(config.projectId);
3607
+ let socket;
3608
+ try {
3609
+ const opened = await openProject();
3610
+ socket = opened.socket;
3611
+ const { docs } = opened;
3612
+ let docId;
3613
+ for (const doc of docs) {
3614
+ const state = await joinDoc(socket, doc._id);
3615
+ if ((state.ranges.comments ?? []).some((c) => c.op?.t === threadId)) {
3616
+ docId = doc._id;
3617
+ break;
3618
+ }
3619
+ }
3620
+ if (!docId) throw new Error(`thread ${threadId} not found in any doc's comments`);
3621
+ const csrf = await getCsrfToken();
3622
+ await deleteThread(docId, threadId, csrf);
3623
+ console.log(`\u2705 Deleted comment thread ${threadId}`);
3624
+ } finally {
3625
+ try {
3626
+ socket?.close();
3627
+ } finally {
3628
+ mutationLock.release();
3629
+ }
3630
+ }
3631
+ }
3632
+
3633
+ // src/commands/delete-message.ts
3634
+ async function deleteThreadMessage(messageId, threadId) {
3635
+ const mutationLock = acquireMutationLock(config.projectId);
3636
+ try {
3637
+ const csrf = await getCsrfToken();
3638
+ let tid = threadId;
3639
+ if (!tid) {
3640
+ const threads = await getThreads();
3641
+ for (const [t, v] of Object.entries(threads)) {
3642
+ if (v.messages?.some((m) => m.id === messageId)) {
3643
+ tid = t;
3644
+ break;
3645
+ }
3646
+ }
3647
+ }
3648
+ if (!tid) throw new Error(`message ${messageId} not found in any thread`);
3649
+ await deleteMessage(tid, messageId, csrf);
3650
+ console.log(`\u2705 Deleted message ${messageId} from thread ${tid}`);
3651
+ } finally {
3652
+ mutationLock.release();
3653
+ }
3654
+ }
3655
+
3656
+ // src/commands/accept.ts
3657
+ var VERIFY_ATTEMPTS = 6;
3658
+ var VERIFY_INTERVAL_MS = 200;
3659
+ function changesFrom(state) {
3660
+ return state.ranges.changes ?? [];
3661
+ }
3662
+ async function verifyAcceptedDocument(socket, docId, changeIds) {
3663
+ let last;
3664
+ let lastError;
3665
+ for (let attempt = 0; attempt < VERIFY_ATTEMPTS; attempt++) {
3666
+ try {
3667
+ last = await joinDoc(socket, docId);
3668
+ lastError = void 0;
3669
+ if (!remainingChangeIds(changesFrom(last), changeIds).length) return last;
3670
+ } catch (error) {
3671
+ lastError = error;
3672
+ }
3673
+ if (attempt + 1 < VERIFY_ATTEMPTS) {
3674
+ await new Promise((resolve3) => setTimeout(resolve3, VERIFY_INTERVAL_MS));
3675
+ }
3676
+ }
3677
+ if (!last) throw lastError ?? new Error(`could not read back document ${docId}`);
3678
+ return last;
3679
+ }
3680
+ function errorMessage4(error) {
3681
+ return error instanceof Error ? error.message : String(error);
3682
+ }
3683
+ async function accept(changeIds) {
3684
+ const requestedIds = uniqueChangeIds(changeIds);
3685
+ if (!requestedIds.length) throw new Error("no tracked change ids requested");
3686
+ const { socket, docs } = await openProject();
3687
+ let mutationLock;
3688
+ const found = /* @__PURE__ */ new Set();
3689
+ const locations = /* @__PURE__ */ new Map();
3690
+ const verifiedDocs = /* @__PURE__ */ new Set();
3691
+ const result = {
3692
+ action: "accept",
3693
+ requestedIds,
3694
+ foundIds: [],
3695
+ missingIds: [],
3696
+ attemptedIds: [],
3697
+ verifiedAbsentIds: [],
3698
+ documents: [],
3699
+ verified: false
3700
+ };
3701
+ const refreshVerifiedIds = () => {
3702
+ const verified = new Set(result.missingIds);
3703
+ for (const id of result.foundIds) {
3704
+ const everyKnownLocationVerified = [...locations.values()].filter((location) => location.ids.includes(id)).every((location) => verifiedDocs.has(location.doc._id));
3705
+ if (everyKnownLocationVerified) verified.add(id);
3706
+ }
3707
+ result.verifiedAbsentIds = requestedIds.filter((id) => verified.has(id));
3708
+ result.verified = result.verifiedAbsentIds.length === requestedIds.length;
3709
+ };
3710
+ try {
3711
+ mutationLock = acquireMutationLock(config.projectId);
3712
+ for (const doc of docs) {
3713
+ const state = await joinDoc(socket, doc._id);
3714
+ const ids = remainingChangeIds(changesFrom(state), requestedIds);
3715
+ if (!ids.length) continue;
3716
+ locations.set(doc._id, { doc, ids });
3717
+ for (const id of ids) found.add(id);
3718
+ }
3719
+ result.foundIds = requestedIds.filter((id) => found.has(id));
3720
+ result.missingIds = requestedIds.filter((id) => !found.has(id));
3721
+ refreshVerifiedIds();
3722
+ if (result.missingIds.length) {
3723
+ console.log(
3724
+ `\u26A0\uFE0F not found (already accepted/rejected?): ${result.missingIds.join(", ")}`
3725
+ );
3726
+ }
3727
+ if (!result.foundIds.length) {
3728
+ throw new Error("no matching tracked changes found");
3729
+ }
3730
+ let csrf;
3731
+ try {
3732
+ csrf = await getCsrfToken();
3733
+ } catch (error) {
3734
+ throw new TrackedChangeMutationError(
3735
+ `Could not obtain a CSRF token before accepting tracked changes: ${errorMessage4(error)}`,
3736
+ result,
3737
+ error
3738
+ );
3739
+ }
3740
+ for (const { doc, ids } of locations.values()) {
3741
+ let before;
3742
+ try {
3743
+ before = await joinDoc(socket, doc._id);
3744
+ } catch (error) {
3745
+ const outcome2 = {
3746
+ docId: doc._id,
3747
+ docPath: doc.path,
3748
+ requestedIds: ids,
3749
+ attemptedIds: [],
3750
+ fragmentCount: 0,
3751
+ status: "failed",
3752
+ remainingIds: ids,
3753
+ error: errorMessage4(error)
3754
+ };
3755
+ result.documents.push(outcome2);
3756
+ refreshVerifiedIds();
3757
+ throw new TrackedChangeMutationError(
3758
+ `Could not read ${doc.path} before accepting tracked changes`,
3759
+ result,
3760
+ error
3761
+ );
3762
+ }
3763
+ const beforeChanges = changesFrom(before);
3764
+ const presentIds = remainingChangeIds(beforeChanges, ids);
3765
+ const fragmentCount = beforeChanges.filter((range) => presentIds.includes(range.id)).length;
3766
+ if (!presentIds.length) {
3767
+ result.documents.push({
3768
+ docId: doc._id,
3769
+ docPath: doc.path,
3770
+ requestedIds: ids,
3771
+ attemptedIds: [],
3772
+ fragmentCount: 0,
3773
+ beforeVersion: before.version,
3774
+ afterVersion: before.version,
3775
+ status: "already-absent",
3776
+ remainingIds: []
3777
+ });
3778
+ verifiedDocs.add(doc._id);
3779
+ refreshVerifiedIds();
3780
+ continue;
3781
+ }
3782
+ const outcome = {
3783
+ docId: doc._id,
3784
+ docPath: doc.path,
3785
+ requestedIds: ids,
3786
+ attemptedIds: presentIds,
3787
+ fragmentCount,
3788
+ beforeVersion: before.version,
3789
+ status: "failed",
3790
+ remainingIds: presentIds
3791
+ };
3792
+ result.documents.push(outcome);
3793
+ result.attemptedIds = uniqueChangeIds([...result.attemptedIds, ...presentIds]);
3794
+ let mutationError;
3795
+ try {
3796
+ await acceptChanges(doc._id, presentIds, csrf);
3797
+ } catch (error) {
3798
+ mutationError = error;
3799
+ }
3800
+ let after;
3801
+ let readbackError;
3802
+ try {
3803
+ after = await verifyAcceptedDocument(socket, doc._id, ids);
3804
+ } catch (error) {
3805
+ readbackError = error;
3806
+ }
3807
+ if (after) {
3808
+ outcome.afterVersion = after.version;
3809
+ outcome.remainingIds = remainingChangeIds(changesFrom(after), ids);
3810
+ }
3811
+ if (after && !outcome.remainingIds.length) {
3812
+ outcome.status = "verified";
3813
+ if (mutationError) outcome.error = `transport warning: ${errorMessage4(mutationError)}`;
3814
+ verifiedDocs.add(doc._id);
3815
+ refreshVerifiedIds();
3816
+ continue;
3817
+ }
3818
+ const reasons = [];
3819
+ if (mutationError) reasons.push(errorMessage4(mutationError));
3820
+ if (readbackError) reasons.push(`readback failed: ${errorMessage4(readbackError)}`);
3821
+ if (outcome.remainingIds.length) {
3822
+ reasons.push(`change ids still present: ${outcome.remainingIds.join(", ")}`);
3823
+ }
3824
+ outcome.error = reasons.join("; ") || "acceptance could not be verified";
3825
+ refreshVerifiedIds();
3826
+ throw new TrackedChangeMutationError(
3827
+ `Accepted changes in ${doc.path} could not be verified: ${outcome.error}`,
3828
+ result,
3829
+ mutationError ?? readbackError
3830
+ );
3831
+ }
3832
+ refreshVerifiedIds();
3833
+ if (!result.verified) {
3834
+ throw new TrackedChangeMutationError(
3835
+ `Acceptance incomplete; unverified ids: ${requestedIds.filter((id) => !result.verifiedAbsentIds.includes(id)).join(", ")}`,
3836
+ result
3837
+ );
3838
+ }
3839
+ console.log(
3840
+ `\u2705 Accepted ${result.foundIds.length} tracked change(s); verified by readback`
3841
+ );
3842
+ return result;
3843
+ } finally {
3844
+ try {
3845
+ socket.close();
3846
+ } finally {
3847
+ mutationLock?.release();
3848
+ }
3849
+ }
3850
+ }
3851
+
813
3852
  // src/commands/reject.ts
3853
+ var VERIFY_ATTEMPTS2 = 6;
3854
+ var VERIFY_INTERVAL_MS2 = 200;
3855
+ function changesFrom2(state) {
3856
+ return state.ranges.changes ?? [];
3857
+ }
3858
+ async function verifyRejectedDocument(socket, docId, changeIds, expectedText) {
3859
+ let last;
3860
+ let lastError;
3861
+ for (let attempt = 0; attempt < VERIFY_ATTEMPTS2; attempt++) {
3862
+ try {
3863
+ last = await joinDoc(socket, docId);
3864
+ lastError = void 0;
3865
+ const remaining = remainingChangeIds(changesFrom2(last), changeIds);
3866
+ if (!remaining.length && last.lines.join("\n") === expectedText) return last;
3867
+ } catch (error) {
3868
+ lastError = error;
3869
+ }
3870
+ if (attempt + 1 < VERIFY_ATTEMPTS2) {
3871
+ await new Promise((resolve3) => setTimeout(resolve3, VERIFY_INTERVAL_MS2));
3872
+ }
3873
+ }
3874
+ if (!last) throw lastError ?? new Error(`could not read back document ${docId}`);
3875
+ return last;
3876
+ }
3877
+ function errorMessage5(error) {
3878
+ return error instanceof Error ? error.message : String(error);
3879
+ }
814
3880
  async function reject(changeIds) {
3881
+ const requestedIds = uniqueChangeIds(changeIds);
3882
+ if (!requestedIds.length) throw new Error("no tracked change ids requested");
815
3883
  const { socket, docs } = await openProject();
816
- const docOf = /* @__PURE__ */ new Map();
817
- for (const doc of docs) {
818
- const state = await joinDoc(socket, doc._id);
819
- for (const c of state.ranges.changes ?? []) {
820
- if (changeIds.includes(c.id)) docOf.set(c.id, doc._id);
3884
+ let mutationLock;
3885
+ const found = /* @__PURE__ */ new Set();
3886
+ const locations = /* @__PURE__ */ new Map();
3887
+ const verifiedDocs = /* @__PURE__ */ new Set();
3888
+ const result = {
3889
+ action: "reject",
3890
+ requestedIds,
3891
+ foundIds: [],
3892
+ missingIds: [],
3893
+ attemptedIds: [],
3894
+ verifiedAbsentIds: [],
3895
+ documents: [],
3896
+ verified: false
3897
+ };
3898
+ const refreshVerifiedIds = () => {
3899
+ const verified = new Set(result.missingIds);
3900
+ for (const id of result.foundIds) {
3901
+ const everyKnownLocationVerified = [...locations.values()].filter((location) => location.ids.includes(id)).every((location) => verifiedDocs.has(location.doc._id));
3902
+ if (everyKnownLocationVerified) verified.add(id);
821
3903
  }
822
- }
823
- let rejected = 0;
824
- for (const id of changeIds) {
825
- const docId = docOf.get(id);
826
- if (!docId) {
827
- console.log(`\u26A0\uFE0F not found (already accepted/rejected?): ${id}`);
828
- continue;
3904
+ result.verifiedAbsentIds = requestedIds.filter((id) => verified.has(id));
3905
+ result.verified = result.verifiedAbsentIds.length === requestedIds.length;
3906
+ };
3907
+ try {
3908
+ mutationLock = acquireMutationLock(config.projectId);
3909
+ for (const doc of docs) {
3910
+ const state = await joinDoc(socket, doc._id);
3911
+ const ids = remainingChangeIds(changesFrom2(state), requestedIds);
3912
+ if (!ids.length) continue;
3913
+ locations.set(doc._id, { doc, ids });
3914
+ for (const id of ids) found.add(id);
3915
+ }
3916
+ result.foundIds = requestedIds.filter((id) => found.has(id));
3917
+ result.missingIds = requestedIds.filter((id) => !found.has(id));
3918
+ refreshVerifiedIds();
3919
+ if (result.missingIds.length) {
3920
+ console.log(
3921
+ `\u26A0\uFE0F not found (already accepted/rejected?): ${result.missingIds.join(", ")}`
3922
+ );
3923
+ }
3924
+ if (!result.foundIds.length) {
3925
+ throw new Error("no matching tracked changes found");
3926
+ }
3927
+ for (const { doc, ids } of locations.values()) {
3928
+ let before;
3929
+ try {
3930
+ before = await joinDoc(socket, doc._id);
3931
+ } catch (error) {
3932
+ const outcome2 = {
3933
+ docId: doc._id,
3934
+ docPath: doc.path,
3935
+ requestedIds: ids,
3936
+ attemptedIds: [],
3937
+ fragmentCount: 0,
3938
+ status: "failed",
3939
+ remainingIds: ids,
3940
+ error: errorMessage5(error)
3941
+ };
3942
+ result.documents.push(outcome2);
3943
+ refreshVerifiedIds();
3944
+ throw new TrackedChangeMutationError(
3945
+ `Could not read ${doc.path} before rejecting tracked changes`,
3946
+ result,
3947
+ error
3948
+ );
3949
+ }
3950
+ const presentIds = remainingChangeIds(changesFrom2(before), ids);
3951
+ if (!presentIds.length) {
3952
+ result.documents.push({
3953
+ docId: doc._id,
3954
+ docPath: doc.path,
3955
+ requestedIds: ids,
3956
+ attemptedIds: [],
3957
+ fragmentCount: 0,
3958
+ beforeVersion: before.version,
3959
+ afterVersion: before.version,
3960
+ status: "already-absent",
3961
+ textVerified: true,
3962
+ remainingIds: []
3963
+ });
3964
+ verifiedDocs.add(doc._id);
3965
+ refreshVerifiedIds();
3966
+ continue;
3967
+ }
3968
+ let plan;
3969
+ try {
3970
+ plan = buildRejectionPlan(before.lines.join("\n"), changesFrom2(before), presentIds);
3971
+ } catch (error) {
3972
+ const outcome2 = {
3973
+ docId: doc._id,
3974
+ docPath: doc.path,
3975
+ requestedIds: ids,
3976
+ attemptedIds: [],
3977
+ fragmentCount: 0,
3978
+ beforeVersion: before.version,
3979
+ status: "failed",
3980
+ remainingIds: presentIds,
3981
+ error: errorMessage5(error)
3982
+ };
3983
+ result.documents.push(outcome2);
3984
+ refreshVerifiedIds();
3985
+ throw new TrackedChangeMutationError(
3986
+ `Could not safely plan rejection in ${doc.path}: ${errorMessage5(error)}`,
3987
+ result,
3988
+ error
3989
+ );
3990
+ }
3991
+ const outcome = {
3992
+ docId: doc._id,
3993
+ docPath: doc.path,
3994
+ requestedIds: ids,
3995
+ attemptedIds: presentIds,
3996
+ fragmentCount: plan.fragmentCount,
3997
+ beforeVersion: before.version,
3998
+ status: "failed",
3999
+ textVerified: false,
4000
+ remainingIds: presentIds
4001
+ };
4002
+ result.documents.push(outcome);
4003
+ result.attemptedIds = uniqueChangeIds([...result.attemptedIds, ...presentIds]);
4004
+ let mutationError;
4005
+ try {
4006
+ await applyOtUpdateAndWait(socket, doc._id, {
4007
+ doc: doc._id,
4008
+ op: plan.operations,
4009
+ v: before.version,
4010
+ meta: {}
4011
+ });
4012
+ } catch (error) {
4013
+ mutationError = error;
4014
+ }
4015
+ let after;
4016
+ let readbackError;
4017
+ try {
4018
+ after = await verifyRejectedDocument(socket, doc._id, presentIds, plan.expectedText);
4019
+ } catch (error) {
4020
+ readbackError = error;
4021
+ }
4022
+ if (after) {
4023
+ outcome.afterVersion = after.version;
4024
+ outcome.remainingIds = remainingChangeIds(changesFrom2(after), presentIds);
4025
+ outcome.textVerified = after.lines.join("\n") === plan.expectedText;
4026
+ }
4027
+ if (after && !outcome.remainingIds.length && outcome.textVerified) {
4028
+ outcome.status = "verified";
4029
+ if (mutationError) outcome.error = `transport warning: ${errorMessage5(mutationError)}`;
4030
+ verifiedDocs.add(doc._id);
4031
+ refreshVerifiedIds();
4032
+ continue;
4033
+ }
4034
+ const reasons = [];
4035
+ if (mutationError) reasons.push(errorMessage5(mutationError));
4036
+ if (readbackError) reasons.push(`readback failed: ${errorMessage5(readbackError)}`);
4037
+ if (outcome.remainingIds.length) {
4038
+ reasons.push(`change ids still present: ${outcome.remainingIds.join(", ")}`);
4039
+ }
4040
+ if (after && !outcome.textVerified) {
4041
+ reasons.push("final document text did not match the planned rejection");
4042
+ }
4043
+ outcome.error = reasons.join("; ") || "rejection could not be verified";
4044
+ refreshVerifiedIds();
4045
+ throw new TrackedChangeMutationError(
4046
+ `Rejected changes in ${doc.path} could not be verified: ${outcome.error}`,
4047
+ result,
4048
+ mutationError ?? readbackError
4049
+ );
4050
+ }
4051
+ refreshVerifiedIds();
4052
+ if (!result.verified) {
4053
+ throw new TrackedChangeMutationError(
4054
+ `Rejection incomplete; unverified ids: ${requestedIds.filter((id) => !result.verifiedAbsentIds.includes(id)).join(", ")}`,
4055
+ result
4056
+ );
829
4057
  }
830
- const state = await joinDoc(socket, docId);
831
- const c = (state.ranges.changes ?? []).find((x) => x.id === id);
832
- if (!c) continue;
833
- const op = typeof c.op?.i === "string" ? { p: c.op.p, d: c.op.i } : { p: c.op.p, i: c.op.d };
834
- const update = { doc: docId, op: [op], v: state.version, meta: {} };
835
- const ack = await socket.emit("applyOtUpdate", [docId, update], 15e3);
836
- if (ack?.[0]) {
4058
+ console.log(
4059
+ `\u2705 Rejected ${result.foundIds.length} tracked change(s) (${result.documents.reduce((count, doc) => count + doc.fragmentCount, 0)} range fragment(s)); verified by readback`
4060
+ );
4061
+ return result;
4062
+ } finally {
4063
+ try {
837
4064
  socket.close();
838
- throw new Error(`Overleaf rejected the inverse op for ${id}: ${JSON.stringify(ack[0])}`);
4065
+ } finally {
4066
+ mutationLock?.release();
839
4067
  }
840
- rejected++;
841
4068
  }
842
- socket.close();
843
- if (!rejected) throw new Error("no matching tracked changes found");
844
- console.log(`\u2705 Rejected ${rejected} tracked change(s)`);
845
4069
  }
846
4070
 
847
4071
  // src/commands/login.ts
@@ -864,6 +4088,9 @@ async function login(opts) {
864
4088
  const path = saveCredentials({ baseUrl, session2: cookie });
865
4089
  console.log(`
866
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
+ }
867
4094
  }
868
4095
  async function captureCookieViaBrowser(baseUrl) {
869
4096
  let chromium;
@@ -893,20 +4120,27 @@ async function captureCookieViaBrowser(baseUrl) {
893
4120
 
894
4121
  // src/commands/link.ts
895
4122
  function link(projectId, baseUrl) {
896
- const path = saveProjectConfig({ projectId, ...baseUrl ? { baseUrl } : {} });
897
- console.log(`\u2705 Linked this repo to Overleaf project ${projectId} \u2192 ${path}`);
898
- console.log(" (safe to commit \u2014 it contains no secrets.)");
4123
+ const mutationLock = acquireMutationLock(projectId);
4124
+ try {
4125
+ const path = saveProjectConfig({ projectId, ...baseUrl ? { baseUrl } : {} });
4126
+ console.log(`\u2705 Linked this repo to Overleaf project ${projectId} \u2192 ${path}`);
4127
+ console.log(" (safe to commit \u2014 it contains no secrets.)");
4128
+ } finally {
4129
+ mutationLock.release();
4130
+ }
899
4131
  }
900
4132
 
901
4133
  // src/cli.ts
902
4134
  function getFlag(name) {
903
4135
  const i = process.argv.indexOf(`--${name}`);
904
- return i >= 0 ? process.argv[i + 1] : void 0;
4136
+ const value = i >= 0 ? process.argv[i + 1] : void 0;
4137
+ return value && !value.startsWith("--") ? value : void 0;
905
4138
  }
906
4139
  function getAll(name) {
907
4140
  const out = [];
908
4141
  process.argv.forEach((a, i) => {
909
- if (a === `--${name}` && process.argv[i + 1]) out.push(process.argv[i + 1]);
4142
+ const value = process.argv[i + 1];
4143
+ if (a === `--${name}` && value && !value.startsWith("--")) out.push(value);
910
4144
  });
911
4145
  return out.flatMap((v) => v.split(",")).map((s) => s.trim()).filter(Boolean);
912
4146
  }
@@ -917,22 +4151,87 @@ function usage() {
917
4151
  console.log(" link --project <id> Link this repo to an Overleaf project\n");
918
4152
  console.log("Read:");
919
4153
  console.log(" pull [--out <dir>] Comments + tracked changes \u2192 sidecar\n");
920
- console.log("Content (replaces the git bridge \u2014 review-safe):");
921
- console.log(" fetch [--file <f>] [--dry-run] Overleaf text \u2192 local files (read-only)");
4154
+ console.log("Safe review workflow:");
4155
+ console.log(" review start [--file <f>] [--out <dir>] Fetch text/base, then pull review data");
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");
4158
+ console.log(" review submit --plan <plan.json> Validate, apply, and verify that plan");
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");
4163
+ console.log("Content (replaces the git bridge):");
4164
+ console.log(" fetch [--file <f>] [--dry-run] Overleaf text \u2192 local files + saved base");
922
4165
  console.log(" upload <path\u2026> [--folder <name>] Upload figures / new files to Overleaf\n");
923
4166
  console.log("Comments:");
924
- console.log(" comment --anchor <text> --message <text> [--doc <name>] [--nth <n>]");
925
- console.log(" reply --thread <id> --message <text> Reply to an existing thread");
4167
+ console.log(" comment --anchor <text> --message <text> [--doc <name>] [--nth <n>] [--force]");
4168
+ console.log(" reply --thread <id> --message <text> [--force]");
926
4169
  console.log(" resolve --thread <id> [--reopen] Resolve/reopen a thread");
927
4170
  console.log(" delete-comment --thread <id> Delete a whole thread");
928
4171
  console.log(" delete-message --message-id <id> Delete a single message\n");
929
4172
  console.log("Tracked changes:");
930
4173
  console.log(" push [--file <f>] [--doc <name>] [--direct] [--dry-run]");
4174
+ console.log(" [--plan-out <plan.json> | --plan <plan.json>] [--allow-overlap]");
4175
+ console.log(" [--acknowledge-ambiguous] (after manually reconciling a prior uncertain push)");
4176
+ console.log(" [--unsafe-no-base] (legacy escape hatch; disables three-way protection)");
931
4177
  console.log(" Send local edits as tracked suggestions (--direct = plain edits)");
932
4178
  console.log(" accept --change <id> [--change <id> \u2026] Accept collaborators\u2019 changes");
933
4179
  console.log(" reject --change <id> [--change <id> \u2026] Reject collaborators\u2019 changes");
934
4180
  console.log("\n(thread/change ids come from `pull`; --change accepts comma-separated lists too)");
935
4181
  }
4182
+ async function pullAndReport(out, options = {}) {
4183
+ const data = await pull(out, options);
4184
+ console.log(
4185
+ `Pulled ${data.comments.length} comment(s) and ${data.changes.length} tracked change(s) from "${data.project}" \u2192 ${out}/`
4186
+ );
4187
+ }
4188
+ function pushOptions() {
4189
+ return {
4190
+ edits: getFlag("edits"),
4191
+ file: getFlag("file"),
4192
+ docName: getFlag("doc"),
4193
+ direct: process.argv.includes("--direct"),
4194
+ dryRun: process.argv.includes("--dry-run"),
4195
+ unsafeNoBase: process.argv.includes("--unsafe-no-base"),
4196
+ allowOverlap: process.argv.includes("--allow-overlap"),
4197
+ planOut: getFlag("plan-out"),
4198
+ plan: getFlag("plan"),
4199
+ allowAmbiguousRetry: process.argv.includes("--acknowledge-ambiguous")
4200
+ };
4201
+ }
4202
+ async function mutateTrackedChanges(action, ids, mutate) {
4203
+ let receipt = beginReceipt(action, {
4204
+ projectId: config.projectId,
4205
+ requestedIds: ids,
4206
+ phase: "preflight"
4207
+ });
4208
+ try {
4209
+ receipt = updateReceipt(receipt, "in_progress", {
4210
+ phase: "mutating",
4211
+ mutationStartedAt: (/* @__PURE__ */ new Date()).toISOString()
4212
+ });
4213
+ const result = await mutate(ids);
4214
+ receipt = updateReceipt(receipt, "succeeded", {
4215
+ phase: "complete",
4216
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
4217
+ result
4218
+ });
4219
+ console.log(`Audit receipt: ${receipt.path}`);
4220
+ } catch (error) {
4221
+ const result = error instanceof TrackedChangeMutationError ? error.result : void 0;
4222
+ const outcomeUnknown = Boolean(result?.attemptedIds.length && !result.verified);
4223
+ receipt = updateReceipt(receipt, outcomeUnknown ? "ambiguous" : "failed", {
4224
+ phase: outcomeUnknown ? "outcome_unknown" : "failed",
4225
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
4226
+ error: error instanceof Error ? error.message : String(error),
4227
+ ...result ? { result } : {}
4228
+ });
4229
+ throw new Error(
4230
+ `${error instanceof Error ? error.message : String(error)}. Audit receipt: ${receipt.path}`,
4231
+ { cause: error }
4232
+ );
4233
+ }
4234
+ }
936
4235
  async function main() {
937
4236
  const cmd = process.argv[2];
938
4237
  switch (cmd) {
@@ -951,20 +4250,65 @@ async function main() {
951
4250
  }
952
4251
  case "pull": {
953
4252
  const out = getFlag("out") ?? ".overleaf";
954
- const data = await pull(out);
955
- console.log(
956
- `Pulled ${data.comments.length} comment(s) and ${data.changes.length} tracked change(s) from "${data.project}" \u2192 ${out}/`
957
- );
4253
+ await pullAndReport(out);
958
4254
  break;
959
4255
  }
960
4256
  case "push":
961
- await push({
962
- file: getFlag("file"),
963
- docName: getFlag("doc"),
964
- direct: process.argv.includes("--direct"),
965
- dryRun: process.argv.includes("--dry-run")
966
- });
4257
+ await push(pushOptions());
4258
+ break;
4259
+ case "review": {
4260
+ const reviewCommand = process.argv[3];
4261
+ if (process.argv.includes("--through")) {
4262
+ fail("Section-scoped --through is not implemented; no review action was performed.");
4263
+ }
4264
+ if (reviewCommand === "start") {
4265
+ const file = getFlag("file") ?? getFlag("doc");
4266
+ const mutationLock = acquireMutationLock(config.projectId);
4267
+ try {
4268
+ await fetchDocs({ file, dryRun: false, acquireLock: false });
4269
+ await pullAndReport(getFlag("out") ?? ".overleaf", { acquireLock: false });
4270
+ } finally {
4271
+ mutationLock.release();
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
+ });
4295
+ } else if (reviewCommand === "plan") {
4296
+ const out = getFlag("out");
4297
+ if (!out) fail("review plan requires --out <plan.json>");
4298
+ await push({ ...pushOptions(), plan: void 0, planOut: out, dryRun: true });
4299
+ } else if (reviewCommand === "submit") {
4300
+ const plan = getFlag("plan");
4301
+ if (!plan) fail("review submit requires --plan <plan.json>");
4302
+ await push({
4303
+ plan,
4304
+ allowAmbiguousRetry: process.argv.includes("--acknowledge-ambiguous")
4305
+ });
4306
+ } else {
4307
+ usage();
4308
+ fail("review requires start, plan, submit, or consolidate");
4309
+ }
967
4310
  break;
4311
+ }
968
4312
  case "fetch":
969
4313
  await fetchDocs({ file: getFlag("file"), dryRun: process.argv.includes("--dry-run") });
970
4314
  break;
@@ -988,20 +4332,30 @@ async function main() {
988
4332
  const message = getFlag("message");
989
4333
  if (!anchor || !message) fail("comment requires --anchor <text> and --message <text>");
990
4334
  const nthRaw = getFlag("nth");
991
- await comment({ docName: getFlag("doc"), anchor, message, occurrence: nthRaw ? Number(nthRaw) : void 0 });
4335
+ const occurrence = nthRaw === void 0 ? void 0 : Number(nthRaw);
4336
+ if (occurrence !== void 0 && (!Number.isSafeInteger(occurrence) || occurrence < 1)) {
4337
+ fail("comment --nth must be a positive integer");
4338
+ }
4339
+ await comment({
4340
+ docName: getFlag("doc"),
4341
+ anchor,
4342
+ message,
4343
+ occurrence,
4344
+ force: process.argv.includes("--force")
4345
+ });
992
4346
  break;
993
4347
  }
994
4348
  case "reply": {
995
4349
  const thread = getFlag("thread");
996
4350
  const message = getFlag("message");
997
4351
  if (!thread || !message) fail("reply requires --thread <id> and --message <text>");
998
- await reply(thread, message);
4352
+ await reply(thread, message, { force: process.argv.includes("--force") });
999
4353
  break;
1000
4354
  }
1001
4355
  case "resolve": {
1002
4356
  const thread = getFlag("thread");
1003
4357
  if (!thread) fail("resolve requires --thread <id>");
1004
- await resolve(thread, process.argv.includes("--reopen"));
4358
+ await resolve2(thread, process.argv.includes("--reopen"));
1005
4359
  break;
1006
4360
  }
1007
4361
  case "delete-comment": {
@@ -1019,13 +4373,13 @@ async function main() {
1019
4373
  case "accept": {
1020
4374
  const ids = getAll("change");
1021
4375
  if (!ids.length) fail("accept requires --change <id>");
1022
- await accept(ids);
4376
+ await mutateTrackedChanges("accept", ids, accept);
1023
4377
  break;
1024
4378
  }
1025
4379
  case "reject": {
1026
4380
  const ids = getAll("change");
1027
4381
  if (!ids.length) fail("reject requires --change <id>");
1028
- await reject(ids);
4382
+ await mutateTrackedChanges("reject", ids, reject);
1029
4383
  break;
1030
4384
  }
1031
4385
  default: