overleaf-review 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +180 -1
- package/dist/chunk-TWIDV42A.js +2729 -0
- package/dist/cli.js +244 -2339
- package/dist/reviewed-replace-A7C4UNJS.js +431 -0
- package/package.json +5 -1
package/dist/cli.js
CHANGED
|
@@ -1,2254 +1,139 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
} catch {
|
|
56
|
-
}
|
|
57
|
-
throw error;
|
|
58
|
-
}
|
|
59
|
-
return REPO_CONFIG_PATH;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// src/config.ts
|
|
63
|
-
function resolveBaseUrl() {
|
|
64
|
-
return process.env.OVERLEAF_BASE_URL ?? loadProjectConfig().baseUrl ?? loadCredentials().baseUrl ?? "https://www.overleaf.com";
|
|
65
|
-
}
|
|
66
|
-
function resolveSession2() {
|
|
67
|
-
const value = process.env.OVERLEAF_SESSION2 || loadCredentials().session2;
|
|
68
|
-
if (!value) {
|
|
69
|
-
throw new Error("Not authenticated \u2014 run `overleaf-review login` (or set OVERLEAF_SESSION2).");
|
|
70
|
-
}
|
|
71
|
-
return value;
|
|
72
|
-
}
|
|
73
|
-
function resolveProjectId() {
|
|
74
|
-
const value = process.env.OVERLEAF_PROJECT_ID || loadProjectConfig().projectId;
|
|
75
|
-
if (!value) {
|
|
76
|
-
throw new Error(
|
|
77
|
-
"No project linked \u2014 run `overleaf-review link --project <id>` (or set OVERLEAF_PROJECT_ID)."
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
return value;
|
|
81
|
-
}
|
|
82
|
-
var config = {
|
|
83
|
-
get baseUrl() {
|
|
84
|
-
return resolveBaseUrl();
|
|
85
|
-
},
|
|
86
|
-
get session2() {
|
|
87
|
-
return resolveSession2();
|
|
88
|
-
},
|
|
89
|
-
get projectId() {
|
|
90
|
-
return resolveProjectId();
|
|
91
|
-
},
|
|
92
|
-
/** Cookie header value used for both HTTP and the websocket handshake. */
|
|
93
|
-
get cookie() {
|
|
94
|
-
return `overleaf_session2=${this.session2}`;
|
|
95
|
-
}
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
// src/overleaf-socket.ts
|
|
99
|
-
import WebSocket from "ws";
|
|
100
|
-
function utf8Decode(binary) {
|
|
101
|
-
return Buffer.from(binary, "latin1").toString("utf8");
|
|
102
|
-
}
|
|
103
|
-
var OverleafSocket = class {
|
|
104
|
-
constructor(baseUrl, cookie, opts = {}) {
|
|
105
|
-
this.baseUrl = baseUrl;
|
|
106
|
-
this.debug = opts.debug ?? true;
|
|
107
|
-
for (const part of cookie.split(";")) {
|
|
108
|
-
const eq = part.indexOf("=");
|
|
109
|
-
if (eq > 0) this.cookies.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
baseUrl;
|
|
113
|
-
ws;
|
|
114
|
-
ackId = 1;
|
|
115
|
-
pendingAcks = /* @__PURE__ */ new Map();
|
|
116
|
-
handlers = /* @__PURE__ */ new Map();
|
|
117
|
-
heartbeat;
|
|
118
|
-
debug;
|
|
119
|
-
cookies = /* @__PURE__ */ new Map();
|
|
120
|
-
/** Browser-like headers — Overleaf's proxy can 502 non-browser upgrades. */
|
|
121
|
-
get browserHeaders() {
|
|
122
|
-
return {
|
|
123
|
-
Cookie: this.cookieHeader,
|
|
124
|
-
Origin: this.baseUrl,
|
|
125
|
-
"User-Agent": "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"
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
get cookieHeader() {
|
|
129
|
-
return [...this.cookies].map(([k, v]) => `${k}=${v}`).join("; ");
|
|
130
|
-
}
|
|
131
|
-
/** Merge Set-Cookie values — notably the GCLB load-balancer affinity cookie. */
|
|
132
|
-
absorbCookies(setCookies) {
|
|
133
|
-
for (const sc of setCookies) {
|
|
134
|
-
const pair = sc.split(";", 1)[0];
|
|
135
|
-
const eq = pair.indexOf("=");
|
|
136
|
-
if (eq > 0) this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
/** Subscribe to a server-pushed event (e.g. otUpdateApplied). */
|
|
140
|
-
on(event, handler) {
|
|
141
|
-
const list = this.handlers.get(event) ?? [];
|
|
142
|
-
list.push(handler);
|
|
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);
|
|
153
|
-
}
|
|
154
|
-
async connect(projectId) {
|
|
155
|
-
const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${projectId}&t=${Date.now()}`;
|
|
156
|
-
const res = await fetch(handshakeUrl, { headers: this.browserHeaders });
|
|
157
|
-
const setCookies = res.headers.getSetCookie?.() ?? [];
|
|
158
|
-
this.absorbCookies(setCookies);
|
|
159
|
-
if (this.debug) console.log(`[socket] cookies now: ${[...this.cookies.keys()].join(", ")}`);
|
|
160
|
-
if (!res.ok) {
|
|
161
|
-
throw new Error(
|
|
162
|
-
`socket.io handshake failed: ${res.status} ${res.statusText}. Check the session cookie and that the account is logged in.`
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
const body = await res.text();
|
|
166
|
-
const [sid, hbTimeout, , transports] = body.split(":");
|
|
167
|
-
if (this.debug) console.log(`[socket] handshake ok: ${body}`);
|
|
168
|
-
const wsUrl = `${this.baseUrl.replace(/^http/, "ws")}/socket.io/1/websocket/${sid}`;
|
|
169
|
-
if (this.debug) console.log(`[socket] transports=${transports}; ws=${wsUrl}`);
|
|
170
|
-
this.ws = new WebSocket(wsUrl, { headers: this.browserHeaders });
|
|
171
|
-
await new Promise((resolve3, reject2) => {
|
|
172
|
-
this.ws.once("open", () => resolve3());
|
|
173
|
-
this.ws.once("error", reject2);
|
|
174
|
-
this.ws.once("unexpected-response", (_req, response) => {
|
|
175
|
-
let errBody = "";
|
|
176
|
-
response.on("data", (chunk) => errBody += chunk.toString());
|
|
177
|
-
response.on(
|
|
178
|
-
"end",
|
|
179
|
-
() => reject2(
|
|
180
|
-
new Error(
|
|
181
|
-
`ws upgrade rejected: ${response.statusCode} ${response.statusMessage}
|
|
182
|
-
headers: ${JSON.stringify(response.headers)}
|
|
183
|
-
body: ${errBody.slice(0, 800)}`
|
|
184
|
-
)
|
|
185
|
-
)
|
|
186
|
-
);
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
this.ws.on("message", (data) => this.onFrame(utf8Decode(data.toString())));
|
|
190
|
-
const intervalMs = (Number(hbTimeout) || 30) * 800;
|
|
191
|
-
this.heartbeat = setInterval(() => this.send("2::"), intervalMs);
|
|
192
|
-
if (this.debug) console.log("[socket] websocket open");
|
|
193
|
-
}
|
|
194
|
-
/** Emit an event and resolve with the server's ack payload (array of args). */
|
|
195
|
-
emit(name, args, timeoutMs = 15e3) {
|
|
196
|
-
const id = this.ackId++;
|
|
197
|
-
return new Promise((resolve3, reject2) => {
|
|
198
|
-
const timer = setTimeout(() => {
|
|
199
|
-
this.pendingAcks.delete(id);
|
|
200
|
-
reject2(new Error(`emit('${name}') timed out after ${timeoutMs}ms with no ack`));
|
|
201
|
-
}, timeoutMs);
|
|
202
|
-
this.pendingAcks.set(id, (payload) => {
|
|
203
|
-
clearTimeout(timer);
|
|
204
|
-
resolve3(payload);
|
|
205
|
-
});
|
|
206
|
-
this.send(`5:${id}+::${JSON.stringify({ name, args })}`);
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
onFrame(frame) {
|
|
210
|
-
if (this.debug) console.log("[socket] <-", frame.slice(0, 200));
|
|
211
|
-
const type = frame[0];
|
|
212
|
-
const c1 = frame.indexOf(":");
|
|
213
|
-
const c2 = frame.indexOf(":", c1 + 1);
|
|
214
|
-
const c3 = frame.indexOf(":", c2 + 1);
|
|
215
|
-
const data = c3 === -1 ? "" : frame.slice(c3 + 1);
|
|
216
|
-
switch (type) {
|
|
217
|
-
case "2":
|
|
218
|
-
this.send("2::");
|
|
219
|
-
break;
|
|
220
|
-
case "5": {
|
|
221
|
-
try {
|
|
222
|
-
const { name, args } = JSON.parse(data);
|
|
223
|
-
for (const h of this.handlers.get(name) ?? []) h(args);
|
|
224
|
-
} catch {
|
|
225
|
-
}
|
|
226
|
-
break;
|
|
227
|
-
}
|
|
228
|
-
case "6": {
|
|
229
|
-
const plus = data.indexOf("+");
|
|
230
|
-
const ackId = Number(plus === -1 ? data : data.slice(0, plus));
|
|
231
|
-
const payload = plus === -1 ? [] : JSON.parse(data.slice(plus + 1));
|
|
232
|
-
this.pendingAcks.get(ackId)?.(payload);
|
|
233
|
-
this.pendingAcks.delete(ackId);
|
|
234
|
-
break;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
send(frame) {
|
|
239
|
-
if (this.debug && !frame.startsWith("2")) console.log("[socket] ->", frame.slice(0, 200));
|
|
240
|
-
this.ws.send(frame);
|
|
241
|
-
}
|
|
242
|
-
close() {
|
|
243
|
-
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
244
|
-
this.ws?.close();
|
|
245
|
-
}
|
|
246
|
-
};
|
|
247
|
-
|
|
248
|
-
// src/lib/session.ts
|
|
249
|
-
function collectDocs(rootFolder) {
|
|
250
|
-
const out = [];
|
|
251
|
-
const walk = (folders, prefix) => {
|
|
252
|
-
for (const f of folders ?? []) {
|
|
253
|
-
const dir = f.name && f.name !== "rootFolder" ? prefix ? `${prefix}/${f.name}` : f.name : prefix;
|
|
254
|
-
for (const d of f.docs ?? []) {
|
|
255
|
-
out.push({ _id: d._id, name: d.name, path: dir ? `${dir}/${d.name}` : d.name });
|
|
256
|
-
}
|
|
257
|
-
walk(f.folders ?? [], dir);
|
|
258
|
-
}
|
|
259
|
-
};
|
|
260
|
-
walk(rootFolder ?? [], "");
|
|
261
|
-
return out;
|
|
262
|
-
}
|
|
263
|
-
async function openProject(opts = {}) {
|
|
264
|
-
const socket = new OverleafSocket(config.baseUrl, config.cookie, { debug: opts.debug ?? false });
|
|
265
|
-
const pushed = new Promise((resolve3) => socket.on("joinProjectResponse", resolve3));
|
|
266
|
-
await socket.connect(config.projectId);
|
|
267
|
-
const first = await Promise.race([
|
|
268
|
-
pushed,
|
|
269
|
-
new Promise((r) => setTimeout(() => r(null), 6e3))
|
|
270
|
-
]);
|
|
271
|
-
let publicId = "";
|
|
272
|
-
let project;
|
|
273
|
-
if (first) {
|
|
274
|
-
publicId = first[0]?.publicId ?? "";
|
|
275
|
-
project = first[0]?.project;
|
|
276
|
-
} else {
|
|
277
|
-
const r = await socket.emit("joinProject", [{ project_id: config.projectId }]);
|
|
278
|
-
project = r[1];
|
|
279
|
-
}
|
|
280
|
-
return { socket, publicId, project, docs: collectDocs(project?.rootFolder) };
|
|
281
|
-
}
|
|
282
|
-
async function joinDoc(socket, docId) {
|
|
283
|
-
const res = await socket.emit("joinDoc", [docId, { encodeRanges: true }]);
|
|
284
|
-
const [err, lines, version, , ranges] = res;
|
|
285
|
-
if (err) throw new Error(`joinDoc error: ${JSON.stringify(err)}`);
|
|
286
|
-
return { version, lines: lines ?? [], ranges: ranges ?? {} };
|
|
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
|
-
}
|
|
323
|
-
|
|
324
|
-
// src/lib/rest.ts
|
|
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";
|
|
326
|
-
function headers(extra = {}) {
|
|
327
|
-
return { Cookie: config.cookie, "User-Agent": UA, Origin: config.baseUrl, ...extra };
|
|
328
|
-
}
|
|
329
|
-
async function getCsrfToken() {
|
|
330
|
-
const res = await fetch(`${config.baseUrl}/project/${config.projectId}`, { headers: headers() });
|
|
331
|
-
const html = await res.text();
|
|
332
|
-
const m = html.match(/<meta\s+name="ol-csrfToken"\s+content="([^"]+)"/) ?? html.match(/"csrfToken"\s*:\s*"([^"]+)"/) ?? html.match(/csrfToken\s*[:=]\s*["']([^"']+)["']/);
|
|
333
|
-
if (!m) {
|
|
334
|
-
throw new Error(
|
|
335
|
-
`CSRF token not found (HTTP ${res.status}). If this is a login page, the session cookie is invalid/expired.`
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
return m[1];
|
|
339
|
-
}
|
|
340
|
-
async function getThreads() {
|
|
341
|
-
const res = await fetch(`${config.baseUrl}/project/${config.projectId}/threads`, {
|
|
342
|
-
headers: headers()
|
|
343
|
-
});
|
|
344
|
-
if (!res.ok) throw new Error(`getThreads ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
345
|
-
return res.json();
|
|
346
|
-
}
|
|
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) {
|
|
452
|
-
const res = await fetch(
|
|
453
|
-
`${config.baseUrl}/project/${config.projectId}/thread/${threadId}/messages`,
|
|
454
|
-
{
|
|
455
|
-
method: "POST",
|
|
456
|
-
headers: headers({ "Content-Type": "application/json", "X-CSRF-Token": csrf }),
|
|
457
|
-
body: JSON.stringify({ content })
|
|
458
|
-
}
|
|
459
|
-
);
|
|
460
|
-
const rawBody = await res.text();
|
|
461
|
-
if (!res.ok) {
|
|
462
|
-
throw new RestRequestError(
|
|
463
|
-
`postThreadMessage ${res.status}: ${rawBody.slice(0, 300)}`,
|
|
464
|
-
res.status,
|
|
465
|
-
rawBody.slice(0, 1e3)
|
|
466
|
-
);
|
|
467
|
-
}
|
|
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
|
-
};
|
|
481
|
-
}
|
|
482
|
-
async function setThreadResolved(docId, threadId, reopen, csrf) {
|
|
483
|
-
const action = reopen ? "reopen" : "resolve";
|
|
484
|
-
const res = await fetch(
|
|
485
|
-
`${config.baseUrl}/project/${config.projectId}/doc/${docId}/thread/${threadId}/${action}`,
|
|
486
|
-
{ method: "POST", headers: headers({ "X-CSRF-Token": csrf }) }
|
|
487
|
-
);
|
|
488
|
-
if (!res.ok) {
|
|
489
|
-
throw new Error(`${action} thread ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
490
|
-
}
|
|
491
|
-
return res.status;
|
|
492
|
-
}
|
|
493
|
-
async function acceptChanges(docId, changeIds, csrf) {
|
|
494
|
-
const res = await fetch(
|
|
495
|
-
`${config.baseUrl}/project/${config.projectId}/doc/${docId}/changes/accept`,
|
|
496
|
-
{
|
|
497
|
-
method: "POST",
|
|
498
|
-
headers: headers({ "X-CSRF-Token": csrf, "Content-Type": "application/json" }),
|
|
499
|
-
body: JSON.stringify({ change_ids: changeIds })
|
|
500
|
-
}
|
|
501
|
-
);
|
|
502
|
-
if (!res.ok) throw new Error(`acceptChanges ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
503
|
-
return res.status;
|
|
504
|
-
}
|
|
505
|
-
async function uploadFile(folderId, name, bytes, csrf) {
|
|
506
|
-
const form = new FormData();
|
|
507
|
-
form.append("qqfile", new Blob([new Uint8Array(bytes)]), name);
|
|
508
|
-
form.append("name", name);
|
|
509
|
-
form.append("relativePath", "null");
|
|
510
|
-
const res = await fetch(
|
|
511
|
-
`${config.baseUrl}/project/${config.projectId}/upload?folder_id=${folderId}`,
|
|
512
|
-
// Deliberately no Content-Type — fetch sets the multipart boundary itself.
|
|
513
|
-
{ method: "POST", headers: headers({ "X-CSRF-Token": csrf }), body: form }
|
|
514
|
-
);
|
|
515
|
-
if (!res.ok) throw new Error(`upload ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
516
|
-
return res.json();
|
|
517
|
-
}
|
|
518
|
-
async function deleteMessage(threadId, messageId, csrf) {
|
|
519
|
-
const res = await fetch(
|
|
520
|
-
`${config.baseUrl}/project/${config.projectId}/thread/${threadId}/messages/${messageId}`,
|
|
521
|
-
{ method: "DELETE", headers: headers({ "X-CSRF-Token": csrf }) }
|
|
522
|
-
);
|
|
523
|
-
if (!res.ok) throw new Error(`deleteMessage ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
524
|
-
return res.status;
|
|
525
|
-
}
|
|
526
|
-
async function deleteThread(docId, threadId, csrf) {
|
|
527
|
-
const res = await fetch(
|
|
528
|
-
`${config.baseUrl}/project/${config.projectId}/doc/${docId}/thread/${threadId}`,
|
|
529
|
-
{ method: "DELETE", headers: headers({ "X-CSRF-Token": csrf }) }
|
|
530
|
-
);
|
|
531
|
-
if (!res.ok) throw new Error(`deleteThread ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
532
|
-
return res.status;
|
|
533
|
-
}
|
|
534
|
-
async function validateSession(baseUrl, session2) {
|
|
535
|
-
const res = await fetch(`${baseUrl}/project`, {
|
|
536
|
-
headers: { Cookie: `overleaf_session2=${session2}`, "User-Agent": UA },
|
|
537
|
-
redirect: "follow"
|
|
538
|
-
});
|
|
539
|
-
const html = await res.text();
|
|
540
|
-
const looksLikeLogin = res.url.includes("/login") || /name="ol-page"\s+content="login"/.test(html) || html.includes('id="loginForm"');
|
|
541
|
-
if (looksLikeLogin) {
|
|
542
|
-
throw new Error("Session cookie is invalid or expired (got the login page).");
|
|
543
|
-
}
|
|
544
|
-
const m = html.match(/name="ol-usersEmail"\s+content="([^"]+)"/) ?? html.match(/"email":"([^"@]+@[^"]+)"/);
|
|
545
|
-
return m ? m[1] : "your Overleaf account";
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
// src/lib/anchors.ts
|
|
549
|
-
function offsetToLine(lines, offset) {
|
|
550
|
-
let remaining = offset;
|
|
551
|
-
for (let i = 0; i < lines.length; i++) {
|
|
552
|
-
const len = lines[i].length;
|
|
553
|
-
if (remaining <= len) return i;
|
|
554
|
-
remaining -= len + 1;
|
|
555
|
-
}
|
|
556
|
-
return Math.max(0, lines.length - 1);
|
|
557
|
-
}
|
|
558
|
-
function lineContext(lines, line, radius = 1) {
|
|
559
|
-
const start = Math.max(0, line - radius);
|
|
560
|
-
const end = Math.min(lines.length - 1, line + radius);
|
|
561
|
-
const out = [];
|
|
562
|
-
for (let i = start; i <= end; i++) {
|
|
563
|
-
out.push(`${i === line ? ">" : " "} ${String(i + 1).padStart(3)} ${lines[i]}`);
|
|
564
|
-
}
|
|
565
|
-
return out.join("\n");
|
|
566
|
-
}
|
|
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
|
-
|
|
718
|
-
// src/commands/pull.ts
|
|
719
|
-
function buildMemberMap(project) {
|
|
720
|
-
const map = {};
|
|
721
|
-
const add = (u) => {
|
|
722
|
-
if (!u?._id) return;
|
|
723
|
-
map[u._id] = [u.first_name, u.last_name].filter(Boolean).join(" ") || u.email || u._id;
|
|
724
|
-
};
|
|
725
|
-
add(project?.owner);
|
|
726
|
-
for (const m of project?.members ?? []) add(m);
|
|
727
|
-
return map;
|
|
728
|
-
}
|
|
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
|
-
}
|
|
775
|
-
}
|
|
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();
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
function renderMarkdown(d) {
|
|
796
|
-
const L = [];
|
|
797
|
-
L.push(`# Overleaf review \u2014 ${d.project}`, "");
|
|
798
|
-
L.push(`_Pulled ${d.pulledAt} from project \`${d.projectId}\`._`, "");
|
|
799
|
-
L.push(`**${d.comments.length}** comment(s), **${d.changes.length}** tracked change(s).`, "");
|
|
800
|
-
L.push(
|
|
801
|
-
"_Act on these:_ `reply --thread <id> --message <text>` \xB7 `resolve --thread <id>` \xB7 `delete-comment --thread <id>` \xB7 `delete-message --message-id <id>` \xB7 `accept --change <id>` \xB7 `reject --change <id>`.",
|
|
802
|
-
""
|
|
803
|
-
);
|
|
804
|
-
const byDoc = (items) => {
|
|
805
|
-
const m = /* @__PURE__ */ new Map();
|
|
806
|
-
for (const it of items) (m.get(it.doc) ?? m.set(it.doc, []).get(it.doc)).push(it);
|
|
807
|
-
return m;
|
|
808
|
-
};
|
|
809
|
-
if (d.comments.length) {
|
|
810
|
-
L.push("## Comments", "");
|
|
811
|
-
for (const [doc, items] of byDoc(d.comments)) {
|
|
812
|
-
L.push(`### ${doc}`, "");
|
|
813
|
-
for (const c of items) {
|
|
814
|
-
const flag = c.resolved ? " \u2014 \u2705 resolved" : "";
|
|
815
|
-
L.push(`#### \u{1F4AC} \u201C${c.anchor}\u201D \xB7 line ${c.line}${flag}`);
|
|
816
|
-
L.push(`\`thread ${c.threadId}\``);
|
|
817
|
-
L.push("```", c.context, "```");
|
|
818
|
-
for (const m of c.messages) {
|
|
819
|
-
L.push(`- **${m.author}**: ${m.content} \`msg ${m.id}\``);
|
|
820
|
-
}
|
|
821
|
-
if (!c.messages.length) L.push("- _(no message text)_");
|
|
822
|
-
L.push("");
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
if (d.changes.length) {
|
|
827
|
-
L.push("## Tracked changes", "");
|
|
828
|
-
for (const [doc, items] of byDoc(d.changes)) {
|
|
829
|
-
L.push(`### ${doc}`, "");
|
|
830
|
-
for (const ch of items) {
|
|
831
|
-
const verb = ch.type === "insert" ? "inserted" : "deleted";
|
|
832
|
-
L.push(
|
|
833
|
-
`- **${verb}** at line ${ch.line} by ${ch.author} \xB7 \`change ${ch.id}\`: \`${ch.text.replace(/\n/g, "\u23CE")}\``
|
|
834
|
-
);
|
|
835
|
-
L.push(" ```", ch.context.replace(/^/gm, " "), " ```");
|
|
836
|
-
}
|
|
837
|
-
L.push("");
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
return L.join("\n");
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
// src/commands/push.ts
|
|
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";
|
|
855
|
-
import { diffWordsWithSpace } from "diff";
|
|
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";
|
|
864
|
-
}
|
|
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];
|
|
875
|
-
}
|
|
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";
|
|
897
|
-
}
|
|
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);
|
|
925
|
-
}
|
|
926
|
-
}
|
|
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 };
|
|
944
|
-
}
|
|
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 [];
|
|
961
|
-
}
|
|
962
|
-
const receipts = [];
|
|
963
|
-
for (const name of names) {
|
|
964
|
-
const path = join5(receiptsDir, name);
|
|
965
|
-
try {
|
|
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
|
-
}
|
|
970
|
-
} catch {
|
|
971
|
-
}
|
|
972
|
-
}
|
|
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;
|
|
1000
|
-
}
|
|
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
|
-
})
|
|
1019
|
-
);
|
|
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
|
-
);
|
|
1028
|
-
}
|
|
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}`);
|
|
1033
|
-
}
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
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;
|
|
1043
|
-
}
|
|
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.`);
|
|
1049
|
-
}
|
|
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;
|
|
1065
|
-
}
|
|
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;
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
// src/lib/three-way.ts
|
|
1082
|
-
import { diffChars } from "diff";
|
|
1083
|
-
function textEdits(base, target) {
|
|
1084
|
-
const edits = [];
|
|
1085
|
-
let basePos = 0;
|
|
1086
|
-
let pending;
|
|
1087
|
-
const flush = () => {
|
|
1088
|
-
if (!pending) return;
|
|
1089
|
-
if (pending.start !== pending.end || pending.text.length) edits.push(pending);
|
|
1090
|
-
pending = void 0;
|
|
1091
|
-
};
|
|
1092
|
-
for (const part of diffChars(base, target)) {
|
|
1093
|
-
if (!part.added && !part.removed) {
|
|
1094
|
-
flush();
|
|
1095
|
-
basePos += part.value.length;
|
|
1096
|
-
continue;
|
|
1097
|
-
}
|
|
1098
|
-
pending ??= { start: basePos, end: basePos, text: "" };
|
|
1099
|
-
if (part.removed) {
|
|
1100
|
-
pending.end += part.value.length;
|
|
1101
|
-
basePos += part.value.length;
|
|
1102
|
-
} else {
|
|
1103
|
-
pending.text += part.value;
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
flush();
|
|
1107
|
-
return edits;
|
|
1108
|
-
}
|
|
1109
|
-
function sameEdit(a, b) {
|
|
1110
|
-
return a.start === b.start && a.end === b.end && a.text === b.text;
|
|
1111
|
-
}
|
|
1112
|
-
function reverseCodePoints(text) {
|
|
1113
|
-
return Array.from(text).reverse().join("");
|
|
1114
|
-
}
|
|
1115
|
-
function ambiguousEditAnchors(base, target) {
|
|
1116
|
-
const forward = textEdits(base, target);
|
|
1117
|
-
const reverse = textEdits(reverseCodePoints(base), reverseCodePoints(target)).map((edit) => ({
|
|
1118
|
-
start: base.length - edit.end,
|
|
1119
|
-
end: base.length - edit.start,
|
|
1120
|
-
text: reverseCodePoints(edit.text)
|
|
1121
|
-
})).sort((a, b) => a.start - b.start || a.end - b.end);
|
|
1122
|
-
const ambiguities = [];
|
|
1123
|
-
const count = Math.max(forward.length, reverse.length);
|
|
1124
|
-
for (let index = 0; index < count; index++) {
|
|
1125
|
-
const forwardEdit = forward[index] ?? reverse[index];
|
|
1126
|
-
const reverseEdit = reverse[index] ?? forward[index];
|
|
1127
|
-
if (sameEdit(forwardEdit, reverseEdit)) continue;
|
|
1128
|
-
ambiguities.push({
|
|
1129
|
-
forward: forwardEdit,
|
|
1130
|
-
reverse: reverseEdit,
|
|
1131
|
-
envelopeStart: Math.min(forwardEdit.start, reverseEdit.start),
|
|
1132
|
-
envelopeEnd: Math.max(
|
|
1133
|
-
forwardEdit.start,
|
|
1134
|
-
forwardEdit.end,
|
|
1135
|
-
reverseEdit.start,
|
|
1136
|
-
reverseEdit.end
|
|
1137
|
-
)
|
|
1138
|
-
});
|
|
1139
|
-
}
|
|
1140
|
-
return ambiguities;
|
|
1141
|
-
}
|
|
1142
|
-
function editTouchesEnvelope(edit, start, end) {
|
|
1143
|
-
if (edit.start === edit.end) return edit.start >= start && edit.start <= end;
|
|
1144
|
-
return edit.start <= end && edit.end >= start;
|
|
1145
|
-
}
|
|
1146
|
-
function editsConflict(a, b) {
|
|
1147
|
-
const aInsert = a.start === a.end;
|
|
1148
|
-
const bInsert = b.start === b.end;
|
|
1149
|
-
if (aInsert && bInsert) return a.start === b.start;
|
|
1150
|
-
if (aInsert) return a.start > b.start && a.start < b.end;
|
|
1151
|
-
if (bInsert) return b.start > a.start && b.start < a.end;
|
|
1152
|
-
return a.start < b.end && b.start < a.end;
|
|
1153
|
-
}
|
|
1154
|
-
function mapBasePosition(position, liveEdits, includeInsertionAtPosition) {
|
|
1155
|
-
let mapped = position;
|
|
1156
|
-
for (const edit of liveEdits) {
|
|
1157
|
-
if (edit.start === edit.end) {
|
|
1158
|
-
if (edit.start < position || includeInsertionAtPosition && edit.start === position) {
|
|
1159
|
-
mapped += edit.text.length;
|
|
1160
|
-
}
|
|
1161
|
-
continue;
|
|
1162
|
-
}
|
|
1163
|
-
if (edit.end <= position) mapped += edit.text.length - (edit.end - edit.start);
|
|
1164
|
-
}
|
|
1165
|
-
return mapped;
|
|
1166
|
-
}
|
|
1167
|
-
function applyEdits(source, edits) {
|
|
1168
|
-
let result = source;
|
|
1169
|
-
const ordered = edits.map((edit, index) => ({ edit, index })).sort(
|
|
1170
|
-
(a, b) => b.edit.start - a.edit.start || b.edit.end - a.edit.end || b.index - a.index
|
|
1171
|
-
);
|
|
1172
|
-
for (const { edit } of ordered) {
|
|
1173
|
-
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
|
1174
|
-
}
|
|
1175
|
-
return result;
|
|
1176
|
-
}
|
|
1177
|
-
function threeWayMerge(base, local, live) {
|
|
1178
|
-
const localEdits = textEdits(base, local);
|
|
1179
|
-
const liveEdits = textEdits(base, live);
|
|
1180
|
-
const ambiguousAnchors = ambiguousEditAnchors(base, local);
|
|
1181
|
-
const conflicts = [];
|
|
1182
|
-
const alreadyAppliedLocalEdits = [];
|
|
1183
|
-
const toApply = [];
|
|
1184
|
-
for (const localEdit of localEdits) {
|
|
1185
|
-
if (liveEdits.some((liveEdit) => sameEdit(localEdit, liveEdit))) {
|
|
1186
|
-
alreadyAppliedLocalEdits.push(localEdit);
|
|
1187
|
-
continue;
|
|
1188
|
-
}
|
|
1189
|
-
const ambiguity = ambiguousAnchors.find((candidate) => sameEdit(candidate.forward, localEdit));
|
|
1190
|
-
if (ambiguity) {
|
|
1191
|
-
const touching = liveEdits.filter(
|
|
1192
|
-
(liveEdit) => editTouchesEnvelope(liveEdit, ambiguity.envelopeStart, ambiguity.envelopeEnd)
|
|
1193
|
-
);
|
|
1194
|
-
if (touching.length) {
|
|
1195
|
-
for (const liveEdit of touching) {
|
|
1196
|
-
conflicts.push({ local: localEdit, live: liveEdit, reason: "ambiguous-local-anchor" });
|
|
1197
|
-
}
|
|
1198
|
-
continue;
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
const overlapping = liveEdits.filter((liveEdit) => editsConflict(localEdit, liveEdit));
|
|
1202
|
-
if (overlapping.length) {
|
|
1203
|
-
for (const liveEdit of overlapping) {
|
|
1204
|
-
conflicts.push({ local: localEdit, live: liveEdit, reason: "overlapping-edits" });
|
|
1205
|
-
}
|
|
1206
|
-
continue;
|
|
1207
|
-
}
|
|
1208
|
-
if (localEdit.start === localEdit.end) {
|
|
1209
|
-
const point = mapBasePosition(localEdit.start, liveEdits, false);
|
|
1210
|
-
toApply.push({ start: point, end: point, text: localEdit.text });
|
|
1211
|
-
} else {
|
|
1212
|
-
const start = mapBasePosition(localEdit.start, liveEdits, true);
|
|
1213
|
-
const end = mapBasePosition(localEdit.end, liveEdits, false);
|
|
1214
|
-
toApply.push({ start, end, text: localEdit.text });
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
return {
|
|
1218
|
-
text: conflicts.length ? void 0 : applyEdits(live, toApply),
|
|
1219
|
-
localEdits,
|
|
1220
|
-
liveEdits,
|
|
1221
|
-
appliedLocalEdits: toApply,
|
|
1222
|
-
alreadyAppliedLocalEdits,
|
|
1223
|
-
conflicts
|
|
1224
|
-
};
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
// 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 });
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
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
|
-
);
|
|
2
|
+
import {
|
|
3
|
+
RestRequestError,
|
|
4
|
+
SNAPSHOTS_DIR,
|
|
5
|
+
TrackedChangeMutationError,
|
|
6
|
+
acceptChanges,
|
|
7
|
+
acquireMutationLock,
|
|
8
|
+
applyOtUpdateAndWait,
|
|
9
|
+
beginReceipt,
|
|
10
|
+
buildRejectionPlan,
|
|
11
|
+
config,
|
|
12
|
+
consolidateApply,
|
|
13
|
+
consolidatePreview,
|
|
14
|
+
deleteMessage,
|
|
15
|
+
deleteThread,
|
|
16
|
+
findRecentIdenticalMessage,
|
|
17
|
+
fingerprintRanges,
|
|
18
|
+
getCsrfToken,
|
|
19
|
+
getThreads,
|
|
20
|
+
joinDoc,
|
|
21
|
+
matchDocument,
|
|
22
|
+
mergeBaseDocuments,
|
|
23
|
+
observePostedThreadMessage,
|
|
24
|
+
openProject,
|
|
25
|
+
postThreadMessageDetailed,
|
|
26
|
+
push,
|
|
27
|
+
readReceipts,
|
|
28
|
+
remainingChangeIds,
|
|
29
|
+
saveCredentials,
|
|
30
|
+
saveProjectConfig,
|
|
31
|
+
setThreadResolved,
|
|
32
|
+
sha256,
|
|
33
|
+
snapshotRelativePath,
|
|
34
|
+
snapshotTimestamp,
|
|
35
|
+
threadMessageId,
|
|
36
|
+
threadMessages,
|
|
37
|
+
uniqueChangeIds,
|
|
38
|
+
updateReceipt,
|
|
39
|
+
uploadFile,
|
|
40
|
+
validateSession,
|
|
41
|
+
workspaceWritePath
|
|
42
|
+
} from "./chunk-TWIDV42A.js";
|
|
43
|
+
|
|
44
|
+
// src/commands/pull.ts
|
|
45
|
+
import { writeFileSync, mkdirSync } from "fs";
|
|
46
|
+
import { join } from "path";
|
|
47
|
+
|
|
48
|
+
// src/lib/anchors.ts
|
|
49
|
+
function offsetToLine(lines, offset) {
|
|
50
|
+
let remaining = offset;
|
|
51
|
+
for (let i = 0; i < lines.length; i++) {
|
|
52
|
+
const len = lines[i].length;
|
|
53
|
+
if (remaining <= len) return i;
|
|
54
|
+
remaining -= len + 1;
|
|
1916
55
|
}
|
|
1917
|
-
return
|
|
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
|
-
});
|
|
56
|
+
return Math.max(0, lines.length - 1);
|
|
1950
57
|
}
|
|
1951
|
-
function
|
|
1952
|
-
const
|
|
1953
|
-
const
|
|
1954
|
-
const
|
|
1955
|
-
|
|
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
|
-
}
|
|
58
|
+
function lineContext(lines, line, radius = 1) {
|
|
59
|
+
const start = Math.max(0, line - radius);
|
|
60
|
+
const end = Math.min(lines.length - 1, line + radius);
|
|
61
|
+
const out = [];
|
|
62
|
+
for (let i = start; i <= end; i++) {
|
|
63
|
+
out.push(`${i === line ? ">" : " "} ${String(i + 1).padStart(3)} ${lines[i]}`);
|
|
1969
64
|
}
|
|
1970
|
-
return
|
|
65
|
+
return out.join("\n");
|
|
1971
66
|
}
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
const
|
|
1982
|
-
|
|
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 };
|
|
67
|
+
|
|
68
|
+
// src/commands/pull.ts
|
|
69
|
+
function buildMemberMap(project) {
|
|
70
|
+
const map = {};
|
|
71
|
+
const add = (u) => {
|
|
72
|
+
if (!u?._id) return;
|
|
73
|
+
map[u._id] = [u.first_name, u.last_name].filter(Boolean).join(" ") || u.email || u._id;
|
|
74
|
+
};
|
|
75
|
+
add(project?.owner);
|
|
76
|
+
for (const m of project?.members ?? []) add(m);
|
|
77
|
+
return map;
|
|
2000
78
|
}
|
|
2001
|
-
async function
|
|
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;
|
|
79
|
+
async function pull(outDir = ".overleaf", options = {}) {
|
|
2023
80
|
let mutationLock;
|
|
2024
|
-
let
|
|
2025
|
-
let failureStatus;
|
|
81
|
+
let opened;
|
|
2026
82
|
try {
|
|
2027
|
-
if (
|
|
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
|
-
}
|
|
83
|
+
if (options.acquireLock !== false) mutationLock = acquireMutationLock(config.projectId);
|
|
2054
84
|
opened = await openProject();
|
|
2055
85
|
const { socket, project, docs } = opened;
|
|
2056
|
-
const
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
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)
|
|
86
|
+
const members = buildMemberMap(project);
|
|
87
|
+
const threads = await getThreads();
|
|
88
|
+
const comments = [];
|
|
89
|
+
const changes = [];
|
|
90
|
+
for (const doc of docs) {
|
|
91
|
+
const state = await joinDoc(socket, doc._id);
|
|
92
|
+
for (const c of state.ranges.comments ?? []) {
|
|
93
|
+
const line = offsetToLine(state.lines, c.op.p);
|
|
94
|
+
const thread = threads[c.op.t] ?? {};
|
|
95
|
+
comments.push({
|
|
96
|
+
doc: doc.path,
|
|
97
|
+
threadId: c.op.t,
|
|
98
|
+
anchor: c.op.c,
|
|
99
|
+
line: line + 1,
|
|
100
|
+
context: lineContext(state.lines, line),
|
|
101
|
+
resolved: Boolean(thread.resolved),
|
|
102
|
+
messages: (thread.messages ?? []).map((m) => ({
|
|
103
|
+
id: m.id,
|
|
104
|
+
author: m.user?.first_name ?? members[m.user_id] ?? m.user_id,
|
|
105
|
+
email: m.user?.email,
|
|
106
|
+
content: m.content,
|
|
107
|
+
timestamp: m.timestamp
|
|
108
|
+
}))
|
|
2111
109
|
});
|
|
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
110
|
}
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
const
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
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
|
-
);
|
|
111
|
+
for (const ch of state.ranges.changes ?? []) {
|
|
112
|
+
const isInsert = typeof ch.op.i === "string";
|
|
113
|
+
const line = offsetToLine(state.lines, ch.op.p);
|
|
114
|
+
changes.push({
|
|
115
|
+
doc: doc.path,
|
|
116
|
+
id: ch.id,
|
|
117
|
+
type: isInsert ? "insert" : "delete",
|
|
118
|
+
text: isInsert ? ch.op.i : ch.op.d,
|
|
119
|
+
line: line + 1,
|
|
120
|
+
context: lineContext(state.lines, line),
|
|
121
|
+
author: members[ch.metadata?.user_id] ?? ch.metadata?.user_id ?? "unknown",
|
|
122
|
+
ts: ch.metadata?.ts
|
|
123
|
+
});
|
|
2200
124
|
}
|
|
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
125
|
}
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
return {
|
|
2230
|
-
projectId: plan.projectId,
|
|
2231
|
-
direct: plan.direct,
|
|
2232
|
-
totalOps,
|
|
2233
|
-
documents: completed,
|
|
2234
|
-
receiptPath: receipt.path
|
|
126
|
+
const data = {
|
|
127
|
+
project: project?.name ?? "(unknown)",
|
|
128
|
+
projectId: config.projectId,
|
|
129
|
+
pulledAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
130
|
+
comments,
|
|
131
|
+
changes
|
|
2235
132
|
};
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
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
|
-
);
|
|
133
|
+
mkdirSync(outDir, { recursive: true });
|
|
134
|
+
writeFileSync(join(outDir, "reviews.json"), JSON.stringify(data, null, 2) + "\n");
|
|
135
|
+
writeFileSync(join(outDir, "reviews.md"), renderMarkdown(data));
|
|
136
|
+
return data;
|
|
2252
137
|
} finally {
|
|
2253
138
|
try {
|
|
2254
139
|
opened?.socket.close();
|
|
@@ -2257,71 +142,57 @@ async function submitPlan(planOrPath, opts = {}) {
|
|
|
2257
142
|
}
|
|
2258
143
|
}
|
|
2259
144
|
}
|
|
2260
|
-
function
|
|
2261
|
-
|
|
2262
|
-
|
|
145
|
+
function renderMarkdown(d) {
|
|
146
|
+
const L = [];
|
|
147
|
+
L.push(`# Overleaf review \u2014 ${d.project}`, "");
|
|
148
|
+
L.push(`_Pulled ${d.pulledAt} from project \`${d.projectId}\`._`, "");
|
|
149
|
+
L.push(`**${d.comments.length}** comment(s), **${d.changes.length}** tracked change(s).`, "");
|
|
150
|
+
L.push(
|
|
151
|
+
"_Act on these:_ `reply --thread <id> --message <text>` \xB7 `resolve --thread <id>` \xB7 `delete-comment --thread <id>` \xB7 `delete-message --message-id <id>` \xB7 `accept --change <id>` \xB7 `reject --change <id>`.",
|
|
152
|
+
""
|
|
2263
153
|
);
|
|
2264
|
-
|
|
2265
|
-
const
|
|
2266
|
-
const
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
);
|
|
2271
|
-
for (const
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
154
|
+
const byDoc = (items) => {
|
|
155
|
+
const m = /* @__PURE__ */ new Map();
|
|
156
|
+
for (const it of items) (m.get(it.doc) ?? m.set(it.doc, []).get(it.doc)).push(it);
|
|
157
|
+
return m;
|
|
158
|
+
};
|
|
159
|
+
if (d.comments.length) {
|
|
160
|
+
L.push("## Comments", "");
|
|
161
|
+
for (const [doc, items] of byDoc(d.comments)) {
|
|
162
|
+
L.push(`### ${doc}`, "");
|
|
163
|
+
for (const c of items) {
|
|
164
|
+
const flag = c.resolved ? " \u2014 \u2705 resolved" : "";
|
|
165
|
+
L.push(`#### \u{1F4AC} \u201C${c.anchor}\u201D \xB7 line ${c.line}${flag}`);
|
|
166
|
+
L.push(`\`thread ${c.threadId}\``);
|
|
167
|
+
L.push("```", c.context, "```");
|
|
168
|
+
for (const m of c.messages) {
|
|
169
|
+
L.push(`- **${m.author}**: ${m.content} \`msg ${m.id}\``);
|
|
170
|
+
}
|
|
171
|
+
if (!c.messages.length) L.push("- _(no message text)_");
|
|
172
|
+
L.push("");
|
|
173
|
+
}
|
|
2277
174
|
}
|
|
2278
175
|
}
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
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;
|
|
176
|
+
if (d.changes.length) {
|
|
177
|
+
L.push("## Tracked changes", "");
|
|
178
|
+
for (const [doc, items] of byDoc(d.changes)) {
|
|
179
|
+
L.push(`### ${doc}`, "");
|
|
180
|
+
for (const ch of items) {
|
|
181
|
+
const verb = ch.type === "insert" ? "inserted" : "deleted";
|
|
182
|
+
L.push(
|
|
183
|
+
`- **${verb}** at line ${ch.line} by ${ch.author} \xB7 \`change ${ch.id}\`: \`${ch.text.replace(/\n/g, "\u23CE")}\``
|
|
184
|
+
);
|
|
185
|
+
L.push(" ```", ch.context.replace(/^/gm, " "), " ```");
|
|
186
|
+
}
|
|
187
|
+
L.push("");
|
|
188
|
+
}
|
|
2309
189
|
}
|
|
2310
|
-
|
|
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}`);
|
|
190
|
+
return L.join("\n");
|
|
2320
191
|
}
|
|
2321
192
|
|
|
2322
193
|
// src/commands/fetch.ts
|
|
2323
|
-
import { writeFileSync as
|
|
2324
|
-
import { dirname
|
|
194
|
+
import { writeFileSync as writeFileSync2, readFileSync, mkdirSync as mkdirSync2, existsSync } from "fs";
|
|
195
|
+
import { dirname } from "path";
|
|
2325
196
|
async function fetchDocs(opts) {
|
|
2326
197
|
const { socket, project, docs } = await openProject();
|
|
2327
198
|
let mutationLock;
|
|
@@ -2356,7 +227,7 @@ async function fetchDocs(opts) {
|
|
|
2356
227
|
for (const { doc, localPath } of localTargets) {
|
|
2357
228
|
const state = await joinDoc(socket, doc._id);
|
|
2358
229
|
const remote = state.lines.join("\n");
|
|
2359
|
-
const local =
|
|
230
|
+
const local = existsSync(localPath) ? readFileSync(localPath, "utf8") : null;
|
|
2360
231
|
entries.push({
|
|
2361
232
|
doc,
|
|
2362
233
|
localPath,
|
|
@@ -2382,7 +253,7 @@ async function fetchDocs(opts) {
|
|
|
2382
253
|
if (!opts.dryRun) {
|
|
2383
254
|
for (const entry of changedEntries) {
|
|
2384
255
|
if (entry.local === null) continue;
|
|
2385
|
-
const current =
|
|
256
|
+
const current = readFileSync(entry.localPath, "utf8");
|
|
2386
257
|
if (current !== entry.local) {
|
|
2387
258
|
throw new Error(`${entry.doc.path} changed while fetch was reading the project; retry fetch.`);
|
|
2388
259
|
}
|
|
@@ -2393,15 +264,15 @@ async function fetchDocs(opts) {
|
|
|
2393
264
|
snapshotPath: workspaceWritePath(snapshotRelativePath(timestamp, entry.doc.path))
|
|
2394
265
|
}));
|
|
2395
266
|
for (const { entry, snapshotPath } of snapshotTargets) {
|
|
2396
|
-
|
|
2397
|
-
|
|
267
|
+
mkdirSync2(dirname(snapshotPath), { recursive: true });
|
|
268
|
+
writeFileSync2(snapshotPath, entry.local, { mode: 384 });
|
|
2398
269
|
}
|
|
2399
270
|
if (snapshotTargets.length) {
|
|
2400
271
|
snapshotRoot = `${SNAPSHOTS_DIR}/${timestamp}`;
|
|
2401
272
|
}
|
|
2402
273
|
for (const { localPath, remote } of changedEntries) {
|
|
2403
|
-
|
|
2404
|
-
|
|
274
|
+
mkdirSync2(dirname(localPath), { recursive: true });
|
|
275
|
+
writeFileSync2(localPath, remote);
|
|
2405
276
|
}
|
|
2406
277
|
}
|
|
2407
278
|
if (!opts.dryRun) mergeBaseDocuments(projectId, entries.map((entry) => entry.base));
|
|
@@ -2427,8 +298,8 @@ async function fetchDocs(opts) {
|
|
|
2427
298
|
}
|
|
2428
299
|
|
|
2429
300
|
// src/commands/upload.ts
|
|
2430
|
-
import { readFileSync as
|
|
2431
|
-
import { basename
|
|
301
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
302
|
+
import { basename } from "path";
|
|
2432
303
|
function findFolder(folder, wanted, prefix = "") {
|
|
2433
304
|
for (const f of folder?.folders ?? []) {
|
|
2434
305
|
const path = prefix ? `${prefix}/${f.name}` : f.name;
|
|
@@ -2455,8 +326,8 @@ async function upload(paths, folderName) {
|
|
|
2455
326
|
}
|
|
2456
327
|
const csrf = await getCsrfToken();
|
|
2457
328
|
for (const path of paths) {
|
|
2458
|
-
const bytes =
|
|
2459
|
-
const res = await uploadFile(folderId,
|
|
329
|
+
const bytes = readFileSync2(path);
|
|
330
|
+
const res = await uploadFile(folderId, basename(path), bytes, csrf);
|
|
2460
331
|
console.log(`\u2705 Uploaded ${path} \u2192 ${res.entity_type} ${res.entity_id}`);
|
|
2461
332
|
}
|
|
2462
333
|
} finally {
|
|
@@ -2469,7 +340,7 @@ async function upload(paths, folderName) {
|
|
|
2469
340
|
}
|
|
2470
341
|
|
|
2471
342
|
// src/commands/comment.ts
|
|
2472
|
-
import { createHash
|
|
343
|
+
import { createHash, randomBytes } from "crypto";
|
|
2473
344
|
var DEFAULT_DUPLICATE_WINDOW_MS = 5 * 60 * 1e3;
|
|
2474
345
|
function isRecentAttempt(prior, nowMs, windowMs) {
|
|
2475
346
|
const updatedAt = prior.updatedAt ? Date.parse(prior.updatedAt) : Number.NaN;
|
|
@@ -2510,7 +381,7 @@ function commentIntentHash(intent) {
|
|
|
2510
381
|
intent.occurrence,
|
|
2511
382
|
intent.message
|
|
2512
383
|
];
|
|
2513
|
-
return
|
|
384
|
+
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
2514
385
|
}
|
|
2515
386
|
function rangeThreadId(range) {
|
|
2516
387
|
const value = range?.op?.t;
|
|
@@ -2532,7 +403,7 @@ function threadSnapshot(thread) {
|
|
|
2532
403
|
messageIds: messages.map(threadMessageId).filter((id) => Boolean(id))
|
|
2533
404
|
};
|
|
2534
405
|
}
|
|
2535
|
-
function
|
|
406
|
+
function errorMessage(error) {
|
|
2536
407
|
return error instanceof Error ? error.message : String(error);
|
|
2537
408
|
}
|
|
2538
409
|
function priorThreadIdForIntent(intentHash, receiptsDir) {
|
|
@@ -2663,7 +534,7 @@ async function commentWithResult(opts) {
|
|
|
2663
534
|
const canResume = Boolean(
|
|
2664
535
|
priorRange && prior.threadId && threadMessages(threads[prior.threadId]).length === 0
|
|
2665
536
|
);
|
|
2666
|
-
const threadId = canResume ? prior.threadId :
|
|
537
|
+
const threadId = canResume ? prior.threadId : randomBytes(12).toString("hex");
|
|
2667
538
|
let anchorCreated = false;
|
|
2668
539
|
let beforeThread = threads[threadId];
|
|
2669
540
|
receipt = beginReceipt(
|
|
@@ -2712,8 +583,8 @@ async function commentWithResult(opts) {
|
|
|
2712
583
|
} catch (readbackError) {
|
|
2713
584
|
receipt = updateReceipt(receipt, "ambiguous", {
|
|
2714
585
|
phase: "anchor_outcome_unknown",
|
|
2715
|
-
...anchorError ? { transportError:
|
|
2716
|
-
verificationError:
|
|
586
|
+
...anchorError ? { transportError: errorMessage(anchorError) } : {},
|
|
587
|
+
verificationError: errorMessage(readbackError)
|
|
2717
588
|
});
|
|
2718
589
|
throw new Error(
|
|
2719
590
|
`Comment anchor outcome is ambiguous; inspect ${receipt.path} before retrying`
|
|
@@ -2722,7 +593,7 @@ async function commentWithResult(opts) {
|
|
|
2722
593
|
if (!commentRangeAnchorsText(verifiedRange, afterAnchorText ?? "", opts.anchor)) {
|
|
2723
594
|
receipt = updateReceipt(receipt, anchorError ? "ambiguous" : "failed", {
|
|
2724
595
|
phase: anchorError ? "anchor_outcome_unknown" : "anchor_unverified",
|
|
2725
|
-
...anchorError ? { transportError:
|
|
596
|
+
...anchorError ? { transportError: errorMessage(anchorError) } : {},
|
|
2726
597
|
anchorRange: rangeSnapshot(verifiedRange)
|
|
2727
598
|
});
|
|
2728
599
|
throw new Error(
|
|
@@ -2734,7 +605,7 @@ async function commentWithResult(opts) {
|
|
|
2734
605
|
phase: "anchor_verified",
|
|
2735
606
|
anchorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2736
607
|
anchorRange: rangeSnapshot(verifiedRange),
|
|
2737
|
-
...anchorError ? { transportError:
|
|
608
|
+
...anchorError ? { transportError: errorMessage(anchorError) } : {}
|
|
2738
609
|
});
|
|
2739
610
|
} else {
|
|
2740
611
|
receipt = updateReceipt(receipt, "in_progress", {
|
|
@@ -2783,7 +654,7 @@ async function commentWithResult(opts) {
|
|
|
2783
654
|
receipt = updateReceipt(receipt, definitelyRejected ? "failed" : "ambiguous", {
|
|
2784
655
|
phase: !postAttempted ? "message_preflight_failed" : definitelyRejected ? "message_rejected" : "message_outcome_unknown",
|
|
2785
656
|
anchorRange: rangeSnapshot(verifiedRange),
|
|
2786
|
-
...postError ? { error:
|
|
657
|
+
...postError ? { error: errorMessage(postError) } : {},
|
|
2787
658
|
...postError instanceof RestRequestError ? { responseStatus: postError.status, responseBody: postError.responseBody } : responseStatus ? { responseStatus } : {},
|
|
2788
659
|
verificationAttempts: observed.attempts,
|
|
2789
660
|
...observed.lastError ? { verificationError: observed.lastError } : {},
|
|
@@ -2805,7 +676,7 @@ async function commentWithResult(opts) {
|
|
|
2805
676
|
phase: "final_anchor_verification_failed",
|
|
2806
677
|
messageId: observedMessageId,
|
|
2807
678
|
messageVerifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2808
|
-
verificationError:
|
|
679
|
+
verificationError: errorMessage(error)
|
|
2809
680
|
});
|
|
2810
681
|
throw new Error(
|
|
2811
682
|
`Comment message exists, but its anchor could not be rechecked. Inspect ${receipt.path}`
|
|
@@ -2827,7 +698,7 @@ async function commentWithResult(opts) {
|
|
|
2827
698
|
verificationAttempts: observed.attempts,
|
|
2828
699
|
finalAnchorRange: rangeSnapshot(finalRange),
|
|
2829
700
|
afterThread: threadSnapshot(observed.thread),
|
|
2830
|
-
...postError ? { transportError:
|
|
701
|
+
...postError ? { transportError: errorMessage(postError) } : {},
|
|
2831
702
|
...observedMessageId ? { messageId: observedMessageId } : {}
|
|
2832
703
|
});
|
|
2833
704
|
return {
|
|
@@ -2843,7 +714,7 @@ async function commentWithResult(opts) {
|
|
|
2843
714
|
if (receipt && receipt.receipt.status !== "failed" && receipt.receipt.status !== "ambiguous" && receipt.receipt.status !== "skipped" && receipt.receipt.status !== "succeeded") {
|
|
2844
715
|
receipt = updateReceipt(receipt, "failed", {
|
|
2845
716
|
phase: "command_failed",
|
|
2846
|
-
error:
|
|
717
|
+
error: errorMessage(error)
|
|
2847
718
|
});
|
|
2848
719
|
}
|
|
2849
720
|
throw error;
|
|
@@ -2870,7 +741,7 @@ async function comment(opts) {
|
|
|
2870
741
|
|
|
2871
742
|
// src/commands/reply.ts
|
|
2872
743
|
var DEFAULT_DUPLICATE_WINDOW_MS2 = 5 * 60 * 1e3;
|
|
2873
|
-
function
|
|
744
|
+
function errorMessage2(error) {
|
|
2874
745
|
return error instanceof Error ? error.message : String(error);
|
|
2875
746
|
}
|
|
2876
747
|
function threadSnapshot2(thread) {
|
|
@@ -3024,7 +895,7 @@ async function replyWithResult(threadId, message, options = {}) {
|
|
|
3024
895
|
receipt = updateReceipt(receipt, "succeeded", {
|
|
3025
896
|
phase: "complete",
|
|
3026
897
|
outcome: "verified_after_transport_error",
|
|
3027
|
-
transportError:
|
|
898
|
+
transportError: errorMessage2(error),
|
|
3028
899
|
...responseStatus ? { responseStatus } : {},
|
|
3029
900
|
verificationAttempts: observed.attempts,
|
|
3030
901
|
verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -3042,7 +913,7 @@ async function replyWithResult(threadId, message, options = {}) {
|
|
|
3042
913
|
const definitelyRejected = error instanceof RestRequestError && error.status < 500;
|
|
3043
914
|
receipt = updateReceipt(receipt, definitelyRejected ? "failed" : "ambiguous", {
|
|
3044
915
|
phase: definitelyRejected ? "message_rejected" : "message_outcome_unknown",
|
|
3045
|
-
error:
|
|
916
|
+
error: errorMessage2(error),
|
|
3046
917
|
...error instanceof RestRequestError ? { responseStatus: error.status, responseBody: error.responseBody } : {},
|
|
3047
918
|
verificationAttempts: observed.attempts,
|
|
3048
919
|
...observed.lastError ? { verificationError: observed.lastError } : {},
|
|
@@ -3050,13 +921,13 @@ async function replyWithResult(threadId, message, options = {}) {
|
|
|
3050
921
|
});
|
|
3051
922
|
const qualification = definitelyRejected ? "failed" : "has an ambiguous outcome";
|
|
3052
923
|
throw new Error(
|
|
3053
|
-
`Reply to thread ${threadId} ${qualification}: ${
|
|
924
|
+
`Reply to thread ${threadId} ${qualification}: ${errorMessage2(error)}. Receipt: ${receipt.path}`
|
|
3054
925
|
);
|
|
3055
926
|
}
|
|
3056
927
|
if (receipt.receipt.status !== "ambiguous" && receipt.receipt.status !== "failed" && receipt.receipt.status !== "skipped" && receipt.receipt.status !== "succeeded") {
|
|
3057
928
|
receipt = updateReceipt(receipt, "failed", {
|
|
3058
929
|
phase: "preflight_failed",
|
|
3059
|
-
error:
|
|
930
|
+
error: errorMessage2(error)
|
|
3060
931
|
});
|
|
3061
932
|
}
|
|
3062
933
|
throw error;
|
|
@@ -3078,7 +949,7 @@ async function reply(threadId, message, options = {}) {
|
|
|
3078
949
|
}
|
|
3079
950
|
|
|
3080
951
|
// src/commands/resolve.ts
|
|
3081
|
-
async function
|
|
952
|
+
async function resolve(threadId, reopen = false) {
|
|
3082
953
|
const mutationLock = acquireMutationLock(config.projectId);
|
|
3083
954
|
let socket;
|
|
3084
955
|
try {
|
|
@@ -3180,13 +1051,13 @@ async function verifyAcceptedDocument(socket, docId, changeIds) {
|
|
|
3180
1051
|
lastError = error;
|
|
3181
1052
|
}
|
|
3182
1053
|
if (attempt + 1 < VERIFY_ATTEMPTS) {
|
|
3183
|
-
await new Promise((
|
|
1054
|
+
await new Promise((resolve2) => setTimeout(resolve2, VERIFY_INTERVAL_MS));
|
|
3184
1055
|
}
|
|
3185
1056
|
}
|
|
3186
1057
|
if (!last) throw lastError ?? new Error(`could not read back document ${docId}`);
|
|
3187
1058
|
return last;
|
|
3188
1059
|
}
|
|
3189
|
-
function
|
|
1060
|
+
function errorMessage3(error) {
|
|
3190
1061
|
return error instanceof Error ? error.message : String(error);
|
|
3191
1062
|
}
|
|
3192
1063
|
async function accept(changeIds) {
|
|
@@ -3241,7 +1112,7 @@ async function accept(changeIds) {
|
|
|
3241
1112
|
csrf = await getCsrfToken();
|
|
3242
1113
|
} catch (error) {
|
|
3243
1114
|
throw new TrackedChangeMutationError(
|
|
3244
|
-
`Could not obtain a CSRF token before accepting tracked changes: ${
|
|
1115
|
+
`Could not obtain a CSRF token before accepting tracked changes: ${errorMessage3(error)}`,
|
|
3245
1116
|
result,
|
|
3246
1117
|
error
|
|
3247
1118
|
);
|
|
@@ -3259,7 +1130,7 @@ async function accept(changeIds) {
|
|
|
3259
1130
|
fragmentCount: 0,
|
|
3260
1131
|
status: "failed",
|
|
3261
1132
|
remainingIds: ids,
|
|
3262
|
-
error:
|
|
1133
|
+
error: errorMessage3(error)
|
|
3263
1134
|
};
|
|
3264
1135
|
result.documents.push(outcome2);
|
|
3265
1136
|
refreshVerifiedIds();
|
|
@@ -3319,14 +1190,14 @@ async function accept(changeIds) {
|
|
|
3319
1190
|
}
|
|
3320
1191
|
if (after && !outcome.remainingIds.length) {
|
|
3321
1192
|
outcome.status = "verified";
|
|
3322
|
-
if (mutationError) outcome.error = `transport warning: ${
|
|
1193
|
+
if (mutationError) outcome.error = `transport warning: ${errorMessage3(mutationError)}`;
|
|
3323
1194
|
verifiedDocs.add(doc._id);
|
|
3324
1195
|
refreshVerifiedIds();
|
|
3325
1196
|
continue;
|
|
3326
1197
|
}
|
|
3327
1198
|
const reasons = [];
|
|
3328
|
-
if (mutationError) reasons.push(
|
|
3329
|
-
if (readbackError) reasons.push(`readback failed: ${
|
|
1199
|
+
if (mutationError) reasons.push(errorMessage3(mutationError));
|
|
1200
|
+
if (readbackError) reasons.push(`readback failed: ${errorMessage3(readbackError)}`);
|
|
3330
1201
|
if (outcome.remainingIds.length) {
|
|
3331
1202
|
reasons.push(`change ids still present: ${outcome.remainingIds.join(", ")}`);
|
|
3332
1203
|
}
|
|
@@ -3377,13 +1248,13 @@ async function verifyRejectedDocument(socket, docId, changeIds, expectedText) {
|
|
|
3377
1248
|
lastError = error;
|
|
3378
1249
|
}
|
|
3379
1250
|
if (attempt + 1 < VERIFY_ATTEMPTS2) {
|
|
3380
|
-
await new Promise((
|
|
1251
|
+
await new Promise((resolve2) => setTimeout(resolve2, VERIFY_INTERVAL_MS2));
|
|
3381
1252
|
}
|
|
3382
1253
|
}
|
|
3383
1254
|
if (!last) throw lastError ?? new Error(`could not read back document ${docId}`);
|
|
3384
1255
|
return last;
|
|
3385
1256
|
}
|
|
3386
|
-
function
|
|
1257
|
+
function errorMessage4(error) {
|
|
3387
1258
|
return error instanceof Error ? error.message : String(error);
|
|
3388
1259
|
}
|
|
3389
1260
|
async function reject(changeIds) {
|
|
@@ -3446,7 +1317,7 @@ async function reject(changeIds) {
|
|
|
3446
1317
|
fragmentCount: 0,
|
|
3447
1318
|
status: "failed",
|
|
3448
1319
|
remainingIds: ids,
|
|
3449
|
-
error:
|
|
1320
|
+
error: errorMessage4(error)
|
|
3450
1321
|
};
|
|
3451
1322
|
result.documents.push(outcome2);
|
|
3452
1323
|
refreshVerifiedIds();
|
|
@@ -3487,12 +1358,12 @@ async function reject(changeIds) {
|
|
|
3487
1358
|
beforeVersion: before.version,
|
|
3488
1359
|
status: "failed",
|
|
3489
1360
|
remainingIds: presentIds,
|
|
3490
|
-
error:
|
|
1361
|
+
error: errorMessage4(error)
|
|
3491
1362
|
};
|
|
3492
1363
|
result.documents.push(outcome2);
|
|
3493
1364
|
refreshVerifiedIds();
|
|
3494
1365
|
throw new TrackedChangeMutationError(
|
|
3495
|
-
`Could not safely plan rejection in ${doc.path}: ${
|
|
1366
|
+
`Could not safely plan rejection in ${doc.path}: ${errorMessage4(error)}`,
|
|
3496
1367
|
result,
|
|
3497
1368
|
error
|
|
3498
1369
|
);
|
|
@@ -3535,14 +1406,14 @@ async function reject(changeIds) {
|
|
|
3535
1406
|
}
|
|
3536
1407
|
if (after && !outcome.remainingIds.length && outcome.textVerified) {
|
|
3537
1408
|
outcome.status = "verified";
|
|
3538
|
-
if (mutationError) outcome.error = `transport warning: ${
|
|
1409
|
+
if (mutationError) outcome.error = `transport warning: ${errorMessage4(mutationError)}`;
|
|
3539
1410
|
verifiedDocs.add(doc._id);
|
|
3540
1411
|
refreshVerifiedIds();
|
|
3541
1412
|
continue;
|
|
3542
1413
|
}
|
|
3543
1414
|
const reasons = [];
|
|
3544
|
-
if (mutationError) reasons.push(
|
|
3545
|
-
if (readbackError) reasons.push(`readback failed: ${
|
|
1415
|
+
if (mutationError) reasons.push(errorMessage4(mutationError));
|
|
1416
|
+
if (readbackError) reasons.push(`readback failed: ${errorMessage4(readbackError)}`);
|
|
3546
1417
|
if (outcome.remainingIds.length) {
|
|
3547
1418
|
reasons.push(`change ids still present: ${outcome.remainingIds.join(", ")}`);
|
|
3548
1419
|
}
|
|
@@ -3597,6 +1468,9 @@ async function login(opts) {
|
|
|
3597
1468
|
const path = saveCredentials({ baseUrl, session2: cookie });
|
|
3598
1469
|
console.log(`
|
|
3599
1470
|
\u2705 Logged in as ${account}. Saved to ${path} (chmod 600).`);
|
|
1471
|
+
if (process.env.OVERLEAF_SESSION2 && process.env.OVERLEAF_SESSION2 !== cookie) {
|
|
1472
|
+
console.warn("An OVERLEAF_SESSION2 override is still set (possibly in .env). Remove or update it to use this saved login.");
|
|
1473
|
+
}
|
|
3600
1474
|
}
|
|
3601
1475
|
async function captureCookieViaBrowser(baseUrl) {
|
|
3602
1476
|
let chromium;
|
|
@@ -3660,8 +1534,13 @@ function usage() {
|
|
|
3660
1534
|
console.log("Safe review workflow:");
|
|
3661
1535
|
console.log(" review start [--file <f>] [--out <dir>] Fetch text/base, then pull review data");
|
|
3662
1536
|
console.log(" review plan --out <plan.json> [options] Save a complete binding push plan");
|
|
1537
|
+
console.log(" --file <f> --edits <blocks.json> Preserve explicit before/after blocks");
|
|
1538
|
+
console.log(" Blocks with supersedes/comments/reason approve specific reviewed overlaps");
|
|
3663
1539
|
console.log(" review submit --plan <plan.json> Validate, apply, and verify that plan");
|
|
3664
1540
|
console.log(" [--acknowledge-ambiguous] Continue only after manual reconciliation\n");
|
|
1541
|
+
console.log(" review consolidate --doc <path> --author <user-id> --out <preview.json>");
|
|
1542
|
+
console.log(" [--snapshot <snapshot.json>] [--change <id> \u2026] Plan only");
|
|
1543
|
+
console.log(" review consolidate --apply --plan <plan.json> Apply a checked consolidation\n");
|
|
3665
1544
|
console.log("Content (replaces the git bridge):");
|
|
3666
1545
|
console.log(" fetch [--file <f>] [--dry-run] Overleaf text \u2192 local files + saved base");
|
|
3667
1546
|
console.log(" upload <path\u2026> [--folder <name>] Upload figures / new files to Overleaf\n");
|
|
@@ -3689,6 +1568,7 @@ async function pullAndReport(out, options = {}) {
|
|
|
3689
1568
|
}
|
|
3690
1569
|
function pushOptions() {
|
|
3691
1570
|
return {
|
|
1571
|
+
edits: getFlag("edits"),
|
|
3692
1572
|
file: getFlag("file"),
|
|
3693
1573
|
docName: getFlag("doc"),
|
|
3694
1574
|
direct: process.argv.includes("--direct"),
|
|
@@ -3771,11 +1651,36 @@ async function main() {
|
|
|
3771
1651
|
} finally {
|
|
3772
1652
|
mutationLock.release();
|
|
3773
1653
|
}
|
|
1654
|
+
} else if (reviewCommand === "consolidate") {
|
|
1655
|
+
if (process.argv.includes("--submit")) fail("Use review consolidate --apply --plan <file>.");
|
|
1656
|
+
if (process.argv.includes("--apply")) {
|
|
1657
|
+
const plan = getFlag("plan");
|
|
1658
|
+
if (!plan || process.argv.slice(4).some((arg) => arg.startsWith("--") && !["--apply", "--plan"].includes(arg))) {
|
|
1659
|
+
fail("Consolidation apply requires only --apply --plan <file>; create a new plan to change its scope.");
|
|
1660
|
+
}
|
|
1661
|
+
await consolidateApply(plan);
|
|
1662
|
+
break;
|
|
1663
|
+
}
|
|
1664
|
+
if (getFlag("plan")) fail("--plan requires --apply for consolidation.");
|
|
1665
|
+
const author = getFlag("author");
|
|
1666
|
+
const out = getFlag("out");
|
|
1667
|
+
if (!author || !out) fail("review consolidate requires --author <user-id> and --out <preview.json>");
|
|
1668
|
+
const changeIds = getAll("change");
|
|
1669
|
+
await consolidatePreview({
|
|
1670
|
+
author,
|
|
1671
|
+
out,
|
|
1672
|
+
doc: getFlag("doc"),
|
|
1673
|
+
snapshot: getFlag("snapshot"),
|
|
1674
|
+
changeIds: changeIds.length ? changeIds : void 0
|
|
1675
|
+
});
|
|
3774
1676
|
} else if (reviewCommand === "plan") {
|
|
3775
1677
|
const out = getFlag("out");
|
|
3776
1678
|
if (!out) fail("review plan requires --out <plan.json>");
|
|
3777
1679
|
await push({ ...pushOptions(), plan: void 0, planOut: out, dryRun: true });
|
|
3778
1680
|
} else if (reviewCommand === "submit") {
|
|
1681
|
+
if (process.argv.slice(4).some((arg) => arg.startsWith("--") && !["--plan", "--acknowledge-ambiguous"].includes(arg))) {
|
|
1682
|
+
fail("review submit accepts only --plan (and --acknowledge-ambiguous for ordinary push plans). Use review plan for a dry run.");
|
|
1683
|
+
}
|
|
3779
1684
|
const plan = getFlag("plan");
|
|
3780
1685
|
if (!plan) fail("review submit requires --plan <plan.json>");
|
|
3781
1686
|
await push({
|
|
@@ -3784,7 +1689,7 @@ async function main() {
|
|
|
3784
1689
|
});
|
|
3785
1690
|
} else {
|
|
3786
1691
|
usage();
|
|
3787
|
-
fail("review requires start, plan, or
|
|
1692
|
+
fail("review requires start, plan, submit, or consolidate");
|
|
3788
1693
|
}
|
|
3789
1694
|
break;
|
|
3790
1695
|
}
|
|
@@ -3834,7 +1739,7 @@ async function main() {
|
|
|
3834
1739
|
case "resolve": {
|
|
3835
1740
|
const thread = getFlag("thread");
|
|
3836
1741
|
if (!thread) fail("resolve requires --thread <id>");
|
|
3837
|
-
await
|
|
1742
|
+
await resolve(thread, process.argv.includes("--reopen"));
|
|
3838
1743
|
break;
|
|
3839
1744
|
}
|
|
3840
1745
|
case "delete-comment": {
|