overleaf-review 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -15
- package/dist/cli.js +3204 -371
- package/package.json +4 -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
|
|
5
|
-
import { join as
|
|
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
|
-
|
|
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((
|
|
153
|
-
this.ws.once("open", () =>
|
|
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((
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
|
462
|
+
throw new RestRequestError(
|
|
463
|
+
`postThreadMessage ${res.status}: ${rawBody.slice(0, 300)}`,
|
|
464
|
+
res.status,
|
|
465
|
+
rawBody.slice(0, 1e3)
|
|
466
|
+
);
|
|
304
467
|
}
|
|
305
|
-
|
|
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";
|
|
@@ -390,6 +565,156 @@ function lineContext(lines, line, radius = 1) {
|
|
|
390
565
|
return out.join("\n");
|
|
391
566
|
}
|
|
392
567
|
|
|
568
|
+
// src/lib/submission-lock.ts
|
|
569
|
+
import { createHash, randomUUID } from "crypto";
|
|
570
|
+
import {
|
|
571
|
+
closeSync,
|
|
572
|
+
existsSync as existsSync2,
|
|
573
|
+
fsyncSync,
|
|
574
|
+
mkdirSync as mkdirSync3,
|
|
575
|
+
openSync,
|
|
576
|
+
readFileSync as readFileSync3,
|
|
577
|
+
realpathSync as realpathSync2,
|
|
578
|
+
unlinkSync as unlinkSync2,
|
|
579
|
+
writeFileSync as writeFileSync3
|
|
580
|
+
} from "fs";
|
|
581
|
+
import { tmpdir } from "os";
|
|
582
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
583
|
+
|
|
584
|
+
// src/lib/workspace-path.ts
|
|
585
|
+
import { existsSync, realpathSync } from "fs";
|
|
586
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "path";
|
|
587
|
+
function isOutside(root, candidate) {
|
|
588
|
+
const rel = relative(root, candidate);
|
|
589
|
+
return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
590
|
+
}
|
|
591
|
+
function workspaceRelativePath(input, root = process.cwd()) {
|
|
592
|
+
if (typeof input !== "string" || !input.length) throw new Error("workspace path is empty");
|
|
593
|
+
if (isAbsolute(input) || /^[\\/]/.test(input) || /^[a-zA-Z]:[\\/]/.test(input)) {
|
|
594
|
+
throw new Error(`Absolute paths are not allowed: ${input}`);
|
|
595
|
+
}
|
|
596
|
+
const portable = input.replace(/\\/g, "/");
|
|
597
|
+
if (portable.split("/").includes("..")) {
|
|
598
|
+
throw new Error(`Parent path traversal is not allowed: ${input}`);
|
|
599
|
+
}
|
|
600
|
+
const target = resolve(root, ...portable.split("/"));
|
|
601
|
+
const absoluteRoot = resolve(root);
|
|
602
|
+
if (isOutside(absoluteRoot, target) || target === absoluteRoot) {
|
|
603
|
+
throw new Error(`Path must identify a file inside the working tree: ${input}`);
|
|
604
|
+
}
|
|
605
|
+
return relative(absoluteRoot, target).split(sep).join("/");
|
|
606
|
+
}
|
|
607
|
+
function workspaceReadPath(input, root = process.cwd()) {
|
|
608
|
+
const rel = workspaceRelativePath(input, root);
|
|
609
|
+
const realRoot = realpathSync(root);
|
|
610
|
+
const lexicalTarget = resolve(root, ...rel.split("/"));
|
|
611
|
+
const realTarget = realpathSync(lexicalTarget);
|
|
612
|
+
if (isOutside(realRoot, realTarget) || realTarget === realRoot) {
|
|
613
|
+
throw new Error(`Path resolves outside the working tree: ${input}`);
|
|
614
|
+
}
|
|
615
|
+
return lexicalTarget;
|
|
616
|
+
}
|
|
617
|
+
function workspaceWritePath(input, root = process.cwd()) {
|
|
618
|
+
const rel = workspaceRelativePath(input, root);
|
|
619
|
+
const lexicalTarget = resolve(root, ...rel.split("/"));
|
|
620
|
+
const realRoot = realpathSync(root);
|
|
621
|
+
if (existsSync(lexicalTarget)) {
|
|
622
|
+
const target = realpathSync(lexicalTarget);
|
|
623
|
+
if (isOutside(realRoot, target) || target === realRoot) {
|
|
624
|
+
throw new Error(`Path resolves outside the working tree: ${input}`);
|
|
625
|
+
}
|
|
626
|
+
return lexicalTarget;
|
|
627
|
+
}
|
|
628
|
+
let ancestor = dirname(lexicalTarget);
|
|
629
|
+
while (!existsSync(ancestor)) {
|
|
630
|
+
const parent = dirname(ancestor);
|
|
631
|
+
if (parent === ancestor) throw new Error(`Cannot resolve a safe parent for ${input}`);
|
|
632
|
+
ancestor = parent;
|
|
633
|
+
}
|
|
634
|
+
const realAncestor = realpathSync(ancestor);
|
|
635
|
+
if (isOutside(realRoot, realAncestor)) {
|
|
636
|
+
throw new Error(`Path has an ancestor outside the working tree: ${input}`);
|
|
637
|
+
}
|
|
638
|
+
return lexicalTarget;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// src/lib/submission-lock.ts
|
|
642
|
+
var MUTATION_LOCK_PATH = join3(".overleaf", "mutation.lock");
|
|
643
|
+
function mutationLockPath(root = process.cwd()) {
|
|
644
|
+
const key = createHash("sha256").update(realpathSync2(root), "utf8").digest("hex").slice(0, 32);
|
|
645
|
+
return join3(tmpdir(), `overleaf-review-${key}.lock`);
|
|
646
|
+
}
|
|
647
|
+
function acquireMutationLock(projectId, options = {}) {
|
|
648
|
+
const root = options.root ?? process.cwd();
|
|
649
|
+
const path = options.path ? workspaceWritePath(options.path, root) : mutationLockPath(root);
|
|
650
|
+
const displayPath = options.path ?? path;
|
|
651
|
+
mkdirSync3(dirname2(path), { recursive: true });
|
|
652
|
+
const token = randomUUID();
|
|
653
|
+
let fd;
|
|
654
|
+
try {
|
|
655
|
+
fd = openSync(path, "wx", 384);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
if (error.code !== "EEXIST") throw error;
|
|
658
|
+
let owner = "";
|
|
659
|
+
try {
|
|
660
|
+
const parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
661
|
+
const details = [
|
|
662
|
+
typeof parsed.pid === "number" ? `pid ${parsed.pid}` : void 0,
|
|
663
|
+
typeof parsed.startedAt === "string" ? `since ${parsed.startedAt}` : void 0,
|
|
664
|
+
typeof parsed.projectId === "string" ? `project ${parsed.projectId}` : void 0
|
|
665
|
+
].filter(Boolean);
|
|
666
|
+
if (details.length) owner = ` (${details.join(", ")})`;
|
|
667
|
+
} catch {
|
|
668
|
+
}
|
|
669
|
+
throw new Error(
|
|
670
|
+
`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.`,
|
|
671
|
+
{ cause: error }
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
try {
|
|
675
|
+
writeFileSync3(
|
|
676
|
+
fd,
|
|
677
|
+
`${JSON.stringify(
|
|
678
|
+
{
|
|
679
|
+
token,
|
|
680
|
+
pid: process.pid,
|
|
681
|
+
projectId,
|
|
682
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
683
|
+
},
|
|
684
|
+
null,
|
|
685
|
+
2
|
|
686
|
+
)}
|
|
687
|
+
`,
|
|
688
|
+
"utf8"
|
|
689
|
+
);
|
|
690
|
+
fsyncSync(fd);
|
|
691
|
+
} catch (error) {
|
|
692
|
+
closeSync(fd);
|
|
693
|
+
if (existsSync2(path)) unlinkSync2(path);
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
696
|
+
closeSync(fd);
|
|
697
|
+
let released = false;
|
|
698
|
+
return {
|
|
699
|
+
path,
|
|
700
|
+
token,
|
|
701
|
+
release() {
|
|
702
|
+
if (released) return;
|
|
703
|
+
try {
|
|
704
|
+
const current = JSON.parse(readFileSync3(path, "utf8"));
|
|
705
|
+
if (current.token === token) unlinkSync2(path);
|
|
706
|
+
released = true;
|
|
707
|
+
} catch (error) {
|
|
708
|
+
if (error.code === "ENOENT") {
|
|
709
|
+
released = true;
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
throw error;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
393
718
|
// src/commands/pull.ts
|
|
394
719
|
function buildMemberMap(project) {
|
|
395
720
|
const map = {};
|
|
@@ -401,60 +726,71 @@ function buildMemberMap(project) {
|
|
|
401
726
|
for (const m of project?.members ?? []) add(m);
|
|
402
727
|
return map;
|
|
403
728
|
}
|
|
404
|
-
async function pull(outDir = ".overleaf") {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
729
|
+
async function pull(outDir = ".overleaf", options = {}) {
|
|
730
|
+
let mutationLock;
|
|
731
|
+
let opened;
|
|
732
|
+
try {
|
|
733
|
+
if (options.acquireLock !== false) mutationLock = acquireMutationLock(config.projectId);
|
|
734
|
+
opened = await openProject();
|
|
735
|
+
const { socket, project, docs } = opened;
|
|
736
|
+
const members = buildMemberMap(project);
|
|
737
|
+
const threads = await getThreads();
|
|
738
|
+
const comments = [];
|
|
739
|
+
const changes = [];
|
|
740
|
+
for (const doc of docs) {
|
|
741
|
+
const state = await joinDoc(socket, doc._id);
|
|
742
|
+
for (const c of state.ranges.comments ?? []) {
|
|
743
|
+
const line = offsetToLine(state.lines, c.op.p);
|
|
744
|
+
const thread = threads[c.op.t] ?? {};
|
|
745
|
+
comments.push({
|
|
746
|
+
doc: doc.path,
|
|
747
|
+
threadId: c.op.t,
|
|
748
|
+
anchor: c.op.c,
|
|
749
|
+
line: line + 1,
|
|
750
|
+
context: lineContext(state.lines, line),
|
|
751
|
+
resolved: Boolean(thread.resolved),
|
|
752
|
+
messages: (thread.messages ?? []).map((m) => ({
|
|
753
|
+
id: m.id,
|
|
754
|
+
author: m.user?.first_name ?? members[m.user_id] ?? m.user_id,
|
|
755
|
+
email: m.user?.email,
|
|
756
|
+
content: m.content,
|
|
757
|
+
timestamp: m.timestamp
|
|
758
|
+
}))
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
for (const ch of state.ranges.changes ?? []) {
|
|
762
|
+
const isInsert = typeof ch.op.i === "string";
|
|
763
|
+
const line = offsetToLine(state.lines, ch.op.p);
|
|
764
|
+
changes.push({
|
|
765
|
+
doc: doc.path,
|
|
766
|
+
id: ch.id,
|
|
767
|
+
type: isInsert ? "insert" : "delete",
|
|
768
|
+
text: isInsert ? ch.op.i : ch.op.d,
|
|
769
|
+
line: line + 1,
|
|
770
|
+
context: lineContext(state.lines, line),
|
|
771
|
+
author: members[ch.metadata?.user_id] ?? ch.metadata?.user_id ?? "unknown",
|
|
772
|
+
ts: ch.metadata?.ts
|
|
773
|
+
});
|
|
774
|
+
}
|
|
430
775
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
776
|
+
const data = {
|
|
777
|
+
project: project?.name ?? "(unknown)",
|
|
778
|
+
projectId: config.projectId,
|
|
779
|
+
pulledAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
780
|
+
comments,
|
|
781
|
+
changes
|
|
782
|
+
};
|
|
783
|
+
mkdirSync4(outDir, { recursive: true });
|
|
784
|
+
writeFileSync4(join4(outDir, "reviews.json"), JSON.stringify(data, null, 2) + "\n");
|
|
785
|
+
writeFileSync4(join4(outDir, "reviews.md"), renderMarkdown(data));
|
|
786
|
+
return data;
|
|
787
|
+
} finally {
|
|
788
|
+
try {
|
|
789
|
+
opened?.socket.close();
|
|
790
|
+
} finally {
|
|
791
|
+
mutationLock?.release();
|
|
444
792
|
}
|
|
445
793
|
}
|
|
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
794
|
}
|
|
459
795
|
function renderMarkdown(d) {
|
|
460
796
|
const L = [];
|
|
@@ -505,343 +841,2740 @@ function renderMarkdown(d) {
|
|
|
505
841
|
}
|
|
506
842
|
|
|
507
843
|
// src/commands/push.ts
|
|
508
|
-
import {
|
|
509
|
-
|
|
510
|
-
|
|
844
|
+
import {
|
|
845
|
+
mkdirSync as mkdirSync7,
|
|
846
|
+
readFileSync as readFileSync6,
|
|
847
|
+
readdirSync as readdirSync2,
|
|
848
|
+
renameSync as renameSync4,
|
|
849
|
+
statSync,
|
|
850
|
+
unlinkSync as unlinkSync5,
|
|
851
|
+
writeFileSync as writeFileSync7
|
|
852
|
+
} from "fs";
|
|
853
|
+
import { createHash as createHash3 } from "crypto";
|
|
854
|
+
import { dirname as dirname5, relative as relative2, resolve as resolvePath, sep as sep2 } from "path";
|
|
511
855
|
import { diffWordsWithSpace } from "diff";
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
ops.push({ p, d: part.value });
|
|
521
|
-
} else {
|
|
522
|
-
p += part.value.length;
|
|
523
|
-
}
|
|
856
|
+
|
|
857
|
+
// src/lib/document-match.ts
|
|
858
|
+
var AmbiguousDocumentError = class extends Error {
|
|
859
|
+
constructor(identifier, matches) {
|
|
860
|
+
super(
|
|
861
|
+
`Document name "${identifier}" is ambiguous (${matches.map((doc) => doc.path).join(", ")}); use the exact project path.`
|
|
862
|
+
);
|
|
863
|
+
this.name = "AmbiguousDocumentError";
|
|
524
864
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
865
|
+
};
|
|
866
|
+
function matchDocument(identifier, docs) {
|
|
867
|
+
const exact = docs.filter((doc) => doc.path === identifier);
|
|
868
|
+
if (exact.length > 1) throw new AmbiguousDocumentError(identifier, exact);
|
|
869
|
+
if (exact.length === 1) return exact[0];
|
|
870
|
+
if (identifier.includes("/") || identifier.includes("\\")) return void 0;
|
|
871
|
+
const basename3 = identifier.replace(/\\/g, "/").split("/").pop() ?? identifier;
|
|
872
|
+
const matches = docs.filter((doc) => doc.name === basename3);
|
|
873
|
+
if (matches.length > 1) throw new AmbiguousDocumentError(identifier, matches);
|
|
874
|
+
return matches[0];
|
|
532
875
|
}
|
|
533
|
-
|
|
534
|
-
|
|
876
|
+
|
|
877
|
+
// src/lib/receipts.ts
|
|
878
|
+
import {
|
|
879
|
+
closeSync as closeSync2,
|
|
880
|
+
existsSync as existsSync3,
|
|
881
|
+
fsyncSync as fsyncSync2,
|
|
882
|
+
mkdirSync as mkdirSync5,
|
|
883
|
+
openSync as openSync2,
|
|
884
|
+
readFileSync as readFileSync4,
|
|
885
|
+
readdirSync,
|
|
886
|
+
renameSync as renameSync2,
|
|
887
|
+
unlinkSync as unlinkSync3,
|
|
888
|
+
writeFileSync as writeFileSync5
|
|
889
|
+
} from "fs";
|
|
890
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
891
|
+
import { basename, dirname as dirname3, join as join5 } from "path";
|
|
892
|
+
var RECEIPT_SCHEMA_VERSION = 1;
|
|
893
|
+
var DEFAULT_RECEIPTS_DIR = join5(".overleaf", "receipts");
|
|
894
|
+
function safeFilenamePart(value) {
|
|
895
|
+
const safe = value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
896
|
+
return safe || "operation";
|
|
535
897
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
898
|
+
function writeJsonAtomic(path, value) {
|
|
899
|
+
const targetDir = dirname3(path);
|
|
900
|
+
mkdirSync5(targetDir, { recursive: true });
|
|
901
|
+
const tempPath = join5(
|
|
902
|
+
targetDir,
|
|
903
|
+
`.${basename(path)}.${process.pid}.${randomUUID2()}.tmp`
|
|
904
|
+
);
|
|
905
|
+
let fd;
|
|
906
|
+
try {
|
|
907
|
+
fd = openSync2(tempPath, "wx", 384);
|
|
908
|
+
writeFileSync5(fd, `${JSON.stringify(value, null, 2)}
|
|
909
|
+
`, "utf8");
|
|
910
|
+
fsyncSync2(fd);
|
|
911
|
+
closeSync2(fd);
|
|
912
|
+
fd = void 0;
|
|
913
|
+
renameSync2(tempPath, path);
|
|
914
|
+
let dirFd;
|
|
915
|
+
try {
|
|
916
|
+
dirFd = openSync2(targetDir, "r");
|
|
917
|
+
fsyncSync2(dirFd);
|
|
918
|
+
} catch {
|
|
919
|
+
} finally {
|
|
920
|
+
if (dirFd !== void 0) closeSync2(dirFd);
|
|
921
|
+
}
|
|
922
|
+
} finally {
|
|
923
|
+
if (fd !== void 0) closeSync2(fd);
|
|
924
|
+
if (existsSync3(tempPath)) unlinkSync3(tempPath);
|
|
543
925
|
}
|
|
544
|
-
return acc;
|
|
545
926
|
}
|
|
546
|
-
function
|
|
547
|
-
|
|
548
|
-
const
|
|
549
|
-
const
|
|
550
|
-
|
|
927
|
+
function beginReceipt(operation, details, options = {}) {
|
|
928
|
+
const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
|
|
929
|
+
const operationId = options.operationId ?? randomUUID2();
|
|
930
|
+
const receipt = {
|
|
931
|
+
schemaVersion: RECEIPT_SCHEMA_VERSION,
|
|
932
|
+
operationId,
|
|
933
|
+
operation,
|
|
934
|
+
status: "started",
|
|
935
|
+
startedAt: now,
|
|
936
|
+
updatedAt: now,
|
|
937
|
+
details
|
|
938
|
+
};
|
|
939
|
+
const dir = options.receiptsDir ?? DEFAULT_RECEIPTS_DIR;
|
|
940
|
+
const timestamp = now.replace(/[:.]/g, "-");
|
|
941
|
+
const path = join5(dir, `${timestamp}-${safeFilenamePart(operation)}-${operationId}.json`);
|
|
942
|
+
writeJsonAtomic(path, receipt);
|
|
943
|
+
return { path, receipt };
|
|
551
944
|
}
|
|
552
|
-
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
945
|
+
function updateReceipt(handle, status, details, options = {}) {
|
|
946
|
+
const receipt = {
|
|
947
|
+
...handle.receipt,
|
|
948
|
+
status,
|
|
949
|
+
updatedAt: (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
|
|
950
|
+
details: { ...handle.receipt.details, ...details }
|
|
951
|
+
};
|
|
952
|
+
writeJsonAtomic(handle.path, receipt);
|
|
953
|
+
return { path: handle.path, receipt };
|
|
954
|
+
}
|
|
955
|
+
function readReceipts(receiptsDir = DEFAULT_RECEIPTS_DIR) {
|
|
956
|
+
let names;
|
|
957
|
+
try {
|
|
958
|
+
names = readdirSync(receiptsDir).filter((name) => name.endsWith(".json"));
|
|
959
|
+
} catch {
|
|
960
|
+
return [];
|
|
559
961
|
}
|
|
560
|
-
const
|
|
561
|
-
for (const
|
|
562
|
-
|
|
962
|
+
const receipts = [];
|
|
963
|
+
for (const name of names) {
|
|
964
|
+
const path = join5(receiptsDir, name);
|
|
563
965
|
try {
|
|
564
|
-
|
|
966
|
+
const receipt = JSON.parse(readFileSync4(path, "utf8"));
|
|
967
|
+
if (receipt?.schemaVersion === RECEIPT_SCHEMA_VERSION && typeof receipt.operationId === "string" && typeof receipt.operation === "string" && typeof receipt.updatedAt === "string" && receipt.details && typeof receipt.details === "object") {
|
|
968
|
+
receipts.push({ path, receipt });
|
|
969
|
+
}
|
|
565
970
|
} catch {
|
|
566
|
-
console.log(`- ${file}: cannot read, skipped`);
|
|
567
|
-
continue;
|
|
568
|
-
}
|
|
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`);
|
|
572
|
-
continue;
|
|
573
971
|
}
|
|
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 });
|
|
577
972
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
973
|
+
return receipts.sort((a, b) => b.receipt.updatedAt.localeCompare(a.receipt.updatedAt));
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// src/lib/sync-state.ts
|
|
977
|
+
import { createHash as createHash2 } from "crypto";
|
|
978
|
+
import {
|
|
979
|
+
mkdirSync as mkdirSync6,
|
|
980
|
+
readFileSync as readFileSync5,
|
|
981
|
+
renameSync as renameSync3,
|
|
982
|
+
unlinkSync as unlinkSync4,
|
|
983
|
+
writeFileSync as writeFileSync6
|
|
984
|
+
} from "fs";
|
|
985
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
986
|
+
var BASE_STATE_SCHEMA_VERSION = 1;
|
|
987
|
+
var BASE_STATE_PATH = join6(".overleaf", "base.json");
|
|
988
|
+
function sha256(text) {
|
|
989
|
+
return createHash2("sha256").update(text, "utf8").digest("hex");
|
|
990
|
+
}
|
|
991
|
+
function canonicalize(value) {
|
|
992
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
993
|
+
if (value && typeof value === "object") {
|
|
994
|
+
const out = {};
|
|
995
|
+
for (const key of Object.keys(value).sort()) {
|
|
996
|
+
const item = value[key];
|
|
997
|
+
if (item !== void 0) out[key] = canonicalize(item);
|
|
998
|
+
}
|
|
999
|
+
return out;
|
|
582
1000
|
}
|
|
583
|
-
|
|
584
|
-
|
|
1001
|
+
return value;
|
|
1002
|
+
}
|
|
1003
|
+
function stableJson(value) {
|
|
1004
|
+
return JSON.stringify(canonicalize(value));
|
|
1005
|
+
}
|
|
1006
|
+
function sortedRanges(values) {
|
|
1007
|
+
return (values ?? []).map(canonicalize).sort((a, b) => {
|
|
1008
|
+
const left = stableJson(a);
|
|
1009
|
+
const right = stableJson(b);
|
|
1010
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
function fingerprintRanges(ranges) {
|
|
1014
|
+
return sha256(
|
|
1015
|
+
stableJson({
|
|
1016
|
+
comments: sortedRanges(ranges.comments),
|
|
1017
|
+
changes: sortedRanges(ranges.changes)
|
|
1018
|
+
})
|
|
585
1019
|
);
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
if (opts.dryRun) {
|
|
595
|
-
console.log("\n(dry run \u2014 nothing sent to Overleaf)");
|
|
596
|
-
socket.close();
|
|
597
|
-
return;
|
|
1020
|
+
}
|
|
1021
|
+
function assertBaseState(value, path) {
|
|
1022
|
+
if (!value || typeof value !== "object") throw new Error(`Invalid base state in ${path}`);
|
|
1023
|
+
const state = value;
|
|
1024
|
+
if (state.schemaVersion !== BASE_STATE_SCHEMA_VERSION || typeof state.projectId !== "string" || !state.documents || typeof state.documents !== "object") {
|
|
1025
|
+
throw new Error(
|
|
1026
|
+
`Unsupported or invalid base state in ${path}; run fetch to create a new synchronization base.`
|
|
1027
|
+
);
|
|
598
1028
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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])}`);
|
|
1029
|
+
for (const [docId, raw] of Object.entries(state.documents)) {
|
|
1030
|
+
const doc = raw;
|
|
1031
|
+
if (doc.docId !== docId || typeof doc.path !== "string" || typeof doc.text !== "string" || typeof doc.hash !== "string" || doc.hash !== sha256(doc.text)) {
|
|
1032
|
+
throw new Error(`Invalid document ${docId} in ${path}`);
|
|
607
1033
|
}
|
|
608
1034
|
}
|
|
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;
|
|
613
|
-
}
|
|
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
|
-
);
|
|
621
1035
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
if (!targets.length) {
|
|
630
|
-
console.log(`No matching doc for "${opts.file}".`);
|
|
631
|
-
socket.close();
|
|
632
|
-
return;
|
|
1036
|
+
function loadBaseState(path = BASE_STATE_PATH) {
|
|
1037
|
+
let raw;
|
|
1038
|
+
try {
|
|
1039
|
+
raw = readFileSync5(path, "utf8");
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
if (error.code === "ENOENT") return void 0;
|
|
1042
|
+
throw error;
|
|
633
1043
|
}
|
|
634
|
-
let
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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);
|
|
646
|
-
}
|
|
1044
|
+
let parsed;
|
|
1045
|
+
try {
|
|
1046
|
+
parsed = JSON.parse(raw);
|
|
1047
|
+
} catch {
|
|
1048
|
+
throw new Error(`Invalid JSON in ${path}; run fetch to recreate the synchronization base.`);
|
|
647
1049
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
1050
|
+
assertBaseState(parsed, path);
|
|
1051
|
+
return parsed;
|
|
1052
|
+
}
|
|
1053
|
+
function saveBaseState(state, path = BASE_STATE_PATH) {
|
|
1054
|
+
mkdirSync6(dirname4(path), { recursive: true });
|
|
1055
|
+
const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
1056
|
+
try {
|
|
1057
|
+
writeFileSync6(temp, JSON.stringify(state, null, 2) + "\n", { mode: 384 });
|
|
1058
|
+
renameSync3(temp, path);
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
try {
|
|
1061
|
+
unlinkSync4(temp);
|
|
1062
|
+
} catch {
|
|
1063
|
+
}
|
|
1064
|
+
throw error;
|
|
652
1065
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
1066
|
+
}
|
|
1067
|
+
function mergeBaseDocuments(projectId, documents, path = BASE_STATE_PATH) {
|
|
1068
|
+
const previous = loadBaseState(path);
|
|
1069
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1070
|
+
const state = {
|
|
1071
|
+
schemaVersion: BASE_STATE_SCHEMA_VERSION,
|
|
1072
|
+
projectId,
|
|
1073
|
+
updatedAt: now,
|
|
1074
|
+
documents: previous?.projectId === projectId ? { ...previous.documents } : {}
|
|
1075
|
+
};
|
|
1076
|
+
for (const doc of documents) state.documents[doc.docId] = doc;
|
|
1077
|
+
saveBaseState(state, path);
|
|
1078
|
+
return state;
|
|
658
1079
|
}
|
|
659
1080
|
|
|
660
|
-
// src/
|
|
661
|
-
import {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
if (
|
|
1081
|
+
// src/lib/three-way.ts
|
|
1082
|
+
import { diffChars } from "diff";
|
|
1083
|
+
function textEdits(base, target) {
|
|
1084
|
+
const edits = [];
|
|
1085
|
+
let basePos = 0;
|
|
1086
|
+
let pending;
|
|
1087
|
+
const flush = () => {
|
|
1088
|
+
if (!pending) return;
|
|
1089
|
+
if (pending.start !== pending.end || pending.text.length) edits.push(pending);
|
|
1090
|
+
pending = void 0;
|
|
1091
|
+
};
|
|
1092
|
+
for (const part of diffChars(base, target)) {
|
|
1093
|
+
if (!part.added && !part.removed) {
|
|
1094
|
+
flush();
|
|
1095
|
+
basePos += part.value.length;
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
pending ??= { start: basePos, end: basePos, text: "" };
|
|
1099
|
+
if (part.removed) {
|
|
1100
|
+
pending.end += part.value.length;
|
|
1101
|
+
basePos += part.value.length;
|
|
1102
|
+
} else {
|
|
1103
|
+
pending.text += part.value;
|
|
1104
|
+
}
|
|
669
1105
|
}
|
|
670
|
-
|
|
1106
|
+
flush();
|
|
1107
|
+
return edits;
|
|
671
1108
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
1109
|
+
function sameEdit(a, b) {
|
|
1110
|
+
return a.start === b.start && a.end === b.end && a.text === b.text;
|
|
1111
|
+
}
|
|
1112
|
+
function reverseCodePoints(text) {
|
|
1113
|
+
return Array.from(text).reverse().join("");
|
|
1114
|
+
}
|
|
1115
|
+
function ambiguousEditAnchors(base, target) {
|
|
1116
|
+
const forward = textEdits(base, target);
|
|
1117
|
+
const reverse = textEdits(reverseCodePoints(base), reverseCodePoints(target)).map((edit) => ({
|
|
1118
|
+
start: base.length - edit.end,
|
|
1119
|
+
end: base.length - edit.start,
|
|
1120
|
+
text: reverseCodePoints(edit.text)
|
|
1121
|
+
})).sort((a, b) => a.start - b.start || a.end - b.end);
|
|
1122
|
+
const ambiguities = [];
|
|
1123
|
+
const count = Math.max(forward.length, reverse.length);
|
|
1124
|
+
for (let index = 0; index < count; index++) {
|
|
1125
|
+
const forwardEdit = forward[index] ?? reverse[index];
|
|
1126
|
+
const reverseEdit = reverse[index] ?? forward[index];
|
|
1127
|
+
if (sameEdit(forwardEdit, reverseEdit)) continue;
|
|
1128
|
+
ambiguities.push({
|
|
1129
|
+
forward: forwardEdit,
|
|
1130
|
+
reverse: reverseEdit,
|
|
1131
|
+
envelopeStart: Math.min(forwardEdit.start, reverseEdit.start),
|
|
1132
|
+
envelopeEnd: Math.max(
|
|
1133
|
+
forwardEdit.start,
|
|
1134
|
+
forwardEdit.end,
|
|
1135
|
+
reverseEdit.start,
|
|
1136
|
+
reverseEdit.end
|
|
1137
|
+
)
|
|
1138
|
+
});
|
|
688
1139
|
}
|
|
1140
|
+
return ambiguities;
|
|
689
1141
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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: {}
|
|
720
|
-
};
|
|
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})`);
|
|
1142
|
+
function editTouchesEnvelope(edit, start, end) {
|
|
1143
|
+
if (edit.start === edit.end) return edit.start >= start && edit.start <= end;
|
|
1144
|
+
return edit.start <= end && edit.end >= start;
|
|
727
1145
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1146
|
+
function editsConflict(a, b) {
|
|
1147
|
+
const aInsert = a.start === a.end;
|
|
1148
|
+
const bInsert = b.start === b.end;
|
|
1149
|
+
if (aInsert && bInsert) return a.start === b.start;
|
|
1150
|
+
if (aInsert) return a.start > b.start && a.start < b.end;
|
|
1151
|
+
if (bInsert) return b.start > a.start && b.start < a.end;
|
|
1152
|
+
return a.start < b.end && b.start < a.end;
|
|
734
1153
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
docId = doc._id;
|
|
744
|
-
break;
|
|
1154
|
+
function mapBasePosition(position, liveEdits, includeInsertionAtPosition) {
|
|
1155
|
+
let mapped = position;
|
|
1156
|
+
for (const edit of liveEdits) {
|
|
1157
|
+
if (edit.start === edit.end) {
|
|
1158
|
+
if (edit.start < position || includeInsertionAtPosition && edit.start === position) {
|
|
1159
|
+
mapped += edit.text.length;
|
|
1160
|
+
}
|
|
1161
|
+
continue;
|
|
745
1162
|
}
|
|
1163
|
+
if (edit.end <= position) mapped += edit.text.length - (edit.end - edit.start);
|
|
746
1164
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
1165
|
+
return mapped;
|
|
1166
|
+
}
|
|
1167
|
+
function applyEdits(source, edits) {
|
|
1168
|
+
let result = source;
|
|
1169
|
+
const ordered = edits.map((edit, index) => ({ edit, index })).sort(
|
|
1170
|
+
(a, b) => b.edit.start - a.edit.start || b.edit.end - a.edit.end || b.index - a.index
|
|
1171
|
+
);
|
|
1172
|
+
for (const { edit } of ordered) {
|
|
1173
|
+
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
|
752
1174
|
}
|
|
753
|
-
|
|
754
|
-
await setThreadResolved(docId, threadId, reopen, csrf);
|
|
755
|
-
console.log(`\u2705 Thread ${threadId} ${reopen ? "reopened" : "resolved"}`);
|
|
1175
|
+
return result;
|
|
756
1176
|
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
1177
|
+
function threeWayMerge(base, local, live) {
|
|
1178
|
+
const localEdits = textEdits(base, local);
|
|
1179
|
+
const liveEdits = textEdits(base, live);
|
|
1180
|
+
const ambiguousAnchors = ambiguousEditAnchors(base, local);
|
|
1181
|
+
const conflicts = [];
|
|
1182
|
+
const alreadyAppliedLocalEdits = [];
|
|
1183
|
+
const toApply = [];
|
|
1184
|
+
for (const localEdit of localEdits) {
|
|
1185
|
+
if (liveEdits.some((liveEdit) => sameEdit(localEdit, liveEdit))) {
|
|
1186
|
+
alreadyAppliedLocalEdits.push(localEdit);
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
const ambiguity = ambiguousAnchors.find((candidate) => sameEdit(candidate.forward, localEdit));
|
|
1190
|
+
if (ambiguity) {
|
|
1191
|
+
const touching = liveEdits.filter(
|
|
1192
|
+
(liveEdit) => editTouchesEnvelope(liveEdit, ambiguity.envelopeStart, ambiguity.envelopeEnd)
|
|
1193
|
+
);
|
|
1194
|
+
if (touching.length) {
|
|
1195
|
+
for (const liveEdit of touching) {
|
|
1196
|
+
conflicts.push({ local: localEdit, live: liveEdit, reason: "ambiguous-local-anchor" });
|
|
1197
|
+
}
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
const overlapping = liveEdits.filter((liveEdit) => editsConflict(localEdit, liveEdit));
|
|
1202
|
+
if (overlapping.length) {
|
|
1203
|
+
for (const liveEdit of overlapping) {
|
|
1204
|
+
conflicts.push({ local: localEdit, live: liveEdit, reason: "overlapping-edits" });
|
|
1205
|
+
}
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
if (localEdit.start === localEdit.end) {
|
|
1209
|
+
const point = mapBasePosition(localEdit.start, liveEdits, false);
|
|
1210
|
+
toApply.push({ start: point, end: point, text: localEdit.text });
|
|
1211
|
+
} else {
|
|
1212
|
+
const start = mapBasePosition(localEdit.start, liveEdits, true);
|
|
1213
|
+
const end = mapBasePosition(localEdit.end, liveEdits, false);
|
|
1214
|
+
toApply.push({ start, end, text: localEdit.text });
|
|
767
1215
|
}
|
|
768
1216
|
}
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1217
|
+
return {
|
|
1218
|
+
text: conflicts.length ? void 0 : applyEdits(live, toApply),
|
|
1219
|
+
localEdits,
|
|
1220
|
+
liveEdits,
|
|
1221
|
+
appliedLocalEdits: toApply,
|
|
1222
|
+
alreadyAppliedLocalEdits,
|
|
1223
|
+
conflicts
|
|
1224
|
+
};
|
|
774
1225
|
}
|
|
775
1226
|
|
|
776
|
-
// src/
|
|
777
|
-
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
if (
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1227
|
+
// src/lib/tracked-overlap.ts
|
|
1228
|
+
function spanOverlapsEdit(start, end, edit) {
|
|
1229
|
+
const editIsPoint = edit.start === edit.end;
|
|
1230
|
+
const rangeIsPoint = start === end;
|
|
1231
|
+
if (editIsPoint && rangeIsPoint) return edit.start === start;
|
|
1232
|
+
if (editIsPoint) return edit.start > start && edit.start < end;
|
|
1233
|
+
if (rangeIsPoint) return start >= edit.start && start < edit.end;
|
|
1234
|
+
return edit.start < end && start < edit.end;
|
|
1235
|
+
}
|
|
1236
|
+
function overlapsEdit(change, edit) {
|
|
1237
|
+
const p = change.op?.p;
|
|
1238
|
+
if (typeof p !== "number") return false;
|
|
1239
|
+
const inserted = typeof change.op?.i === "string" ? change.op.i : void 0;
|
|
1240
|
+
const changeStart = p;
|
|
1241
|
+
const changeEnd = p + (inserted?.length ?? 0);
|
|
1242
|
+
return spanOverlapsEdit(changeStart, changeEnd, edit);
|
|
1243
|
+
}
|
|
1244
|
+
function findTrackedChangeOverlaps(changes, proposedEdits) {
|
|
1245
|
+
const out = [];
|
|
1246
|
+
for (const change of changes ?? []) {
|
|
1247
|
+
for (const proposedEdit of proposedEdits) {
|
|
1248
|
+
if (overlapsEdit(change, proposedEdit)) {
|
|
1249
|
+
out.push({ changeId: change.id ?? "(unknown)", change, proposedEdit });
|
|
786
1250
|
}
|
|
787
1251
|
}
|
|
788
1252
|
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
1253
|
+
return out;
|
|
1254
|
+
}
|
|
1255
|
+
function findCommentOverlaps(comments, proposedEdits) {
|
|
1256
|
+
const out = [];
|
|
1257
|
+
for (const comment2 of comments ?? []) {
|
|
1258
|
+
const p = comment2.op?.p;
|
|
1259
|
+
const anchor = comment2.op?.c;
|
|
1260
|
+
if (typeof p !== "number" || typeof anchor !== "string") continue;
|
|
1261
|
+
for (const proposedEdit of proposedEdits) {
|
|
1262
|
+
if (spanOverlapsEdit(p, p + anchor.length, proposedEdit)) {
|
|
1263
|
+
out.push({
|
|
1264
|
+
threadId: comment2.op?.t ?? comment2.id ?? "(unknown)",
|
|
1265
|
+
position: p,
|
|
1266
|
+
anchor,
|
|
1267
|
+
comment: comment2,
|
|
1268
|
+
proposedEdit
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
return out;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// src/lib/tracked-changes.ts
|
|
1277
|
+
import { randomBytes } from "crypto";
|
|
1278
|
+
var TRACKED_CHANGE_SEED_BYTES = 9;
|
|
1279
|
+
function createTrackedChangeSeed(bytes = randomBytes) {
|
|
1280
|
+
const entropy = bytes(TRACKED_CHANGE_SEED_BYTES);
|
|
1281
|
+
if (entropy.byteLength !== TRACKED_CHANGE_SEED_BYTES) {
|
|
1282
|
+
throw new Error(
|
|
1283
|
+
`tracked-change seed source returned ${entropy.byteLength} bytes; expected ${TRACKED_CHANGE_SEED_BYTES}`
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
return Buffer.from(entropy).toString("hex");
|
|
1287
|
+
}
|
|
1288
|
+
var TrackedChangeMutationError = class extends Error {
|
|
1289
|
+
constructor(message, result, cause) {
|
|
1290
|
+
super(message, cause === void 0 ? void 0 : { cause });
|
|
1291
|
+
this.result = result;
|
|
1292
|
+
this.name = "TrackedChangeMutationError";
|
|
1293
|
+
}
|
|
1294
|
+
result;
|
|
1295
|
+
};
|
|
1296
|
+
function uniqueChangeIds(changeIds) {
|
|
1297
|
+
return [...new Set(changeIds)];
|
|
1298
|
+
}
|
|
1299
|
+
function changeIdsInRanges(ranges) {
|
|
1300
|
+
return [...new Set(ranges.map((range) => range.id))];
|
|
1301
|
+
}
|
|
1302
|
+
function remainingChangeIds(ranges, requestedIds) {
|
|
1303
|
+
const present = new Set(changeIdsInRanges(ranges));
|
|
1304
|
+
return uniqueChangeIds(requestedIds).filter((id) => present.has(id));
|
|
1305
|
+
}
|
|
1306
|
+
function inverseOf(range) {
|
|
1307
|
+
const { p, i, d } = range.op ?? {};
|
|
1308
|
+
if (!Number.isSafeInteger(p) || p < 0) {
|
|
1309
|
+
throw new Error(`tracked change ${range.id} has an invalid position: ${String(p)}`);
|
|
1310
|
+
}
|
|
1311
|
+
if (typeof i === "string" && d === void 0) return { p, d: i, u: true };
|
|
1312
|
+
if (typeof d === "string" && i === void 0) return { p, i: d, u: true };
|
|
1313
|
+
throw new Error(`tracked change ${range.id} does not contain exactly one insert/delete op`);
|
|
1314
|
+
}
|
|
1315
|
+
function applyUndo(text, op, changeId) {
|
|
1316
|
+
if (op.p > text.length) {
|
|
1317
|
+
throw new Error(
|
|
1318
|
+
`tracked change ${changeId} starts at ${op.p}, beyond document length ${text.length}`
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
if ("d" in op) {
|
|
1322
|
+
const actual = text.slice(op.p, op.p + op.d.length);
|
|
1323
|
+
if (actual !== op.d) {
|
|
1324
|
+
throw new Error(
|
|
1325
|
+
`tracked insertion ${changeId} no longer matches document text at ${op.p}: expected ${JSON.stringify(op.d)}, found ${JSON.stringify(actual)}`
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
return text.slice(0, op.p) + text.slice(op.p + op.d.length);
|
|
1329
|
+
}
|
|
1330
|
+
return text.slice(0, op.p) + op.i + text.slice(op.p);
|
|
1331
|
+
}
|
|
1332
|
+
function buildRejectionPlan(currentText, ranges, requestedIds) {
|
|
1333
|
+
const requested = new Set(uniqueChangeIds(requestedIds));
|
|
1334
|
+
const fragments = ranges.filter((range) => requested.has(range.id));
|
|
1335
|
+
fragments.sort((a, b) => b.op.p - a.op.p);
|
|
1336
|
+
const operations = [];
|
|
1337
|
+
let expectedText = currentText;
|
|
1338
|
+
for (const range of fragments) {
|
|
1339
|
+
const inverse = inverseOf(range);
|
|
1340
|
+
expectedText = applyUndo(expectedText, inverse, range.id);
|
|
1341
|
+
operations.push(inverse);
|
|
1342
|
+
}
|
|
1343
|
+
return {
|
|
1344
|
+
changeIds: changeIdsInRanges(fragments),
|
|
1345
|
+
fragmentCount: fragments.length,
|
|
1346
|
+
operations,
|
|
1347
|
+
expectedText
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// src/lib/snapshots.ts
|
|
1352
|
+
import { join as join7 } from "path";
|
|
1353
|
+
var SNAPSHOTS_DIR = join7(".overleaf", "snapshots");
|
|
1354
|
+
function snapshotTimestamp(date = /* @__PURE__ */ new Date()) {
|
|
1355
|
+
return date.toISOString().replace(/[:.]/g, "-");
|
|
1356
|
+
}
|
|
1357
|
+
function snapshotRelativePath(timestamp, projectPath, root = process.cwd()) {
|
|
1358
|
+
if (!/^[0-9TZ-]+$/.test(timestamp)) throw new Error(`Invalid snapshot timestamp: ${timestamp}`);
|
|
1359
|
+
const safeProjectPath = workspaceRelativePath(projectPath, root);
|
|
1360
|
+
return workspaceRelativePath(join7(SNAPSHOTS_DIR, timestamp, safeProjectPath), root);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// src/commands/push.ts
|
|
1364
|
+
var PUSH_PLAN_SCHEMA_VERSION = 2;
|
|
1365
|
+
var PUSH_PLAN_KIND = "overleaf-review-push-plan";
|
|
1366
|
+
var PushSubmissionError = class extends Error {
|
|
1367
|
+
constructor(message, receiptPath, status, documents, cause) {
|
|
1368
|
+
super(message, cause === void 0 ? void 0 : { cause });
|
|
1369
|
+
this.receiptPath = receiptPath;
|
|
1370
|
+
this.status = status;
|
|
1371
|
+
this.documents = documents;
|
|
1372
|
+
this.name = "PushSubmissionError";
|
|
1373
|
+
}
|
|
1374
|
+
receiptPath;
|
|
1375
|
+
status;
|
|
1376
|
+
documents;
|
|
1377
|
+
};
|
|
1378
|
+
var PushPlanningError = class extends Error {
|
|
1379
|
+
constructor(message, conflicts = [], overlaps = []) {
|
|
1380
|
+
super(message);
|
|
1381
|
+
this.conflicts = conflicts;
|
|
1382
|
+
this.overlaps = overlaps;
|
|
1383
|
+
this.name = "PushPlanningError";
|
|
1384
|
+
}
|
|
1385
|
+
conflicts;
|
|
1386
|
+
overlaps;
|
|
1387
|
+
};
|
|
1388
|
+
var PushPlanValidationError = class extends Error {
|
|
1389
|
+
constructor(message) {
|
|
1390
|
+
super(message);
|
|
1391
|
+
this.name = "PushPlanValidationError";
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
function validatePushOptions(opts) {
|
|
1395
|
+
if (opts.docName && !opts.file) {
|
|
1396
|
+
throw new Error("--doc requires --file; bulk pushes cannot map multiple local files to one document.");
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
function buildOps(source, target) {
|
|
1400
|
+
const ops = [];
|
|
1401
|
+
let p = 0;
|
|
1402
|
+
for (const part of diffWordsWithSpace(source, target)) {
|
|
1403
|
+
if (part.added) {
|
|
1404
|
+
ops.push({ p, i: part.value });
|
|
1405
|
+
p += part.value.length;
|
|
1406
|
+
} else if (part.removed) {
|
|
1407
|
+
ops.push({ p, d: part.value });
|
|
1408
|
+
} else {
|
|
1409
|
+
p += part.value.length;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
const rebuilt = applyOps(source, ops);
|
|
1413
|
+
if (rebuilt !== target) {
|
|
1414
|
+
throw new Error("internal error: generated OT operations do not reconstruct target text");
|
|
1415
|
+
}
|
|
1416
|
+
return ops;
|
|
1417
|
+
}
|
|
1418
|
+
function buildOperationFootprint(source, target) {
|
|
1419
|
+
const edits = [];
|
|
1420
|
+
let sourcePos = 0;
|
|
1421
|
+
let pending;
|
|
1422
|
+
const flush = () => {
|
|
1423
|
+
if (!pending) return;
|
|
1424
|
+
if (pending.start !== pending.end || pending.text.length) edits.push(pending);
|
|
1425
|
+
pending = void 0;
|
|
1426
|
+
};
|
|
1427
|
+
for (const part of diffWordsWithSpace(source, target)) {
|
|
1428
|
+
if (!part.added && !part.removed) {
|
|
1429
|
+
flush();
|
|
1430
|
+
sourcePos += part.value.length;
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
pending ??= { start: sourcePos, end: sourcePos, text: "" };
|
|
1434
|
+
if (part.removed) {
|
|
1435
|
+
pending.end += part.value.length;
|
|
1436
|
+
sourcePos += part.value.length;
|
|
1437
|
+
} else {
|
|
1438
|
+
pending.text += part.value;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
flush();
|
|
1442
|
+
return edits;
|
|
1443
|
+
}
|
|
1444
|
+
function applyOps(source, ops) {
|
|
1445
|
+
let text = source;
|
|
1446
|
+
for (const op of ops) {
|
|
1447
|
+
if (!Number.isSafeInteger(op.p) || op.p < 0 || op.p > text.length) {
|
|
1448
|
+
throw new Error(`invalid operation position ${String(op.p)} for ${text.length}-character text`);
|
|
1449
|
+
}
|
|
1450
|
+
const hasInsert = typeof op.i === "string";
|
|
1451
|
+
const hasDelete = typeof op.d === "string";
|
|
1452
|
+
if (hasInsert === hasDelete) throw new Error("operation must contain exactly one of i or d");
|
|
1453
|
+
if (hasInsert) {
|
|
1454
|
+
text = text.slice(0, op.p) + op.i + text.slice(op.p);
|
|
1455
|
+
} else {
|
|
1456
|
+
const deletion = op.d;
|
|
1457
|
+
const actual = text.slice(op.p, op.p + deletion.length);
|
|
1458
|
+
if (actual !== deletion) {
|
|
1459
|
+
throw new Error(
|
|
1460
|
+
`delete operation mismatch at ${op.p}: expected ${JSON.stringify(deletion)}, found ${JSON.stringify(actual)}`
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
text = text.slice(0, op.p) + text.slice(op.p + deletion.length);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
return text;
|
|
1467
|
+
}
|
|
1468
|
+
function preview(op) {
|
|
1469
|
+
const kind = op.i != null ? "insert" : "delete";
|
|
1470
|
+
const text = (op.i ?? op.d ?? "").replace(/\n/g, "\u23CE");
|
|
1471
|
+
const clip = text.length > 60 ? text.slice(0, 60) + "\u2026" : text;
|
|
1472
|
+
return ` ${kind.padEnd(6)} @ ${String(op.p).padStart(5)} "${clip}"`;
|
|
1473
|
+
}
|
|
1474
|
+
function toOverleafPath(file) {
|
|
1475
|
+
return workspaceRelativePath(file);
|
|
1476
|
+
}
|
|
1477
|
+
var IGNORE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".overleaf", "tmp", "dist"]);
|
|
1478
|
+
function discoverLocalTex(dir = process.cwd(), acc = []) {
|
|
1479
|
+
for (const entry of readdirSync2(dir, { withFileTypes: true })) {
|
|
1480
|
+
if (entry.name.startsWith(".") || IGNORE_DIRS.has(entry.name)) continue;
|
|
1481
|
+
const full = resolvePath(dir, entry.name);
|
|
1482
|
+
if (entry.isDirectory()) discoverLocalTex(full, acc);
|
|
1483
|
+
else if (entry.name.endsWith(".tex")) {
|
|
1484
|
+
acc.push(relative2(process.cwd(), full).split(sep2).join("/"));
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
return acc;
|
|
1488
|
+
}
|
|
1489
|
+
function pickDoc(file, docName, docs) {
|
|
1490
|
+
return matchDocument(docName?.replace(/\\/g, "/") ?? toOverleafPath(file), docs);
|
|
1491
|
+
}
|
|
1492
|
+
function formatConflicts(conflicts) {
|
|
1493
|
+
return conflicts.flatMap(
|
|
1494
|
+
({ docPath, conflicts: items }) => items.map(
|
|
1495
|
+
({ 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})`
|
|
1496
|
+
)
|
|
1497
|
+
).join("\n ");
|
|
1498
|
+
}
|
|
1499
|
+
function serializeOverlaps(overlaps) {
|
|
1500
|
+
return overlaps.map(({ changeId, proposedEdit, change }) => ({
|
|
1501
|
+
changeId,
|
|
1502
|
+
proposedEdit,
|
|
1503
|
+
trackedOp: {
|
|
1504
|
+
p: change.op?.p,
|
|
1505
|
+
i: change.op?.i,
|
|
1506
|
+
d: change.op?.d
|
|
1507
|
+
}
|
|
1508
|
+
})).sort((a, b) => {
|
|
1509
|
+
const left = stableJson(a);
|
|
1510
|
+
const right = stableJson(b);
|
|
1511
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
function serializeActiveTrackedRanges(changes) {
|
|
1515
|
+
const ranges = (changes ?? []).map((change, index) => {
|
|
1516
|
+
const id = change?.id;
|
|
1517
|
+
const p = change?.op?.p;
|
|
1518
|
+
const hasInsert = typeof change?.op?.i === "string";
|
|
1519
|
+
const hasDelete = typeof change?.op?.d === "string";
|
|
1520
|
+
if (typeof id !== "string" || !Number.isSafeInteger(p) || p < 0 || hasInsert === hasDelete) {
|
|
1521
|
+
throw new Error(`Overleaf returned an invalid tracked range at index ${index}`);
|
|
1522
|
+
}
|
|
1523
|
+
return {
|
|
1524
|
+
id,
|
|
1525
|
+
op: {
|
|
1526
|
+
p,
|
|
1527
|
+
...hasInsert ? { i: change.op.i } : { d: change.op.d }
|
|
1528
|
+
},
|
|
1529
|
+
...change.metadata && typeof change.metadata === "object" ? { metadata: change.metadata } : {}
|
|
1530
|
+
};
|
|
1531
|
+
});
|
|
1532
|
+
return ranges.sort((a, b) => {
|
|
1533
|
+
const left = stableJson(a);
|
|
1534
|
+
const right = stableJson(b);
|
|
1535
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
function serializeCommentOverlaps(overlaps) {
|
|
1539
|
+
return overlaps.map(({ threadId, position, anchor, proposedEdit }) => ({
|
|
1540
|
+
threadId,
|
|
1541
|
+
position,
|
|
1542
|
+
anchor,
|
|
1543
|
+
proposedEdit
|
|
1544
|
+
})).sort((a, b) => {
|
|
1545
|
+
const left = stableJson(a);
|
|
1546
|
+
const right = stableJson(b);
|
|
1547
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
async function createPlan(opts = {}) {
|
|
1551
|
+
validatePushOptions(opts);
|
|
1552
|
+
if (opts.plan) throw new Error("createPlan does not accept an existing plan");
|
|
1553
|
+
const basePath = opts.basePath ?? BASE_STATE_PATH;
|
|
1554
|
+
const baseState = loadBaseState(basePath);
|
|
1555
|
+
if (baseState && baseState.projectId !== config.projectId && !opts.unsafeNoBase) {
|
|
1556
|
+
throw new PushPlanningError(
|
|
1557
|
+
`Saved base belongs to project ${baseState.projectId}, not ${config.projectId}; run fetch first.`
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1560
|
+
const { socket, project, docs } = await openProject();
|
|
1561
|
+
try {
|
|
1562
|
+
const projectId = String(project?._id ?? config.projectId);
|
|
1563
|
+
if (projectId !== config.projectId) {
|
|
1564
|
+
throw new PushPlanningError(
|
|
1565
|
+
`Connected project id ${projectId} does not match configured project ${config.projectId}.`
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1568
|
+
const files = opts.file ? [workspaceRelativePath(opts.file)] : discoverLocalTex();
|
|
1569
|
+
if (!files.length) {
|
|
1570
|
+
const emptyPlan = {
|
|
1571
|
+
kind: PUSH_PLAN_KIND,
|
|
1572
|
+
schemaVersion: PUSH_PLAN_SCHEMA_VERSION,
|
|
1573
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1574
|
+
projectId,
|
|
1575
|
+
projectName: String(project?.name ?? "(unknown)"),
|
|
1576
|
+
direct: Boolean(opts.direct),
|
|
1577
|
+
unsafeNoBase: Boolean(opts.unsafeNoBase),
|
|
1578
|
+
allowOverlap: Boolean(opts.allowOverlap),
|
|
1579
|
+
documents: []
|
|
1580
|
+
};
|
|
1581
|
+
if (opts.planOut) writePushPlan(emptyPlan, opts.planOut);
|
|
1582
|
+
return emptyPlan;
|
|
1583
|
+
}
|
|
1584
|
+
const documents = [];
|
|
1585
|
+
const conflicts = [];
|
|
1586
|
+
const blockedOverlaps = [];
|
|
1587
|
+
for (const file of files) {
|
|
1588
|
+
let local;
|
|
1589
|
+
try {
|
|
1590
|
+
local = readFileSync6(workspaceReadPath(file), "utf8");
|
|
1591
|
+
} catch (error) {
|
|
1592
|
+
throw new Error(`Cannot safely read ${file}: ${error.message}`);
|
|
1593
|
+
}
|
|
1594
|
+
const doc = pickDoc(file, opts.docName, docs);
|
|
1595
|
+
if (!doc) {
|
|
1596
|
+
throw new Error(
|
|
1597
|
+
`No Overleaf document matches ${file}; push it with --file and --doc using the exact project path.`
|
|
1598
|
+
);
|
|
1599
|
+
}
|
|
1600
|
+
const state = await joinDoc(socket, doc._id);
|
|
1601
|
+
const live = state.lines.join("\n");
|
|
1602
|
+
const savedBase = baseState?.projectId === projectId ? baseState.documents[doc._id] : void 0;
|
|
1603
|
+
if (!savedBase && !opts.unsafeNoBase) {
|
|
1604
|
+
throw new PushPlanningError(
|
|
1605
|
+
`No saved synchronization base for ${doc.path}. Run fetch first, or explicitly use \`--unsafe-no-base\` to request legacy two-way behavior.`
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
const base = savedBase?.text ?? live;
|
|
1609
|
+
const merge = threeWayMerge(base, local, live);
|
|
1610
|
+
if (merge.conflicts.length) {
|
|
1611
|
+
conflicts.push({ localPath: file, docPath: doc.path, conflicts: merge.conflicts });
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
const expected = merge.text;
|
|
1615
|
+
const ops = buildOps(live, expected);
|
|
1616
|
+
if (!ops.length) continue;
|
|
1617
|
+
const proposedEdits = buildOperationFootprint(live, expected);
|
|
1618
|
+
const activeTrackedRanges = serializeActiveTrackedRanges(state.ranges.changes);
|
|
1619
|
+
const overlaps = serializeOverlaps(
|
|
1620
|
+
findTrackedChangeOverlaps(state.ranges.changes, proposedEdits)
|
|
1621
|
+
);
|
|
1622
|
+
const commentOverlaps = serializeCommentOverlaps(
|
|
1623
|
+
findCommentOverlaps(state.ranges.comments, proposedEdits)
|
|
1624
|
+
);
|
|
1625
|
+
if (overlaps.length && !opts.allowOverlap) {
|
|
1626
|
+
blockedOverlaps.push({ localPath: file, docPath: doc.path, overlaps });
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
documents.push({
|
|
1630
|
+
localPath: toOverleafPath(file),
|
|
1631
|
+
docId: doc._id,
|
|
1632
|
+
docPath: doc.path,
|
|
1633
|
+
baseSource: savedBase ? "saved" : "live-unsafe",
|
|
1634
|
+
baseHash: sha256(base),
|
|
1635
|
+
localHash: sha256(local),
|
|
1636
|
+
liveHash: sha256(live),
|
|
1637
|
+
liveVersion: state.version,
|
|
1638
|
+
rangeFingerprint: fingerprintRanges(state.ranges),
|
|
1639
|
+
ops,
|
|
1640
|
+
expectedHash: sha256(expected),
|
|
1641
|
+
tcSeed: opts.direct ? null : createTrackedChangeSeed(),
|
|
1642
|
+
activeTrackedRanges,
|
|
1643
|
+
trackedChangeOverlaps: overlaps,
|
|
1644
|
+
commentOverlaps
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
if (conflicts.length || blockedOverlaps.length) {
|
|
1648
|
+
const parts = [];
|
|
1649
|
+
if (conflicts.length) parts.push(`Concurrent edit conflicts:
|
|
1650
|
+
${formatConflicts(conflicts)}`);
|
|
1651
|
+
if (blockedOverlaps.length) {
|
|
1652
|
+
const lines = blockedOverlaps.flatMap(
|
|
1653
|
+
({ docPath, overlaps }) => overlaps.map(
|
|
1654
|
+
({ changeId, proposedEdit }) => `${docPath}: proposed [${proposedEdit.start},${proposedEdit.end}) overlaps change ${changeId}`
|
|
1655
|
+
)
|
|
1656
|
+
);
|
|
1657
|
+
parts.push(
|
|
1658
|
+
`Active tracked-change overlaps:
|
|
1659
|
+
${lines.join("\n ")}
|
|
1660
|
+
Re-plan with --allow-overlap only after inspecting these changes.`
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
throw new PushPlanningError(parts.join("\n\n"), conflicts, blockedOverlaps);
|
|
1664
|
+
}
|
|
1665
|
+
const plan = {
|
|
1666
|
+
kind: PUSH_PLAN_KIND,
|
|
1667
|
+
schemaVersion: PUSH_PLAN_SCHEMA_VERSION,
|
|
1668
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1669
|
+
projectId,
|
|
1670
|
+
projectName: String(project?.name ?? "(unknown)"),
|
|
1671
|
+
direct: Boolean(opts.direct),
|
|
1672
|
+
unsafeNoBase: documents.some((doc) => doc.baseSource === "live-unsafe"),
|
|
1673
|
+
allowOverlap: Boolean(opts.allowOverlap),
|
|
1674
|
+
documents
|
|
1675
|
+
};
|
|
1676
|
+
if (opts.planOut) writePushPlan(plan, opts.planOut);
|
|
1677
|
+
return plan;
|
|
1678
|
+
} finally {
|
|
1679
|
+
socket.close();
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
function assertHash(value, field) {
|
|
1683
|
+
if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) {
|
|
1684
|
+
throw new PushPlanValidationError(`Invalid ${field} in push plan`);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
function validatePushPlan(value) {
|
|
1688
|
+
if (!value || typeof value !== "object") throw new PushPlanValidationError("Push plan is not an object");
|
|
1689
|
+
const plan = value;
|
|
1690
|
+
if (plan.kind !== PUSH_PLAN_KIND || plan.schemaVersion !== PUSH_PLAN_SCHEMA_VERSION) {
|
|
1691
|
+
throw new PushPlanValidationError("Unsupported push-plan kind or schema version");
|
|
1692
|
+
}
|
|
1693
|
+
if (typeof plan.projectId !== "string" || typeof plan.projectName !== "string" || typeof plan.createdAt !== "string" || typeof plan.direct !== "boolean" || typeof plan.unsafeNoBase !== "boolean" || typeof plan.allowOverlap !== "boolean" || !Array.isArray(plan.documents)) {
|
|
1694
|
+
throw new PushPlanValidationError("Push plan is missing required fields");
|
|
1695
|
+
}
|
|
1696
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1697
|
+
const seenLocalPaths = /* @__PURE__ */ new Set();
|
|
1698
|
+
for (const doc of plan.documents) {
|
|
1699
|
+
if (!doc || typeof doc.localPath !== "string" || typeof doc.docId !== "string" || typeof doc.docPath !== "string" || doc.baseSource !== "saved" && doc.baseSource !== "live-unsafe" || !Number.isSafeInteger(doc.liveVersion) || doc.liveVersion < 0 || !Array.isArray(doc.ops) || doc.ops.length === 0 || !Array.isArray(doc.activeTrackedRanges) || !Array.isArray(doc.trackedChangeOverlaps) || !Array.isArray(doc.commentOverlaps)) {
|
|
1700
|
+
throw new PushPlanValidationError("Push plan contains an invalid document");
|
|
1701
|
+
}
|
|
1702
|
+
try {
|
|
1703
|
+
if (workspaceRelativePath(doc.localPath) !== doc.localPath || workspaceRelativePath(doc.docPath) !== doc.docPath) {
|
|
1704
|
+
throw new Error("path is not normalized");
|
|
1705
|
+
}
|
|
1706
|
+
} catch (error) {
|
|
1707
|
+
throw new PushPlanValidationError(
|
|
1708
|
+
`Unsafe or invalid path in push plan: ${error.message}`
|
|
1709
|
+
);
|
|
1710
|
+
}
|
|
1711
|
+
if (seen.has(doc.docId)) throw new PushPlanValidationError(`Duplicate document ${doc.docId} in plan`);
|
|
1712
|
+
seen.add(doc.docId);
|
|
1713
|
+
if (seenLocalPaths.has(doc.localPath)) {
|
|
1714
|
+
throw new PushPlanValidationError(`Duplicate local path ${doc.localPath} in plan`);
|
|
1715
|
+
}
|
|
1716
|
+
seenLocalPaths.add(doc.localPath);
|
|
1717
|
+
assertHash(doc.baseHash, "baseHash");
|
|
1718
|
+
assertHash(doc.localHash, "localHash");
|
|
1719
|
+
assertHash(doc.liveHash, "liveHash");
|
|
1720
|
+
assertHash(doc.rangeFingerprint, "rangeFingerprint");
|
|
1721
|
+
assertHash(doc.expectedHash, "expectedHash");
|
|
1722
|
+
for (const op of doc.ops) {
|
|
1723
|
+
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) {
|
|
1724
|
+
throw new PushPlanValidationError(`Invalid operation in ${doc.docPath}`);
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
for (const range of doc.activeTrackedRanges) {
|
|
1728
|
+
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")) {
|
|
1729
|
+
throw new PushPlanValidationError(`Invalid active tracked range in ${doc.docPath}`);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
for (const overlap of doc.trackedChangeOverlaps) {
|
|
1733
|
+
if (!overlap || typeof overlap.changeId !== "string" || !validTextEdit(overlap.proposedEdit) || !Number.isSafeInteger(overlap.trackedOp?.p)) {
|
|
1734
|
+
throw new PushPlanValidationError(`Invalid tracked-change overlap in ${doc.docPath}`);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
for (const overlap of doc.commentOverlaps) {
|
|
1738
|
+
if (!overlap || typeof overlap.threadId !== "string" || !Number.isSafeInteger(overlap.position) || overlap.position < 0 || typeof overlap.anchor !== "string" || !validTextEdit(overlap.proposedEdit)) {
|
|
1739
|
+
throw new PushPlanValidationError(`Invalid comment overlap in ${doc.docPath}`);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
if (plan.direct) {
|
|
1743
|
+
if (doc.tcSeed !== null) throw new PushPlanValidationError("Direct plan must not contain tcSeed");
|
|
1744
|
+
} else if (typeof doc.tcSeed !== "string" || !/^[0-9a-f]{18}$/.test(doc.tcSeed)) {
|
|
1745
|
+
throw new PushPlanValidationError(`Invalid tracked-change seed in ${doc.docPath}`);
|
|
1746
|
+
}
|
|
1747
|
+
if (!plan.allowOverlap && doc.trackedChangeOverlaps.length) {
|
|
1748
|
+
throw new PushPlanValidationError("Plan contains blocked tracked-change overlaps");
|
|
1749
|
+
}
|
|
1750
|
+
if (doc.baseSource === "live-unsafe" && doc.baseHash !== doc.liveHash) {
|
|
1751
|
+
throw new PushPlanValidationError(`Unsafe base must equal planned live text in ${doc.docPath}`);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
return plan;
|
|
1755
|
+
}
|
|
1756
|
+
function validTextEdit(value) {
|
|
1757
|
+
if (!value || typeof value !== "object") return false;
|
|
1758
|
+
const edit = value;
|
|
1759
|
+
return Boolean(
|
|
1760
|
+
Number.isSafeInteger(edit.start) && Number.isSafeInteger(edit.end) && edit.start >= 0 && edit.end >= edit.start && typeof edit.text === "string"
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
function readPushPlan(path, workspaceRoot = process.cwd()) {
|
|
1764
|
+
let parsed;
|
|
1765
|
+
try {
|
|
1766
|
+
parsed = JSON.parse(readFileSync6(workspaceReadPath(path, workspaceRoot), "utf8"));
|
|
1767
|
+
} catch (error) {
|
|
1768
|
+
throw new PushPlanValidationError(`Cannot read push plan ${path}: ${error.message}`);
|
|
1769
|
+
}
|
|
1770
|
+
return validatePushPlan(parsed);
|
|
1771
|
+
}
|
|
1772
|
+
function writePushPlan(plan, path, workspaceRoot = process.cwd()) {
|
|
1773
|
+
validatePushPlan(plan);
|
|
1774
|
+
const target = workspaceWritePath(path, workspaceRoot);
|
|
1775
|
+
mkdirSync7(dirname5(target), { recursive: true });
|
|
1776
|
+
const temp = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
1777
|
+
try {
|
|
1778
|
+
writeFileSync7(temp, JSON.stringify(plan, null, 2) + "\n", { mode: 384 });
|
|
1779
|
+
renameSync4(temp, target);
|
|
1780
|
+
} catch (error) {
|
|
1781
|
+
try {
|
|
1782
|
+
unlinkSync5(temp);
|
|
1783
|
+
} catch {
|
|
1784
|
+
}
|
|
1785
|
+
throw error;
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
function trackedChangeIdsForSeed(seed, count) {
|
|
1789
|
+
return Array.from(
|
|
1790
|
+
{ length: count },
|
|
1791
|
+
(_, index) => `${seed}${(index + 1).toString(16).padStart(6, "0")}`
|
|
1792
|
+
);
|
|
1793
|
+
}
|
|
1794
|
+
function errorMessage(error) {
|
|
1795
|
+
return error instanceof Error ? error.message : String(error);
|
|
1796
|
+
}
|
|
1797
|
+
function overleafSnapshotHash(text) {
|
|
1798
|
+
return createHash3("sha1").update(`blob ${text.length}\0`, "utf8").update(text, "utf8").digest("hex");
|
|
1799
|
+
}
|
|
1800
|
+
function validatePlannedIntent(planned, base, local, live) {
|
|
1801
|
+
if (sha256(base) !== planned.baseHash) {
|
|
1802
|
+
throw new PushPlanValidationError(`Synchronization base changed for ${planned.docPath}.`);
|
|
1803
|
+
}
|
|
1804
|
+
if (sha256(local) !== planned.localHash) {
|
|
1805
|
+
throw new PushPlanValidationError(`${planned.localPath} changed after planning.`);
|
|
1806
|
+
}
|
|
1807
|
+
if (sha256(live) !== planned.liveHash) {
|
|
1808
|
+
throw new PushPlanValidationError(`${planned.docPath} text changed after planning.`);
|
|
1809
|
+
}
|
|
1810
|
+
const merge = threeWayMerge(base, local, live);
|
|
1811
|
+
if (merge.conflicts.length || merge.text === void 0) {
|
|
1812
|
+
throw new PushPlanValidationError(
|
|
1813
|
+
`${planned.docPath} no longer has the conflict-free intent recorded by the plan.`
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
const expected = merge.text;
|
|
1817
|
+
if (stableJson(buildOps(live, expected)) !== stableJson(planned.ops) || sha256(expected) !== planned.expectedHash) {
|
|
1818
|
+
throw new PushPlanValidationError(
|
|
1819
|
+
`Operations in ${planned.docPath} do not match its saved Base\u2192Local intent.`
|
|
1820
|
+
);
|
|
1821
|
+
}
|
|
1822
|
+
return expected;
|
|
1823
|
+
}
|
|
1824
|
+
function validateReviewBinding(planned, state, live, expected, allowOverlap) {
|
|
1825
|
+
if (state.version !== planned.liveVersion) {
|
|
1826
|
+
throw new PushPlanValidationError(
|
|
1827
|
+
`${planned.docPath} version changed from ${planned.liveVersion} to ${state.version}; create a new plan.`
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1830
|
+
if (fingerprintRanges(state.ranges) !== planned.rangeFingerprint) {
|
|
1831
|
+
throw new PushPlanValidationError(
|
|
1832
|
+
`${planned.docPath} comments or tracked ranges changed after planning; create a new plan.`
|
|
1833
|
+
);
|
|
1834
|
+
}
|
|
1835
|
+
const footprint = buildOperationFootprint(live, expected);
|
|
1836
|
+
const activeTrackedRanges = serializeActiveTrackedRanges(state.ranges.changes);
|
|
1837
|
+
const trackedOverlaps = serializeOverlaps(
|
|
1838
|
+
findTrackedChangeOverlaps(state.ranges.changes, footprint)
|
|
1839
|
+
);
|
|
1840
|
+
const commentOverlaps = serializeCommentOverlaps(
|
|
1841
|
+
findCommentOverlaps(state.ranges.comments, footprint)
|
|
1842
|
+
);
|
|
1843
|
+
if (stableJson(activeTrackedRanges) !== stableJson(planned.activeTrackedRanges)) {
|
|
1844
|
+
throw new PushPlanValidationError(
|
|
1845
|
+
`Active tracked-range data is invalid for ${planned.docPath}; create a new plan.`
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
if (stableJson(trackedOverlaps) !== stableJson(planned.trackedChangeOverlaps)) {
|
|
1849
|
+
throw new PushPlanValidationError(
|
|
1850
|
+
`Tracked-change overlap data is invalid for ${planned.docPath}; create a new plan.`
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
if (trackedOverlaps.length && !allowOverlap) {
|
|
1854
|
+
throw new PushPlanValidationError(
|
|
1855
|
+
`${planned.docPath} operations overlap active tracked changes; create a plan with --allow-overlap only after inspecting them.`
|
|
1856
|
+
);
|
|
1857
|
+
}
|
|
1858
|
+
if (stableJson(commentOverlaps) !== stableJson(planned.commentOverlaps)) {
|
|
1859
|
+
throw new PushPlanValidationError(
|
|
1860
|
+
`Comment-overlap data is invalid for ${planned.docPath}; create a new plan.`
|
|
1861
|
+
);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
function baseTextForPlan(plan, planned, live, basePath) {
|
|
1865
|
+
if (planned.baseSource === "live-unsafe") {
|
|
1866
|
+
if (!plan.unsafeNoBase) {
|
|
1867
|
+
throw new PushPlanValidationError(`${planned.docPath} uses an unauthorized unsafe base.`);
|
|
1868
|
+
}
|
|
1869
|
+
return live;
|
|
1870
|
+
}
|
|
1871
|
+
const state = loadBaseState(basePath);
|
|
1872
|
+
const saved = state?.projectId === plan.projectId ? state.documents[planned.docId] : void 0;
|
|
1873
|
+
if (!saved || saved.hash !== planned.baseHash || sha256(saved.text) !== planned.baseHash) {
|
|
1874
|
+
throw new PushPlanValidationError(
|
|
1875
|
+
`Synchronization base for ${planned.docPath} changed after planning; create a new plan.`
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1878
|
+
return saved.text;
|
|
1879
|
+
}
|
|
1880
|
+
async function bindPlanDocument(plan, planned, socket, basePath) {
|
|
1881
|
+
let local;
|
|
1882
|
+
try {
|
|
1883
|
+
local = readFileSync6(workspaceReadPath(planned.localPath), "utf8");
|
|
1884
|
+
} catch (error) {
|
|
1885
|
+
throw new PushPlanValidationError(
|
|
1886
|
+
`Cannot read planned local file ${planned.localPath}: ${errorMessage(error)}`
|
|
1887
|
+
);
|
|
1888
|
+
}
|
|
1889
|
+
const state = await joinDoc(socket, planned.docId);
|
|
1890
|
+
const live = state.lines.join("\n");
|
|
1891
|
+
const base = baseTextForPlan(plan, planned, live, basePath);
|
|
1892
|
+
const expected = validatePlannedIntent(planned, base, local, live);
|
|
1893
|
+
validateReviewBinding(planned, state, live, expected, plan.allowOverlap);
|
|
1894
|
+
return { plan: planned, state, expected };
|
|
1895
|
+
}
|
|
1896
|
+
function verifiedTrackedIds(plan, planned, after) {
|
|
1897
|
+
if (plan.direct) return [];
|
|
1898
|
+
const actualIds = new Set((after.ranges.changes ?? []).map((change) => String(change.id)));
|
|
1899
|
+
if (planned.trackedChangeOverlaps.length) {
|
|
1900
|
+
const ids = [...actualIds].filter(
|
|
1901
|
+
(id) => new RegExp(`^${planned.tcSeed}[0-9a-f]{6}$`).test(id)
|
|
1902
|
+
);
|
|
1903
|
+
if (!ids.length) {
|
|
1904
|
+
throw new Error(
|
|
1905
|
+
`Verification failed for ${planned.docPath}: no tracked ranges with seed ${planned.tcSeed} were created.`
|
|
1906
|
+
);
|
|
1907
|
+
}
|
|
1908
|
+
return ids;
|
|
1909
|
+
}
|
|
1910
|
+
const expectedIds = trackedChangeIdsForSeed(planned.tcSeed, planned.ops.length);
|
|
1911
|
+
const missing = expectedIds.filter((id) => !actualIds.has(id));
|
|
1912
|
+
if (missing.length) {
|
|
1913
|
+
throw new Error(
|
|
1914
|
+
`Verification failed for ${planned.docPath}: tracked ranges were not created for ${missing.join(", ")}.`
|
|
1915
|
+
);
|
|
1916
|
+
}
|
|
1917
|
+
return expectedIds;
|
|
1918
|
+
}
|
|
1919
|
+
function definitelyRejectedApply(error) {
|
|
1920
|
+
const message = errorMessage(error);
|
|
1921
|
+
return message.includes("Overleaf rejected the OT update") || message.includes("Overleaf failed to apply the OT update");
|
|
1922
|
+
}
|
|
1923
|
+
function initialReceiptDocuments(plan) {
|
|
1924
|
+
return plan.documents.map((doc) => ({
|
|
1925
|
+
docId: doc.docId,
|
|
1926
|
+
docPath: doc.docPath,
|
|
1927
|
+
localPath: doc.localPath,
|
|
1928
|
+
opCount: doc.ops.length,
|
|
1929
|
+
expectedHash: doc.expectedHash,
|
|
1930
|
+
status: "pending",
|
|
1931
|
+
mutationAttempted: false
|
|
1932
|
+
}));
|
|
1933
|
+
}
|
|
1934
|
+
function pushReceiptNeedsQuarantine(receipt) {
|
|
1935
|
+
if (receipt.status === "ambiguous") return true;
|
|
1936
|
+
if (receipt.status !== "in_progress") return false;
|
|
1937
|
+
const documents = receipt.details?.documents;
|
|
1938
|
+
return Array.isArray(documents) && documents.some(
|
|
1939
|
+
(document) => Boolean(document) && typeof document === "object" && (document.mutationAttempted === true || typeof document.mutationStartedAt === "string")
|
|
1940
|
+
);
|
|
1941
|
+
}
|
|
1942
|
+
function receiptDocumentIds(receipt) {
|
|
1943
|
+
const documents = receipt.details?.documents;
|
|
1944
|
+
if (!Array.isArray(documents)) return [];
|
|
1945
|
+
return documents.flatMap((document) => {
|
|
1946
|
+
if (!document || typeof document !== "object") return [];
|
|
1947
|
+
const docId = document.docId;
|
|
1948
|
+
return typeof docId === "string" ? [docId] : [];
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
function quarantinedPlanDocuments(plan, receipts) {
|
|
1952
|
+
const plannedIds = new Set(plan.documents.map((document) => document.docId));
|
|
1953
|
+
const disposition = /* @__PURE__ */ new Map();
|
|
1954
|
+
const newestFirst = [...receipts].sort(
|
|
1955
|
+
(a, b) => String(b.receipt.updatedAt ?? "").localeCompare(String(a.receipt.updatedAt ?? ""))
|
|
1956
|
+
);
|
|
1957
|
+
for (const { receipt } of newestFirst) {
|
|
1958
|
+
if (receipt.operation !== "push" || receipt.details?.projectId !== plan.projectId) {
|
|
1959
|
+
continue;
|
|
1960
|
+
}
|
|
1961
|
+
const reconciles = receipt.status === "succeeded" && receipt.details.acknowledgedAmbiguousRetry === true;
|
|
1962
|
+
const quarantines = pushReceiptNeedsQuarantine(receipt);
|
|
1963
|
+
if (!reconciles && !quarantines) continue;
|
|
1964
|
+
for (const docId of receiptDocumentIds(receipt)) {
|
|
1965
|
+
if (plannedIds.has(docId) && !disposition.has(docId)) {
|
|
1966
|
+
disposition.set(docId, reconciles ? "reconciled" : "quarantined");
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
return plan.documents.filter((document) => disposition.get(document.docId) === "quarantined").map((document) => document.docPath);
|
|
1971
|
+
}
|
|
1972
|
+
function synchronizeLocalAfterRemote(localPath, plannedLocalHash, remoteText, workspaceRoot = process.cwd()) {
|
|
1973
|
+
const localFile = workspaceReadPath(localPath, workspaceRoot);
|
|
1974
|
+
const currentLocal = readFileSync6(localFile, "utf8");
|
|
1975
|
+
if (sha256(currentLocal) !== plannedLocalHash) {
|
|
1976
|
+
throw new Error(`${localPath} changed while the plan was being submitted; local file left untouched.`);
|
|
1977
|
+
}
|
|
1978
|
+
if (currentLocal === remoteText) return {};
|
|
1979
|
+
const timestamp = `${snapshotTimestamp()}-${process.pid}`;
|
|
1980
|
+
const snapshotPath = snapshotRelativePath(timestamp, localPath, workspaceRoot);
|
|
1981
|
+
const snapshotFile = workspaceWritePath(snapshotPath, workspaceRoot);
|
|
1982
|
+
mkdirSync7(dirname5(snapshotFile), { recursive: true });
|
|
1983
|
+
writeFileSync7(snapshotFile, currentLocal, { flag: "wx", mode: 384 });
|
|
1984
|
+
const target = workspaceWritePath(localPath, workspaceRoot);
|
|
1985
|
+
const temp = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
1986
|
+
try {
|
|
1987
|
+
writeFileSync7(temp, remoteText, { mode: statSync(localFile).mode & 511 });
|
|
1988
|
+
if (sha256(readFileSync6(localFile, "utf8")) !== plannedLocalHash) {
|
|
1989
|
+
throw new Error(`${localPath} changed while the plan was being submitted; local file left untouched.`);
|
|
1990
|
+
}
|
|
1991
|
+
renameSync4(temp, target);
|
|
1992
|
+
} catch (error) {
|
|
1993
|
+
try {
|
|
1994
|
+
unlinkSync5(temp);
|
|
1995
|
+
} catch {
|
|
1996
|
+
}
|
|
1997
|
+
throw error;
|
|
1998
|
+
}
|
|
1999
|
+
return { snapshotPath };
|
|
2000
|
+
}
|
|
2001
|
+
async function submitPlan(planOrPath, opts = {}) {
|
|
2002
|
+
const plan = typeof planOrPath === "string" ? readPushPlan(planOrPath) : validatePushPlan(planOrPath);
|
|
2003
|
+
const totalOps = plan.documents.reduce((sum, doc) => sum + doc.ops.length, 0);
|
|
2004
|
+
const planHash = sha256(stableJson(plan));
|
|
2005
|
+
const receiptDocuments = initialReceiptDocuments(plan);
|
|
2006
|
+
let receipt = beginReceipt(
|
|
2007
|
+
"push",
|
|
2008
|
+
{
|
|
2009
|
+
projectId: plan.projectId,
|
|
2010
|
+
direct: plan.direct,
|
|
2011
|
+
planCreatedAt: plan.createdAt,
|
|
2012
|
+
planHash,
|
|
2013
|
+
totalOps,
|
|
2014
|
+
phase: "preflight",
|
|
2015
|
+
plan,
|
|
2016
|
+
documents: receiptDocuments
|
|
2017
|
+
},
|
|
2018
|
+
{ receiptsDir: opts.receiptsDir }
|
|
2019
|
+
);
|
|
2020
|
+
const completed = [];
|
|
2021
|
+
const basePath = opts.basePath ?? BASE_STATE_PATH;
|
|
2022
|
+
let opened;
|
|
2023
|
+
let mutationLock;
|
|
2024
|
+
let unknownMutationOutcome = false;
|
|
2025
|
+
let failureStatus;
|
|
2026
|
+
try {
|
|
2027
|
+
if (plan.projectId !== config.projectId) {
|
|
2028
|
+
throw new PushPlanValidationError(
|
|
2029
|
+
`Plan is for project ${plan.projectId}, but this repository is linked to ${config.projectId}.`
|
|
2030
|
+
);
|
|
2031
|
+
}
|
|
2032
|
+
mutationLock = acquireMutationLock(plan.projectId);
|
|
2033
|
+
const priorReceipts = readReceipts(opts.receiptsDir);
|
|
2034
|
+
const quarantinedDocuments = quarantinedPlanDocuments(plan, priorReceipts);
|
|
2035
|
+
if (quarantinedDocuments.length && !opts.allowAmbiguousRetry) {
|
|
2036
|
+
throw new PushPlanValidationError(
|
|
2037
|
+
`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.`
|
|
2038
|
+
);
|
|
2039
|
+
}
|
|
2040
|
+
if (!plan.documents.length) {
|
|
2041
|
+
receipt = updateReceipt(receipt, "skipped", {
|
|
2042
|
+
phase: "complete",
|
|
2043
|
+
outcome: "empty_plan",
|
|
2044
|
+
documents: receiptDocuments
|
|
2045
|
+
});
|
|
2046
|
+
return {
|
|
2047
|
+
projectId: plan.projectId,
|
|
2048
|
+
direct: plan.direct,
|
|
2049
|
+
totalOps,
|
|
2050
|
+
documents: completed,
|
|
2051
|
+
receiptPath: receipt.path
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
opened = await openProject();
|
|
2055
|
+
const { socket, project, docs } = opened;
|
|
2056
|
+
const connectedProjectId = String(project?._id ?? config.projectId);
|
|
2057
|
+
if (connectedProjectId !== plan.projectId) {
|
|
2058
|
+
throw new PushPlanValidationError(
|
|
2059
|
+
`Connected project ${connectedProjectId} does not match plan ${plan.projectId}.`
|
|
2060
|
+
);
|
|
2061
|
+
}
|
|
2062
|
+
const docsById = new Map(docs.map((doc) => [doc._id, doc]));
|
|
2063
|
+
for (const planned of plan.documents) {
|
|
2064
|
+
const doc = docsById.get(planned.docId);
|
|
2065
|
+
if (!doc || doc.path !== planned.docPath) {
|
|
2066
|
+
throw new PushPlanValidationError(
|
|
2067
|
+
`Document ${planned.docPath} (${planned.docId}) no longer exists at its planned path.`
|
|
2068
|
+
);
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
for (const planned of plan.documents) {
|
|
2072
|
+
await bindPlanDocument(plan, planned, socket, basePath);
|
|
2073
|
+
}
|
|
2074
|
+
receipt = updateReceipt(receipt, "in_progress", {
|
|
2075
|
+
phase: "applying",
|
|
2076
|
+
preflightVerifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2077
|
+
...opts.allowAmbiguousRetry && quarantinedDocuments.length ? {
|
|
2078
|
+
acknowledgedAmbiguousRetry: true,
|
|
2079
|
+
reconciledAmbiguousDocuments: quarantinedDocuments
|
|
2080
|
+
} : {},
|
|
2081
|
+
documents: receiptDocuments
|
|
2082
|
+
});
|
|
2083
|
+
for (let index = 0; index < plan.documents.length; index++) {
|
|
2084
|
+
const planned = plan.documents[index];
|
|
2085
|
+
const { state, expected } = await bindPlanDocument(
|
|
2086
|
+
plan,
|
|
2087
|
+
planned,
|
|
2088
|
+
socket,
|
|
2089
|
+
basePath
|
|
2090
|
+
);
|
|
2091
|
+
receiptDocuments[index] = {
|
|
2092
|
+
...receiptDocuments[index],
|
|
2093
|
+
status: "applying",
|
|
2094
|
+
mutationAttempted: true,
|
|
2095
|
+
mutationStartedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2096
|
+
};
|
|
2097
|
+
receipt = updateReceipt(receipt, "in_progress", {
|
|
2098
|
+
phase: "applying",
|
|
2099
|
+
currentDocument: planned.docPath,
|
|
2100
|
+
documents: receiptDocuments
|
|
2101
|
+
});
|
|
2102
|
+
unknownMutationOutcome = true;
|
|
2103
|
+
let applyError;
|
|
2104
|
+
try {
|
|
2105
|
+
await applyOtUpdateAndWait(socket, planned.docId, {
|
|
2106
|
+
doc: planned.docId,
|
|
2107
|
+
op: planned.ops,
|
|
2108
|
+
v: state.version,
|
|
2109
|
+
meta: plan.direct ? {} : { tc: planned.tcSeed },
|
|
2110
|
+
hash: overleafSnapshotHash(expected)
|
|
2111
|
+
});
|
|
2112
|
+
} catch (error) {
|
|
2113
|
+
applyError = error;
|
|
2114
|
+
}
|
|
2115
|
+
let after;
|
|
2116
|
+
try {
|
|
2117
|
+
after = await joinDoc(socket, planned.docId);
|
|
2118
|
+
} catch (readbackError) {
|
|
2119
|
+
const definitelyRejected = Boolean(applyError && definitelyRejectedApply(applyError));
|
|
2120
|
+
failureStatus = definitelyRejected ? "failed" : "ambiguous";
|
|
2121
|
+
unknownMutationOutcome = !definitelyRejected;
|
|
2122
|
+
receiptDocuments[index] = {
|
|
2123
|
+
...receiptDocuments[index],
|
|
2124
|
+
status: failureStatus,
|
|
2125
|
+
...applyError ? { transportError: errorMessage(applyError) } : {},
|
|
2126
|
+
error: `Readback failed: ${errorMessage(readbackError)}`
|
|
2127
|
+
};
|
|
2128
|
+
throw new Error(
|
|
2129
|
+
`${planned.docPath} could not be verified after its update: ${errorMessage(readbackError)}`
|
|
2130
|
+
);
|
|
2131
|
+
}
|
|
2132
|
+
const afterText = after.lines.join("\n");
|
|
2133
|
+
if (afterText !== expected || sha256(afterText) !== planned.expectedHash) {
|
|
2134
|
+
const definitelyRejected = Boolean(applyError && definitelyRejectedApply(applyError));
|
|
2135
|
+
failureStatus = definitelyRejected ? "failed" : "ambiguous";
|
|
2136
|
+
unknownMutationOutcome = !definitelyRejected;
|
|
2137
|
+
receiptDocuments[index] = {
|
|
2138
|
+
...receiptDocuments[index],
|
|
2139
|
+
status: failureStatus,
|
|
2140
|
+
afterVersion: after.version,
|
|
2141
|
+
...applyError ? { transportError: errorMessage(applyError) } : {},
|
|
2142
|
+
error: "Overleaf text does not match the planned result"
|
|
2143
|
+
};
|
|
2144
|
+
throw new Error(
|
|
2145
|
+
`Verification failed for ${planned.docPath}: Overleaf text does not match the planned result.`
|
|
2146
|
+
);
|
|
2147
|
+
}
|
|
2148
|
+
let trackedChangeIds;
|
|
2149
|
+
try {
|
|
2150
|
+
trackedChangeIds = verifiedTrackedIds(plan, planned, after);
|
|
2151
|
+
} catch (error) {
|
|
2152
|
+
const ambiguousTransport = Boolean(applyError && !definitelyRejectedApply(applyError));
|
|
2153
|
+
unknownMutationOutcome = ambiguousTransport;
|
|
2154
|
+
failureStatus = ambiguousTransport ? "ambiguous" : "failed";
|
|
2155
|
+
receiptDocuments[index] = {
|
|
2156
|
+
...receiptDocuments[index],
|
|
2157
|
+
status: failureStatus,
|
|
2158
|
+
afterVersion: after.version,
|
|
2159
|
+
...applyError ? { transportError: errorMessage(applyError) } : {},
|
|
2160
|
+
error: errorMessage(error)
|
|
2161
|
+
};
|
|
2162
|
+
throw error;
|
|
2163
|
+
}
|
|
2164
|
+
unknownMutationOutcome = false;
|
|
2165
|
+
try {
|
|
2166
|
+
const localSync = synchronizeLocalAfterRemote(
|
|
2167
|
+
planned.localPath,
|
|
2168
|
+
planned.localHash,
|
|
2169
|
+
afterText
|
|
2170
|
+
);
|
|
2171
|
+
if (localSync.snapshotPath) {
|
|
2172
|
+
receiptDocuments[index] = {
|
|
2173
|
+
...receiptDocuments[index],
|
|
2174
|
+
localSnapshotPath: localSync.snapshotPath
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
const base = {
|
|
2178
|
+
docId: planned.docId,
|
|
2179
|
+
path: planned.docPath,
|
|
2180
|
+
text: afterText,
|
|
2181
|
+
hash: planned.expectedHash,
|
|
2182
|
+
version: after.version,
|
|
2183
|
+
rangeFingerprint: fingerprintRanges(after.ranges),
|
|
2184
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2185
|
+
};
|
|
2186
|
+
mergeBaseDocuments(plan.projectId, [base], basePath);
|
|
2187
|
+
} catch (error) {
|
|
2188
|
+
failureStatus = "failed";
|
|
2189
|
+
receiptDocuments[index] = {
|
|
2190
|
+
...receiptDocuments[index],
|
|
2191
|
+
status: "remote_verified_local_failed",
|
|
2192
|
+
afterVersion: after.version,
|
|
2193
|
+
trackedChangeIds,
|
|
2194
|
+
...applyError ? { transportError: errorMessage(applyError) } : {},
|
|
2195
|
+
error: errorMessage(error)
|
|
2196
|
+
};
|
|
2197
|
+
throw new Error(
|
|
2198
|
+
`${planned.docPath} was verified on Overleaf, but local synchronization failed: ` + errorMessage(error)
|
|
2199
|
+
);
|
|
2200
|
+
}
|
|
2201
|
+
const result = {
|
|
2202
|
+
docId: planned.docId,
|
|
2203
|
+
docPath: planned.docPath,
|
|
2204
|
+
version: after.version,
|
|
2205
|
+
hash: planned.expectedHash,
|
|
2206
|
+
trackedChangeIds
|
|
2207
|
+
};
|
|
2208
|
+
completed.push(result);
|
|
2209
|
+
receiptDocuments[index] = {
|
|
2210
|
+
...receiptDocuments[index],
|
|
2211
|
+
status: "verified",
|
|
2212
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2213
|
+
afterVersion: after.version,
|
|
2214
|
+
trackedChangeIds,
|
|
2215
|
+
...applyError ? { transportError: errorMessage(applyError) } : {}
|
|
2216
|
+
};
|
|
2217
|
+
receipt = updateReceipt(receipt, "in_progress", {
|
|
2218
|
+
phase: "applying",
|
|
2219
|
+
completedDocuments: completed.map((doc) => doc.docPath),
|
|
2220
|
+
documents: receiptDocuments
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
receipt = updateReceipt(receipt, "succeeded", {
|
|
2224
|
+
phase: "complete",
|
|
2225
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2226
|
+
completedDocuments: completed.map((doc) => doc.docPath),
|
|
2227
|
+
documents: receiptDocuments
|
|
2228
|
+
});
|
|
2229
|
+
return {
|
|
2230
|
+
projectId: plan.projectId,
|
|
2231
|
+
direct: plan.direct,
|
|
2232
|
+
totalOps,
|
|
2233
|
+
documents: completed,
|
|
2234
|
+
receiptPath: receipt.path
|
|
2235
|
+
};
|
|
2236
|
+
} catch (error) {
|
|
2237
|
+
const status = failureStatus ?? (unknownMutationOutcome ? "ambiguous" : "failed");
|
|
2238
|
+
receipt = updateReceipt(receipt, status, {
|
|
2239
|
+
phase: status === "ambiguous" ? "mutation_outcome_unknown" : "failed",
|
|
2240
|
+
failedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2241
|
+
error: errorMessage(error),
|
|
2242
|
+
completedDocuments: completed.map((doc) => doc.docPath),
|
|
2243
|
+
documents: receiptDocuments
|
|
2244
|
+
});
|
|
2245
|
+
throw new PushSubmissionError(
|
|
2246
|
+
`${errorMessage(error)} Audit receipt: ${receipt.path}`,
|
|
2247
|
+
receipt.path,
|
|
2248
|
+
status,
|
|
2249
|
+
receiptDocuments,
|
|
2250
|
+
error
|
|
2251
|
+
);
|
|
2252
|
+
} finally {
|
|
2253
|
+
try {
|
|
2254
|
+
opened?.socket.close();
|
|
2255
|
+
} finally {
|
|
2256
|
+
mutationLock?.release();
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
function printPlan(plan) {
|
|
2261
|
+
console.log(
|
|
2262
|
+
plan.direct ? "Mode: DIRECT \u2014 plain edits (not marked as suggestions)" : "Mode: SUGGESTIONS \u2014 tracked changes for co-authors to accept/reject"
|
|
2263
|
+
);
|
|
2264
|
+
for (const doc of plan.documents) {
|
|
2265
|
+
const ins = doc.ops.filter((op) => op.i != null).length;
|
|
2266
|
+
const del = doc.ops.filter((op) => op.d != null).length;
|
|
2267
|
+
console.log(
|
|
2268
|
+
`
|
|
2269
|
+
${doc.localPath} \u2192 ${doc.docPath} (v${doc.liveVersion}): ${doc.ops.length} op(s), ${ins} ins / ${del} del`
|
|
2270
|
+
);
|
|
2271
|
+
for (const op of doc.ops.slice(0, 12)) console.log(preview(op));
|
|
2272
|
+
if (doc.ops.length > 12) console.log(` \u2026 and ${doc.ops.length - 12} more`);
|
|
2273
|
+
for (const overlap of doc.commentOverlaps) {
|
|
2274
|
+
console.log(
|
|
2275
|
+
` \u2139\uFE0F touches comment ${overlap.threadId} @ ${overlap.position}: ${JSON.stringify(overlap.anchor)}`
|
|
2276
|
+
);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
async function push(opts) {
|
|
2281
|
+
validatePushOptions(opts);
|
|
2282
|
+
if (opts.plan) {
|
|
2283
|
+
const result2 = await submitPlan(opts.plan, {
|
|
2284
|
+
basePath: opts.basePath,
|
|
2285
|
+
receiptsDir: opts.receiptsDir,
|
|
2286
|
+
allowAmbiguousRetry: opts.allowAmbiguousRetry
|
|
2287
|
+
});
|
|
2288
|
+
console.log(
|
|
2289
|
+
`\u2705 Submitted and verified ${result2.totalOps} ${result2.direct ? "direct edit(s)" : "tracked suggestion(s)"} across ${result2.documents.length} file(s).`
|
|
2290
|
+
);
|
|
2291
|
+
console.log(`Audit receipt: ${result2.receiptPath}`);
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2294
|
+
const plan = await createPlan(opts);
|
|
2295
|
+
if (!plan.documents.length) {
|
|
2296
|
+
console.log("Nothing to push \u2014 no unapplied local edits were found.");
|
|
2297
|
+
if (opts.planOut) console.log(`Saved empty plan to ${opts.planOut}.`);
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
printPlan(plan);
|
|
2301
|
+
if (opts.planOut) {
|
|
2302
|
+
console.log(`
|
|
2303
|
+
Saved binding plan to ${opts.planOut}; nothing sent to Overleaf.`);
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2306
|
+
if (opts.dryRun) {
|
|
2307
|
+
console.log("\n(dry run \u2014 nothing sent to Overleaf)");
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
const result = await submitPlan(plan, {
|
|
2311
|
+
basePath: opts.basePath,
|
|
2312
|
+
receiptsDir: opts.receiptsDir,
|
|
2313
|
+
allowAmbiguousRetry: opts.allowAmbiguousRetry
|
|
2314
|
+
});
|
|
2315
|
+
console.log(
|
|
2316
|
+
`
|
|
2317
|
+
\u2705 Pushed and verified ${result.totalOps} ${result.direct ? "direct edit(s)" : "tracked suggestion(s)"} across ${result.documents.length} file(s).`
|
|
2318
|
+
);
|
|
2319
|
+
console.log(`Audit receipt: ${result.receiptPath}`);
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
// src/commands/fetch.ts
|
|
2323
|
+
import { writeFileSync as writeFileSync8, readFileSync as readFileSync7, mkdirSync as mkdirSync8, existsSync as existsSync4 } from "fs";
|
|
2324
|
+
import { dirname as dirname6 } from "path";
|
|
2325
|
+
async function fetchDocs(opts) {
|
|
2326
|
+
const { socket, project, docs } = await openProject();
|
|
2327
|
+
let mutationLock;
|
|
2328
|
+
try {
|
|
2329
|
+
if (!opts.dryRun && opts.acquireLock !== false) {
|
|
2330
|
+
mutationLock = acquireMutationLock(config.projectId);
|
|
2331
|
+
}
|
|
2332
|
+
const projectId = String(project?._id ?? config.projectId);
|
|
2333
|
+
if (projectId !== config.projectId) {
|
|
2334
|
+
throw new Error(
|
|
2335
|
+
`Connected project id ${projectId} does not match configured project ${config.projectId}.`
|
|
2336
|
+
);
|
|
2337
|
+
}
|
|
2338
|
+
const requested = opts.file?.replace(/\\/g, "/");
|
|
2339
|
+
const match = requested ? matchDocument(requested, docs) : void 0;
|
|
2340
|
+
const targets = requested ? match ? [match] : [] : docs;
|
|
2341
|
+
if (!targets.length) {
|
|
2342
|
+
if (requested) {
|
|
2343
|
+
throw new Error(
|
|
2344
|
+
`No matching document for "${opts.file}"; use its exact Overleaf project path.`
|
|
2345
|
+
);
|
|
2346
|
+
}
|
|
2347
|
+
console.log("No Overleaf documents found.");
|
|
2348
|
+
return;
|
|
2349
|
+
}
|
|
2350
|
+
const localTargets = targets.map((doc) => ({
|
|
2351
|
+
doc,
|
|
2352
|
+
localPath: workspaceWritePath(doc.path)
|
|
2353
|
+
}));
|
|
2354
|
+
const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2355
|
+
const entries = [];
|
|
2356
|
+
for (const { doc, localPath } of localTargets) {
|
|
2357
|
+
const state = await joinDoc(socket, doc._id);
|
|
2358
|
+
const remote = state.lines.join("\n");
|
|
2359
|
+
const local = existsSync4(localPath) ? readFileSync7(localPath, "utf8") : null;
|
|
2360
|
+
entries.push({
|
|
2361
|
+
doc,
|
|
2362
|
+
localPath,
|
|
2363
|
+
remote,
|
|
2364
|
+
local,
|
|
2365
|
+
base: {
|
|
2366
|
+
docId: doc._id,
|
|
2367
|
+
path: doc.path,
|
|
2368
|
+
text: remote,
|
|
2369
|
+
hash: sha256(remote),
|
|
2370
|
+
version: state.version,
|
|
2371
|
+
rangeFingerprint: fingerprintRanges(state.ranges),
|
|
2372
|
+
fetchedAt
|
|
2373
|
+
}
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
const changedEntries = entries.filter((entry) => entry.local !== entry.remote);
|
|
2377
|
+
for (const { doc, local, remote } of changedEntries) {
|
|
2378
|
+
const delta = local === null ? "(new file)" : `${local.length} \u2192 ${remote.length} chars`;
|
|
2379
|
+
console.log(` ${doc.path} ${delta}`);
|
|
2380
|
+
}
|
|
2381
|
+
let snapshotRoot;
|
|
2382
|
+
if (!opts.dryRun) {
|
|
2383
|
+
for (const entry of changedEntries) {
|
|
2384
|
+
if (entry.local === null) continue;
|
|
2385
|
+
const current = readFileSync7(entry.localPath, "utf8");
|
|
2386
|
+
if (current !== entry.local) {
|
|
2387
|
+
throw new Error(`${entry.doc.path} changed while fetch was reading the project; retry fetch.`);
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
const timestamp = snapshotTimestamp();
|
|
2391
|
+
const snapshotTargets = changedEntries.filter((entry) => entry.local !== null).map((entry) => ({
|
|
2392
|
+
entry,
|
|
2393
|
+
snapshotPath: workspaceWritePath(snapshotRelativePath(timestamp, entry.doc.path))
|
|
2394
|
+
}));
|
|
2395
|
+
for (const { entry, snapshotPath } of snapshotTargets) {
|
|
2396
|
+
mkdirSync8(dirname6(snapshotPath), { recursive: true });
|
|
2397
|
+
writeFileSync8(snapshotPath, entry.local, { mode: 384 });
|
|
2398
|
+
}
|
|
2399
|
+
if (snapshotTargets.length) {
|
|
2400
|
+
snapshotRoot = `${SNAPSHOTS_DIR}/${timestamp}`;
|
|
2401
|
+
}
|
|
2402
|
+
for (const { localPath, remote } of changedEntries) {
|
|
2403
|
+
mkdirSync8(dirname6(localPath), { recursive: true });
|
|
2404
|
+
writeFileSync8(localPath, remote);
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
if (!opts.dryRun) mergeBaseDocuments(projectId, entries.map((entry) => entry.base));
|
|
2408
|
+
if (!changedEntries.length) {
|
|
2409
|
+
console.log(
|
|
2410
|
+
opts.dryRun ? "Already up to date \u2014 local files match Overleaf." : `Already up to date \u2014 refreshed synchronization base for ${entries.length} file(s).`
|
|
2411
|
+
);
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
if (snapshotRoot) console.log(`Recoverable local snapshot: ${snapshotRoot}`);
|
|
2415
|
+
console.log(
|
|
2416
|
+
opts.dryRun ? `
|
|
2417
|
+
(dry run \u2014 ${changedEntries.length} local file(s) would be overwritten; base unchanged)` : `
|
|
2418
|
+
\u2705 Fetched ${changedEntries.length} file(s) from Overleaf and saved their synchronization base.`
|
|
2419
|
+
);
|
|
2420
|
+
} finally {
|
|
2421
|
+
try {
|
|
2422
|
+
socket.close();
|
|
2423
|
+
} finally {
|
|
2424
|
+
mutationLock?.release();
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
// src/commands/upload.ts
|
|
2430
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
2431
|
+
import { basename as basename2 } from "path";
|
|
2432
|
+
function findFolder(folder, wanted, prefix = "") {
|
|
2433
|
+
for (const f of folder?.folders ?? []) {
|
|
2434
|
+
const path = prefix ? `${prefix}/${f.name}` : f.name;
|
|
2435
|
+
if (f.name === wanted || path === wanted) return f;
|
|
2436
|
+
const deeper = findFolder(f, wanted, path);
|
|
2437
|
+
if (deeper) return deeper;
|
|
2438
|
+
}
|
|
2439
|
+
return void 0;
|
|
2440
|
+
}
|
|
2441
|
+
async function upload(paths, folderName) {
|
|
2442
|
+
const mutationLock = acquireMutationLock(config.projectId);
|
|
2443
|
+
let socket;
|
|
2444
|
+
try {
|
|
2445
|
+
const opened = await openProject();
|
|
2446
|
+
socket = opened.socket;
|
|
2447
|
+
const { project } = opened;
|
|
2448
|
+
const root = project?.rootFolder?.[0];
|
|
2449
|
+
if (!root?._id) throw new Error("could not resolve the project root folder");
|
|
2450
|
+
let folderId = root._id;
|
|
2451
|
+
if (folderName) {
|
|
2452
|
+
const found = findFolder(root, folderName);
|
|
2453
|
+
if (!found) throw new Error(`folder not found in project: ${folderName}`);
|
|
2454
|
+
folderId = found._id;
|
|
2455
|
+
}
|
|
2456
|
+
const csrf = await getCsrfToken();
|
|
2457
|
+
for (const path of paths) {
|
|
2458
|
+
const bytes = readFileSync8(path);
|
|
2459
|
+
const res = await uploadFile(folderId, basename2(path), bytes, csrf);
|
|
2460
|
+
console.log(`\u2705 Uploaded ${path} \u2192 ${res.entity_type} ${res.entity_id}`);
|
|
2461
|
+
}
|
|
2462
|
+
} finally {
|
|
2463
|
+
try {
|
|
2464
|
+
socket?.close();
|
|
2465
|
+
} finally {
|
|
2466
|
+
mutationLock.release();
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
// src/commands/comment.ts
|
|
2472
|
+
import { createHash as createHash4, randomBytes as randomBytes2 } from "crypto";
|
|
2473
|
+
var DEFAULT_DUPLICATE_WINDOW_MS = 5 * 60 * 1e3;
|
|
2474
|
+
function isRecentAttempt(prior, nowMs, windowMs) {
|
|
2475
|
+
const updatedAt = prior.updatedAt ? Date.parse(prior.updatedAt) : Number.NaN;
|
|
2476
|
+
return Number.isFinite(updatedAt) && updatedAt >= nowMs - Math.max(0, windowMs) && updatedAt <= nowMs + 6e4;
|
|
2477
|
+
}
|
|
2478
|
+
function shouldQuarantineCommentAnchorRetry(prior, nowMs, windowMs) {
|
|
2479
|
+
return (prior.status === "ambiguous" || prior.status === "in_progress" && Boolean(prior.anchorAttemptedAt)) && isRecentAttempt(prior, nowMs, windowMs);
|
|
2480
|
+
}
|
|
2481
|
+
function shouldQuarantineCommentRetry(prior, nowMs, windowMs) {
|
|
2482
|
+
if (!prior.postAttemptedAt || prior.status !== "ambiguous" && prior.status !== "in_progress") {
|
|
2483
|
+
return false;
|
|
2484
|
+
}
|
|
2485
|
+
return isRecentAttempt(prior, nowMs, windowMs);
|
|
2486
|
+
}
|
|
2487
|
+
function findCommentRangeByThreadId(ranges, threadId) {
|
|
2488
|
+
if (!Array.isArray(ranges)) return void 0;
|
|
2489
|
+
return ranges.find(
|
|
2490
|
+
(range) => Boolean(range && typeof range === "object" && range.op?.t === threadId)
|
|
2491
|
+
);
|
|
2492
|
+
}
|
|
2493
|
+
function findCommentRangesAt(ranges, position, anchor) {
|
|
2494
|
+
if (!Array.isArray(ranges)) return [];
|
|
2495
|
+
return ranges.filter(
|
|
2496
|
+
(range) => Boolean(
|
|
2497
|
+
range && typeof range === "object" && range.op?.p === position && range.op?.c === anchor
|
|
2498
|
+
)
|
|
2499
|
+
);
|
|
2500
|
+
}
|
|
2501
|
+
function commentRangeAnchorsText(range, text, anchor) {
|
|
2502
|
+
const position = range?.op?.p;
|
|
2503
|
+
return Number.isSafeInteger(position) && position >= 0 && range?.op?.c === anchor && text.slice(position, position + anchor.length) === anchor;
|
|
2504
|
+
}
|
|
2505
|
+
function commentIntentHash(intent) {
|
|
2506
|
+
const canonical = [
|
|
2507
|
+
intent.projectId,
|
|
2508
|
+
intent.docId,
|
|
2509
|
+
intent.anchor,
|
|
2510
|
+
intent.occurrence,
|
|
2511
|
+
intent.message
|
|
2512
|
+
];
|
|
2513
|
+
return createHash4("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
2514
|
+
}
|
|
2515
|
+
function rangeThreadId(range) {
|
|
2516
|
+
const value = range?.op?.t;
|
|
2517
|
+
return typeof value === "string" && value ? value : void 0;
|
|
2518
|
+
}
|
|
2519
|
+
function rangeSnapshot(range) {
|
|
2520
|
+
return range ? {
|
|
2521
|
+
present: true,
|
|
2522
|
+
position: range.op?.p,
|
|
2523
|
+
anchor: range.op?.c,
|
|
2524
|
+
threadId: range.op?.t
|
|
2525
|
+
} : { present: false };
|
|
2526
|
+
}
|
|
2527
|
+
function threadSnapshot(thread) {
|
|
2528
|
+
const messages = threadMessages(thread);
|
|
2529
|
+
return {
|
|
2530
|
+
exists: Boolean(thread),
|
|
2531
|
+
messageCount: messages.length,
|
|
2532
|
+
messageIds: messages.map(threadMessageId).filter((id) => Boolean(id))
|
|
2533
|
+
};
|
|
2534
|
+
}
|
|
2535
|
+
function errorMessage2(error) {
|
|
2536
|
+
return error instanceof Error ? error.message : String(error);
|
|
2537
|
+
}
|
|
2538
|
+
function priorThreadIdForIntent(intentHash, receiptsDir) {
|
|
2539
|
+
const prior = readReceipts(receiptsDir).find(
|
|
2540
|
+
({ receipt }) => receipt.operation === "comment" && receipt.details.intentHash === intentHash
|
|
2541
|
+
);
|
|
2542
|
+
const threadId = prior?.receipt.details.threadId;
|
|
2543
|
+
const anchorAttemptedAt = prior?.receipt.details.anchorAttemptedAt;
|
|
2544
|
+
const postAttemptedAt = prior?.receipt.details.postAttemptedAt;
|
|
2545
|
+
return {
|
|
2546
|
+
...typeof threadId === "string" ? { threadId } : {},
|
|
2547
|
+
...prior ? { receiptPath: prior.path } : {},
|
|
2548
|
+
...prior ? { status: prior.receipt.status, updatedAt: prior.receipt.updatedAt } : {},
|
|
2549
|
+
...typeof anchorAttemptedAt === "string" ? { anchorAttemptedAt } : {},
|
|
2550
|
+
...typeof postAttemptedAt === "string" ? { postAttemptedAt } : {}
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2553
|
+
async function commentWithResult(opts) {
|
|
2554
|
+
const { socket, project, docs } = await openProject();
|
|
2555
|
+
let mutationLock;
|
|
2556
|
+
let receipt;
|
|
2557
|
+
try {
|
|
2558
|
+
mutationLock = acquireMutationLock(config.projectId);
|
|
2559
|
+
const doc = opts.docName ? matchDocument(opts.docName.replace(/\\/g, "/"), docs) : docs.find((d) => d._id === project.rootDoc_id) ?? docs[0];
|
|
2560
|
+
if (!doc) throw new Error(`doc not found: ${opts.docName ?? "(root)"}`);
|
|
2561
|
+
const state = await joinDoc(socket, doc._id);
|
|
2562
|
+
const flat = state.lines.join("\n");
|
|
2563
|
+
const nth = Math.max(1, opts.occurrence ?? 1);
|
|
2564
|
+
let p = -1;
|
|
2565
|
+
let from = 0;
|
|
2566
|
+
for (let i = 0; i < nth; i++) {
|
|
2567
|
+
p = flat.indexOf(opts.anchor, from);
|
|
2568
|
+
if (p < 0) break;
|
|
2569
|
+
from = p + 1;
|
|
2570
|
+
}
|
|
2571
|
+
if (p < 0) {
|
|
2572
|
+
throw new Error(`anchor text not found in ${doc.name}: "${opts.anchor}"`);
|
|
2573
|
+
}
|
|
2574
|
+
const duplicateWindowMs = Math.max(
|
|
2575
|
+
0,
|
|
2576
|
+
opts.duplicateWindowMs ?? DEFAULT_DUPLICATE_WINDOW_MS
|
|
2577
|
+
);
|
|
2578
|
+
const intentHash = commentIntentHash({
|
|
2579
|
+
projectId: config.projectId,
|
|
2580
|
+
docId: doc._id,
|
|
2581
|
+
anchor: opts.anchor,
|
|
2582
|
+
occurrence: nth,
|
|
2583
|
+
message: opts.message
|
|
2584
|
+
});
|
|
2585
|
+
const prior = priorThreadIdForIntent(intentHash, opts.receiptsDir);
|
|
2586
|
+
const threads = await getThreads();
|
|
2587
|
+
const commentRanges = state.ranges.comments ?? [];
|
|
2588
|
+
let duplicateRange;
|
|
2589
|
+
if (!opts.force) {
|
|
2590
|
+
const candidates = findCommentRangesAt(commentRanges, p, opts.anchor).filter(
|
|
2591
|
+
(range) => commentRangeAnchorsText(range, flat, opts.anchor)
|
|
2592
|
+
);
|
|
2593
|
+
const priorRange2 = prior.threadId ? findCommentRangeByThreadId(commentRanges, prior.threadId) : void 0;
|
|
2594
|
+
if (priorRange2 && !candidates.includes(priorRange2)) candidates.push(priorRange2);
|
|
2595
|
+
duplicateRange = candidates.find((range) => {
|
|
2596
|
+
const candidateThreadId = rangeThreadId(range);
|
|
2597
|
+
return Boolean(
|
|
2598
|
+
candidateThreadId && findRecentIdenticalMessage(
|
|
2599
|
+
threads[candidateThreadId],
|
|
2600
|
+
opts.message,
|
|
2601
|
+
Date.now(),
|
|
2602
|
+
duplicateWindowMs
|
|
2603
|
+
)
|
|
2604
|
+
);
|
|
2605
|
+
});
|
|
2606
|
+
}
|
|
2607
|
+
if (duplicateRange) {
|
|
2608
|
+
const threadId2 = rangeThreadId(duplicateRange);
|
|
2609
|
+
const duplicateMessage = findRecentIdenticalMessage(
|
|
2610
|
+
threads[threadId2],
|
|
2611
|
+
opts.message,
|
|
2612
|
+
Date.now(),
|
|
2613
|
+
duplicateWindowMs
|
|
2614
|
+
);
|
|
2615
|
+
const messageId = threadMessageId(duplicateMessage);
|
|
2616
|
+
receipt = beginReceipt(
|
|
2617
|
+
"comment",
|
|
2618
|
+
{
|
|
2619
|
+
projectId: config.projectId,
|
|
2620
|
+
docId: doc._id,
|
|
2621
|
+
doc: doc.path,
|
|
2622
|
+
threadId: threadId2,
|
|
2623
|
+
message: opts.message,
|
|
2624
|
+
anchor: opts.anchor,
|
|
2625
|
+
occurrence: nth,
|
|
2626
|
+
position: p,
|
|
2627
|
+
intentHash,
|
|
2628
|
+
force: false,
|
|
2629
|
+
phase: "preflight"
|
|
2630
|
+
},
|
|
2631
|
+
{ receiptsDir: opts.receiptsDir }
|
|
2632
|
+
);
|
|
2633
|
+
receipt = updateReceipt(receipt, "skipped", {
|
|
2634
|
+
phase: "complete",
|
|
2635
|
+
outcome: "recent_identical_comment",
|
|
2636
|
+
anchorRange: rangeSnapshot(duplicateRange),
|
|
2637
|
+
...messageId ? { messageId } : {},
|
|
2638
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2639
|
+
});
|
|
2640
|
+
return {
|
|
2641
|
+
doc: doc.path,
|
|
2642
|
+
threadId: threadId2,
|
|
2643
|
+
anchorCreated: false,
|
|
2644
|
+
messagePosted: false,
|
|
2645
|
+
duplicate: true,
|
|
2646
|
+
...messageId ? { messageId } : {},
|
|
2647
|
+
receiptPath: receipt.path
|
|
2648
|
+
};
|
|
2649
|
+
}
|
|
2650
|
+
const priorRangeCandidate = !opts.force && prior.threadId ? findCommentRangeByThreadId(commentRanges, prior.threadId) : void 0;
|
|
2651
|
+
const priorRange = commentRangeAnchorsText(priorRangeCandidate, flat, opts.anchor) ? priorRangeCandidate : void 0;
|
|
2652
|
+
const now = Date.now();
|
|
2653
|
+
if (!opts.force && !priorRange && shouldQuarantineCommentAnchorRetry(prior, now, duplicateWindowMs)) {
|
|
2654
|
+
throw new Error(
|
|
2655
|
+
`A recent comment attempt has an ambiguous anchor outcome (${prior.receiptPath}). Inspect Overleaf before retrying, or use force: true explicitly`
|
|
2656
|
+
);
|
|
2657
|
+
}
|
|
2658
|
+
if (!opts.force && priorRange && shouldQuarantineCommentRetry(prior, now, duplicateWindowMs)) {
|
|
2659
|
+
throw new Error(
|
|
2660
|
+
`A recent comment message attempt has an unverified outcome (${prior.receiptPath}). Inspect the thread before retrying, or use force: true explicitly`
|
|
2661
|
+
);
|
|
2662
|
+
}
|
|
2663
|
+
const canResume = Boolean(
|
|
2664
|
+
priorRange && prior.threadId && threadMessages(threads[prior.threadId]).length === 0
|
|
2665
|
+
);
|
|
2666
|
+
const threadId = canResume ? prior.threadId : randomBytes2(12).toString("hex");
|
|
2667
|
+
let anchorCreated = false;
|
|
2668
|
+
let beforeThread = threads[threadId];
|
|
2669
|
+
receipt = beginReceipt(
|
|
2670
|
+
"comment",
|
|
2671
|
+
{
|
|
2672
|
+
projectId: config.projectId,
|
|
2673
|
+
docId: doc._id,
|
|
2674
|
+
doc: doc.path,
|
|
2675
|
+
threadId,
|
|
2676
|
+
message: opts.message,
|
|
2677
|
+
anchor: opts.anchor,
|
|
2678
|
+
occurrence: nth,
|
|
2679
|
+
position: p,
|
|
2680
|
+
sourceVersion: state.version,
|
|
2681
|
+
intentHash,
|
|
2682
|
+
force: opts.force ?? false,
|
|
2683
|
+
phase: "preflight",
|
|
2684
|
+
beforeThread: threadSnapshot(beforeThread),
|
|
2685
|
+
...canResume && prior.receiptPath ? { resumedFromReceipt: prior.receiptPath } : {}
|
|
2686
|
+
},
|
|
2687
|
+
{ receiptsDir: opts.receiptsDir }
|
|
2688
|
+
);
|
|
2689
|
+
let verifiedRange = canResume ? priorRange : void 0;
|
|
2690
|
+
if (!verifiedRange) {
|
|
2691
|
+
receipt = updateReceipt(receipt, "in_progress", {
|
|
2692
|
+
phase: "creating_anchor",
|
|
2693
|
+
anchorAttemptedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2694
|
+
});
|
|
2695
|
+
const update = {
|
|
2696
|
+
doc: doc._id,
|
|
2697
|
+
op: [{ p, c: opts.anchor, t: threadId }],
|
|
2698
|
+
v: state.version,
|
|
2699
|
+
meta: {}
|
|
2700
|
+
};
|
|
2701
|
+
let anchorError;
|
|
2702
|
+
try {
|
|
2703
|
+
await applyOtUpdateAndWait(socket, doc._id, update);
|
|
2704
|
+
} catch (error) {
|
|
2705
|
+
anchorError = error;
|
|
2706
|
+
}
|
|
2707
|
+
let afterAnchorText;
|
|
2708
|
+
try {
|
|
2709
|
+
const afterAnchor = await joinDoc(socket, doc._id);
|
|
2710
|
+
afterAnchorText = afterAnchor.lines.join("\n");
|
|
2711
|
+
verifiedRange = findCommentRangeByThreadId(afterAnchor.ranges.comments, threadId);
|
|
2712
|
+
} catch (readbackError) {
|
|
2713
|
+
receipt = updateReceipt(receipt, "ambiguous", {
|
|
2714
|
+
phase: "anchor_outcome_unknown",
|
|
2715
|
+
...anchorError ? { transportError: errorMessage2(anchorError) } : {},
|
|
2716
|
+
verificationError: errorMessage2(readbackError)
|
|
2717
|
+
});
|
|
2718
|
+
throw new Error(
|
|
2719
|
+
`Comment anchor outcome is ambiguous; inspect ${receipt.path} before retrying`
|
|
2720
|
+
);
|
|
2721
|
+
}
|
|
2722
|
+
if (!commentRangeAnchorsText(verifiedRange, afterAnchorText ?? "", opts.anchor)) {
|
|
2723
|
+
receipt = updateReceipt(receipt, anchorError ? "ambiguous" : "failed", {
|
|
2724
|
+
phase: anchorError ? "anchor_outcome_unknown" : "anchor_unverified",
|
|
2725
|
+
...anchorError ? { transportError: errorMessage2(anchorError) } : {},
|
|
2726
|
+
anchorRange: rangeSnapshot(verifiedRange)
|
|
2727
|
+
});
|
|
2728
|
+
throw new Error(
|
|
2729
|
+
`Comment anchor was not visible on readback; inspect ${receipt.path} before retrying`
|
|
2730
|
+
);
|
|
2731
|
+
}
|
|
2732
|
+
anchorCreated = true;
|
|
2733
|
+
receipt = updateReceipt(receipt, "in_progress", {
|
|
2734
|
+
phase: "anchor_verified",
|
|
2735
|
+
anchorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2736
|
+
anchorRange: rangeSnapshot(verifiedRange),
|
|
2737
|
+
...anchorError ? { transportError: errorMessage2(anchorError) } : {}
|
|
2738
|
+
});
|
|
2739
|
+
} else {
|
|
2740
|
+
receipt = updateReceipt(receipt, "in_progress", {
|
|
2741
|
+
phase: "anchor_verified",
|
|
2742
|
+
outcome: "resumed_anchor_only_operation",
|
|
2743
|
+
anchorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2744
|
+
anchorRange: rangeSnapshot(verifiedRange)
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2747
|
+
let postError;
|
|
2748
|
+
let postAttempted = false;
|
|
2749
|
+
let responseStatus;
|
|
2750
|
+
let returnedMessageId;
|
|
2751
|
+
try {
|
|
2752
|
+
const csrf = await getCsrfToken();
|
|
2753
|
+
receipt = updateReceipt(receipt, "in_progress", {
|
|
2754
|
+
phase: "posting_message",
|
|
2755
|
+
postAttemptedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2756
|
+
});
|
|
2757
|
+
postAttempted = true;
|
|
2758
|
+
const response = await postThreadMessageDetailed(threadId, opts.message, csrf);
|
|
2759
|
+
responseStatus = response.status;
|
|
2760
|
+
returnedMessageId = response.messageId;
|
|
2761
|
+
receipt = updateReceipt(receipt, "in_progress", {
|
|
2762
|
+
phase: "verifying_message",
|
|
2763
|
+
responseStatus,
|
|
2764
|
+
...returnedMessageId ? { returnedMessageId } : {},
|
|
2765
|
+
...response.responseBody === void 0 ? {} : { responseBody: response.responseBody }
|
|
2766
|
+
});
|
|
2767
|
+
} catch (error) {
|
|
2768
|
+
postError = error;
|
|
2769
|
+
}
|
|
2770
|
+
const observed = postAttempted ? await observePostedThreadMessage(
|
|
2771
|
+
threadId,
|
|
2772
|
+
beforeThread,
|
|
2773
|
+
opts.message,
|
|
2774
|
+
returnedMessageId,
|
|
2775
|
+
{
|
|
2776
|
+
timeoutMs: opts.verificationTimeoutMs,
|
|
2777
|
+
intervalMs: opts.verificationIntervalMs
|
|
2778
|
+
}
|
|
2779
|
+
) : { attempts: 0, thread: beforeThread };
|
|
2780
|
+
const observedMessageId = threadMessageId(observed.message);
|
|
2781
|
+
if (!observed.message) {
|
|
2782
|
+
const definitelyRejected = !postAttempted || postError instanceof RestRequestError && postError.status < 500;
|
|
2783
|
+
receipt = updateReceipt(receipt, definitelyRejected ? "failed" : "ambiguous", {
|
|
2784
|
+
phase: !postAttempted ? "message_preflight_failed" : definitelyRejected ? "message_rejected" : "message_outcome_unknown",
|
|
2785
|
+
anchorRange: rangeSnapshot(verifiedRange),
|
|
2786
|
+
...postError ? { error: errorMessage2(postError) } : {},
|
|
2787
|
+
...postError instanceof RestRequestError ? { responseStatus: postError.status, responseBody: postError.responseBody } : responseStatus ? { responseStatus } : {},
|
|
2788
|
+
verificationAttempts: observed.attempts,
|
|
2789
|
+
...observed.lastError ? { verificationError: observed.lastError } : {},
|
|
2790
|
+
afterThread: threadSnapshot(observed.thread)
|
|
2791
|
+
});
|
|
2792
|
+
const outcome = !postAttempted ? "could not be attempted" : definitelyRejected ? "was rejected" : "has an ambiguous outcome";
|
|
2793
|
+
throw new Error(
|
|
2794
|
+
`Comment message ${outcome}; the anchor may remain. Inspect ${receipt.path} before retrying`
|
|
2795
|
+
);
|
|
2796
|
+
}
|
|
2797
|
+
let finalRange;
|
|
2798
|
+
let finalStateText;
|
|
2799
|
+
try {
|
|
2800
|
+
const finalState = await joinDoc(socket, doc._id);
|
|
2801
|
+
finalStateText = finalState.lines.join("\n");
|
|
2802
|
+
finalRange = findCommentRangeByThreadId(finalState.ranges.comments, threadId);
|
|
2803
|
+
} catch (error) {
|
|
2804
|
+
receipt = updateReceipt(receipt, "ambiguous", {
|
|
2805
|
+
phase: "final_anchor_verification_failed",
|
|
2806
|
+
messageId: observedMessageId,
|
|
2807
|
+
messageVerifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2808
|
+
verificationError: errorMessage2(error)
|
|
2809
|
+
});
|
|
2810
|
+
throw new Error(
|
|
2811
|
+
`Comment message exists, but its anchor could not be rechecked. Inspect ${receipt.path}`
|
|
2812
|
+
);
|
|
2813
|
+
}
|
|
2814
|
+
if (!finalRange || !commentRangeAnchorsText(finalRange, finalStateText ?? "", opts.anchor)) {
|
|
2815
|
+
receipt = updateReceipt(receipt, "failed", {
|
|
2816
|
+
phase: "partial_message_without_anchor",
|
|
2817
|
+
...observedMessageId ? { messageId: observedMessageId } : {},
|
|
2818
|
+
finalAnchorRange: rangeSnapshot(finalRange)
|
|
2819
|
+
});
|
|
2820
|
+
throw new Error(
|
|
2821
|
+
`Comment message exists but anchor ${threadId} is missing; see ${receipt.path}`
|
|
2822
|
+
);
|
|
2823
|
+
}
|
|
2824
|
+
receipt = updateReceipt(receipt, "succeeded", {
|
|
2825
|
+
phase: "complete",
|
|
2826
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2827
|
+
verificationAttempts: observed.attempts,
|
|
2828
|
+
finalAnchorRange: rangeSnapshot(finalRange),
|
|
2829
|
+
afterThread: threadSnapshot(observed.thread),
|
|
2830
|
+
...postError ? { transportError: errorMessage2(postError) } : {},
|
|
2831
|
+
...observedMessageId ? { messageId: observedMessageId } : {}
|
|
2832
|
+
});
|
|
2833
|
+
return {
|
|
2834
|
+
doc: doc.path,
|
|
2835
|
+
threadId,
|
|
2836
|
+
anchorCreated,
|
|
2837
|
+
messagePosted: true,
|
|
2838
|
+
duplicate: false,
|
|
2839
|
+
...observedMessageId ? { messageId: observedMessageId } : {},
|
|
2840
|
+
receiptPath: receipt.path
|
|
2841
|
+
};
|
|
2842
|
+
} catch (error) {
|
|
2843
|
+
if (receipt && receipt.receipt.status !== "failed" && receipt.receipt.status !== "ambiguous" && receipt.receipt.status !== "skipped" && receipt.receipt.status !== "succeeded") {
|
|
2844
|
+
receipt = updateReceipt(receipt, "failed", {
|
|
2845
|
+
phase: "command_failed",
|
|
2846
|
+
error: errorMessage2(error)
|
|
2847
|
+
});
|
|
2848
|
+
}
|
|
2849
|
+
throw error;
|
|
2850
|
+
} finally {
|
|
2851
|
+
try {
|
|
2852
|
+
socket.close();
|
|
2853
|
+
} finally {
|
|
2854
|
+
mutationLock?.release();
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
async function comment(opts) {
|
|
2859
|
+
const result = await commentWithResult(opts);
|
|
2860
|
+
if (result.duplicate) {
|
|
2861
|
+
console.log(
|
|
2862
|
+
`\u21AA\uFE0F Skipped duplicate comment on "${opts.anchor}" (thread ${result.threadId})`
|
|
2863
|
+
);
|
|
2864
|
+
} else {
|
|
2865
|
+
console.log(
|
|
2866
|
+
`\u2705 Commented on "${opts.anchor}" in ${result.doc} (thread ${result.threadId}` + (result.messageId ? `, message ${result.messageId}` : "") + ")"
|
|
2867
|
+
);
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
// src/commands/reply.ts
|
|
2872
|
+
var DEFAULT_DUPLICATE_WINDOW_MS2 = 5 * 60 * 1e3;
|
|
2873
|
+
function errorMessage3(error) {
|
|
2874
|
+
return error instanceof Error ? error.message : String(error);
|
|
2875
|
+
}
|
|
2876
|
+
function threadSnapshot2(thread) {
|
|
2877
|
+
const messages = threadMessages(thread);
|
|
2878
|
+
return {
|
|
2879
|
+
exists: Boolean(thread),
|
|
2880
|
+
messageCount: messages.length,
|
|
2881
|
+
messageIds: messages.map(threadMessageId).filter((id) => Boolean(id))
|
|
2882
|
+
};
|
|
2883
|
+
}
|
|
2884
|
+
function recentUnverifiedReply(projectId, threadId, message, windowMs, receiptsDir) {
|
|
2885
|
+
const now = Date.now();
|
|
2886
|
+
const earliest = now - windowMs;
|
|
2887
|
+
const prior = readReceipts(receiptsDir).find(({ receipt }) => {
|
|
2888
|
+
const updatedAt = Date.parse(receipt.updatedAt);
|
|
2889
|
+
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;
|
|
2890
|
+
});
|
|
2891
|
+
return prior ? { path: prior.path, updatedAt: prior.receipt.updatedAt } : void 0;
|
|
2892
|
+
}
|
|
2893
|
+
async function replyWithResult(threadId, message, options = {}) {
|
|
2894
|
+
const duplicateWindowMs = Math.max(
|
|
2895
|
+
0,
|
|
2896
|
+
options.duplicateWindowMs ?? DEFAULT_DUPLICATE_WINDOW_MS2
|
|
2897
|
+
);
|
|
2898
|
+
let receipt = beginReceipt(
|
|
2899
|
+
"reply",
|
|
2900
|
+
{
|
|
2901
|
+
projectId: config.projectId,
|
|
2902
|
+
threadId,
|
|
2903
|
+
message,
|
|
2904
|
+
force: options.force ?? false,
|
|
2905
|
+
duplicateWindowMs,
|
|
2906
|
+
phase: "preflight"
|
|
2907
|
+
},
|
|
2908
|
+
{ receiptsDir: options.receiptsDir }
|
|
2909
|
+
);
|
|
2910
|
+
let beforeThread;
|
|
2911
|
+
let postAttempted = false;
|
|
2912
|
+
let returnedMessageId;
|
|
2913
|
+
let responseStatus;
|
|
2914
|
+
let mutationLock;
|
|
2915
|
+
try {
|
|
2916
|
+
mutationLock = acquireMutationLock(config.projectId);
|
|
2917
|
+
const threads = await getThreads();
|
|
2918
|
+
beforeThread = threads[threadId];
|
|
2919
|
+
receipt = updateReceipt(receipt, "in_progress", {
|
|
2920
|
+
preflightAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2921
|
+
before: threadSnapshot2(beforeThread)
|
|
2922
|
+
});
|
|
2923
|
+
if (!beforeThread) throw new Error(`thread ${threadId} not found`);
|
|
2924
|
+
const duplicate = !options.force ? findRecentIdenticalMessage(beforeThread, message, Date.now(), duplicateWindowMs) : void 0;
|
|
2925
|
+
if (duplicate) {
|
|
2926
|
+
const messageId = threadMessageId(duplicate);
|
|
2927
|
+
receipt = updateReceipt(receipt, "skipped", {
|
|
2928
|
+
phase: "complete",
|
|
2929
|
+
outcome: "recent_identical_message",
|
|
2930
|
+
...messageId ? { messageId } : {},
|
|
2931
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2932
|
+
});
|
|
2933
|
+
return {
|
|
2934
|
+
threadId,
|
|
2935
|
+
posted: false,
|
|
2936
|
+
duplicate: true,
|
|
2937
|
+
...messageId ? { messageId } : {},
|
|
2938
|
+
receiptPath: receipt.path
|
|
2939
|
+
};
|
|
2940
|
+
}
|
|
2941
|
+
const priorUnverified = !options.force ? recentUnverifiedReply(
|
|
2942
|
+
config.projectId,
|
|
2943
|
+
threadId,
|
|
2944
|
+
message,
|
|
2945
|
+
duplicateWindowMs,
|
|
2946
|
+
options.receiptsDir
|
|
2947
|
+
) : void 0;
|
|
2948
|
+
if (priorUnverified) {
|
|
2949
|
+
receipt = updateReceipt(receipt, "skipped", {
|
|
2950
|
+
phase: "blocked_by_prior_ambiguity",
|
|
2951
|
+
outcome: "retry_not_sent",
|
|
2952
|
+
priorReceipt: priorUnverified.path,
|
|
2953
|
+
priorUpdatedAt: priorUnverified.updatedAt
|
|
2954
|
+
});
|
|
2955
|
+
throw new Error(
|
|
2956
|
+
`A recent reply attempt has an unverified outcome (${priorUnverified.path}). Inspect the thread before retrying, or use force: true explicitly`
|
|
2957
|
+
);
|
|
2958
|
+
}
|
|
2959
|
+
const csrf = await getCsrfToken();
|
|
2960
|
+
postAttempted = true;
|
|
2961
|
+
receipt = updateReceipt(receipt, "in_progress", {
|
|
2962
|
+
phase: "posting_message",
|
|
2963
|
+
postAttemptedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2964
|
+
});
|
|
2965
|
+
const response = await postThreadMessageDetailed(threadId, message, csrf);
|
|
2966
|
+
responseStatus = response.status;
|
|
2967
|
+
returnedMessageId = response.messageId;
|
|
2968
|
+
receipt = updateReceipt(receipt, "in_progress", {
|
|
2969
|
+
phase: "verifying_message",
|
|
2970
|
+
responseStatus,
|
|
2971
|
+
...returnedMessageId ? { returnedMessageId } : {},
|
|
2972
|
+
...response.responseBody === void 0 ? {} : { responseBody: response.responseBody }
|
|
2973
|
+
});
|
|
2974
|
+
const observed = await observePostedThreadMessage(
|
|
2975
|
+
threadId,
|
|
2976
|
+
beforeThread,
|
|
2977
|
+
message,
|
|
2978
|
+
returnedMessageId,
|
|
2979
|
+
{
|
|
2980
|
+
timeoutMs: options.verificationTimeoutMs,
|
|
2981
|
+
intervalMs: options.verificationIntervalMs
|
|
2982
|
+
}
|
|
2983
|
+
);
|
|
2984
|
+
const observedMessageId = threadMessageId(observed.message);
|
|
2985
|
+
if (!observed.message) {
|
|
2986
|
+
receipt = updateReceipt(receipt, "ambiguous", {
|
|
2987
|
+
phase: "message_unverified",
|
|
2988
|
+
verificationAttempts: observed.attempts,
|
|
2989
|
+
...observed.lastError ? { verificationError: observed.lastError } : {},
|
|
2990
|
+
after: threadSnapshot2(observed.thread)
|
|
2991
|
+
});
|
|
2992
|
+
throw new Error(
|
|
2993
|
+
`Overleaf accepted the reply request, but the message was not visible on readback. Outcome is ambiguous; inspect ${receipt.path} before retrying`
|
|
2994
|
+
);
|
|
2995
|
+
}
|
|
2996
|
+
receipt = updateReceipt(receipt, "succeeded", {
|
|
2997
|
+
phase: "complete",
|
|
2998
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2999
|
+
verificationAttempts: observed.attempts,
|
|
3000
|
+
after: threadSnapshot2(observed.thread),
|
|
3001
|
+
...observedMessageId ? { messageId: observedMessageId } : {}
|
|
3002
|
+
});
|
|
3003
|
+
return {
|
|
3004
|
+
threadId,
|
|
3005
|
+
posted: true,
|
|
3006
|
+
duplicate: false,
|
|
3007
|
+
...observedMessageId ? { messageId: observedMessageId } : {},
|
|
3008
|
+
receiptPath: receipt.path
|
|
3009
|
+
};
|
|
3010
|
+
} catch (error) {
|
|
3011
|
+
if (postAttempted && receipt.receipt.status !== "ambiguous") {
|
|
3012
|
+
const observed = await observePostedThreadMessage(
|
|
3013
|
+
threadId,
|
|
3014
|
+
beforeThread,
|
|
3015
|
+
message,
|
|
3016
|
+
returnedMessageId,
|
|
3017
|
+
{
|
|
3018
|
+
timeoutMs: options.verificationTimeoutMs,
|
|
3019
|
+
intervalMs: options.verificationIntervalMs
|
|
3020
|
+
}
|
|
3021
|
+
);
|
|
3022
|
+
const observedMessageId = threadMessageId(observed.message);
|
|
3023
|
+
if (observed.message) {
|
|
3024
|
+
receipt = updateReceipt(receipt, "succeeded", {
|
|
3025
|
+
phase: "complete",
|
|
3026
|
+
outcome: "verified_after_transport_error",
|
|
3027
|
+
transportError: errorMessage3(error),
|
|
3028
|
+
...responseStatus ? { responseStatus } : {},
|
|
3029
|
+
verificationAttempts: observed.attempts,
|
|
3030
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3031
|
+
after: threadSnapshot2(observed.thread),
|
|
3032
|
+
...observedMessageId ? { messageId: observedMessageId } : {}
|
|
3033
|
+
});
|
|
3034
|
+
return {
|
|
3035
|
+
threadId,
|
|
3036
|
+
posted: true,
|
|
3037
|
+
duplicate: false,
|
|
3038
|
+
...observedMessageId ? { messageId: observedMessageId } : {},
|
|
3039
|
+
receiptPath: receipt.path
|
|
3040
|
+
};
|
|
3041
|
+
}
|
|
3042
|
+
const definitelyRejected = error instanceof RestRequestError && error.status < 500;
|
|
3043
|
+
receipt = updateReceipt(receipt, definitelyRejected ? "failed" : "ambiguous", {
|
|
3044
|
+
phase: definitelyRejected ? "message_rejected" : "message_outcome_unknown",
|
|
3045
|
+
error: errorMessage3(error),
|
|
3046
|
+
...error instanceof RestRequestError ? { responseStatus: error.status, responseBody: error.responseBody } : {},
|
|
3047
|
+
verificationAttempts: observed.attempts,
|
|
3048
|
+
...observed.lastError ? { verificationError: observed.lastError } : {},
|
|
3049
|
+
after: threadSnapshot2(observed.thread)
|
|
3050
|
+
});
|
|
3051
|
+
const qualification = definitelyRejected ? "failed" : "has an ambiguous outcome";
|
|
3052
|
+
throw new Error(
|
|
3053
|
+
`Reply to thread ${threadId} ${qualification}: ${errorMessage3(error)}. Receipt: ${receipt.path}`
|
|
3054
|
+
);
|
|
3055
|
+
}
|
|
3056
|
+
if (receipt.receipt.status !== "ambiguous" && receipt.receipt.status !== "failed" && receipt.receipt.status !== "skipped" && receipt.receipt.status !== "succeeded") {
|
|
3057
|
+
receipt = updateReceipt(receipt, "failed", {
|
|
3058
|
+
phase: "preflight_failed",
|
|
3059
|
+
error: errorMessage3(error)
|
|
3060
|
+
});
|
|
3061
|
+
}
|
|
3062
|
+
throw error;
|
|
3063
|
+
} finally {
|
|
3064
|
+
mutationLock?.release();
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
async function reply(threadId, message, options = {}) {
|
|
3068
|
+
const result = await replyWithResult(threadId, message, options);
|
|
3069
|
+
if (result.duplicate) {
|
|
3070
|
+
console.log(
|
|
3071
|
+
`\u21AA\uFE0F Skipped duplicate reply in thread ${threadId}` + (result.messageId ? ` (message ${result.messageId})` : "")
|
|
3072
|
+
);
|
|
3073
|
+
} else {
|
|
3074
|
+
console.log(
|
|
3075
|
+
`\u2705 Replied to thread ${threadId}` + (result.messageId ? ` (message ${result.messageId})` : "")
|
|
3076
|
+
);
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
// src/commands/resolve.ts
|
|
3081
|
+
async function resolve2(threadId, reopen = false) {
|
|
3082
|
+
const mutationLock = acquireMutationLock(config.projectId);
|
|
3083
|
+
let socket;
|
|
3084
|
+
try {
|
|
3085
|
+
const opened = await openProject();
|
|
3086
|
+
socket = opened.socket;
|
|
3087
|
+
const { docs } = opened;
|
|
3088
|
+
let docId;
|
|
3089
|
+
for (const doc of docs) {
|
|
3090
|
+
const state = await joinDoc(socket, doc._id);
|
|
3091
|
+
if ((state.ranges.comments ?? []).some((c) => c.op?.t === threadId)) {
|
|
3092
|
+
docId = doc._id;
|
|
3093
|
+
break;
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
if (!docId) {
|
|
3097
|
+
throw new Error(
|
|
3098
|
+
`thread ${threadId} not found in any doc's active comments (already resolved threads may not be locatable this way)`
|
|
3099
|
+
);
|
|
3100
|
+
}
|
|
3101
|
+
const csrf = await getCsrfToken();
|
|
3102
|
+
await setThreadResolved(docId, threadId, reopen, csrf);
|
|
3103
|
+
console.log(`\u2705 Thread ${threadId} ${reopen ? "reopened" : "resolved"}`);
|
|
3104
|
+
} finally {
|
|
3105
|
+
try {
|
|
3106
|
+
socket?.close();
|
|
3107
|
+
} finally {
|
|
3108
|
+
mutationLock.release();
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
// src/commands/delete-comment.ts
|
|
3114
|
+
async function deleteComment(threadId) {
|
|
3115
|
+
const mutationLock = acquireMutationLock(config.projectId);
|
|
3116
|
+
let socket;
|
|
3117
|
+
try {
|
|
3118
|
+
const opened = await openProject();
|
|
3119
|
+
socket = opened.socket;
|
|
3120
|
+
const { docs } = opened;
|
|
3121
|
+
let docId;
|
|
3122
|
+
for (const doc of docs) {
|
|
3123
|
+
const state = await joinDoc(socket, doc._id);
|
|
3124
|
+
if ((state.ranges.comments ?? []).some((c) => c.op?.t === threadId)) {
|
|
3125
|
+
docId = doc._id;
|
|
3126
|
+
break;
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
if (!docId) throw new Error(`thread ${threadId} not found in any doc's comments`);
|
|
3130
|
+
const csrf = await getCsrfToken();
|
|
3131
|
+
await deleteThread(docId, threadId, csrf);
|
|
3132
|
+
console.log(`\u2705 Deleted comment thread ${threadId}`);
|
|
3133
|
+
} finally {
|
|
3134
|
+
try {
|
|
3135
|
+
socket?.close();
|
|
3136
|
+
} finally {
|
|
3137
|
+
mutationLock.release();
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
// src/commands/delete-message.ts
|
|
3143
|
+
async function deleteThreadMessage(messageId, threadId) {
|
|
3144
|
+
const mutationLock = acquireMutationLock(config.projectId);
|
|
3145
|
+
try {
|
|
3146
|
+
const csrf = await getCsrfToken();
|
|
3147
|
+
let tid = threadId;
|
|
3148
|
+
if (!tid) {
|
|
3149
|
+
const threads = await getThreads();
|
|
3150
|
+
for (const [t, v] of Object.entries(threads)) {
|
|
3151
|
+
if (v.messages?.some((m) => m.id === messageId)) {
|
|
3152
|
+
tid = t;
|
|
3153
|
+
break;
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
if (!tid) throw new Error(`message ${messageId} not found in any thread`);
|
|
3158
|
+
await deleteMessage(tid, messageId, csrf);
|
|
3159
|
+
console.log(`\u2705 Deleted message ${messageId} from thread ${tid}`);
|
|
3160
|
+
} finally {
|
|
3161
|
+
mutationLock.release();
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
// src/commands/accept.ts
|
|
3166
|
+
var VERIFY_ATTEMPTS = 6;
|
|
3167
|
+
var VERIFY_INTERVAL_MS = 200;
|
|
3168
|
+
function changesFrom(state) {
|
|
3169
|
+
return state.ranges.changes ?? [];
|
|
3170
|
+
}
|
|
3171
|
+
async function verifyAcceptedDocument(socket, docId, changeIds) {
|
|
3172
|
+
let last;
|
|
3173
|
+
let lastError;
|
|
3174
|
+
for (let attempt = 0; attempt < VERIFY_ATTEMPTS; attempt++) {
|
|
3175
|
+
try {
|
|
3176
|
+
last = await joinDoc(socket, docId);
|
|
3177
|
+
lastError = void 0;
|
|
3178
|
+
if (!remainingChangeIds(changesFrom(last), changeIds).length) return last;
|
|
3179
|
+
} catch (error) {
|
|
3180
|
+
lastError = error;
|
|
3181
|
+
}
|
|
3182
|
+
if (attempt + 1 < VERIFY_ATTEMPTS) {
|
|
3183
|
+
await new Promise((resolve3) => setTimeout(resolve3, VERIFY_INTERVAL_MS));
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
if (!last) throw lastError ?? new Error(`could not read back document ${docId}`);
|
|
3187
|
+
return last;
|
|
3188
|
+
}
|
|
3189
|
+
function errorMessage4(error) {
|
|
3190
|
+
return error instanceof Error ? error.message : String(error);
|
|
792
3191
|
}
|
|
793
|
-
|
|
794
|
-
// src/commands/accept.ts
|
|
795
3192
|
async function accept(changeIds) {
|
|
3193
|
+
const requestedIds = uniqueChangeIds(changeIds);
|
|
3194
|
+
if (!requestedIds.length) throw new Error("no tracked change ids requested");
|
|
796
3195
|
const { socket, docs } = await openProject();
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
3196
|
+
let mutationLock;
|
|
3197
|
+
const found = /* @__PURE__ */ new Set();
|
|
3198
|
+
const locations = /* @__PURE__ */ new Map();
|
|
3199
|
+
const verifiedDocs = /* @__PURE__ */ new Set();
|
|
3200
|
+
const result = {
|
|
3201
|
+
action: "accept",
|
|
3202
|
+
requestedIds,
|
|
3203
|
+
foundIds: [],
|
|
3204
|
+
missingIds: [],
|
|
3205
|
+
attemptedIds: [],
|
|
3206
|
+
verifiedAbsentIds: [],
|
|
3207
|
+
documents: [],
|
|
3208
|
+
verified: false
|
|
3209
|
+
};
|
|
3210
|
+
const refreshVerifiedIds = () => {
|
|
3211
|
+
const verified = new Set(result.missingIds);
|
|
3212
|
+
for (const id of result.foundIds) {
|
|
3213
|
+
const everyKnownLocationVerified = [...locations.values()].filter((location) => location.ids.includes(id)).every((location) => verifiedDocs.has(location.doc._id));
|
|
3214
|
+
if (everyKnownLocationVerified) verified.add(id);
|
|
3215
|
+
}
|
|
3216
|
+
result.verifiedAbsentIds = requestedIds.filter((id) => verified.has(id));
|
|
3217
|
+
result.verified = result.verifiedAbsentIds.length === requestedIds.length;
|
|
3218
|
+
};
|
|
3219
|
+
try {
|
|
3220
|
+
mutationLock = acquireMutationLock(config.projectId);
|
|
3221
|
+
for (const doc of docs) {
|
|
3222
|
+
const state = await joinDoc(socket, doc._id);
|
|
3223
|
+
const ids = remainingChangeIds(changesFrom(state), requestedIds);
|
|
3224
|
+
if (!ids.length) continue;
|
|
3225
|
+
locations.set(doc._id, { doc, ids });
|
|
3226
|
+
for (const id of ids) found.add(id);
|
|
3227
|
+
}
|
|
3228
|
+
result.foundIds = requestedIds.filter((id) => found.has(id));
|
|
3229
|
+
result.missingIds = requestedIds.filter((id) => !found.has(id));
|
|
3230
|
+
refreshVerifiedIds();
|
|
3231
|
+
if (result.missingIds.length) {
|
|
3232
|
+
console.log(
|
|
3233
|
+
`\u26A0\uFE0F not found (already accepted/rejected?): ${result.missingIds.join(", ")}`
|
|
3234
|
+
);
|
|
3235
|
+
}
|
|
3236
|
+
if (!result.foundIds.length) {
|
|
3237
|
+
throw new Error("no matching tracked changes found");
|
|
3238
|
+
}
|
|
3239
|
+
let csrf;
|
|
3240
|
+
try {
|
|
3241
|
+
csrf = await getCsrfToken();
|
|
3242
|
+
} catch (error) {
|
|
3243
|
+
throw new TrackedChangeMutationError(
|
|
3244
|
+
`Could not obtain a CSRF token before accepting tracked changes: ${errorMessage4(error)}`,
|
|
3245
|
+
result,
|
|
3246
|
+
error
|
|
3247
|
+
);
|
|
3248
|
+
}
|
|
3249
|
+
for (const { doc, ids } of locations.values()) {
|
|
3250
|
+
let before;
|
|
3251
|
+
try {
|
|
3252
|
+
before = await joinDoc(socket, doc._id);
|
|
3253
|
+
} catch (error) {
|
|
3254
|
+
const outcome2 = {
|
|
3255
|
+
docId: doc._id,
|
|
3256
|
+
docPath: doc.path,
|
|
3257
|
+
requestedIds: ids,
|
|
3258
|
+
attemptedIds: [],
|
|
3259
|
+
fragmentCount: 0,
|
|
3260
|
+
status: "failed",
|
|
3261
|
+
remainingIds: ids,
|
|
3262
|
+
error: errorMessage4(error)
|
|
3263
|
+
};
|
|
3264
|
+
result.documents.push(outcome2);
|
|
3265
|
+
refreshVerifiedIds();
|
|
3266
|
+
throw new TrackedChangeMutationError(
|
|
3267
|
+
`Could not read ${doc.path} before accepting tracked changes`,
|
|
3268
|
+
result,
|
|
3269
|
+
error
|
|
3270
|
+
);
|
|
3271
|
+
}
|
|
3272
|
+
const beforeChanges = changesFrom(before);
|
|
3273
|
+
const presentIds = remainingChangeIds(beforeChanges, ids);
|
|
3274
|
+
const fragmentCount = beforeChanges.filter((range) => presentIds.includes(range.id)).length;
|
|
3275
|
+
if (!presentIds.length) {
|
|
3276
|
+
result.documents.push({
|
|
3277
|
+
docId: doc._id,
|
|
3278
|
+
docPath: doc.path,
|
|
3279
|
+
requestedIds: ids,
|
|
3280
|
+
attemptedIds: [],
|
|
3281
|
+
fragmentCount: 0,
|
|
3282
|
+
beforeVersion: before.version,
|
|
3283
|
+
afterVersion: before.version,
|
|
3284
|
+
status: "already-absent",
|
|
3285
|
+
remainingIds: []
|
|
3286
|
+
});
|
|
3287
|
+
verifiedDocs.add(doc._id);
|
|
3288
|
+
refreshVerifiedIds();
|
|
3289
|
+
continue;
|
|
3290
|
+
}
|
|
3291
|
+
const outcome = {
|
|
3292
|
+
docId: doc._id,
|
|
3293
|
+
docPath: doc.path,
|
|
3294
|
+
requestedIds: ids,
|
|
3295
|
+
attemptedIds: presentIds,
|
|
3296
|
+
fragmentCount,
|
|
3297
|
+
beforeVersion: before.version,
|
|
3298
|
+
status: "failed",
|
|
3299
|
+
remainingIds: presentIds
|
|
3300
|
+
};
|
|
3301
|
+
result.documents.push(outcome);
|
|
3302
|
+
result.attemptedIds = uniqueChangeIds([...result.attemptedIds, ...presentIds]);
|
|
3303
|
+
let mutationError;
|
|
3304
|
+
try {
|
|
3305
|
+
await acceptChanges(doc._id, presentIds, csrf);
|
|
3306
|
+
} catch (error) {
|
|
3307
|
+
mutationError = error;
|
|
3308
|
+
}
|
|
3309
|
+
let after;
|
|
3310
|
+
let readbackError;
|
|
3311
|
+
try {
|
|
3312
|
+
after = await verifyAcceptedDocument(socket, doc._id, ids);
|
|
3313
|
+
} catch (error) {
|
|
3314
|
+
readbackError = error;
|
|
3315
|
+
}
|
|
3316
|
+
if (after) {
|
|
3317
|
+
outcome.afterVersion = after.version;
|
|
3318
|
+
outcome.remainingIds = remainingChangeIds(changesFrom(after), ids);
|
|
3319
|
+
}
|
|
3320
|
+
if (after && !outcome.remainingIds.length) {
|
|
3321
|
+
outcome.status = "verified";
|
|
3322
|
+
if (mutationError) outcome.error = `transport warning: ${errorMessage4(mutationError)}`;
|
|
3323
|
+
verifiedDocs.add(doc._id);
|
|
3324
|
+
refreshVerifiedIds();
|
|
3325
|
+
continue;
|
|
3326
|
+
}
|
|
3327
|
+
const reasons = [];
|
|
3328
|
+
if (mutationError) reasons.push(errorMessage4(mutationError));
|
|
3329
|
+
if (readbackError) reasons.push(`readback failed: ${errorMessage4(readbackError)}`);
|
|
3330
|
+
if (outcome.remainingIds.length) {
|
|
3331
|
+
reasons.push(`change ids still present: ${outcome.remainingIds.join(", ")}`);
|
|
3332
|
+
}
|
|
3333
|
+
outcome.error = reasons.join("; ") || "acceptance could not be verified";
|
|
3334
|
+
refreshVerifiedIds();
|
|
3335
|
+
throw new TrackedChangeMutationError(
|
|
3336
|
+
`Accepted changes in ${doc.path} could not be verified: ${outcome.error}`,
|
|
3337
|
+
result,
|
|
3338
|
+
mutationError ?? readbackError
|
|
3339
|
+
);
|
|
3340
|
+
}
|
|
3341
|
+
refreshVerifiedIds();
|
|
3342
|
+
if (!result.verified) {
|
|
3343
|
+
throw new TrackedChangeMutationError(
|
|
3344
|
+
`Acceptance incomplete; unverified ids: ${requestedIds.filter((id) => !result.verifiedAbsentIds.includes(id)).join(", ")}`,
|
|
3345
|
+
result
|
|
3346
|
+
);
|
|
3347
|
+
}
|
|
3348
|
+
console.log(
|
|
3349
|
+
`\u2705 Accepted ${result.foundIds.length} tracked change(s); verified by readback`
|
|
3350
|
+
);
|
|
3351
|
+
return result;
|
|
3352
|
+
} finally {
|
|
3353
|
+
try {
|
|
3354
|
+
socket.close();
|
|
3355
|
+
} finally {
|
|
3356
|
+
mutationLock?.release();
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
811
3359
|
}
|
|
812
3360
|
|
|
813
3361
|
// src/commands/reject.ts
|
|
3362
|
+
var VERIFY_ATTEMPTS2 = 6;
|
|
3363
|
+
var VERIFY_INTERVAL_MS2 = 200;
|
|
3364
|
+
function changesFrom2(state) {
|
|
3365
|
+
return state.ranges.changes ?? [];
|
|
3366
|
+
}
|
|
3367
|
+
async function verifyRejectedDocument(socket, docId, changeIds, expectedText) {
|
|
3368
|
+
let last;
|
|
3369
|
+
let lastError;
|
|
3370
|
+
for (let attempt = 0; attempt < VERIFY_ATTEMPTS2; attempt++) {
|
|
3371
|
+
try {
|
|
3372
|
+
last = await joinDoc(socket, docId);
|
|
3373
|
+
lastError = void 0;
|
|
3374
|
+
const remaining = remainingChangeIds(changesFrom2(last), changeIds);
|
|
3375
|
+
if (!remaining.length && last.lines.join("\n") === expectedText) return last;
|
|
3376
|
+
} catch (error) {
|
|
3377
|
+
lastError = error;
|
|
3378
|
+
}
|
|
3379
|
+
if (attempt + 1 < VERIFY_ATTEMPTS2) {
|
|
3380
|
+
await new Promise((resolve3) => setTimeout(resolve3, VERIFY_INTERVAL_MS2));
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
if (!last) throw lastError ?? new Error(`could not read back document ${docId}`);
|
|
3384
|
+
return last;
|
|
3385
|
+
}
|
|
3386
|
+
function errorMessage5(error) {
|
|
3387
|
+
return error instanceof Error ? error.message : String(error);
|
|
3388
|
+
}
|
|
814
3389
|
async function reject(changeIds) {
|
|
3390
|
+
const requestedIds = uniqueChangeIds(changeIds);
|
|
3391
|
+
if (!requestedIds.length) throw new Error("no tracked change ids requested");
|
|
815
3392
|
const { socket, docs } = await openProject();
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
3393
|
+
let mutationLock;
|
|
3394
|
+
const found = /* @__PURE__ */ new Set();
|
|
3395
|
+
const locations = /* @__PURE__ */ new Map();
|
|
3396
|
+
const verifiedDocs = /* @__PURE__ */ new Set();
|
|
3397
|
+
const result = {
|
|
3398
|
+
action: "reject",
|
|
3399
|
+
requestedIds,
|
|
3400
|
+
foundIds: [],
|
|
3401
|
+
missingIds: [],
|
|
3402
|
+
attemptedIds: [],
|
|
3403
|
+
verifiedAbsentIds: [],
|
|
3404
|
+
documents: [],
|
|
3405
|
+
verified: false
|
|
3406
|
+
};
|
|
3407
|
+
const refreshVerifiedIds = () => {
|
|
3408
|
+
const verified = new Set(result.missingIds);
|
|
3409
|
+
for (const id of result.foundIds) {
|
|
3410
|
+
const everyKnownLocationVerified = [...locations.values()].filter((location) => location.ids.includes(id)).every((location) => verifiedDocs.has(location.doc._id));
|
|
3411
|
+
if (everyKnownLocationVerified) verified.add(id);
|
|
821
3412
|
}
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
3413
|
+
result.verifiedAbsentIds = requestedIds.filter((id) => verified.has(id));
|
|
3414
|
+
result.verified = result.verifiedAbsentIds.length === requestedIds.length;
|
|
3415
|
+
};
|
|
3416
|
+
try {
|
|
3417
|
+
mutationLock = acquireMutationLock(config.projectId);
|
|
3418
|
+
for (const doc of docs) {
|
|
3419
|
+
const state = await joinDoc(socket, doc._id);
|
|
3420
|
+
const ids = remainingChangeIds(changesFrom2(state), requestedIds);
|
|
3421
|
+
if (!ids.length) continue;
|
|
3422
|
+
locations.set(doc._id, { doc, ids });
|
|
3423
|
+
for (const id of ids) found.add(id);
|
|
3424
|
+
}
|
|
3425
|
+
result.foundIds = requestedIds.filter((id) => found.has(id));
|
|
3426
|
+
result.missingIds = requestedIds.filter((id) => !found.has(id));
|
|
3427
|
+
refreshVerifiedIds();
|
|
3428
|
+
if (result.missingIds.length) {
|
|
3429
|
+
console.log(
|
|
3430
|
+
`\u26A0\uFE0F not found (already accepted/rejected?): ${result.missingIds.join(", ")}`
|
|
3431
|
+
);
|
|
3432
|
+
}
|
|
3433
|
+
if (!result.foundIds.length) {
|
|
3434
|
+
throw new Error("no matching tracked changes found");
|
|
3435
|
+
}
|
|
3436
|
+
for (const { doc, ids } of locations.values()) {
|
|
3437
|
+
let before;
|
|
3438
|
+
try {
|
|
3439
|
+
before = await joinDoc(socket, doc._id);
|
|
3440
|
+
} catch (error) {
|
|
3441
|
+
const outcome2 = {
|
|
3442
|
+
docId: doc._id,
|
|
3443
|
+
docPath: doc.path,
|
|
3444
|
+
requestedIds: ids,
|
|
3445
|
+
attemptedIds: [],
|
|
3446
|
+
fragmentCount: 0,
|
|
3447
|
+
status: "failed",
|
|
3448
|
+
remainingIds: ids,
|
|
3449
|
+
error: errorMessage5(error)
|
|
3450
|
+
};
|
|
3451
|
+
result.documents.push(outcome2);
|
|
3452
|
+
refreshVerifiedIds();
|
|
3453
|
+
throw new TrackedChangeMutationError(
|
|
3454
|
+
`Could not read ${doc.path} before rejecting tracked changes`,
|
|
3455
|
+
result,
|
|
3456
|
+
error
|
|
3457
|
+
);
|
|
3458
|
+
}
|
|
3459
|
+
const presentIds = remainingChangeIds(changesFrom2(before), ids);
|
|
3460
|
+
if (!presentIds.length) {
|
|
3461
|
+
result.documents.push({
|
|
3462
|
+
docId: doc._id,
|
|
3463
|
+
docPath: doc.path,
|
|
3464
|
+
requestedIds: ids,
|
|
3465
|
+
attemptedIds: [],
|
|
3466
|
+
fragmentCount: 0,
|
|
3467
|
+
beforeVersion: before.version,
|
|
3468
|
+
afterVersion: before.version,
|
|
3469
|
+
status: "already-absent",
|
|
3470
|
+
textVerified: true,
|
|
3471
|
+
remainingIds: []
|
|
3472
|
+
});
|
|
3473
|
+
verifiedDocs.add(doc._id);
|
|
3474
|
+
refreshVerifiedIds();
|
|
3475
|
+
continue;
|
|
3476
|
+
}
|
|
3477
|
+
let plan;
|
|
3478
|
+
try {
|
|
3479
|
+
plan = buildRejectionPlan(before.lines.join("\n"), changesFrom2(before), presentIds);
|
|
3480
|
+
} catch (error) {
|
|
3481
|
+
const outcome2 = {
|
|
3482
|
+
docId: doc._id,
|
|
3483
|
+
docPath: doc.path,
|
|
3484
|
+
requestedIds: ids,
|
|
3485
|
+
attemptedIds: [],
|
|
3486
|
+
fragmentCount: 0,
|
|
3487
|
+
beforeVersion: before.version,
|
|
3488
|
+
status: "failed",
|
|
3489
|
+
remainingIds: presentIds,
|
|
3490
|
+
error: errorMessage5(error)
|
|
3491
|
+
};
|
|
3492
|
+
result.documents.push(outcome2);
|
|
3493
|
+
refreshVerifiedIds();
|
|
3494
|
+
throw new TrackedChangeMutationError(
|
|
3495
|
+
`Could not safely plan rejection in ${doc.path}: ${errorMessage5(error)}`,
|
|
3496
|
+
result,
|
|
3497
|
+
error
|
|
3498
|
+
);
|
|
3499
|
+
}
|
|
3500
|
+
const outcome = {
|
|
3501
|
+
docId: doc._id,
|
|
3502
|
+
docPath: doc.path,
|
|
3503
|
+
requestedIds: ids,
|
|
3504
|
+
attemptedIds: presentIds,
|
|
3505
|
+
fragmentCount: plan.fragmentCount,
|
|
3506
|
+
beforeVersion: before.version,
|
|
3507
|
+
status: "failed",
|
|
3508
|
+
textVerified: false,
|
|
3509
|
+
remainingIds: presentIds
|
|
3510
|
+
};
|
|
3511
|
+
result.documents.push(outcome);
|
|
3512
|
+
result.attemptedIds = uniqueChangeIds([...result.attemptedIds, ...presentIds]);
|
|
3513
|
+
let mutationError;
|
|
3514
|
+
try {
|
|
3515
|
+
await applyOtUpdateAndWait(socket, doc._id, {
|
|
3516
|
+
doc: doc._id,
|
|
3517
|
+
op: plan.operations,
|
|
3518
|
+
v: before.version,
|
|
3519
|
+
meta: {}
|
|
3520
|
+
});
|
|
3521
|
+
} catch (error) {
|
|
3522
|
+
mutationError = error;
|
|
3523
|
+
}
|
|
3524
|
+
let after;
|
|
3525
|
+
let readbackError;
|
|
3526
|
+
try {
|
|
3527
|
+
after = await verifyRejectedDocument(socket, doc._id, presentIds, plan.expectedText);
|
|
3528
|
+
} catch (error) {
|
|
3529
|
+
readbackError = error;
|
|
3530
|
+
}
|
|
3531
|
+
if (after) {
|
|
3532
|
+
outcome.afterVersion = after.version;
|
|
3533
|
+
outcome.remainingIds = remainingChangeIds(changesFrom2(after), presentIds);
|
|
3534
|
+
outcome.textVerified = after.lines.join("\n") === plan.expectedText;
|
|
3535
|
+
}
|
|
3536
|
+
if (after && !outcome.remainingIds.length && outcome.textVerified) {
|
|
3537
|
+
outcome.status = "verified";
|
|
3538
|
+
if (mutationError) outcome.error = `transport warning: ${errorMessage5(mutationError)}`;
|
|
3539
|
+
verifiedDocs.add(doc._id);
|
|
3540
|
+
refreshVerifiedIds();
|
|
3541
|
+
continue;
|
|
3542
|
+
}
|
|
3543
|
+
const reasons = [];
|
|
3544
|
+
if (mutationError) reasons.push(errorMessage5(mutationError));
|
|
3545
|
+
if (readbackError) reasons.push(`readback failed: ${errorMessage5(readbackError)}`);
|
|
3546
|
+
if (outcome.remainingIds.length) {
|
|
3547
|
+
reasons.push(`change ids still present: ${outcome.remainingIds.join(", ")}`);
|
|
3548
|
+
}
|
|
3549
|
+
if (after && !outcome.textVerified) {
|
|
3550
|
+
reasons.push("final document text did not match the planned rejection");
|
|
3551
|
+
}
|
|
3552
|
+
outcome.error = reasons.join("; ") || "rejection could not be verified";
|
|
3553
|
+
refreshVerifiedIds();
|
|
3554
|
+
throw new TrackedChangeMutationError(
|
|
3555
|
+
`Rejected changes in ${doc.path} could not be verified: ${outcome.error}`,
|
|
3556
|
+
result,
|
|
3557
|
+
mutationError ?? readbackError
|
|
3558
|
+
);
|
|
829
3559
|
}
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
3560
|
+
refreshVerifiedIds();
|
|
3561
|
+
if (!result.verified) {
|
|
3562
|
+
throw new TrackedChangeMutationError(
|
|
3563
|
+
`Rejection incomplete; unverified ids: ${requestedIds.filter((id) => !result.verifiedAbsentIds.includes(id)).join(", ")}`,
|
|
3564
|
+
result
|
|
3565
|
+
);
|
|
3566
|
+
}
|
|
3567
|
+
console.log(
|
|
3568
|
+
`\u2705 Rejected ${result.foundIds.length} tracked change(s) (${result.documents.reduce((count, doc) => count + doc.fragmentCount, 0)} range fragment(s)); verified by readback`
|
|
3569
|
+
);
|
|
3570
|
+
return result;
|
|
3571
|
+
} finally {
|
|
3572
|
+
try {
|
|
837
3573
|
socket.close();
|
|
838
|
-
|
|
3574
|
+
} finally {
|
|
3575
|
+
mutationLock?.release();
|
|
839
3576
|
}
|
|
840
|
-
rejected++;
|
|
841
3577
|
}
|
|
842
|
-
socket.close();
|
|
843
|
-
if (!rejected) throw new Error("no matching tracked changes found");
|
|
844
|
-
console.log(`\u2705 Rejected ${rejected} tracked change(s)`);
|
|
845
3578
|
}
|
|
846
3579
|
|
|
847
3580
|
// src/commands/login.ts
|
|
@@ -893,20 +3626,27 @@ async function captureCookieViaBrowser(baseUrl) {
|
|
|
893
3626
|
|
|
894
3627
|
// src/commands/link.ts
|
|
895
3628
|
function link(projectId, baseUrl) {
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
3629
|
+
const mutationLock = acquireMutationLock(projectId);
|
|
3630
|
+
try {
|
|
3631
|
+
const path = saveProjectConfig({ projectId, ...baseUrl ? { baseUrl } : {} });
|
|
3632
|
+
console.log(`\u2705 Linked this repo to Overleaf project ${projectId} \u2192 ${path}`);
|
|
3633
|
+
console.log(" (safe to commit \u2014 it contains no secrets.)");
|
|
3634
|
+
} finally {
|
|
3635
|
+
mutationLock.release();
|
|
3636
|
+
}
|
|
899
3637
|
}
|
|
900
3638
|
|
|
901
3639
|
// src/cli.ts
|
|
902
3640
|
function getFlag(name) {
|
|
903
3641
|
const i = process.argv.indexOf(`--${name}`);
|
|
904
|
-
|
|
3642
|
+
const value = i >= 0 ? process.argv[i + 1] : void 0;
|
|
3643
|
+
return value && !value.startsWith("--") ? value : void 0;
|
|
905
3644
|
}
|
|
906
3645
|
function getAll(name) {
|
|
907
3646
|
const out = [];
|
|
908
3647
|
process.argv.forEach((a, i) => {
|
|
909
|
-
|
|
3648
|
+
const value = process.argv[i + 1];
|
|
3649
|
+
if (a === `--${name}` && value && !value.startsWith("--")) out.push(value);
|
|
910
3650
|
});
|
|
911
3651
|
return out.flatMap((v) => v.split(",")).map((s) => s.trim()).filter(Boolean);
|
|
912
3652
|
}
|
|
@@ -917,22 +3657,82 @@ function usage() {
|
|
|
917
3657
|
console.log(" link --project <id> Link this repo to an Overleaf project\n");
|
|
918
3658
|
console.log("Read:");
|
|
919
3659
|
console.log(" pull [--out <dir>] Comments + tracked changes \u2192 sidecar\n");
|
|
920
|
-
console.log("
|
|
921
|
-
console.log("
|
|
3660
|
+
console.log("Safe review workflow:");
|
|
3661
|
+
console.log(" review start [--file <f>] [--out <dir>] Fetch text/base, then pull review data");
|
|
3662
|
+
console.log(" review plan --out <plan.json> [options] Save a complete binding push plan");
|
|
3663
|
+
console.log(" review submit --plan <plan.json> Validate, apply, and verify that plan");
|
|
3664
|
+
console.log(" [--acknowledge-ambiguous] Continue only after manual reconciliation\n");
|
|
3665
|
+
console.log("Content (replaces the git bridge):");
|
|
3666
|
+
console.log(" fetch [--file <f>] [--dry-run] Overleaf text \u2192 local files + saved base");
|
|
922
3667
|
console.log(" upload <path\u2026> [--folder <name>] Upload figures / new files to Overleaf\n");
|
|
923
3668
|
console.log("Comments:");
|
|
924
|
-
console.log(" comment --anchor <text> --message <text> [--doc <name>] [--nth <n>]");
|
|
925
|
-
console.log(" reply --thread <id> --message <text>
|
|
3669
|
+
console.log(" comment --anchor <text> --message <text> [--doc <name>] [--nth <n>] [--force]");
|
|
3670
|
+
console.log(" reply --thread <id> --message <text> [--force]");
|
|
926
3671
|
console.log(" resolve --thread <id> [--reopen] Resolve/reopen a thread");
|
|
927
3672
|
console.log(" delete-comment --thread <id> Delete a whole thread");
|
|
928
3673
|
console.log(" delete-message --message-id <id> Delete a single message\n");
|
|
929
3674
|
console.log("Tracked changes:");
|
|
930
3675
|
console.log(" push [--file <f>] [--doc <name>] [--direct] [--dry-run]");
|
|
3676
|
+
console.log(" [--plan-out <plan.json> | --plan <plan.json>] [--allow-overlap]");
|
|
3677
|
+
console.log(" [--acknowledge-ambiguous] (after manually reconciling a prior uncertain push)");
|
|
3678
|
+
console.log(" [--unsafe-no-base] (legacy escape hatch; disables three-way protection)");
|
|
931
3679
|
console.log(" Send local edits as tracked suggestions (--direct = plain edits)");
|
|
932
3680
|
console.log(" accept --change <id> [--change <id> \u2026] Accept collaborators\u2019 changes");
|
|
933
3681
|
console.log(" reject --change <id> [--change <id> \u2026] Reject collaborators\u2019 changes");
|
|
934
3682
|
console.log("\n(thread/change ids come from `pull`; --change accepts comma-separated lists too)");
|
|
935
3683
|
}
|
|
3684
|
+
async function pullAndReport(out, options = {}) {
|
|
3685
|
+
const data = await pull(out, options);
|
|
3686
|
+
console.log(
|
|
3687
|
+
`Pulled ${data.comments.length} comment(s) and ${data.changes.length} tracked change(s) from "${data.project}" \u2192 ${out}/`
|
|
3688
|
+
);
|
|
3689
|
+
}
|
|
3690
|
+
function pushOptions() {
|
|
3691
|
+
return {
|
|
3692
|
+
file: getFlag("file"),
|
|
3693
|
+
docName: getFlag("doc"),
|
|
3694
|
+
direct: process.argv.includes("--direct"),
|
|
3695
|
+
dryRun: process.argv.includes("--dry-run"),
|
|
3696
|
+
unsafeNoBase: process.argv.includes("--unsafe-no-base"),
|
|
3697
|
+
allowOverlap: process.argv.includes("--allow-overlap"),
|
|
3698
|
+
planOut: getFlag("plan-out"),
|
|
3699
|
+
plan: getFlag("plan"),
|
|
3700
|
+
allowAmbiguousRetry: process.argv.includes("--acknowledge-ambiguous")
|
|
3701
|
+
};
|
|
3702
|
+
}
|
|
3703
|
+
async function mutateTrackedChanges(action, ids, mutate) {
|
|
3704
|
+
let receipt = beginReceipt(action, {
|
|
3705
|
+
projectId: config.projectId,
|
|
3706
|
+
requestedIds: ids,
|
|
3707
|
+
phase: "preflight"
|
|
3708
|
+
});
|
|
3709
|
+
try {
|
|
3710
|
+
receipt = updateReceipt(receipt, "in_progress", {
|
|
3711
|
+
phase: "mutating",
|
|
3712
|
+
mutationStartedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3713
|
+
});
|
|
3714
|
+
const result = await mutate(ids);
|
|
3715
|
+
receipt = updateReceipt(receipt, "succeeded", {
|
|
3716
|
+
phase: "complete",
|
|
3717
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3718
|
+
result
|
|
3719
|
+
});
|
|
3720
|
+
console.log(`Audit receipt: ${receipt.path}`);
|
|
3721
|
+
} catch (error) {
|
|
3722
|
+
const result = error instanceof TrackedChangeMutationError ? error.result : void 0;
|
|
3723
|
+
const outcomeUnknown = Boolean(result?.attemptedIds.length && !result.verified);
|
|
3724
|
+
receipt = updateReceipt(receipt, outcomeUnknown ? "ambiguous" : "failed", {
|
|
3725
|
+
phase: outcomeUnknown ? "outcome_unknown" : "failed",
|
|
3726
|
+
recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3727
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3728
|
+
...result ? { result } : {}
|
|
3729
|
+
});
|
|
3730
|
+
throw new Error(
|
|
3731
|
+
`${error instanceof Error ? error.message : String(error)}. Audit receipt: ${receipt.path}`,
|
|
3732
|
+
{ cause: error }
|
|
3733
|
+
);
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
936
3736
|
async function main() {
|
|
937
3737
|
const cmd = process.argv[2];
|
|
938
3738
|
switch (cmd) {
|
|
@@ -951,20 +3751,43 @@ async function main() {
|
|
|
951
3751
|
}
|
|
952
3752
|
case "pull": {
|
|
953
3753
|
const out = getFlag("out") ?? ".overleaf";
|
|
954
|
-
|
|
955
|
-
console.log(
|
|
956
|
-
`Pulled ${data.comments.length} comment(s) and ${data.changes.length} tracked change(s) from "${data.project}" \u2192 ${out}/`
|
|
957
|
-
);
|
|
3754
|
+
await pullAndReport(out);
|
|
958
3755
|
break;
|
|
959
3756
|
}
|
|
960
3757
|
case "push":
|
|
961
|
-
await push(
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
3758
|
+
await push(pushOptions());
|
|
3759
|
+
break;
|
|
3760
|
+
case "review": {
|
|
3761
|
+
const reviewCommand = process.argv[3];
|
|
3762
|
+
if (process.argv.includes("--through")) {
|
|
3763
|
+
fail("Section-scoped --through is not implemented; no review action was performed.");
|
|
3764
|
+
}
|
|
3765
|
+
if (reviewCommand === "start") {
|
|
3766
|
+
const file = getFlag("file") ?? getFlag("doc");
|
|
3767
|
+
const mutationLock = acquireMutationLock(config.projectId);
|
|
3768
|
+
try {
|
|
3769
|
+
await fetchDocs({ file, dryRun: false, acquireLock: false });
|
|
3770
|
+
await pullAndReport(getFlag("out") ?? ".overleaf", { acquireLock: false });
|
|
3771
|
+
} finally {
|
|
3772
|
+
mutationLock.release();
|
|
3773
|
+
}
|
|
3774
|
+
} else if (reviewCommand === "plan") {
|
|
3775
|
+
const out = getFlag("out");
|
|
3776
|
+
if (!out) fail("review plan requires --out <plan.json>");
|
|
3777
|
+
await push({ ...pushOptions(), plan: void 0, planOut: out, dryRun: true });
|
|
3778
|
+
} else if (reviewCommand === "submit") {
|
|
3779
|
+
const plan = getFlag("plan");
|
|
3780
|
+
if (!plan) fail("review submit requires --plan <plan.json>");
|
|
3781
|
+
await push({
|
|
3782
|
+
plan,
|
|
3783
|
+
allowAmbiguousRetry: process.argv.includes("--acknowledge-ambiguous")
|
|
3784
|
+
});
|
|
3785
|
+
} else {
|
|
3786
|
+
usage();
|
|
3787
|
+
fail("review requires start, plan, or submit");
|
|
3788
|
+
}
|
|
967
3789
|
break;
|
|
3790
|
+
}
|
|
968
3791
|
case "fetch":
|
|
969
3792
|
await fetchDocs({ file: getFlag("file"), dryRun: process.argv.includes("--dry-run") });
|
|
970
3793
|
break;
|
|
@@ -988,20 +3811,30 @@ async function main() {
|
|
|
988
3811
|
const message = getFlag("message");
|
|
989
3812
|
if (!anchor || !message) fail("comment requires --anchor <text> and --message <text>");
|
|
990
3813
|
const nthRaw = getFlag("nth");
|
|
991
|
-
|
|
3814
|
+
const occurrence = nthRaw === void 0 ? void 0 : Number(nthRaw);
|
|
3815
|
+
if (occurrence !== void 0 && (!Number.isSafeInteger(occurrence) || occurrence < 1)) {
|
|
3816
|
+
fail("comment --nth must be a positive integer");
|
|
3817
|
+
}
|
|
3818
|
+
await comment({
|
|
3819
|
+
docName: getFlag("doc"),
|
|
3820
|
+
anchor,
|
|
3821
|
+
message,
|
|
3822
|
+
occurrence,
|
|
3823
|
+
force: process.argv.includes("--force")
|
|
3824
|
+
});
|
|
992
3825
|
break;
|
|
993
3826
|
}
|
|
994
3827
|
case "reply": {
|
|
995
3828
|
const thread = getFlag("thread");
|
|
996
3829
|
const message = getFlag("message");
|
|
997
3830
|
if (!thread || !message) fail("reply requires --thread <id> and --message <text>");
|
|
998
|
-
await reply(thread, message);
|
|
3831
|
+
await reply(thread, message, { force: process.argv.includes("--force") });
|
|
999
3832
|
break;
|
|
1000
3833
|
}
|
|
1001
3834
|
case "resolve": {
|
|
1002
3835
|
const thread = getFlag("thread");
|
|
1003
3836
|
if (!thread) fail("resolve requires --thread <id>");
|
|
1004
|
-
await
|
|
3837
|
+
await resolve2(thread, process.argv.includes("--reopen"));
|
|
1005
3838
|
break;
|
|
1006
3839
|
}
|
|
1007
3840
|
case "delete-comment": {
|
|
@@ -1019,13 +3852,13 @@ async function main() {
|
|
|
1019
3852
|
case "accept": {
|
|
1020
3853
|
const ids = getAll("change");
|
|
1021
3854
|
if (!ids.length) fail("accept requires --change <id>");
|
|
1022
|
-
await accept
|
|
3855
|
+
await mutateTrackedChanges("accept", ids, accept);
|
|
1023
3856
|
break;
|
|
1024
3857
|
}
|
|
1025
3858
|
case "reject": {
|
|
1026
3859
|
const ids = getAll("change");
|
|
1027
3860
|
if (!ids.length) fail("reject requires --change <id>");
|
|
1028
|
-
await reject
|
|
3861
|
+
await mutateTrackedChanges("reject", ids, reject);
|
|
1029
3862
|
break;
|
|
1030
3863
|
}
|
|
1031
3864
|
default:
|