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
|
@@ -0,0 +1,2729 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import "dotenv/config";
|
|
3
|
+
|
|
4
|
+
// src/lib/credentials.ts
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "fs";
|
|
6
|
+
import { homedir } from "os";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
function configDir() {
|
|
9
|
+
return process.env.OVERLEAF_REVIEW_CONFIG_DIR ?? join(homedir(), ".config", "overleaf-review");
|
|
10
|
+
}
|
|
11
|
+
function credentialsPath() {
|
|
12
|
+
return join(configDir(), "credentials.json");
|
|
13
|
+
}
|
|
14
|
+
function loadCredentials() {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(readFileSync(credentialsPath(), "utf8"));
|
|
17
|
+
} catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function saveCredentials(creds) {
|
|
22
|
+
mkdirSync(configDir(), { recursive: true });
|
|
23
|
+
const path = credentialsPath();
|
|
24
|
+
writeFileSync(path, JSON.stringify({ ...creds, savedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n");
|
|
25
|
+
chmodSync(path, 384);
|
|
26
|
+
return path;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/lib/project-config.ts
|
|
30
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, renameSync, unlinkSync } from "fs";
|
|
31
|
+
import { join as join2 } from "path";
|
|
32
|
+
var REPO_CONFIG_PATH = join2(".overleaf", "config.json");
|
|
33
|
+
function loadProjectConfig() {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync2(REPO_CONFIG_PATH, "utf8"));
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function saveProjectConfig(cfg) {
|
|
41
|
+
mkdirSync2(".overleaf", { recursive: true });
|
|
42
|
+
const temp = `${REPO_CONFIG_PATH}.tmp-${process.pid}-${Date.now()}`;
|
|
43
|
+
try {
|
|
44
|
+
writeFileSync2(temp, JSON.stringify(cfg, null, 2) + "\n", { mode: 384 });
|
|
45
|
+
renameSync(temp, REPO_CONFIG_PATH);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
try {
|
|
48
|
+
unlinkSync(temp);
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
return REPO_CONFIG_PATH;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/config.ts
|
|
57
|
+
function resolveBaseUrl() {
|
|
58
|
+
return process.env.OVERLEAF_BASE_URL ?? loadProjectConfig().baseUrl ?? loadCredentials().baseUrl ?? "https://www.overleaf.com";
|
|
59
|
+
}
|
|
60
|
+
function resolveSession2() {
|
|
61
|
+
const value = process.env.OVERLEAF_SESSION2 || loadCredentials().session2;
|
|
62
|
+
if (!value) {
|
|
63
|
+
throw new Error("Not authenticated \u2014 run `overleaf-review login` (or set OVERLEAF_SESSION2).");
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
function resolveProjectId() {
|
|
68
|
+
const value = process.env.OVERLEAF_PROJECT_ID || loadProjectConfig().projectId;
|
|
69
|
+
if (!value) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
"No project linked \u2014 run `overleaf-review link --project <id>` (or set OVERLEAF_PROJECT_ID)."
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
var config = {
|
|
77
|
+
get baseUrl() {
|
|
78
|
+
return resolveBaseUrl();
|
|
79
|
+
},
|
|
80
|
+
get session2() {
|
|
81
|
+
return resolveSession2();
|
|
82
|
+
},
|
|
83
|
+
get projectId() {
|
|
84
|
+
return resolveProjectId();
|
|
85
|
+
},
|
|
86
|
+
/** Cookie header value used for both HTTP and the websocket handshake. */
|
|
87
|
+
get cookie() {
|
|
88
|
+
return `overleaf_session2=${this.session2}`;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// src/overleaf-socket.ts
|
|
93
|
+
import WebSocket from "ws";
|
|
94
|
+
function utf8Decode(binary) {
|
|
95
|
+
return Buffer.from(binary, "latin1").toString("utf8");
|
|
96
|
+
}
|
|
97
|
+
var OverleafSocket = class {
|
|
98
|
+
constructor(baseUrl, cookie, opts = {}) {
|
|
99
|
+
this.baseUrl = baseUrl;
|
|
100
|
+
this.debug = opts.debug ?? true;
|
|
101
|
+
for (const part of cookie.split(";")) {
|
|
102
|
+
const eq = part.indexOf("=");
|
|
103
|
+
if (eq > 0) this.cookies.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
baseUrl;
|
|
107
|
+
ws;
|
|
108
|
+
ackId = 1;
|
|
109
|
+
pendingAcks = /* @__PURE__ */ new Map();
|
|
110
|
+
handlers = /* @__PURE__ */ new Map();
|
|
111
|
+
heartbeat;
|
|
112
|
+
debug;
|
|
113
|
+
cookies = /* @__PURE__ */ new Map();
|
|
114
|
+
/** Browser-like headers — Overleaf's proxy can 502 non-browser upgrades. */
|
|
115
|
+
get browserHeaders() {
|
|
116
|
+
return {
|
|
117
|
+
Cookie: this.cookieHeader,
|
|
118
|
+
Origin: this.baseUrl,
|
|
119
|
+
"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"
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
get cookieHeader() {
|
|
123
|
+
return [...this.cookies].map(([k, v]) => `${k}=${v}`).join("; ");
|
|
124
|
+
}
|
|
125
|
+
/** Merge Set-Cookie values — notably the GCLB load-balancer affinity cookie. */
|
|
126
|
+
absorbCookies(setCookies) {
|
|
127
|
+
for (const sc of setCookies) {
|
|
128
|
+
const pair = sc.split(";", 1)[0];
|
|
129
|
+
const eq = pair.indexOf("=");
|
|
130
|
+
if (eq > 0) this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Subscribe to a server-pushed event (e.g. otUpdateApplied). */
|
|
134
|
+
on(event, handler) {
|
|
135
|
+
const list = this.handlers.get(event) ?? [];
|
|
136
|
+
list.push(handler);
|
|
137
|
+
this.handlers.set(event, list);
|
|
138
|
+
return () => this.off(event, handler);
|
|
139
|
+
}
|
|
140
|
+
/** Remove a previously registered server-pushed event handler. */
|
|
141
|
+
off(event, handler) {
|
|
142
|
+
const list = this.handlers.get(event);
|
|
143
|
+
if (!list) return;
|
|
144
|
+
const next = list.filter((candidate) => candidate !== handler);
|
|
145
|
+
if (next.length) this.handlers.set(event, next);
|
|
146
|
+
else this.handlers.delete(event);
|
|
147
|
+
}
|
|
148
|
+
async connect(projectId) {
|
|
149
|
+
const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${projectId}&t=${Date.now()}`;
|
|
150
|
+
const res = await fetch(handshakeUrl, { headers: this.browserHeaders });
|
|
151
|
+
const setCookies = res.headers.getSetCookie?.() ?? [];
|
|
152
|
+
this.absorbCookies(setCookies);
|
|
153
|
+
if (this.debug) console.log(`[socket] cookies now: ${[...this.cookies.keys()].join(", ")}`);
|
|
154
|
+
if (!res.ok) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`socket.io handshake failed: ${res.status} ${res.statusText}. Check the session cookie and that the account is logged in.`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const body = await res.text();
|
|
160
|
+
const [sid, hbTimeout, , transports] = body.split(":");
|
|
161
|
+
if (this.debug) console.log(`[socket] handshake ok: ${body}`);
|
|
162
|
+
const wsUrl = `${this.baseUrl.replace(/^http/, "ws")}/socket.io/1/websocket/${sid}`;
|
|
163
|
+
if (this.debug) console.log(`[socket] transports=${transports}; ws=${wsUrl}`);
|
|
164
|
+
this.ws = new WebSocket(wsUrl, { headers: this.browserHeaders });
|
|
165
|
+
await new Promise((resolve2, reject) => {
|
|
166
|
+
this.ws.once("open", () => resolve2());
|
|
167
|
+
this.ws.once("error", reject);
|
|
168
|
+
this.ws.once("unexpected-response", (_req, response) => {
|
|
169
|
+
let errBody = "";
|
|
170
|
+
response.on("data", (chunk) => errBody += chunk.toString());
|
|
171
|
+
response.on(
|
|
172
|
+
"end",
|
|
173
|
+
() => reject(
|
|
174
|
+
new Error(
|
|
175
|
+
`ws upgrade rejected: ${response.statusCode} ${response.statusMessage}
|
|
176
|
+
headers: ${JSON.stringify(response.headers)}
|
|
177
|
+
body: ${errBody.slice(0, 800)}`
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
this.ws.on("message", (data) => this.onFrame(utf8Decode(data.toString())));
|
|
184
|
+
const intervalMs = (Number(hbTimeout) || 30) * 800;
|
|
185
|
+
this.heartbeat = setInterval(() => this.send("2::"), intervalMs);
|
|
186
|
+
if (this.debug) console.log("[socket] websocket open");
|
|
187
|
+
}
|
|
188
|
+
/** Emit an event and resolve with the server's ack payload (array of args). */
|
|
189
|
+
emit(name, args, timeoutMs = 15e3) {
|
|
190
|
+
const id = this.ackId++;
|
|
191
|
+
return new Promise((resolve2, reject) => {
|
|
192
|
+
const timer = setTimeout(() => {
|
|
193
|
+
this.pendingAcks.delete(id);
|
|
194
|
+
reject(new Error(`emit('${name}') timed out after ${timeoutMs}ms with no ack`));
|
|
195
|
+
}, timeoutMs);
|
|
196
|
+
this.pendingAcks.set(id, (payload) => {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
resolve2(payload);
|
|
199
|
+
});
|
|
200
|
+
this.send(`5:${id}+::${JSON.stringify({ name, args })}`);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
onFrame(frame) {
|
|
204
|
+
if (this.debug) console.log("[socket] <-", frame.slice(0, 200));
|
|
205
|
+
const type = frame[0];
|
|
206
|
+
const c1 = frame.indexOf(":");
|
|
207
|
+
const c2 = frame.indexOf(":", c1 + 1);
|
|
208
|
+
const c3 = frame.indexOf(":", c2 + 1);
|
|
209
|
+
const data = c3 === -1 ? "" : frame.slice(c3 + 1);
|
|
210
|
+
switch (type) {
|
|
211
|
+
case "2":
|
|
212
|
+
this.send("2::");
|
|
213
|
+
break;
|
|
214
|
+
case "5": {
|
|
215
|
+
try {
|
|
216
|
+
const { name, args } = JSON.parse(data);
|
|
217
|
+
for (const h of this.handlers.get(name) ?? []) h(args);
|
|
218
|
+
} catch {
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
case "6": {
|
|
223
|
+
const plus = data.indexOf("+");
|
|
224
|
+
const ackId = Number(plus === -1 ? data : data.slice(0, plus));
|
|
225
|
+
const payload = plus === -1 ? [] : JSON.parse(data.slice(plus + 1));
|
|
226
|
+
this.pendingAcks.get(ackId)?.(payload);
|
|
227
|
+
this.pendingAcks.delete(ackId);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
send(frame) {
|
|
233
|
+
if (this.debug && !frame.startsWith("2")) console.log("[socket] ->", frame.slice(0, 200));
|
|
234
|
+
this.ws.send(frame);
|
|
235
|
+
}
|
|
236
|
+
close() {
|
|
237
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
238
|
+
this.ws?.close();
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// src/lib/session.ts
|
|
243
|
+
function collectDocs(rootFolder) {
|
|
244
|
+
const out = [];
|
|
245
|
+
const walk = (folders, prefix) => {
|
|
246
|
+
for (const f of folders ?? []) {
|
|
247
|
+
const dir = f.name && f.name !== "rootFolder" ? prefix ? `${prefix}/${f.name}` : f.name : prefix;
|
|
248
|
+
for (const d of f.docs ?? []) {
|
|
249
|
+
out.push({ _id: d._id, name: d.name, path: dir ? `${dir}/${d.name}` : d.name });
|
|
250
|
+
}
|
|
251
|
+
walk(f.folders ?? [], dir);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
walk(rootFolder ?? [], "");
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
async function openProject(opts = {}) {
|
|
258
|
+
const socket = new OverleafSocket(config.baseUrl, config.cookie, { debug: opts.debug ?? false });
|
|
259
|
+
const pushed = new Promise((resolve2) => socket.on("joinProjectResponse", resolve2));
|
|
260
|
+
await socket.connect(config.projectId);
|
|
261
|
+
const first = await Promise.race([
|
|
262
|
+
pushed,
|
|
263
|
+
new Promise((r) => setTimeout(() => r(null), 6e3))
|
|
264
|
+
]);
|
|
265
|
+
let publicId = "";
|
|
266
|
+
let project;
|
|
267
|
+
if (first) {
|
|
268
|
+
publicId = first[0]?.publicId ?? "";
|
|
269
|
+
project = first[0]?.project;
|
|
270
|
+
} else {
|
|
271
|
+
const r = await socket.emit("joinProject", [{ project_id: config.projectId }]);
|
|
272
|
+
project = r[1];
|
|
273
|
+
}
|
|
274
|
+
return { socket, publicId, project, docs: collectDocs(project?.rootFolder) };
|
|
275
|
+
}
|
|
276
|
+
async function joinDoc(socket, docId) {
|
|
277
|
+
const res = await socket.emit("joinDoc", [docId, { encodeRanges: true }]);
|
|
278
|
+
const [err, lines, version, , ranges] = res;
|
|
279
|
+
if (err) throw new Error(`joinDoc error: ${JSON.stringify(err)}`);
|
|
280
|
+
return { version, lines: lines ?? [], ranges: ranges ?? {} };
|
|
281
|
+
}
|
|
282
|
+
async function applyOtUpdateAndWait(socket, docId, update, timeoutMs = 3e4) {
|
|
283
|
+
let timer;
|
|
284
|
+
let stopApplied = () => {
|
|
285
|
+
};
|
|
286
|
+
let stopError = () => {
|
|
287
|
+
};
|
|
288
|
+
const applied = new Promise((resolve2, reject) => {
|
|
289
|
+
stopApplied = socket.on("otUpdateApplied", (args) => {
|
|
290
|
+
const event = args[0];
|
|
291
|
+
if (event?.doc === docId && Number.isSafeInteger(event.v) && event.v >= update.v && !Object.prototype.hasOwnProperty.call(event, "op")) {
|
|
292
|
+
resolve2();
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
stopError = socket.on("otUpdateError", (args) => {
|
|
296
|
+
const metadata = args[1];
|
|
297
|
+
if (metadata?.doc_id && metadata.doc_id !== docId) return;
|
|
298
|
+
reject(new Error(`Overleaf failed to apply the OT update: ${JSON.stringify(args)}`));
|
|
299
|
+
});
|
|
300
|
+
timer = setTimeout(
|
|
301
|
+
() => reject(new Error(`OT update for ${docId} was queued but not confirmed within ${timeoutMs}ms`)),
|
|
302
|
+
timeoutMs
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
void applied.catch(() => {
|
|
306
|
+
});
|
|
307
|
+
try {
|
|
308
|
+
const ack = await socket.emit("applyOtUpdate", [docId, update], timeoutMs);
|
|
309
|
+
if (ack?.[0]) throw new Error(`Overleaf rejected the OT update: ${JSON.stringify(ack[0])}`);
|
|
310
|
+
await applied;
|
|
311
|
+
} finally {
|
|
312
|
+
if (timer) clearTimeout(timer);
|
|
313
|
+
stopApplied();
|
|
314
|
+
stopError();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// src/lib/rest.ts
|
|
319
|
+
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";
|
|
320
|
+
function headers(extra = {}) {
|
|
321
|
+
return { Cookie: config.cookie, "User-Agent": UA, Origin: config.baseUrl, ...extra };
|
|
322
|
+
}
|
|
323
|
+
async function getCsrfToken() {
|
|
324
|
+
const res = await fetch(`${config.baseUrl}/project/${config.projectId}`, { headers: headers() });
|
|
325
|
+
const html = await res.text();
|
|
326
|
+
const m = html.match(/<meta\s+name="ol-csrfToken"\s+content="([^"]+)"/) ?? html.match(/"csrfToken"\s*:\s*"([^"]+)"/) ?? html.match(/csrfToken\s*[:=]\s*["']([^"']+)["']/);
|
|
327
|
+
if (!m) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
`CSRF token not found (HTTP ${res.status}). If this is a login page, the session cookie is invalid/expired.`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
return m[1];
|
|
333
|
+
}
|
|
334
|
+
async function getThreads() {
|
|
335
|
+
const res = await fetch(`${config.baseUrl}/project/${config.projectId}/threads`, {
|
|
336
|
+
headers: headers()
|
|
337
|
+
});
|
|
338
|
+
if (!res.ok) throw new Error(`getThreads ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
339
|
+
return res.json();
|
|
340
|
+
}
|
|
341
|
+
var RestRequestError = class extends Error {
|
|
342
|
+
constructor(message, status, responseBody) {
|
|
343
|
+
super(message);
|
|
344
|
+
this.status = status;
|
|
345
|
+
this.responseBody = responseBody;
|
|
346
|
+
this.name = "RestRequestError";
|
|
347
|
+
}
|
|
348
|
+
status;
|
|
349
|
+
responseBody;
|
|
350
|
+
};
|
|
351
|
+
function threadMessages(thread) {
|
|
352
|
+
if (!thread || typeof thread !== "object") return [];
|
|
353
|
+
const messages = thread.messages;
|
|
354
|
+
return Array.isArray(messages) ? messages.filter((message) => Boolean(message && typeof message === "object")) : [];
|
|
355
|
+
}
|
|
356
|
+
function threadMessageId(message) {
|
|
357
|
+
const value = message?.id ?? message?._id;
|
|
358
|
+
return typeof value === "string" && value ? value : void 0;
|
|
359
|
+
}
|
|
360
|
+
function timestampMs(message) {
|
|
361
|
+
const raw = message.timestamp ?? message.createdAt ?? message.created_at;
|
|
362
|
+
if (typeof raw === "number" && Number.isFinite(raw)) {
|
|
363
|
+
return raw < 1e11 ? raw * 1e3 : raw;
|
|
364
|
+
}
|
|
365
|
+
if (typeof raw === "string") {
|
|
366
|
+
const numeric = Number(raw);
|
|
367
|
+
if (Number.isFinite(numeric) && raw.trim()) {
|
|
368
|
+
return numeric < 1e11 ? numeric * 1e3 : numeric;
|
|
369
|
+
}
|
|
370
|
+
const parsed = Date.parse(raw);
|
|
371
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
372
|
+
}
|
|
373
|
+
return void 0;
|
|
374
|
+
}
|
|
375
|
+
function findRecentIdenticalMessage(thread, content, nowMs = Date.now(), windowMs = 5 * 60 * 1e3) {
|
|
376
|
+
const earliest = nowMs - Math.max(0, windowMs);
|
|
377
|
+
return threadMessages(thread).filter((message) => {
|
|
378
|
+
if (message.content !== content) return false;
|
|
379
|
+
const timestamp = timestampMs(message);
|
|
380
|
+
return timestamp !== void 0 && timestamp >= earliest && timestamp <= nowMs + 6e4;
|
|
381
|
+
}).sort((a, b) => (timestampMs(b) ?? 0) - (timestampMs(a) ?? 0))[0];
|
|
382
|
+
}
|
|
383
|
+
function findNewIdenticalMessage(beforeThread, afterThread, content, returnedMessageId) {
|
|
384
|
+
const after = threadMessages(afterThread);
|
|
385
|
+
if (returnedMessageId) {
|
|
386
|
+
return after.find(
|
|
387
|
+
(message) => threadMessageId(message) === returnedMessageId && message.content === content
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
const beforeIds = new Set(
|
|
391
|
+
threadMessages(beforeThread).map(threadMessageId).filter((id) => Boolean(id))
|
|
392
|
+
);
|
|
393
|
+
const candidates = [...after].reverse().filter((message) => {
|
|
394
|
+
if (message.content !== content) return false;
|
|
395
|
+
const id = threadMessageId(message);
|
|
396
|
+
return Boolean(id && !beforeIds.has(id));
|
|
397
|
+
});
|
|
398
|
+
return candidates.length === 1 ? candidates[0] : void 0;
|
|
399
|
+
}
|
|
400
|
+
function extractPostedMessageId(value) {
|
|
401
|
+
if (!value || typeof value !== "object") return void 0;
|
|
402
|
+
const object = value;
|
|
403
|
+
for (const key of ["message_id", "messageId"]) {
|
|
404
|
+
if (typeof object[key] === "string" && object[key]) return object[key];
|
|
405
|
+
}
|
|
406
|
+
for (const key of ["message", "data"]) {
|
|
407
|
+
const nested = object[key];
|
|
408
|
+
if (nested && typeof nested === "object") {
|
|
409
|
+
const nestedId = threadMessageId(nested) ?? extractPostedMessageId(nested);
|
|
410
|
+
if (nestedId) return nestedId;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return threadMessageId(object);
|
|
414
|
+
}
|
|
415
|
+
async function observePostedThreadMessage(threadId, beforeThread, content, returnedMessageId, options = {}) {
|
|
416
|
+
const timeoutMs = Math.max(0, options.timeoutMs ?? 5e3);
|
|
417
|
+
const intervalMs = Math.max(10, options.intervalMs ?? 250);
|
|
418
|
+
const deadline = Date.now() + timeoutMs;
|
|
419
|
+
let attempts = 0;
|
|
420
|
+
let lastThread;
|
|
421
|
+
let lastError;
|
|
422
|
+
do {
|
|
423
|
+
attempts += 1;
|
|
424
|
+
try {
|
|
425
|
+
lastThread = (await getThreads())[threadId];
|
|
426
|
+
const message = findNewIdenticalMessage(
|
|
427
|
+
beforeThread,
|
|
428
|
+
lastThread,
|
|
429
|
+
content,
|
|
430
|
+
returnedMessageId
|
|
431
|
+
);
|
|
432
|
+
if (message) return { message, thread: lastThread, attempts };
|
|
433
|
+
} catch (error) {
|
|
434
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
435
|
+
}
|
|
436
|
+
if (Date.now() >= deadline) break;
|
|
437
|
+
await new Promise((resolve2) => setTimeout(resolve2, Math.min(intervalMs, deadline - Date.now())));
|
|
438
|
+
} while (Date.now() <= deadline);
|
|
439
|
+
return {
|
|
440
|
+
thread: lastThread,
|
|
441
|
+
attempts,
|
|
442
|
+
...lastError ? { lastError } : {}
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
async function postThreadMessageDetailed(threadId, content, csrf) {
|
|
446
|
+
const res = await fetch(
|
|
447
|
+
`${config.baseUrl}/project/${config.projectId}/thread/${threadId}/messages`,
|
|
448
|
+
{
|
|
449
|
+
method: "POST",
|
|
450
|
+
headers: headers({ "Content-Type": "application/json", "X-CSRF-Token": csrf }),
|
|
451
|
+
body: JSON.stringify({ content })
|
|
452
|
+
}
|
|
453
|
+
);
|
|
454
|
+
const rawBody = await res.text();
|
|
455
|
+
if (!res.ok) {
|
|
456
|
+
throw new RestRequestError(
|
|
457
|
+
`postThreadMessage ${res.status}: ${rawBody.slice(0, 300)}`,
|
|
458
|
+
res.status,
|
|
459
|
+
rawBody.slice(0, 1e3)
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
let responseBody;
|
|
463
|
+
if (rawBody) {
|
|
464
|
+
try {
|
|
465
|
+
responseBody = JSON.parse(rawBody);
|
|
466
|
+
} catch {
|
|
467
|
+
responseBody = rawBody.slice(0, 1e3);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
status: res.status,
|
|
472
|
+
messageId: extractPostedMessageId(responseBody),
|
|
473
|
+
...responseBody === void 0 ? {} : { responseBody }
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
async function setThreadResolved(docId, threadId, reopen, csrf) {
|
|
477
|
+
const action = reopen ? "reopen" : "resolve";
|
|
478
|
+
const res = await fetch(
|
|
479
|
+
`${config.baseUrl}/project/${config.projectId}/doc/${docId}/thread/${threadId}/${action}`,
|
|
480
|
+
{ method: "POST", headers: headers({ "X-CSRF-Token": csrf }) }
|
|
481
|
+
);
|
|
482
|
+
if (!res.ok) {
|
|
483
|
+
throw new Error(`${action} thread ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
484
|
+
}
|
|
485
|
+
return res.status;
|
|
486
|
+
}
|
|
487
|
+
async function acceptChanges(docId, changeIds, csrf) {
|
|
488
|
+
const res = await fetch(
|
|
489
|
+
`${config.baseUrl}/project/${config.projectId}/doc/${docId}/changes/accept`,
|
|
490
|
+
{
|
|
491
|
+
method: "POST",
|
|
492
|
+
headers: headers({ "X-CSRF-Token": csrf, "Content-Type": "application/json" }),
|
|
493
|
+
body: JSON.stringify({ change_ids: changeIds })
|
|
494
|
+
}
|
|
495
|
+
);
|
|
496
|
+
if (!res.ok) throw new Error(`acceptChanges ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
497
|
+
return res.status;
|
|
498
|
+
}
|
|
499
|
+
async function uploadFile(folderId, name, bytes, csrf) {
|
|
500
|
+
const form = new FormData();
|
|
501
|
+
form.append("qqfile", new Blob([new Uint8Array(bytes)]), name);
|
|
502
|
+
form.append("name", name);
|
|
503
|
+
form.append("relativePath", "null");
|
|
504
|
+
const res = await fetch(
|
|
505
|
+
`${config.baseUrl}/project/${config.projectId}/upload?folder_id=${folderId}`,
|
|
506
|
+
// Deliberately no Content-Type — fetch sets the multipart boundary itself.
|
|
507
|
+
{ method: "POST", headers: headers({ "X-CSRF-Token": csrf }), body: form }
|
|
508
|
+
);
|
|
509
|
+
if (!res.ok) throw new Error(`upload ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
510
|
+
return res.json();
|
|
511
|
+
}
|
|
512
|
+
async function deleteMessage(threadId, messageId, csrf) {
|
|
513
|
+
const res = await fetch(
|
|
514
|
+
`${config.baseUrl}/project/${config.projectId}/thread/${threadId}/messages/${messageId}`,
|
|
515
|
+
{ method: "DELETE", headers: headers({ "X-CSRF-Token": csrf }) }
|
|
516
|
+
);
|
|
517
|
+
if (!res.ok) throw new Error(`deleteMessage ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
518
|
+
return res.status;
|
|
519
|
+
}
|
|
520
|
+
async function deleteThread(docId, threadId, csrf) {
|
|
521
|
+
const res = await fetch(
|
|
522
|
+
`${config.baseUrl}/project/${config.projectId}/doc/${docId}/thread/${threadId}`,
|
|
523
|
+
{ method: "DELETE", headers: headers({ "X-CSRF-Token": csrf }) }
|
|
524
|
+
);
|
|
525
|
+
if (!res.ok) throw new Error(`deleteThread ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
526
|
+
return res.status;
|
|
527
|
+
}
|
|
528
|
+
async function validateSession(baseUrl, session2) {
|
|
529
|
+
const res = await fetch(`${baseUrl}/project`, {
|
|
530
|
+
headers: { Cookie: `overleaf_session2=${session2}`, "User-Agent": UA },
|
|
531
|
+
redirect: "follow"
|
|
532
|
+
});
|
|
533
|
+
const html = await res.text();
|
|
534
|
+
if (!res.ok) throw new Error(`Session validation failed (HTTP ${res.status}); log in again or check access.`);
|
|
535
|
+
const looksLikeLogin = res.url.includes("/login") || /name="ol-page"\s+content="login"/.test(html) || html.includes('id="loginForm"');
|
|
536
|
+
if (looksLikeLogin) {
|
|
537
|
+
throw new Error("Session cookie is invalid or expired (got the login page).");
|
|
538
|
+
}
|
|
539
|
+
const m = html.match(/name="ol-usersEmail"\s+content="([^"]+)"/) ?? html.match(/"email":"([^"@]+@[^"]+)"/);
|
|
540
|
+
return m ? m[1] : "your Overleaf account";
|
|
541
|
+
}
|
|
542
|
+
function accountIdFromSettings(html) {
|
|
543
|
+
const tag = html.match(/<meta\b[^>]*\bname=["']ol-user["'][^>]*>/i)?.[0];
|
|
544
|
+
const encoded = tag?.match(/\bcontent=(?:"([^"]*)"|'([^']*)')/i);
|
|
545
|
+
if (!encoded) throw new Error("Authenticated account ID not found; refusing author-sensitive mutation.");
|
|
546
|
+
const json = (encoded[1] ?? encoded[2]).replace(/&(?:quot|apos|amp|lt|gt|#\d+|#x[0-9a-f]+);/gi, (entity) => {
|
|
547
|
+
const named = { """: '"', "'": "'", "&": "&", "<": "<", ">": ">" };
|
|
548
|
+
if (named[entity.toLowerCase()]) return named[entity.toLowerCase()];
|
|
549
|
+
const hex = entity.toLowerCase().startsWith("&#x");
|
|
550
|
+
return String.fromCodePoint(parseInt(entity.slice(hex ? 3 : 2, -1), hex ? 16 : 10));
|
|
551
|
+
});
|
|
552
|
+
const user = JSON.parse(json);
|
|
553
|
+
const id = user?.id ?? user?._id;
|
|
554
|
+
if (user?.id && user?._id && user.id !== user._id) throw new Error("Conflicting authenticated account IDs.");
|
|
555
|
+
if (typeof id !== "string" || !/^[0-9a-f]{24}$/i.test(id)) {
|
|
556
|
+
throw new Error("Invalid authenticated account ID; refusing author-sensitive mutation.");
|
|
557
|
+
}
|
|
558
|
+
return id;
|
|
559
|
+
}
|
|
560
|
+
async function getAuthenticatedUserId() {
|
|
561
|
+
const res = await fetch(`${config.baseUrl}/user/settings`, {
|
|
562
|
+
headers: headers(),
|
|
563
|
+
redirect: "error",
|
|
564
|
+
signal: AbortSignal.timeout(15e3)
|
|
565
|
+
});
|
|
566
|
+
if (!res.ok) throw new Error(`Account verification failed (HTTP ${res.status}); refresh login.`);
|
|
567
|
+
return accountIdFromSettings(await res.text());
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// src/lib/workspace-path.ts
|
|
571
|
+
import { existsSync, realpathSync } from "fs";
|
|
572
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "path";
|
|
573
|
+
function isOutside(root, candidate) {
|
|
574
|
+
const rel = relative(root, candidate);
|
|
575
|
+
return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
576
|
+
}
|
|
577
|
+
function workspaceRelativePath(input, root = process.cwd()) {
|
|
578
|
+
if (typeof input !== "string" || !input.length) throw new Error("workspace path is empty");
|
|
579
|
+
if (isAbsolute(input) || /^[\\/]/.test(input) || /^[a-zA-Z]:[\\/]/.test(input)) {
|
|
580
|
+
throw new Error(`Absolute paths are not allowed: ${input}`);
|
|
581
|
+
}
|
|
582
|
+
const portable = input.replace(/\\/g, "/");
|
|
583
|
+
if (portable.split("/").includes("..")) {
|
|
584
|
+
throw new Error(`Parent path traversal is not allowed: ${input}`);
|
|
585
|
+
}
|
|
586
|
+
const target = resolve(root, ...portable.split("/"));
|
|
587
|
+
const absoluteRoot = resolve(root);
|
|
588
|
+
if (isOutside(absoluteRoot, target) || target === absoluteRoot) {
|
|
589
|
+
throw new Error(`Path must identify a file inside the working tree: ${input}`);
|
|
590
|
+
}
|
|
591
|
+
return relative(absoluteRoot, target).split(sep).join("/");
|
|
592
|
+
}
|
|
593
|
+
function workspaceReadPath(input, root = process.cwd()) {
|
|
594
|
+
const rel = workspaceRelativePath(input, root);
|
|
595
|
+
const realRoot = realpathSync(root);
|
|
596
|
+
const lexicalTarget = resolve(root, ...rel.split("/"));
|
|
597
|
+
const realTarget = realpathSync(lexicalTarget);
|
|
598
|
+
if (isOutside(realRoot, realTarget) || realTarget === realRoot) {
|
|
599
|
+
throw new Error(`Path resolves outside the working tree: ${input}`);
|
|
600
|
+
}
|
|
601
|
+
return lexicalTarget;
|
|
602
|
+
}
|
|
603
|
+
function workspaceWritePath(input, root = process.cwd()) {
|
|
604
|
+
const rel = workspaceRelativePath(input, root);
|
|
605
|
+
const lexicalTarget = resolve(root, ...rel.split("/"));
|
|
606
|
+
const realRoot = realpathSync(root);
|
|
607
|
+
if (existsSync(lexicalTarget)) {
|
|
608
|
+
const target = realpathSync(lexicalTarget);
|
|
609
|
+
if (isOutside(realRoot, target) || target === realRoot) {
|
|
610
|
+
throw new Error(`Path resolves outside the working tree: ${input}`);
|
|
611
|
+
}
|
|
612
|
+
return lexicalTarget;
|
|
613
|
+
}
|
|
614
|
+
let ancestor = dirname(lexicalTarget);
|
|
615
|
+
while (!existsSync(ancestor)) {
|
|
616
|
+
const parent = dirname(ancestor);
|
|
617
|
+
if (parent === ancestor) throw new Error(`Cannot resolve a safe parent for ${input}`);
|
|
618
|
+
ancestor = parent;
|
|
619
|
+
}
|
|
620
|
+
const realAncestor = realpathSync(ancestor);
|
|
621
|
+
if (isOutside(realRoot, realAncestor)) {
|
|
622
|
+
throw new Error(`Path has an ancestor outside the working tree: ${input}`);
|
|
623
|
+
}
|
|
624
|
+
return lexicalTarget;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// src/lib/submission-lock.ts
|
|
628
|
+
import { createHash, randomUUID } from "crypto";
|
|
629
|
+
import {
|
|
630
|
+
closeSync,
|
|
631
|
+
existsSync as existsSync2,
|
|
632
|
+
fsyncSync,
|
|
633
|
+
mkdirSync as mkdirSync3,
|
|
634
|
+
openSync,
|
|
635
|
+
readFileSync as readFileSync3,
|
|
636
|
+
realpathSync as realpathSync2,
|
|
637
|
+
unlinkSync as unlinkSync2,
|
|
638
|
+
writeFileSync as writeFileSync3
|
|
639
|
+
} from "fs";
|
|
640
|
+
import { tmpdir } from "os";
|
|
641
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
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/lib/review-edits.ts
|
|
719
|
+
import { diffWordsWithSpace } from "diff";
|
|
720
|
+
|
|
721
|
+
// src/lib/three-way.ts
|
|
722
|
+
import { diffChars } from "diff";
|
|
723
|
+
function textEdits(base, target) {
|
|
724
|
+
const edits = [];
|
|
725
|
+
let basePos = 0;
|
|
726
|
+
let pending;
|
|
727
|
+
const flush = () => {
|
|
728
|
+
if (!pending) return;
|
|
729
|
+
if (pending.start !== pending.end || pending.text.length) edits.push(pending);
|
|
730
|
+
pending = void 0;
|
|
731
|
+
};
|
|
732
|
+
for (const part of diffChars(base, target)) {
|
|
733
|
+
if (!part.added && !part.removed) {
|
|
734
|
+
flush();
|
|
735
|
+
basePos += part.value.length;
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
pending ??= { start: basePos, end: basePos, text: "" };
|
|
739
|
+
if (part.removed) {
|
|
740
|
+
pending.end += part.value.length;
|
|
741
|
+
basePos += part.value.length;
|
|
742
|
+
} else {
|
|
743
|
+
pending.text += part.value;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
flush();
|
|
747
|
+
return edits;
|
|
748
|
+
}
|
|
749
|
+
function sameEdit(a, b) {
|
|
750
|
+
return a.start === b.start && a.end === b.end && a.text === b.text;
|
|
751
|
+
}
|
|
752
|
+
function reverseCodePoints(text) {
|
|
753
|
+
return Array.from(text).reverse().join("");
|
|
754
|
+
}
|
|
755
|
+
function ambiguousEditAnchors(base, target) {
|
|
756
|
+
const forward = textEdits(base, target);
|
|
757
|
+
const reverse = textEdits(reverseCodePoints(base), reverseCodePoints(target)).map((edit) => ({
|
|
758
|
+
start: base.length - edit.end,
|
|
759
|
+
end: base.length - edit.start,
|
|
760
|
+
text: reverseCodePoints(edit.text)
|
|
761
|
+
})).sort((a, b) => a.start - b.start || a.end - b.end);
|
|
762
|
+
const ambiguities = [];
|
|
763
|
+
const count = Math.max(forward.length, reverse.length);
|
|
764
|
+
for (let index = 0; index < count; index++) {
|
|
765
|
+
const forwardEdit = forward[index] ?? reverse[index];
|
|
766
|
+
const reverseEdit = reverse[index] ?? forward[index];
|
|
767
|
+
if (sameEdit(forwardEdit, reverseEdit)) continue;
|
|
768
|
+
ambiguities.push({
|
|
769
|
+
forward: forwardEdit,
|
|
770
|
+
reverse: reverseEdit,
|
|
771
|
+
envelopeStart: Math.min(forwardEdit.start, reverseEdit.start),
|
|
772
|
+
envelopeEnd: Math.max(
|
|
773
|
+
forwardEdit.start,
|
|
774
|
+
forwardEdit.end,
|
|
775
|
+
reverseEdit.start,
|
|
776
|
+
reverseEdit.end
|
|
777
|
+
)
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
return ambiguities;
|
|
781
|
+
}
|
|
782
|
+
function editTouchesEnvelope(edit, start, end) {
|
|
783
|
+
if (edit.start === edit.end) return edit.start >= start && edit.start <= end;
|
|
784
|
+
return edit.start <= end && edit.end >= start;
|
|
785
|
+
}
|
|
786
|
+
function editsConflict(a, b) {
|
|
787
|
+
const aInsert = a.start === a.end;
|
|
788
|
+
const bInsert = b.start === b.end;
|
|
789
|
+
if (aInsert && bInsert) return a.start === b.start;
|
|
790
|
+
if (aInsert) return a.start > b.start && a.start < b.end;
|
|
791
|
+
if (bInsert) return b.start > a.start && b.start < a.end;
|
|
792
|
+
return a.start < b.end && b.start < a.end;
|
|
793
|
+
}
|
|
794
|
+
function mapBasePosition(position, liveEdits, includeInsertionAtPosition) {
|
|
795
|
+
let mapped = position;
|
|
796
|
+
for (const edit of liveEdits) {
|
|
797
|
+
if (edit.start === edit.end) {
|
|
798
|
+
if (edit.start < position || includeInsertionAtPosition && edit.start === position) {
|
|
799
|
+
mapped += edit.text.length;
|
|
800
|
+
}
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
if (edit.end <= position) mapped += edit.text.length - (edit.end - edit.start);
|
|
804
|
+
}
|
|
805
|
+
return mapped;
|
|
806
|
+
}
|
|
807
|
+
function applyEdits(source, edits) {
|
|
808
|
+
let result = source;
|
|
809
|
+
const ordered = edits.map((edit, index) => ({ edit, index })).sort(
|
|
810
|
+
(a, b) => b.edit.start - a.edit.start || b.edit.end - a.edit.end || b.index - a.index
|
|
811
|
+
);
|
|
812
|
+
for (const { edit } of ordered) {
|
|
813
|
+
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
|
814
|
+
}
|
|
815
|
+
return result;
|
|
816
|
+
}
|
|
817
|
+
function threeWayMerge(base, local, live) {
|
|
818
|
+
const localEdits = textEdits(base, local);
|
|
819
|
+
const liveEdits = textEdits(base, live);
|
|
820
|
+
const ambiguousAnchors = ambiguousEditAnchors(base, local);
|
|
821
|
+
const conflicts = [];
|
|
822
|
+
const alreadyAppliedLocalEdits = [];
|
|
823
|
+
const toApply = [];
|
|
824
|
+
for (const localEdit of localEdits) {
|
|
825
|
+
if (liveEdits.some((liveEdit) => sameEdit(localEdit, liveEdit))) {
|
|
826
|
+
alreadyAppliedLocalEdits.push(localEdit);
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
const ambiguity = ambiguousAnchors.find((candidate) => sameEdit(candidate.forward, localEdit));
|
|
830
|
+
if (ambiguity) {
|
|
831
|
+
const touching = liveEdits.filter(
|
|
832
|
+
(liveEdit) => editTouchesEnvelope(liveEdit, ambiguity.envelopeStart, ambiguity.envelopeEnd)
|
|
833
|
+
);
|
|
834
|
+
if (touching.length) {
|
|
835
|
+
for (const liveEdit of touching) {
|
|
836
|
+
conflicts.push({ local: localEdit, live: liveEdit, reason: "ambiguous-local-anchor" });
|
|
837
|
+
}
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const overlapping = liveEdits.filter((liveEdit) => editsConflict(localEdit, liveEdit));
|
|
842
|
+
if (overlapping.length) {
|
|
843
|
+
for (const liveEdit of overlapping) {
|
|
844
|
+
conflicts.push({ local: localEdit, live: liveEdit, reason: "overlapping-edits" });
|
|
845
|
+
}
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
if (localEdit.start === localEdit.end) {
|
|
849
|
+
const point = mapBasePosition(localEdit.start, liveEdits, false);
|
|
850
|
+
toApply.push({ start: point, end: point, text: localEdit.text });
|
|
851
|
+
} else {
|
|
852
|
+
const start = mapBasePosition(localEdit.start, liveEdits, true);
|
|
853
|
+
const end = mapBasePosition(localEdit.end, liveEdits, false);
|
|
854
|
+
toApply.push({ start, end, text: localEdit.text });
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return {
|
|
858
|
+
text: conflicts.length ? void 0 : applyEdits(live, toApply),
|
|
859
|
+
localEdits,
|
|
860
|
+
liveEdits,
|
|
861
|
+
appliedLocalEdits: toApply,
|
|
862
|
+
alreadyAppliedLocalEdits,
|
|
863
|
+
conflicts
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// src/lib/review-edits.ts
|
|
868
|
+
var MAX_GAP_CHARS = 40;
|
|
869
|
+
var MAX_GAP_WORDS = 3;
|
|
870
|
+
var MAX_GROUP_CHARS = 320;
|
|
871
|
+
var BOUNDARY = /[.!?;:](?:["'”’\)\]]*)\s|[.!?;:]$|\r?\n\s*\r?\n|[\\$%{}&]/u;
|
|
872
|
+
function buildReviewEdits(source, target, options = {}) {
|
|
873
|
+
if (options.explicitEdits) {
|
|
874
|
+
const edits2 = validateExplicitEdits(source, options.explicitEdits);
|
|
875
|
+
if (applyTextEdits(source, edits2) !== target) throw new Error("Explicit blocks do not reconstruct the intended file.");
|
|
876
|
+
for (const edit of edits2) {
|
|
877
|
+
if (options.protectedSpans?.some((span) => edit.start <= span.end && span.start <= edit.end)) {
|
|
878
|
+
throw new Error("Explicit block touches a comment, pending suggestion, or concurrent edit; narrow the block or resolve the conflict first.");
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return edits2;
|
|
882
|
+
}
|
|
883
|
+
const edits = [];
|
|
884
|
+
let sourcePos = 0;
|
|
885
|
+
let pending;
|
|
886
|
+
const flush = () => {
|
|
887
|
+
if (pending) edits.push(pending);
|
|
888
|
+
pending = void 0;
|
|
889
|
+
};
|
|
890
|
+
for (const part of diffWordsWithSpace(source, target)) {
|
|
891
|
+
if (!part.added && !part.removed) {
|
|
892
|
+
flush();
|
|
893
|
+
sourcePos += part.value.length;
|
|
894
|
+
} else {
|
|
895
|
+
pending ??= { start: sourcePos, end: sourcePos, text: "" };
|
|
896
|
+
if (part.removed) {
|
|
897
|
+
sourcePos += part.value.length;
|
|
898
|
+
pending.end = sourcePos;
|
|
899
|
+
} else {
|
|
900
|
+
pending.text += part.value;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
flush();
|
|
905
|
+
if (options.group === false) return edits;
|
|
906
|
+
const groups = [];
|
|
907
|
+
for (const edit of edits) {
|
|
908
|
+
const previous = groups[groups.length - 1];
|
|
909
|
+
if (!previous) {
|
|
910
|
+
groups.push({ ...edit });
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
const gap = source.slice(previous.end, edit.start);
|
|
914
|
+
const protectedGap = options.protectedSpans?.some(
|
|
915
|
+
(span) => span.start === span.end ? span.start >= previous.end && span.start <= edit.start : span.start < edit.start && span.end > previous.end
|
|
916
|
+
);
|
|
917
|
+
const canGroup = !protectedGap && gap.length <= MAX_GAP_CHARS && (gap.match(/\S+/gu)?.length ?? 0) <= MAX_GAP_WORDS && !BOUNDARY.test(source.slice(previous.start, edit.start)) && !BOUNDARY.test(previous.text + gap) && edit.end - previous.start <= MAX_GROUP_CHARS && previous.text.length + gap.length + edit.text.length <= MAX_GROUP_CHARS;
|
|
918
|
+
if (canGroup) {
|
|
919
|
+
previous.end = edit.end;
|
|
920
|
+
previous.text += gap + edit.text;
|
|
921
|
+
} else {
|
|
922
|
+
groups.push({ ...edit });
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return groups;
|
|
926
|
+
}
|
|
927
|
+
function validateExplicitEdits(source, value) {
|
|
928
|
+
if (!Array.isArray(value) || !value.length) throw new Error("Explicit blocks must be a nonempty array.");
|
|
929
|
+
let previousEnd = -1;
|
|
930
|
+
return value.map((edit) => {
|
|
931
|
+
if (!edit || !Number.isSafeInteger(edit.start) || !Number.isSafeInteger(edit.end) || edit.start < 0 || edit.end < edit.start || edit.end > source.length || typeof edit.text !== "string" || edit.start <= previousEnd || source.slice(edit.start, edit.end) === edit.text) {
|
|
932
|
+
throw new Error("Explicit blocks must be valid, ordered, separated replacements with a text change.");
|
|
933
|
+
}
|
|
934
|
+
previousEnd = edit.end;
|
|
935
|
+
return { start: edit.start, end: edit.end, text: edit.text };
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
function applyTextEdits(source, edits) {
|
|
939
|
+
for (const edit of [...edits].reverse()) {
|
|
940
|
+
source = source.slice(0, edit.start) + edit.text + source.slice(edit.end);
|
|
941
|
+
}
|
|
942
|
+
return source;
|
|
943
|
+
}
|
|
944
|
+
function parseReplacementManifest(base, value) {
|
|
945
|
+
const manifest = value;
|
|
946
|
+
if (!Array.isArray(manifest?.replacements) || !manifest.replacements.length) {
|
|
947
|
+
throw new Error("Replacement manifest requires a nonempty replacements array.");
|
|
948
|
+
}
|
|
949
|
+
const edits = manifest.replacements.map((block) => {
|
|
950
|
+
if (block && ["supersedes", "reason", "comments"].some((key) => Object.hasOwn(block, key))) {
|
|
951
|
+
throw new Error("Reviewed overlap fields require the reviewed plan workflow; use review plan --file --edits --out.");
|
|
952
|
+
}
|
|
953
|
+
if (!block || typeof block.before !== "string" || !block.before.length || typeof block.after !== "string" || block.occurrence !== void 0 && (!Number.isSafeInteger(block.occurrence) || block.occurrence < 1)) {
|
|
954
|
+
throw new Error("Each replacement requires nonempty before, after text, and optionally a positive occurrence.");
|
|
955
|
+
}
|
|
956
|
+
const matches = [];
|
|
957
|
+
for (let p = base.indexOf(block.before); p !== -1; p = base.indexOf(block.before, p + 1)) matches.push(p);
|
|
958
|
+
if (!matches.length || matches.length > 1 && block.occurrence === void 0) {
|
|
959
|
+
throw new Error("Replacement before text is absent or ambiguous in the saved base; specify occurrence for repeated text.");
|
|
960
|
+
}
|
|
961
|
+
const start = matches[(block.occurrence ?? 1) - 1];
|
|
962
|
+
if (start === void 0) throw new Error("Replacement occurrence is absent from the saved base.");
|
|
963
|
+
return { start, end: start + block.before.length, text: block.after };
|
|
964
|
+
}).sort((a, b) => a.start - b.start);
|
|
965
|
+
return validateExplicitEdits(base, edits);
|
|
966
|
+
}
|
|
967
|
+
function bindExplicitEdits(base, local, live, edits, options) {
|
|
968
|
+
const checked = validateExplicitEdits(base, edits);
|
|
969
|
+
if (applyTextEdits(base, checked) !== local) {
|
|
970
|
+
throw new Error("Replacement manifest must describe every local change exactly.");
|
|
971
|
+
}
|
|
972
|
+
const changes = textEdits(base, live);
|
|
973
|
+
const mapped = checked.map((edit) => {
|
|
974
|
+
let offset = 0;
|
|
975
|
+
for (const change of changes) {
|
|
976
|
+
if (change.start <= edit.end && edit.start <= change.end) {
|
|
977
|
+
throw new Error("A concurrent edit touches an explicit replacement block; refresh and re-plan.");
|
|
978
|
+
}
|
|
979
|
+
if (change.end < edit.start) offset += change.text.length - (change.end - change.start);
|
|
980
|
+
}
|
|
981
|
+
return { ...edit, start: edit.start + offset, end: edit.end + offset };
|
|
982
|
+
});
|
|
983
|
+
return { ...options, explicitEdits: mapped };
|
|
984
|
+
}
|
|
985
|
+
function reviewGroupingOptions(base, live, ranges, direct = false) {
|
|
986
|
+
const protectedSpans = [];
|
|
987
|
+
for (const range of ranges.changes ?? []) {
|
|
988
|
+
if (typeof range.op?.p === "number") {
|
|
989
|
+
protectedSpans.push({ start: range.op.p, end: range.op.p + (range.op.i?.length ?? 0) });
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
for (const range of ranges.comments ?? []) {
|
|
993
|
+
if (typeof range.op?.p === "number") {
|
|
994
|
+
protectedSpans.push({ start: range.op.p, end: range.op.p + (range.op.c?.length ?? 0) });
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
let offset = 0;
|
|
998
|
+
for (const edit of textEdits(base, live)) {
|
|
999
|
+
const start = edit.start + offset;
|
|
1000
|
+
protectedSpans.push({ start, end: start + edit.text.length });
|
|
1001
|
+
offset += edit.text.length - (edit.end - edit.start);
|
|
1002
|
+
}
|
|
1003
|
+
return { group: !direct, protectedSpans };
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// src/lib/document-match.ts
|
|
1007
|
+
var AmbiguousDocumentError = class extends Error {
|
|
1008
|
+
constructor(identifier, matches) {
|
|
1009
|
+
super(
|
|
1010
|
+
`Document name "${identifier}" is ambiguous (${matches.map((doc) => doc.path).join(", ")}); use the exact project path.`
|
|
1011
|
+
);
|
|
1012
|
+
this.name = "AmbiguousDocumentError";
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
function matchDocument(identifier, docs) {
|
|
1016
|
+
const exact = docs.filter((doc) => doc.path === identifier);
|
|
1017
|
+
if (exact.length > 1) throw new AmbiguousDocumentError(identifier, exact);
|
|
1018
|
+
if (exact.length === 1) return exact[0];
|
|
1019
|
+
if (identifier.includes("/") || identifier.includes("\\")) return void 0;
|
|
1020
|
+
const basename2 = identifier.replace(/\\/g, "/").split("/").pop() ?? identifier;
|
|
1021
|
+
const matches = docs.filter((doc) => doc.name === basename2);
|
|
1022
|
+
if (matches.length > 1) throw new AmbiguousDocumentError(identifier, matches);
|
|
1023
|
+
return matches[0];
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// src/lib/receipts.ts
|
|
1027
|
+
import {
|
|
1028
|
+
closeSync as closeSync2,
|
|
1029
|
+
existsSync as existsSync3,
|
|
1030
|
+
fsyncSync as fsyncSync2,
|
|
1031
|
+
mkdirSync as mkdirSync4,
|
|
1032
|
+
openSync as openSync2,
|
|
1033
|
+
readFileSync as readFileSync4,
|
|
1034
|
+
readdirSync,
|
|
1035
|
+
renameSync as renameSync2,
|
|
1036
|
+
unlinkSync as unlinkSync3,
|
|
1037
|
+
writeFileSync as writeFileSync4
|
|
1038
|
+
} from "fs";
|
|
1039
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1040
|
+
import { basename, dirname as dirname3, join as join4 } from "path";
|
|
1041
|
+
var RECEIPT_SCHEMA_VERSION = 1;
|
|
1042
|
+
var DEFAULT_RECEIPTS_DIR = join4(".overleaf", "receipts");
|
|
1043
|
+
function safeFilenamePart(value) {
|
|
1044
|
+
const safe = value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1045
|
+
return safe || "operation";
|
|
1046
|
+
}
|
|
1047
|
+
function writeJsonAtomic(path, value) {
|
|
1048
|
+
const targetDir = dirname3(path);
|
|
1049
|
+
mkdirSync4(targetDir, { recursive: true });
|
|
1050
|
+
const tempPath = join4(
|
|
1051
|
+
targetDir,
|
|
1052
|
+
`.${basename(path)}.${process.pid}.${randomUUID2()}.tmp`
|
|
1053
|
+
);
|
|
1054
|
+
let fd;
|
|
1055
|
+
try {
|
|
1056
|
+
fd = openSync2(tempPath, "wx", 384);
|
|
1057
|
+
writeFileSync4(fd, `${JSON.stringify(value, null, 2)}
|
|
1058
|
+
`, "utf8");
|
|
1059
|
+
fsyncSync2(fd);
|
|
1060
|
+
closeSync2(fd);
|
|
1061
|
+
fd = void 0;
|
|
1062
|
+
renameSync2(tempPath, path);
|
|
1063
|
+
let dirFd;
|
|
1064
|
+
try {
|
|
1065
|
+
dirFd = openSync2(targetDir, "r");
|
|
1066
|
+
fsyncSync2(dirFd);
|
|
1067
|
+
} catch {
|
|
1068
|
+
} finally {
|
|
1069
|
+
if (dirFd !== void 0) closeSync2(dirFd);
|
|
1070
|
+
}
|
|
1071
|
+
} finally {
|
|
1072
|
+
if (fd !== void 0) closeSync2(fd);
|
|
1073
|
+
if (existsSync3(tempPath)) unlinkSync3(tempPath);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
function beginReceipt(operation, details, options = {}) {
|
|
1077
|
+
const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
|
|
1078
|
+
const operationId = options.operationId ?? randomUUID2();
|
|
1079
|
+
const receipt = {
|
|
1080
|
+
schemaVersion: RECEIPT_SCHEMA_VERSION,
|
|
1081
|
+
operationId,
|
|
1082
|
+
operation,
|
|
1083
|
+
status: "started",
|
|
1084
|
+
startedAt: now,
|
|
1085
|
+
updatedAt: now,
|
|
1086
|
+
details
|
|
1087
|
+
};
|
|
1088
|
+
const dir = options.receiptsDir ?? DEFAULT_RECEIPTS_DIR;
|
|
1089
|
+
const timestamp = now.replace(/[:.]/g, "-");
|
|
1090
|
+
const path = join4(dir, `${timestamp}-${safeFilenamePart(operation)}-${operationId}.json`);
|
|
1091
|
+
writeJsonAtomic(path, receipt);
|
|
1092
|
+
return { path, receipt };
|
|
1093
|
+
}
|
|
1094
|
+
function updateReceipt(handle, status, details, options = {}) {
|
|
1095
|
+
const receipt = {
|
|
1096
|
+
...handle.receipt,
|
|
1097
|
+
status,
|
|
1098
|
+
updatedAt: (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
|
|
1099
|
+
details: { ...handle.receipt.details, ...details }
|
|
1100
|
+
};
|
|
1101
|
+
writeJsonAtomic(handle.path, receipt);
|
|
1102
|
+
return { path: handle.path, receipt };
|
|
1103
|
+
}
|
|
1104
|
+
function readReceipts(receiptsDir = DEFAULT_RECEIPTS_DIR) {
|
|
1105
|
+
let names;
|
|
1106
|
+
try {
|
|
1107
|
+
names = readdirSync(receiptsDir).filter((name) => name.endsWith(".json"));
|
|
1108
|
+
} catch {
|
|
1109
|
+
return [];
|
|
1110
|
+
}
|
|
1111
|
+
const receipts = [];
|
|
1112
|
+
for (const name of names) {
|
|
1113
|
+
const path = join4(receiptsDir, name);
|
|
1114
|
+
try {
|
|
1115
|
+
const receipt = JSON.parse(readFileSync4(path, "utf8"));
|
|
1116
|
+
if (receipt?.schemaVersion === RECEIPT_SCHEMA_VERSION && typeof receipt.operationId === "string" && typeof receipt.operation === "string" && typeof receipt.updatedAt === "string" && receipt.details && typeof receipt.details === "object") {
|
|
1117
|
+
receipts.push({ path, receipt });
|
|
1118
|
+
}
|
|
1119
|
+
} catch {
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
return receipts.sort((a, b) => b.receipt.updatedAt.localeCompare(a.receipt.updatedAt));
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// src/lib/sync-state.ts
|
|
1126
|
+
import { createHash as createHash2 } from "crypto";
|
|
1127
|
+
import {
|
|
1128
|
+
mkdirSync as mkdirSync5,
|
|
1129
|
+
readFileSync as readFileSync5,
|
|
1130
|
+
renameSync as renameSync3,
|
|
1131
|
+
unlinkSync as unlinkSync4,
|
|
1132
|
+
writeFileSync as writeFileSync5
|
|
1133
|
+
} from "fs";
|
|
1134
|
+
import { dirname as dirname4, join as join5 } from "path";
|
|
1135
|
+
var BASE_STATE_SCHEMA_VERSION = 1;
|
|
1136
|
+
var BASE_STATE_PATH = join5(".overleaf", "base.json");
|
|
1137
|
+
function sha256(text) {
|
|
1138
|
+
return createHash2("sha256").update(text, "utf8").digest("hex");
|
|
1139
|
+
}
|
|
1140
|
+
function canonicalize(value) {
|
|
1141
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
1142
|
+
if (value && typeof value === "object") {
|
|
1143
|
+
const out = {};
|
|
1144
|
+
for (const key of Object.keys(value).sort()) {
|
|
1145
|
+
const item = value[key];
|
|
1146
|
+
if (item !== void 0) out[key] = canonicalize(item);
|
|
1147
|
+
}
|
|
1148
|
+
return out;
|
|
1149
|
+
}
|
|
1150
|
+
return value;
|
|
1151
|
+
}
|
|
1152
|
+
function stableJson(value) {
|
|
1153
|
+
return JSON.stringify(canonicalize(value));
|
|
1154
|
+
}
|
|
1155
|
+
function sortedRanges(values) {
|
|
1156
|
+
return (values ?? []).map(canonicalize).sort((a, b) => {
|
|
1157
|
+
const left = stableJson(a);
|
|
1158
|
+
const right = stableJson(b);
|
|
1159
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
function fingerprintRanges(ranges) {
|
|
1163
|
+
return sha256(
|
|
1164
|
+
stableJson({
|
|
1165
|
+
comments: sortedRanges(ranges.comments),
|
|
1166
|
+
changes: sortedRanges(ranges.changes)
|
|
1167
|
+
})
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
function assertBaseState(value, path) {
|
|
1171
|
+
if (!value || typeof value !== "object") throw new Error(`Invalid base state in ${path}`);
|
|
1172
|
+
const state = value;
|
|
1173
|
+
if (state.schemaVersion !== BASE_STATE_SCHEMA_VERSION || typeof state.projectId !== "string" || !state.documents || typeof state.documents !== "object") {
|
|
1174
|
+
throw new Error(
|
|
1175
|
+
`Unsupported or invalid base state in ${path}; run fetch to create a new synchronization base.`
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
for (const [docId, raw] of Object.entries(state.documents)) {
|
|
1179
|
+
const doc = raw;
|
|
1180
|
+
if (doc.docId !== docId || typeof doc.path !== "string" || typeof doc.text !== "string" || typeof doc.hash !== "string" || doc.hash !== sha256(doc.text)) {
|
|
1181
|
+
throw new Error(`Invalid document ${docId} in ${path}`);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
function loadBaseState(path = BASE_STATE_PATH) {
|
|
1186
|
+
let raw;
|
|
1187
|
+
try {
|
|
1188
|
+
raw = readFileSync5(path, "utf8");
|
|
1189
|
+
} catch (error) {
|
|
1190
|
+
if (error.code === "ENOENT") return void 0;
|
|
1191
|
+
throw error;
|
|
1192
|
+
}
|
|
1193
|
+
let parsed;
|
|
1194
|
+
try {
|
|
1195
|
+
parsed = JSON.parse(raw);
|
|
1196
|
+
} catch {
|
|
1197
|
+
throw new Error(`Invalid JSON in ${path}; run fetch to recreate the synchronization base.`);
|
|
1198
|
+
}
|
|
1199
|
+
assertBaseState(parsed, path);
|
|
1200
|
+
return parsed;
|
|
1201
|
+
}
|
|
1202
|
+
function saveBaseState(state, path = BASE_STATE_PATH) {
|
|
1203
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
1204
|
+
const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
1205
|
+
try {
|
|
1206
|
+
writeFileSync5(temp, JSON.stringify(state, null, 2) + "\n", { mode: 384 });
|
|
1207
|
+
renameSync3(temp, path);
|
|
1208
|
+
} catch (error) {
|
|
1209
|
+
try {
|
|
1210
|
+
unlinkSync4(temp);
|
|
1211
|
+
} catch {
|
|
1212
|
+
}
|
|
1213
|
+
throw error;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
function mergeBaseDocuments(projectId, documents, path = BASE_STATE_PATH) {
|
|
1217
|
+
const previous = loadBaseState(path);
|
|
1218
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1219
|
+
const state = {
|
|
1220
|
+
schemaVersion: BASE_STATE_SCHEMA_VERSION,
|
|
1221
|
+
projectId,
|
|
1222
|
+
updatedAt: now,
|
|
1223
|
+
documents: previous?.projectId === projectId ? { ...previous.documents } : {}
|
|
1224
|
+
};
|
|
1225
|
+
for (const doc of documents) state.documents[doc.docId] = doc;
|
|
1226
|
+
saveBaseState(state, path);
|
|
1227
|
+
return state;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// src/lib/tracked-changes.ts
|
|
1231
|
+
import { randomBytes } from "crypto";
|
|
1232
|
+
var TRACKED_CHANGE_SEED_BYTES = 9;
|
|
1233
|
+
function createTrackedChangeSeed(bytes = randomBytes) {
|
|
1234
|
+
const entropy = bytes(TRACKED_CHANGE_SEED_BYTES);
|
|
1235
|
+
if (entropy.byteLength !== TRACKED_CHANGE_SEED_BYTES) {
|
|
1236
|
+
throw new Error(
|
|
1237
|
+
`tracked-change seed source returned ${entropy.byteLength} bytes; expected ${TRACKED_CHANGE_SEED_BYTES}`
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
return Buffer.from(entropy).toString("hex");
|
|
1241
|
+
}
|
|
1242
|
+
var TrackedChangeMutationError = class extends Error {
|
|
1243
|
+
constructor(message, result, cause) {
|
|
1244
|
+
super(message, cause === void 0 ? void 0 : { cause });
|
|
1245
|
+
this.result = result;
|
|
1246
|
+
this.name = "TrackedChangeMutationError";
|
|
1247
|
+
}
|
|
1248
|
+
result;
|
|
1249
|
+
};
|
|
1250
|
+
function uniqueChangeIds(changeIds) {
|
|
1251
|
+
return [...new Set(changeIds)];
|
|
1252
|
+
}
|
|
1253
|
+
function changeIdsInRanges(ranges) {
|
|
1254
|
+
return [...new Set(ranges.map((range) => range.id))];
|
|
1255
|
+
}
|
|
1256
|
+
function remainingChangeIds(ranges, requestedIds) {
|
|
1257
|
+
const present = new Set(changeIdsInRanges(ranges));
|
|
1258
|
+
return uniqueChangeIds(requestedIds).filter((id) => present.has(id));
|
|
1259
|
+
}
|
|
1260
|
+
function inverseOf(range) {
|
|
1261
|
+
const { p, i, d } = range.op ?? {};
|
|
1262
|
+
if (!Number.isSafeInteger(p) || p < 0) {
|
|
1263
|
+
throw new Error(`tracked change ${range.id} has an invalid position: ${String(p)}`);
|
|
1264
|
+
}
|
|
1265
|
+
if (typeof i === "string" && d === void 0) return { p, d: i, u: true };
|
|
1266
|
+
if (typeof d === "string" && i === void 0) return { p, i: d, u: true };
|
|
1267
|
+
throw new Error(`tracked change ${range.id} does not contain exactly one insert/delete op`);
|
|
1268
|
+
}
|
|
1269
|
+
function applyUndo(text, op, changeId) {
|
|
1270
|
+
if (op.p > text.length) {
|
|
1271
|
+
throw new Error(
|
|
1272
|
+
`tracked change ${changeId} starts at ${op.p}, beyond document length ${text.length}`
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
if ("d" in op) {
|
|
1276
|
+
const actual = text.slice(op.p, op.p + op.d.length);
|
|
1277
|
+
if (actual !== op.d) {
|
|
1278
|
+
throw new Error(
|
|
1279
|
+
`tracked insertion ${changeId} no longer matches document text at ${op.p}: expected ${JSON.stringify(op.d)}, found ${JSON.stringify(actual)}`
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
return text.slice(0, op.p) + text.slice(op.p + op.d.length);
|
|
1283
|
+
}
|
|
1284
|
+
return text.slice(0, op.p) + op.i + text.slice(op.p);
|
|
1285
|
+
}
|
|
1286
|
+
function buildRejectionPlan(currentText, ranges, requestedIds) {
|
|
1287
|
+
const requested = new Set(uniqueChangeIds(requestedIds));
|
|
1288
|
+
const fragments = ranges.filter((range) => requested.has(range.id));
|
|
1289
|
+
fragments.sort((a, b) => b.op.p - a.op.p);
|
|
1290
|
+
const operations = [];
|
|
1291
|
+
let expectedText = currentText;
|
|
1292
|
+
for (const range of fragments) {
|
|
1293
|
+
const inverse = inverseOf(range);
|
|
1294
|
+
expectedText = applyUndo(expectedText, inverse, range.id);
|
|
1295
|
+
operations.push(inverse);
|
|
1296
|
+
}
|
|
1297
|
+
return {
|
|
1298
|
+
changeIds: changeIdsInRanges(fragments),
|
|
1299
|
+
fragmentCount: fragments.length,
|
|
1300
|
+
operations,
|
|
1301
|
+
expectedText
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// src/commands/consolidate.ts
|
|
1306
|
+
import { readFileSync as readFileSync7 } from "fs";
|
|
1307
|
+
|
|
1308
|
+
// src/commands/push.ts
|
|
1309
|
+
import {
|
|
1310
|
+
mkdirSync as mkdirSync6,
|
|
1311
|
+
readFileSync as readFileSync6,
|
|
1312
|
+
readdirSync as readdirSync2,
|
|
1313
|
+
renameSync as renameSync4,
|
|
1314
|
+
statSync,
|
|
1315
|
+
unlinkSync as unlinkSync5,
|
|
1316
|
+
writeFileSync as writeFileSync6
|
|
1317
|
+
} from "fs";
|
|
1318
|
+
import { createHash as createHash3 } from "crypto";
|
|
1319
|
+
import { dirname as dirname5, relative as relative2, resolve as resolvePath, sep as sep2 } from "path";
|
|
1320
|
+
|
|
1321
|
+
// src/lib/tracked-overlap.ts
|
|
1322
|
+
function spanOverlapsEdit(start, end, edit) {
|
|
1323
|
+
const editIsPoint = edit.start === edit.end;
|
|
1324
|
+
const rangeIsPoint = start === end;
|
|
1325
|
+
if (editIsPoint && rangeIsPoint) return edit.start === start;
|
|
1326
|
+
if (editIsPoint) return edit.start > start && edit.start < end;
|
|
1327
|
+
if (rangeIsPoint) return start >= edit.start && start < edit.end;
|
|
1328
|
+
return edit.start < end && start < edit.end;
|
|
1329
|
+
}
|
|
1330
|
+
function overlapsEdit(change, edit) {
|
|
1331
|
+
const p = change.op?.p;
|
|
1332
|
+
if (typeof p !== "number") return false;
|
|
1333
|
+
const inserted = typeof change.op?.i === "string" ? change.op.i : void 0;
|
|
1334
|
+
const changeStart = p;
|
|
1335
|
+
const changeEnd = p + (inserted?.length ?? 0);
|
|
1336
|
+
return spanOverlapsEdit(changeStart, changeEnd, edit);
|
|
1337
|
+
}
|
|
1338
|
+
function findTrackedChangeOverlaps(changes, proposedEdits) {
|
|
1339
|
+
const out = [];
|
|
1340
|
+
for (const change of changes ?? []) {
|
|
1341
|
+
for (const proposedEdit of proposedEdits) {
|
|
1342
|
+
if (overlapsEdit(change, proposedEdit)) {
|
|
1343
|
+
out.push({ changeId: change.id ?? "(unknown)", change, proposedEdit });
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
return out;
|
|
1348
|
+
}
|
|
1349
|
+
function findCommentOverlaps(comments, proposedEdits) {
|
|
1350
|
+
const out = [];
|
|
1351
|
+
for (const comment of comments ?? []) {
|
|
1352
|
+
const p = comment.op?.p;
|
|
1353
|
+
const anchor = comment.op?.c;
|
|
1354
|
+
if (typeof p !== "number" || typeof anchor !== "string") continue;
|
|
1355
|
+
for (const proposedEdit of proposedEdits) {
|
|
1356
|
+
if (spanOverlapsEdit(p, p + anchor.length, proposedEdit)) {
|
|
1357
|
+
out.push({
|
|
1358
|
+
threadId: comment.op?.t ?? comment.id ?? "(unknown)",
|
|
1359
|
+
position: p,
|
|
1360
|
+
anchor,
|
|
1361
|
+
comment,
|
|
1362
|
+
proposedEdit
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
return out;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// src/lib/snapshots.ts
|
|
1371
|
+
import { join as join6 } from "path";
|
|
1372
|
+
var SNAPSHOTS_DIR = join6(".overleaf", "snapshots");
|
|
1373
|
+
function snapshotTimestamp(date = /* @__PURE__ */ new Date()) {
|
|
1374
|
+
return date.toISOString().replace(/[:.]/g, "-");
|
|
1375
|
+
}
|
|
1376
|
+
function snapshotRelativePath(timestamp, projectPath, root = process.cwd()) {
|
|
1377
|
+
if (!/^[0-9TZ-]+$/.test(timestamp)) throw new Error(`Invalid snapshot timestamp: ${timestamp}`);
|
|
1378
|
+
const safeProjectPath = workspaceRelativePath(projectPath, root);
|
|
1379
|
+
return workspaceRelativePath(join6(SNAPSHOTS_DIR, timestamp, safeProjectPath), root);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// src/commands/push.ts
|
|
1383
|
+
var PUSH_PLAN_SCHEMA_VERSION = 4;
|
|
1384
|
+
var PUSH_PLAN_KIND = "overleaf-review-push-plan";
|
|
1385
|
+
var PushSubmissionError = class extends Error {
|
|
1386
|
+
constructor(message, receiptPath, status, documents, cause) {
|
|
1387
|
+
super(message, cause === void 0 ? void 0 : { cause });
|
|
1388
|
+
this.receiptPath = receiptPath;
|
|
1389
|
+
this.status = status;
|
|
1390
|
+
this.documents = documents;
|
|
1391
|
+
this.name = "PushSubmissionError";
|
|
1392
|
+
}
|
|
1393
|
+
receiptPath;
|
|
1394
|
+
status;
|
|
1395
|
+
documents;
|
|
1396
|
+
};
|
|
1397
|
+
var PushPlanningError = class extends Error {
|
|
1398
|
+
constructor(message, conflicts = [], overlaps = []) {
|
|
1399
|
+
super(message);
|
|
1400
|
+
this.conflicts = conflicts;
|
|
1401
|
+
this.overlaps = overlaps;
|
|
1402
|
+
this.name = "PushPlanningError";
|
|
1403
|
+
}
|
|
1404
|
+
conflicts;
|
|
1405
|
+
overlaps;
|
|
1406
|
+
};
|
|
1407
|
+
var PushPlanValidationError = class extends Error {
|
|
1408
|
+
constructor(message) {
|
|
1409
|
+
super(message);
|
|
1410
|
+
this.name = "PushPlanValidationError";
|
|
1411
|
+
}
|
|
1412
|
+
};
|
|
1413
|
+
function validatePushOptions(opts) {
|
|
1414
|
+
if (opts.edits && (opts.plan || !opts.file || opts.direct || opts.unsafeNoBase)) {
|
|
1415
|
+
throw new Error("--edits requires --file and a saved base, and cannot be combined with --plan, --direct or --unsafe-no-base.");
|
|
1416
|
+
}
|
|
1417
|
+
if (opts.docName && !opts.file) {
|
|
1418
|
+
throw new Error("--doc requires --file; bulk pushes cannot map multiple local files to one document.");
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
function buildOps(source, target, options = {}) {
|
|
1422
|
+
const ops = [];
|
|
1423
|
+
let offset = 0;
|
|
1424
|
+
for (const edit of buildReviewEdits(source, target, options)) {
|
|
1425
|
+
const p = edit.start + offset;
|
|
1426
|
+
const deleted = source.slice(edit.start, edit.end);
|
|
1427
|
+
if (deleted) ops.push({ p, d: deleted });
|
|
1428
|
+
if (edit.text) ops.push({ p, i: edit.text });
|
|
1429
|
+
offset += edit.text.length - deleted.length;
|
|
1430
|
+
}
|
|
1431
|
+
const rebuilt = applyOps(source, ops);
|
|
1432
|
+
if (rebuilt !== target) {
|
|
1433
|
+
throw new Error("internal error: generated OT operations do not reconstruct target text");
|
|
1434
|
+
}
|
|
1435
|
+
return ops;
|
|
1436
|
+
}
|
|
1437
|
+
function buildOperationFootprint(source, target, options = {}) {
|
|
1438
|
+
return buildReviewEdits(source, target, options);
|
|
1439
|
+
}
|
|
1440
|
+
function applyOps(source, ops) {
|
|
1441
|
+
let text = source;
|
|
1442
|
+
for (const op of ops) {
|
|
1443
|
+
if (!Number.isSafeInteger(op.p) || op.p < 0 || op.p > text.length) {
|
|
1444
|
+
throw new Error(`invalid operation position ${String(op.p)} for ${text.length}-character text`);
|
|
1445
|
+
}
|
|
1446
|
+
const hasInsert = typeof op.i === "string";
|
|
1447
|
+
const hasDelete = typeof op.d === "string";
|
|
1448
|
+
if (hasInsert === hasDelete) throw new Error("operation must contain exactly one of i or d");
|
|
1449
|
+
if (hasInsert) {
|
|
1450
|
+
text = text.slice(0, op.p) + op.i + text.slice(op.p);
|
|
1451
|
+
} else {
|
|
1452
|
+
const deletion = op.d;
|
|
1453
|
+
const actual = text.slice(op.p, op.p + deletion.length);
|
|
1454
|
+
if (actual !== deletion) {
|
|
1455
|
+
throw new Error(
|
|
1456
|
+
`delete operation mismatch at ${op.p}: expected ${JSON.stringify(deletion)}, found ${JSON.stringify(actual)}`
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
text = text.slice(0, op.p) + text.slice(op.p + deletion.length);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
return text;
|
|
1463
|
+
}
|
|
1464
|
+
function preview(op) {
|
|
1465
|
+
const kind = op.i != null ? "insert" : "delete";
|
|
1466
|
+
const text = (op.i ?? op.d ?? "").replace(/\n/g, "\u23CE");
|
|
1467
|
+
const clip = text.length > 60 ? text.slice(0, 60) + "\u2026" : text;
|
|
1468
|
+
return ` ${kind.padEnd(6)} @ ${String(op.p).padStart(5)} "${clip}"`;
|
|
1469
|
+
}
|
|
1470
|
+
function toOverleafPath(file) {
|
|
1471
|
+
return workspaceRelativePath(file);
|
|
1472
|
+
}
|
|
1473
|
+
var IGNORE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".overleaf", "tmp", "dist"]);
|
|
1474
|
+
function discoverLocalTex(dir = process.cwd(), acc = []) {
|
|
1475
|
+
for (const entry of readdirSync2(dir, { withFileTypes: true })) {
|
|
1476
|
+
if (entry.name.startsWith(".") || IGNORE_DIRS.has(entry.name)) continue;
|
|
1477
|
+
const full = resolvePath(dir, entry.name);
|
|
1478
|
+
if (entry.isDirectory()) discoverLocalTex(full, acc);
|
|
1479
|
+
else if (entry.name.endsWith(".tex")) {
|
|
1480
|
+
acc.push(relative2(process.cwd(), full).split(sep2).join("/"));
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
return acc;
|
|
1484
|
+
}
|
|
1485
|
+
function pickDoc(file, docName, docs) {
|
|
1486
|
+
return matchDocument(docName?.replace(/\\/g, "/") ?? toOverleafPath(file), docs);
|
|
1487
|
+
}
|
|
1488
|
+
function formatConflicts(conflicts) {
|
|
1489
|
+
return conflicts.flatMap(
|
|
1490
|
+
({ docPath, conflicts: items }) => items.map(
|
|
1491
|
+
({ 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})`
|
|
1492
|
+
)
|
|
1493
|
+
).join("\n ");
|
|
1494
|
+
}
|
|
1495
|
+
function serializeOverlaps(overlaps) {
|
|
1496
|
+
return overlaps.map(({ changeId, proposedEdit, change }) => ({
|
|
1497
|
+
changeId,
|
|
1498
|
+
proposedEdit,
|
|
1499
|
+
trackedOp: {
|
|
1500
|
+
p: change.op?.p,
|
|
1501
|
+
i: change.op?.i,
|
|
1502
|
+
d: change.op?.d
|
|
1503
|
+
}
|
|
1504
|
+
})).sort((a, b) => {
|
|
1505
|
+
const left = stableJson(a);
|
|
1506
|
+
const right = stableJson(b);
|
|
1507
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
function serializeActiveTrackedRanges(changes) {
|
|
1511
|
+
const ranges = (changes ?? []).map((change, index) => {
|
|
1512
|
+
const id = change?.id;
|
|
1513
|
+
const p = change?.op?.p;
|
|
1514
|
+
const hasInsert = typeof change?.op?.i === "string";
|
|
1515
|
+
const hasDelete = typeof change?.op?.d === "string";
|
|
1516
|
+
if (typeof id !== "string" || !Number.isSafeInteger(p) || p < 0 || hasInsert === hasDelete) {
|
|
1517
|
+
throw new Error(`Overleaf returned an invalid tracked range at index ${index}`);
|
|
1518
|
+
}
|
|
1519
|
+
return {
|
|
1520
|
+
id,
|
|
1521
|
+
op: {
|
|
1522
|
+
p,
|
|
1523
|
+
...hasInsert ? { i: change.op.i } : { d: change.op.d }
|
|
1524
|
+
},
|
|
1525
|
+
...change.metadata && typeof change.metadata === "object" ? { metadata: change.metadata } : {}
|
|
1526
|
+
};
|
|
1527
|
+
});
|
|
1528
|
+
return ranges.sort((a, b) => {
|
|
1529
|
+
const left = stableJson(a);
|
|
1530
|
+
const right = stableJson(b);
|
|
1531
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
function serializeCommentOverlaps(overlaps) {
|
|
1535
|
+
return overlaps.map(({ threadId, position, anchor, proposedEdit }) => ({
|
|
1536
|
+
threadId,
|
|
1537
|
+
position,
|
|
1538
|
+
anchor,
|
|
1539
|
+
proposedEdit
|
|
1540
|
+
})).sort((a, b) => {
|
|
1541
|
+
const left = stableJson(a);
|
|
1542
|
+
const right = stableJson(b);
|
|
1543
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
async function createPlan(opts = {}) {
|
|
1547
|
+
validatePushOptions(opts);
|
|
1548
|
+
const manifest = opts.edits ? JSON.parse(readFileSync6(workspaceReadPath(opts.edits), "utf8")) : void 0;
|
|
1549
|
+
if (opts.plan) throw new Error("createPlan does not accept an existing plan");
|
|
1550
|
+
const basePath = opts.basePath ?? BASE_STATE_PATH;
|
|
1551
|
+
const baseState = loadBaseState(basePath);
|
|
1552
|
+
if (baseState && baseState.projectId !== config.projectId && !opts.unsafeNoBase) {
|
|
1553
|
+
throw new PushPlanningError(
|
|
1554
|
+
`Saved base belongs to project ${baseState.projectId}, not ${config.projectId}; run fetch first.`
|
|
1555
|
+
);
|
|
1556
|
+
}
|
|
1557
|
+
const { socket, project, docs } = await openProject();
|
|
1558
|
+
try {
|
|
1559
|
+
const projectId = String(project?._id ?? config.projectId);
|
|
1560
|
+
if (projectId !== config.projectId) {
|
|
1561
|
+
throw new PushPlanningError(
|
|
1562
|
+
`Connected project id ${projectId} does not match configured project ${config.projectId}.`
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
1565
|
+
const files = opts.file ? [workspaceRelativePath(opts.file)] : discoverLocalTex();
|
|
1566
|
+
if (!files.length) {
|
|
1567
|
+
const emptyPlan = {
|
|
1568
|
+
kind: PUSH_PLAN_KIND,
|
|
1569
|
+
schemaVersion: PUSH_PLAN_SCHEMA_VERSION,
|
|
1570
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1571
|
+
projectId,
|
|
1572
|
+
projectName: String(project?.name ?? "(unknown)"),
|
|
1573
|
+
direct: Boolean(opts.direct),
|
|
1574
|
+
unsafeNoBase: Boolean(opts.unsafeNoBase),
|
|
1575
|
+
allowOverlap: Boolean(opts.allowOverlap),
|
|
1576
|
+
documents: []
|
|
1577
|
+
};
|
|
1578
|
+
if (opts.planOut) writePushPlan(emptyPlan, opts.planOut);
|
|
1579
|
+
return emptyPlan;
|
|
1580
|
+
}
|
|
1581
|
+
const documents = [];
|
|
1582
|
+
const conflicts = [];
|
|
1583
|
+
const blockedOverlaps = [];
|
|
1584
|
+
for (const file of files) {
|
|
1585
|
+
let local;
|
|
1586
|
+
try {
|
|
1587
|
+
local = readFileSync6(workspaceReadPath(file), "utf8");
|
|
1588
|
+
} catch (error) {
|
|
1589
|
+
throw new Error(`Cannot safely read ${file}: ${error.message}`);
|
|
1590
|
+
}
|
|
1591
|
+
const doc = pickDoc(file, opts.docName, docs);
|
|
1592
|
+
if (!doc) {
|
|
1593
|
+
throw new Error(
|
|
1594
|
+
`No Overleaf document matches ${file}; push it with --file and --doc using the exact project path.`
|
|
1595
|
+
);
|
|
1596
|
+
}
|
|
1597
|
+
const state = await joinDoc(socket, doc._id);
|
|
1598
|
+
const live = state.lines.join("\n");
|
|
1599
|
+
const savedBase = baseState?.projectId === projectId ? baseState.documents[doc._id] : void 0;
|
|
1600
|
+
if (!savedBase && !opts.unsafeNoBase) {
|
|
1601
|
+
throw new PushPlanningError(
|
|
1602
|
+
`No saved synchronization base for ${doc.path}. Run fetch first, or explicitly use \`--unsafe-no-base\` to request legacy two-way behavior.`
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
const base = savedBase?.text ?? live;
|
|
1606
|
+
const merge = threeWayMerge(base, local, live);
|
|
1607
|
+
if (merge.conflicts.length) {
|
|
1608
|
+
conflicts.push({ localPath: file, docPath: doc.path, conflicts: merge.conflicts });
|
|
1609
|
+
continue;
|
|
1610
|
+
}
|
|
1611
|
+
const expected = merge.text;
|
|
1612
|
+
const explicitEdits = manifest ? parseReplacementManifest(base, manifest) : void 0;
|
|
1613
|
+
let grouping = reviewGroupingOptions(base, live, state.ranges, opts.direct);
|
|
1614
|
+
if (explicitEdits) grouping = bindExplicitEdits(base, local, live, explicitEdits, grouping);
|
|
1615
|
+
const ops = buildOps(live, expected, grouping);
|
|
1616
|
+
if (!ops.length) continue;
|
|
1617
|
+
const proposedEdits = buildOperationFootprint(live, expected, grouping);
|
|
1618
|
+
const activeTrackedRanges = serializeActiveTrackedRanges(state.ranges.changes);
|
|
1619
|
+
assertTrackedRangeBudget(activeTrackedRanges.length, ops.length, Boolean(opts.direct), doc.path);
|
|
1620
|
+
const overlaps = serializeOverlaps(
|
|
1621
|
+
findTrackedChangeOverlaps(state.ranges.changes, proposedEdits)
|
|
1622
|
+
);
|
|
1623
|
+
const commentOverlaps = serializeCommentOverlaps(
|
|
1624
|
+
findCommentOverlaps(state.ranges.comments, proposedEdits)
|
|
1625
|
+
);
|
|
1626
|
+
if (overlaps.length && !opts.allowOverlap) {
|
|
1627
|
+
blockedOverlaps.push({ localPath: file, docPath: doc.path, overlaps });
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
documents.push({
|
|
1631
|
+
...explicitEdits ? { explicitEdits } : {},
|
|
1632
|
+
localPath: toOverleafPath(file),
|
|
1633
|
+
docId: doc._id,
|
|
1634
|
+
docPath: doc.path,
|
|
1635
|
+
baseSource: savedBase ? "saved" : "live-unsafe",
|
|
1636
|
+
baseHash: sha256(base),
|
|
1637
|
+
localHash: sha256(local),
|
|
1638
|
+
liveHash: sha256(live),
|
|
1639
|
+
liveVersion: state.version,
|
|
1640
|
+
rangeFingerprint: fingerprintRanges(state.ranges),
|
|
1641
|
+
ops,
|
|
1642
|
+
expectedHash: sha256(expected),
|
|
1643
|
+
tcSeed: opts.direct ? null : createTrackedChangeSeed(),
|
|
1644
|
+
activeTrackedRanges,
|
|
1645
|
+
trackedChangeOverlaps: overlaps,
|
|
1646
|
+
commentOverlaps
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
if (conflicts.length || blockedOverlaps.length) {
|
|
1650
|
+
const parts = [];
|
|
1651
|
+
if (conflicts.length) parts.push(`Concurrent edit conflicts:
|
|
1652
|
+
${formatConflicts(conflicts)}`);
|
|
1653
|
+
if (blockedOverlaps.length) {
|
|
1654
|
+
const lines = blockedOverlaps.flatMap(
|
|
1655
|
+
({ docPath, overlaps }) => overlaps.map(
|
|
1656
|
+
({ changeId, proposedEdit }) => `${docPath}: proposed [${proposedEdit.start},${proposedEdit.end}) overlaps change ${changeId}`
|
|
1657
|
+
)
|
|
1658
|
+
);
|
|
1659
|
+
parts.push(
|
|
1660
|
+
`Active tracked-change overlaps:
|
|
1661
|
+
${lines.join("\n ")}
|
|
1662
|
+
Re-plan with --allow-overlap only after inspecting these changes.`
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
1665
|
+
throw new PushPlanningError(parts.join("\n\n"), conflicts, blockedOverlaps);
|
|
1666
|
+
}
|
|
1667
|
+
const plan = {
|
|
1668
|
+
kind: PUSH_PLAN_KIND,
|
|
1669
|
+
schemaVersion: PUSH_PLAN_SCHEMA_VERSION,
|
|
1670
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1671
|
+
projectId,
|
|
1672
|
+
projectName: String(project?.name ?? "(unknown)"),
|
|
1673
|
+
direct: Boolean(opts.direct),
|
|
1674
|
+
unsafeNoBase: documents.some((doc) => doc.baseSource === "live-unsafe"),
|
|
1675
|
+
allowOverlap: Boolean(opts.allowOverlap),
|
|
1676
|
+
documents
|
|
1677
|
+
};
|
|
1678
|
+
if (opts.planOut) writePushPlan(plan, opts.planOut);
|
|
1679
|
+
return plan;
|
|
1680
|
+
} finally {
|
|
1681
|
+
socket.close();
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
function assertHash(value, field) {
|
|
1685
|
+
if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) {
|
|
1686
|
+
throw new PushPlanValidationError(`Invalid ${field} in push plan`);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
function validatePushPlan(value) {
|
|
1690
|
+
if (!value || typeof value !== "object") throw new PushPlanValidationError("Push plan is not an object");
|
|
1691
|
+
const plan = value;
|
|
1692
|
+
if (plan.kind !== PUSH_PLAN_KIND || plan.schemaVersion !== PUSH_PLAN_SCHEMA_VERSION) {
|
|
1693
|
+
throw new PushPlanValidationError("Unsupported push-plan kind or schema version; create a new plan with the current tool.");
|
|
1694
|
+
}
|
|
1695
|
+
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)) {
|
|
1696
|
+
throw new PushPlanValidationError("Push plan is missing required fields");
|
|
1697
|
+
}
|
|
1698
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1699
|
+
const seenLocalPaths = /* @__PURE__ */ new Set();
|
|
1700
|
+
for (const doc of plan.documents) {
|
|
1701
|
+
if (doc?.explicitEdits !== void 0 && (!Array.isArray(doc.explicitEdits) || !doc.explicitEdits.length || !doc.explicitEdits.every(validTextEdit) || plan.direct || doc.baseSource !== "saved")) throw new PushPlanValidationError("Invalid explicit replacement blocks in push plan.");
|
|
1702
|
+
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)) {
|
|
1703
|
+
throw new PushPlanValidationError("Push plan contains an invalid document");
|
|
1704
|
+
}
|
|
1705
|
+
try {
|
|
1706
|
+
if (workspaceRelativePath(doc.localPath) !== doc.localPath || workspaceRelativePath(doc.docPath) !== doc.docPath) {
|
|
1707
|
+
throw new Error("path is not normalized");
|
|
1708
|
+
}
|
|
1709
|
+
} catch (error) {
|
|
1710
|
+
throw new PushPlanValidationError(
|
|
1711
|
+
`Unsafe or invalid path in push plan: ${error.message}`
|
|
1712
|
+
);
|
|
1713
|
+
}
|
|
1714
|
+
if (seen.has(doc.docId)) throw new PushPlanValidationError(`Duplicate document ${doc.docId} in plan`);
|
|
1715
|
+
seen.add(doc.docId);
|
|
1716
|
+
if (seenLocalPaths.has(doc.localPath)) {
|
|
1717
|
+
throw new PushPlanValidationError(`Duplicate local path ${doc.localPath} in plan`);
|
|
1718
|
+
}
|
|
1719
|
+
seenLocalPaths.add(doc.localPath);
|
|
1720
|
+
assertHash(doc.baseHash, "baseHash");
|
|
1721
|
+
assertHash(doc.localHash, "localHash");
|
|
1722
|
+
assertHash(doc.liveHash, "liveHash");
|
|
1723
|
+
assertHash(doc.rangeFingerprint, "rangeFingerprint");
|
|
1724
|
+
assertHash(doc.expectedHash, "expectedHash");
|
|
1725
|
+
for (const op of doc.ops) {
|
|
1726
|
+
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) {
|
|
1727
|
+
throw new PushPlanValidationError(`Invalid operation in ${doc.docPath}`);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
for (const range of doc.activeTrackedRanges) {
|
|
1731
|
+
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")) {
|
|
1732
|
+
throw new PushPlanValidationError(`Invalid active tracked range in ${doc.docPath}`);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
for (const overlap of doc.trackedChangeOverlaps) {
|
|
1736
|
+
if (!overlap || typeof overlap.changeId !== "string" || !validTextEdit(overlap.proposedEdit) || !Number.isSafeInteger(overlap.trackedOp?.p)) {
|
|
1737
|
+
throw new PushPlanValidationError(`Invalid tracked-change overlap in ${doc.docPath}`);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
for (const overlap of doc.commentOverlaps) {
|
|
1741
|
+
if (!overlap || typeof overlap.threadId !== "string" || !Number.isSafeInteger(overlap.position) || overlap.position < 0 || typeof overlap.anchor !== "string" || !validTextEdit(overlap.proposedEdit)) {
|
|
1742
|
+
throw new PushPlanValidationError(`Invalid comment overlap in ${doc.docPath}`);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
if (plan.direct) {
|
|
1746
|
+
if (doc.tcSeed !== null) throw new PushPlanValidationError("Direct plan must not contain tcSeed");
|
|
1747
|
+
} else if (typeof doc.tcSeed !== "string" || !/^[0-9a-f]{18}$/.test(doc.tcSeed)) {
|
|
1748
|
+
throw new PushPlanValidationError(`Invalid tracked-change seed in ${doc.docPath}`);
|
|
1749
|
+
}
|
|
1750
|
+
if (!plan.allowOverlap && doc.trackedChangeOverlaps.length) {
|
|
1751
|
+
throw new PushPlanValidationError("Plan contains blocked tracked-change overlaps");
|
|
1752
|
+
}
|
|
1753
|
+
if (doc.baseSource === "live-unsafe" && doc.baseHash !== doc.liveHash) {
|
|
1754
|
+
throw new PushPlanValidationError(`Unsafe base must equal planned live text in ${doc.docPath}`);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
return plan;
|
|
1758
|
+
}
|
|
1759
|
+
function validTextEdit(value) {
|
|
1760
|
+
if (!value || typeof value !== "object") return false;
|
|
1761
|
+
const edit = value;
|
|
1762
|
+
return Boolean(
|
|
1763
|
+
Number.isSafeInteger(edit.start) && Number.isSafeInteger(edit.end) && edit.start >= 0 && edit.end >= edit.start && typeof edit.text === "string"
|
|
1764
|
+
);
|
|
1765
|
+
}
|
|
1766
|
+
function readPushPlan(path, workspaceRoot = process.cwd()) {
|
|
1767
|
+
let parsed;
|
|
1768
|
+
try {
|
|
1769
|
+
parsed = JSON.parse(readFileSync6(workspaceReadPath(path, workspaceRoot), "utf8"));
|
|
1770
|
+
} catch (error) {
|
|
1771
|
+
throw new PushPlanValidationError(`Cannot read push plan ${path}: ${error.message}`);
|
|
1772
|
+
}
|
|
1773
|
+
return validatePushPlan(parsed);
|
|
1774
|
+
}
|
|
1775
|
+
function writePushPlan(plan, path, workspaceRoot = process.cwd()) {
|
|
1776
|
+
validatePushPlan(plan);
|
|
1777
|
+
const target = workspaceWritePath(path, workspaceRoot);
|
|
1778
|
+
mkdirSync6(dirname5(target), { recursive: true });
|
|
1779
|
+
const temp = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
1780
|
+
try {
|
|
1781
|
+
writeFileSync6(temp, JSON.stringify(plan, null, 2) + "\n", { mode: 384 });
|
|
1782
|
+
renameSync4(temp, target);
|
|
1783
|
+
} catch (error) {
|
|
1784
|
+
try {
|
|
1785
|
+
unlinkSync5(temp);
|
|
1786
|
+
} catch {
|
|
1787
|
+
}
|
|
1788
|
+
throw error;
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
function trackedChangeIdsForSeed(seed, count) {
|
|
1792
|
+
return Array.from(
|
|
1793
|
+
{ length: count },
|
|
1794
|
+
(_, index) => `${seed}${(index + 1).toString(16).padStart(6, "0")}`
|
|
1795
|
+
);
|
|
1796
|
+
}
|
|
1797
|
+
function errorMessage(error) {
|
|
1798
|
+
return error instanceof Error ? error.message : String(error);
|
|
1799
|
+
}
|
|
1800
|
+
function overleafSnapshotHash(text) {
|
|
1801
|
+
return createHash3("sha1").update(`blob ${text.length}\0`, "utf8").update(text, "utf8").digest("hex");
|
|
1802
|
+
}
|
|
1803
|
+
function validatePlannedIntent(planned, base, local, live, grouping = reviewGroupingOptions(base, live, {})) {
|
|
1804
|
+
if (sha256(base) !== planned.baseHash) {
|
|
1805
|
+
throw new PushPlanValidationError(`Synchronization base changed for ${planned.docPath}.`);
|
|
1806
|
+
}
|
|
1807
|
+
if (sha256(local) !== planned.localHash) {
|
|
1808
|
+
throw new PushPlanValidationError(`${planned.localPath} changed after planning.`);
|
|
1809
|
+
}
|
|
1810
|
+
if (sha256(live) !== planned.liveHash) {
|
|
1811
|
+
throw new PushPlanValidationError(`${planned.docPath} text changed after planning.`);
|
|
1812
|
+
}
|
|
1813
|
+
const merge = threeWayMerge(base, local, live);
|
|
1814
|
+
if (merge.conflicts.length || merge.text === void 0) {
|
|
1815
|
+
throw new PushPlanValidationError(
|
|
1816
|
+
`${planned.docPath} no longer has the conflict-free intent recorded by the plan.`
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1819
|
+
const expected = merge.text;
|
|
1820
|
+
if (stableJson(buildOps(live, expected, grouping)) !== stableJson(planned.ops) || sha256(expected) !== planned.expectedHash) {
|
|
1821
|
+
throw new PushPlanValidationError(
|
|
1822
|
+
`Operations in ${planned.docPath} do not match its saved Base\u2192Local intent.`
|
|
1823
|
+
);
|
|
1824
|
+
}
|
|
1825
|
+
return expected;
|
|
1826
|
+
}
|
|
1827
|
+
function validateReviewBinding(planned, state, live, expected, allowOverlap, grouping) {
|
|
1828
|
+
if (state.version !== planned.liveVersion) {
|
|
1829
|
+
throw new PushPlanValidationError(
|
|
1830
|
+
`${planned.docPath} version changed from ${planned.liveVersion} to ${state.version}; create a new plan.`
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
if (fingerprintRanges(state.ranges) !== planned.rangeFingerprint) {
|
|
1834
|
+
throw new PushPlanValidationError(
|
|
1835
|
+
`${planned.docPath} comments or tracked ranges changed after planning; create a new plan.`
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
const footprint = buildOperationFootprint(live, expected, grouping);
|
|
1839
|
+
const activeTrackedRanges = serializeActiveTrackedRanges(state.ranges.changes);
|
|
1840
|
+
const trackedOverlaps = serializeOverlaps(
|
|
1841
|
+
findTrackedChangeOverlaps(state.ranges.changes, footprint)
|
|
1842
|
+
);
|
|
1843
|
+
const commentOverlaps = serializeCommentOverlaps(
|
|
1844
|
+
findCommentOverlaps(state.ranges.comments, footprint)
|
|
1845
|
+
);
|
|
1846
|
+
if (stableJson(activeTrackedRanges) !== stableJson(planned.activeTrackedRanges)) {
|
|
1847
|
+
throw new PushPlanValidationError(
|
|
1848
|
+
`Active tracked-range data is invalid for ${planned.docPath}; create a new plan.`
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
if (stableJson(trackedOverlaps) !== stableJson(planned.trackedChangeOverlaps)) {
|
|
1852
|
+
throw new PushPlanValidationError(
|
|
1853
|
+
`Tracked-change overlap data is invalid for ${planned.docPath}; create a new plan.`
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1856
|
+
if (trackedOverlaps.length && !allowOverlap) {
|
|
1857
|
+
throw new PushPlanValidationError(
|
|
1858
|
+
`${planned.docPath} operations overlap active tracked changes; create a plan with --allow-overlap only after inspecting them.`
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
if (stableJson(commentOverlaps) !== stableJson(planned.commentOverlaps)) {
|
|
1862
|
+
throw new PushPlanValidationError(
|
|
1863
|
+
`Comment-overlap data is invalid for ${planned.docPath}; create a new plan.`
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
function baseTextForPlan(plan, planned, live, basePath) {
|
|
1868
|
+
if (planned.baseSource === "live-unsafe") {
|
|
1869
|
+
if (!plan.unsafeNoBase) {
|
|
1870
|
+
throw new PushPlanValidationError(`${planned.docPath} uses an unauthorized unsafe base.`);
|
|
1871
|
+
}
|
|
1872
|
+
return live;
|
|
1873
|
+
}
|
|
1874
|
+
const state = loadBaseState(basePath);
|
|
1875
|
+
const saved = state?.projectId === plan.projectId ? state.documents[planned.docId] : void 0;
|
|
1876
|
+
if (!saved || saved.hash !== planned.baseHash || sha256(saved.text) !== planned.baseHash) {
|
|
1877
|
+
throw new PushPlanValidationError(
|
|
1878
|
+
`Synchronization base for ${planned.docPath} changed after planning; create a new plan.`
|
|
1879
|
+
);
|
|
1880
|
+
}
|
|
1881
|
+
return saved.text;
|
|
1882
|
+
}
|
|
1883
|
+
async function bindPlanDocument(plan, planned, socket, basePath) {
|
|
1884
|
+
let local;
|
|
1885
|
+
try {
|
|
1886
|
+
local = readFileSync6(workspaceReadPath(planned.localPath), "utf8");
|
|
1887
|
+
} catch (error) {
|
|
1888
|
+
throw new PushPlanValidationError(
|
|
1889
|
+
`Cannot read planned local file ${planned.localPath}: ${errorMessage(error)}`
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
const state = await joinDoc(socket, planned.docId);
|
|
1893
|
+
const live = state.lines.join("\n");
|
|
1894
|
+
const base = baseTextForPlan(plan, planned, live, basePath);
|
|
1895
|
+
let grouping = reviewGroupingOptions(base, live, state.ranges, plan.direct);
|
|
1896
|
+
if (planned.explicitEdits) grouping = bindExplicitEdits(base, local, live, planned.explicitEdits, grouping);
|
|
1897
|
+
const expected = validatePlannedIntent(planned, base, local, live, grouping);
|
|
1898
|
+
validateReviewBinding(planned, state, live, expected, plan.allowOverlap, grouping);
|
|
1899
|
+
assertTrackedRangeBudget(state.ranges.changes?.length ?? 0, planned.ops.length, plan.direct, planned.docPath);
|
|
1900
|
+
return { plan: planned, state, expected };
|
|
1901
|
+
}
|
|
1902
|
+
var MAX_TRACKED_RANGES = 2e3;
|
|
1903
|
+
function assertTrackedRangeBudget(activeCount, operationCount, direct, docPath) {
|
|
1904
|
+
if (direct || operationCount === 0) return;
|
|
1905
|
+
if (activeCount + operationCount > MAX_TRACKED_RANGES) {
|
|
1906
|
+
throw new PushPlanValidationError(
|
|
1907
|
+
`${docPath}: ${activeCount} active tracked ranges + ${operationCount} proposed operations exceeds the conservative ${MAX_TRACKED_RANGES}-range budget. Overleaf rejects documents with too many tracked changes. Reduce the revision or arrange review of existing suggestions before submitting; smaller batches do not remove accumulated ranges.`
|
|
1908
|
+
);
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
function verifiedTrackedIds(plan, planned, after) {
|
|
1912
|
+
if (plan.direct) return [];
|
|
1913
|
+
const actualIds = new Set((after.ranges.changes ?? []).map((change) => String(change.id)));
|
|
1914
|
+
if (planned.trackedChangeOverlaps.length) {
|
|
1915
|
+
const ids = [...actualIds].filter(
|
|
1916
|
+
(id) => new RegExp(`^${planned.tcSeed}[0-9a-f]{6}$`).test(id)
|
|
1917
|
+
);
|
|
1918
|
+
if (!ids.length) {
|
|
1919
|
+
throw new Error(
|
|
1920
|
+
`Verification failed for ${planned.docPath}: no tracked ranges with seed ${planned.tcSeed} were created.`
|
|
1921
|
+
);
|
|
1922
|
+
}
|
|
1923
|
+
return ids;
|
|
1924
|
+
}
|
|
1925
|
+
const expectedIds = trackedChangeIdsForSeed(planned.tcSeed, planned.ops.length);
|
|
1926
|
+
const missing = expectedIds.filter((id) => !actualIds.has(id));
|
|
1927
|
+
if (missing.length) {
|
|
1928
|
+
throw new Error(
|
|
1929
|
+
`Verification failed for ${planned.docPath}: tracked ranges were not created for ${missing.join(", ")}.`
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
return expectedIds;
|
|
1933
|
+
}
|
|
1934
|
+
function definitelyRejectedApply(error) {
|
|
1935
|
+
const message = errorMessage(error);
|
|
1936
|
+
return message.includes("Overleaf rejected the OT update") || message.includes("Overleaf failed to apply the OT update");
|
|
1937
|
+
}
|
|
1938
|
+
function initialReceiptDocuments(plan) {
|
|
1939
|
+
return plan.documents.map((doc) => ({
|
|
1940
|
+
docId: doc.docId,
|
|
1941
|
+
docPath: doc.docPath,
|
|
1942
|
+
localPath: doc.localPath,
|
|
1943
|
+
opCount: doc.ops.length,
|
|
1944
|
+
expectedHash: doc.expectedHash,
|
|
1945
|
+
status: "pending",
|
|
1946
|
+
mutationAttempted: false
|
|
1947
|
+
}));
|
|
1948
|
+
}
|
|
1949
|
+
function pushReceiptNeedsQuarantine(receipt) {
|
|
1950
|
+
if (receipt.status === "ambiguous") return true;
|
|
1951
|
+
if (receipt.status !== "in_progress") return false;
|
|
1952
|
+
const documents = receipt.details?.documents;
|
|
1953
|
+
return Array.isArray(documents) && documents.some(
|
|
1954
|
+
(document) => Boolean(document) && typeof document === "object" && (document.mutationAttempted === true || typeof document.mutationStartedAt === "string")
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
function receiptDocumentIds(receipt) {
|
|
1958
|
+
const documents = receipt.details?.documents;
|
|
1959
|
+
if (!Array.isArray(documents)) return [];
|
|
1960
|
+
return documents.flatMap((document) => {
|
|
1961
|
+
if (!document || typeof document !== "object") return [];
|
|
1962
|
+
const docId = document.docId;
|
|
1963
|
+
return typeof docId === "string" ? [docId] : [];
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
function quarantinedPlanDocuments(plan, receipts) {
|
|
1967
|
+
const plannedIds = new Set(plan.documents.map((document) => document.docId));
|
|
1968
|
+
const disposition = /* @__PURE__ */ new Map();
|
|
1969
|
+
const newestFirst = [...receipts].sort(
|
|
1970
|
+
(a, b) => String(b.receipt.updatedAt ?? "").localeCompare(String(a.receipt.updatedAt ?? ""))
|
|
1971
|
+
);
|
|
1972
|
+
for (const { receipt } of newestFirst) {
|
|
1973
|
+
if (receipt.operation !== "push" || receipt.details?.projectId !== plan.projectId) {
|
|
1974
|
+
continue;
|
|
1975
|
+
}
|
|
1976
|
+
const reconciles = receipt.status === "succeeded" && receipt.details.acknowledgedAmbiguousRetry === true;
|
|
1977
|
+
const quarantines = pushReceiptNeedsQuarantine(receipt);
|
|
1978
|
+
if (!reconciles && !quarantines) continue;
|
|
1979
|
+
for (const docId of receiptDocumentIds(receipt)) {
|
|
1980
|
+
if (plannedIds.has(docId) && !disposition.has(docId)) {
|
|
1981
|
+
disposition.set(docId, reconciles ? "reconciled" : "quarantined");
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
return plan.documents.filter((document) => disposition.get(document.docId) === "quarantined").map((document) => document.docPath);
|
|
1986
|
+
}
|
|
1987
|
+
function synchronizeLocalAfterRemote(localPath, plannedLocalHash, remoteText, workspaceRoot = process.cwd()) {
|
|
1988
|
+
const localFile = workspaceReadPath(localPath, workspaceRoot);
|
|
1989
|
+
const currentLocal = readFileSync6(localFile, "utf8");
|
|
1990
|
+
if (sha256(currentLocal) !== plannedLocalHash) {
|
|
1991
|
+
throw new Error(`${localPath} changed while the plan was being submitted; local file left untouched.`);
|
|
1992
|
+
}
|
|
1993
|
+
if (currentLocal === remoteText) return {};
|
|
1994
|
+
const timestamp = `${snapshotTimestamp()}-${process.pid}`;
|
|
1995
|
+
const snapshotPath = snapshotRelativePath(timestamp, localPath, workspaceRoot);
|
|
1996
|
+
const snapshotFile = workspaceWritePath(snapshotPath, workspaceRoot);
|
|
1997
|
+
mkdirSync6(dirname5(snapshotFile), { recursive: true });
|
|
1998
|
+
writeFileSync6(snapshotFile, currentLocal, { flag: "wx", mode: 384 });
|
|
1999
|
+
const target = workspaceWritePath(localPath, workspaceRoot);
|
|
2000
|
+
const temp = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
2001
|
+
try {
|
|
2002
|
+
writeFileSync6(temp, remoteText, { mode: statSync(localFile).mode & 511 });
|
|
2003
|
+
if (sha256(readFileSync6(localFile, "utf8")) !== plannedLocalHash) {
|
|
2004
|
+
throw new Error(`${localPath} changed while the plan was being submitted; local file left untouched.`);
|
|
2005
|
+
}
|
|
2006
|
+
renameSync4(temp, target);
|
|
2007
|
+
} catch (error) {
|
|
2008
|
+
try {
|
|
2009
|
+
unlinkSync5(temp);
|
|
2010
|
+
} catch {
|
|
2011
|
+
}
|
|
2012
|
+
throw error;
|
|
2013
|
+
}
|
|
2014
|
+
return { snapshotPath };
|
|
2015
|
+
}
|
|
2016
|
+
async function submitPlan(planOrPath, opts = {}) {
|
|
2017
|
+
const plan = typeof planOrPath === "string" ? readPushPlan(planOrPath) : validatePushPlan(planOrPath);
|
|
2018
|
+
const totalOps = plan.documents.reduce((sum, doc) => sum + doc.ops.length, 0);
|
|
2019
|
+
const planHash = sha256(stableJson(plan));
|
|
2020
|
+
const receiptDocuments = initialReceiptDocuments(plan);
|
|
2021
|
+
let receipt = beginReceipt(
|
|
2022
|
+
"push",
|
|
2023
|
+
{
|
|
2024
|
+
projectId: plan.projectId,
|
|
2025
|
+
direct: plan.direct,
|
|
2026
|
+
planCreatedAt: plan.createdAt,
|
|
2027
|
+
planHash,
|
|
2028
|
+
totalOps,
|
|
2029
|
+
phase: "preflight",
|
|
2030
|
+
plan,
|
|
2031
|
+
documents: receiptDocuments
|
|
2032
|
+
},
|
|
2033
|
+
{ receiptsDir: opts.receiptsDir }
|
|
2034
|
+
);
|
|
2035
|
+
const completed = [];
|
|
2036
|
+
const basePath = opts.basePath ?? BASE_STATE_PATH;
|
|
2037
|
+
let opened;
|
|
2038
|
+
let mutationLock;
|
|
2039
|
+
let unknownMutationOutcome = false;
|
|
2040
|
+
let failureStatus;
|
|
2041
|
+
try {
|
|
2042
|
+
if (plan.projectId !== config.projectId) {
|
|
2043
|
+
throw new PushPlanValidationError(
|
|
2044
|
+
`Plan is for project ${plan.projectId}, but this repository is linked to ${config.projectId}.`
|
|
2045
|
+
);
|
|
2046
|
+
}
|
|
2047
|
+
mutationLock = acquireMutationLock(plan.projectId);
|
|
2048
|
+
const priorReceipts = readReceipts(opts.receiptsDir);
|
|
2049
|
+
const quarantinedDocuments = quarantinedPlanDocuments(plan, priorReceipts);
|
|
2050
|
+
if (quarantinedDocuments.length && !opts.allowAmbiguousRetry) {
|
|
2051
|
+
throw new PushPlanValidationError(
|
|
2052
|
+
`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.`
|
|
2053
|
+
);
|
|
2054
|
+
}
|
|
2055
|
+
if (!plan.documents.length) {
|
|
2056
|
+
receipt = updateReceipt(receipt, "skipped", {
|
|
2057
|
+
phase: "complete",
|
|
2058
|
+
outcome: "empty_plan",
|
|
2059
|
+
documents: receiptDocuments
|
|
2060
|
+
});
|
|
2061
|
+
return {
|
|
2062
|
+
projectId: plan.projectId,
|
|
2063
|
+
direct: plan.direct,
|
|
2064
|
+
totalOps,
|
|
2065
|
+
documents: completed,
|
|
2066
|
+
receiptPath: receipt.path
|
|
2067
|
+
};
|
|
2068
|
+
}
|
|
2069
|
+
opened = await openProject();
|
|
2070
|
+
const { socket, project, docs } = opened;
|
|
2071
|
+
const connectedProjectId = String(project?._id ?? config.projectId);
|
|
2072
|
+
if (connectedProjectId !== plan.projectId) {
|
|
2073
|
+
throw new PushPlanValidationError(
|
|
2074
|
+
`Connected project ${connectedProjectId} does not match plan ${plan.projectId}.`
|
|
2075
|
+
);
|
|
2076
|
+
}
|
|
2077
|
+
const docsById = new Map(docs.map((doc) => [doc._id, doc]));
|
|
2078
|
+
for (const planned of plan.documents) {
|
|
2079
|
+
const doc = docsById.get(planned.docId);
|
|
2080
|
+
if (!doc || doc.path !== planned.docPath) {
|
|
2081
|
+
throw new PushPlanValidationError(
|
|
2082
|
+
`Document ${planned.docPath} (${planned.docId}) no longer exists at its planned path.`
|
|
2083
|
+
);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
for (const planned of plan.documents) {
|
|
2087
|
+
await bindPlanDocument(plan, planned, socket, basePath);
|
|
2088
|
+
}
|
|
2089
|
+
receipt = updateReceipt(receipt, "in_progress", {
|
|
2090
|
+
phase: "applying",
|
|
2091
|
+
preflightVerifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2092
|
+
...opts.allowAmbiguousRetry && quarantinedDocuments.length ? {
|
|
2093
|
+
acknowledgedAmbiguousRetry: true,
|
|
2094
|
+
reconciledAmbiguousDocuments: quarantinedDocuments
|
|
2095
|
+
} : {},
|
|
2096
|
+
documents: receiptDocuments
|
|
2097
|
+
});
|
|
2098
|
+
for (let index = 0; index < plan.documents.length; index++) {
|
|
2099
|
+
const planned = plan.documents[index];
|
|
2100
|
+
const { state, expected } = await bindPlanDocument(
|
|
2101
|
+
plan,
|
|
2102
|
+
planned,
|
|
2103
|
+
socket,
|
|
2104
|
+
basePath
|
|
2105
|
+
);
|
|
2106
|
+
receiptDocuments[index] = {
|
|
2107
|
+
...receiptDocuments[index],
|
|
2108
|
+
status: "applying",
|
|
2109
|
+
mutationAttempted: true,
|
|
2110
|
+
mutationStartedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2111
|
+
};
|
|
2112
|
+
receipt = updateReceipt(receipt, "in_progress", {
|
|
2113
|
+
phase: "applying",
|
|
2114
|
+
currentDocument: planned.docPath,
|
|
2115
|
+
documents: receiptDocuments
|
|
2116
|
+
});
|
|
2117
|
+
unknownMutationOutcome = true;
|
|
2118
|
+
let applyError;
|
|
2119
|
+
try {
|
|
2120
|
+
await applyOtUpdateAndWait(socket, planned.docId, {
|
|
2121
|
+
doc: planned.docId,
|
|
2122
|
+
op: planned.ops,
|
|
2123
|
+
v: state.version,
|
|
2124
|
+
meta: plan.direct ? {} : { tc: planned.tcSeed },
|
|
2125
|
+
hash: overleafSnapshotHash(expected)
|
|
2126
|
+
});
|
|
2127
|
+
} catch (error) {
|
|
2128
|
+
applyError = error;
|
|
2129
|
+
}
|
|
2130
|
+
let after;
|
|
2131
|
+
try {
|
|
2132
|
+
after = await joinDoc(socket, planned.docId);
|
|
2133
|
+
} catch (readbackError) {
|
|
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
|
+
...applyError ? { transportError: errorMessage(applyError) } : {},
|
|
2141
|
+
error: `Readback failed: ${errorMessage(readbackError)}`
|
|
2142
|
+
};
|
|
2143
|
+
throw new Error(
|
|
2144
|
+
`${planned.docPath} could not be verified after its update: ${errorMessage(readbackError)}`
|
|
2145
|
+
);
|
|
2146
|
+
}
|
|
2147
|
+
const afterText = after.lines.join("\n");
|
|
2148
|
+
if (afterText !== expected || sha256(afterText) !== planned.expectedHash) {
|
|
2149
|
+
const definitelyRejected = Boolean(applyError && definitelyRejectedApply(applyError));
|
|
2150
|
+
failureStatus = definitelyRejected ? "failed" : "ambiguous";
|
|
2151
|
+
unknownMutationOutcome = !definitelyRejected;
|
|
2152
|
+
receiptDocuments[index] = {
|
|
2153
|
+
...receiptDocuments[index],
|
|
2154
|
+
status: failureStatus,
|
|
2155
|
+
afterVersion: after.version,
|
|
2156
|
+
...applyError ? { transportError: errorMessage(applyError) } : {},
|
|
2157
|
+
error: "Overleaf text does not match the planned result"
|
|
2158
|
+
};
|
|
2159
|
+
throw new Error(
|
|
2160
|
+
`Verification failed for ${planned.docPath}: Overleaf text does not match the planned result.`
|
|
2161
|
+
);
|
|
2162
|
+
}
|
|
2163
|
+
let trackedChangeIds;
|
|
2164
|
+
try {
|
|
2165
|
+
trackedChangeIds = verifiedTrackedIds(plan, planned, after);
|
|
2166
|
+
} catch (error) {
|
|
2167
|
+
const ambiguousTransport = Boolean(applyError && !definitelyRejectedApply(applyError));
|
|
2168
|
+
unknownMutationOutcome = ambiguousTransport;
|
|
2169
|
+
failureStatus = ambiguousTransport ? "ambiguous" : "failed";
|
|
2170
|
+
receiptDocuments[index] = {
|
|
2171
|
+
...receiptDocuments[index],
|
|
2172
|
+
status: failureStatus,
|
|
2173
|
+
afterVersion: after.version,
|
|
2174
|
+
...applyError ? { transportError: errorMessage(applyError) } : {},
|
|
2175
|
+
error: errorMessage(error)
|
|
2176
|
+
};
|
|
2177
|
+
throw error;
|
|
2178
|
+
}
|
|
2179
|
+
unknownMutationOutcome = false;
|
|
2180
|
+
try {
|
|
2181
|
+
const localSync = synchronizeLocalAfterRemote(
|
|
2182
|
+
planned.localPath,
|
|
2183
|
+
planned.localHash,
|
|
2184
|
+
afterText
|
|
2185
|
+
);
|
|
2186
|
+
if (localSync.snapshotPath) {
|
|
2187
|
+
receiptDocuments[index] = {
|
|
2188
|
+
...receiptDocuments[index],
|
|
2189
|
+
localSnapshotPath: localSync.snapshotPath
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
const base = {
|
|
2193
|
+
docId: planned.docId,
|
|
2194
|
+
path: planned.docPath,
|
|
2195
|
+
text: afterText,
|
|
2196
|
+
hash: planned.expectedHash,
|
|
2197
|
+
version: after.version,
|
|
2198
|
+
rangeFingerprint: fingerprintRanges(after.ranges),
|
|
2199
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2200
|
+
};
|
|
2201
|
+
mergeBaseDocuments(plan.projectId, [base], basePath);
|
|
2202
|
+
} catch (error) {
|
|
2203
|
+
failureStatus = "failed";
|
|
2204
|
+
receiptDocuments[index] = {
|
|
2205
|
+
...receiptDocuments[index],
|
|
2206
|
+
status: "remote_verified_local_failed",
|
|
2207
|
+
afterVersion: after.version,
|
|
2208
|
+
trackedChangeIds,
|
|
2209
|
+
...applyError ? { transportError: errorMessage(applyError) } : {},
|
|
2210
|
+
error: errorMessage(error)
|
|
2211
|
+
};
|
|
2212
|
+
throw new Error(
|
|
2213
|
+
`${planned.docPath} was verified on Overleaf, but local synchronization failed: ` + errorMessage(error)
|
|
2214
|
+
);
|
|
2215
|
+
}
|
|
2216
|
+
const result = {
|
|
2217
|
+
docId: planned.docId,
|
|
2218
|
+
docPath: planned.docPath,
|
|
2219
|
+
version: after.version,
|
|
2220
|
+
hash: planned.expectedHash,
|
|
2221
|
+
trackedChangeIds
|
|
2222
|
+
};
|
|
2223
|
+
completed.push(result);
|
|
2224
|
+
receiptDocuments[index] = {
|
|
2225
|
+
...receiptDocuments[index],
|
|
2226
|
+
status: "verified",
|
|
2227
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2228
|
+
afterVersion: after.version,
|
|
2229
|
+
trackedChangeIds,
|
|
2230
|
+
...applyError ? { transportError: errorMessage(applyError) } : {}
|
|
2231
|
+
};
|
|
2232
|
+
receipt = updateReceipt(receipt, "in_progress", {
|
|
2233
|
+
phase: "applying",
|
|
2234
|
+
completedDocuments: completed.map((doc) => doc.docPath),
|
|
2235
|
+
documents: receiptDocuments
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
receipt = updateReceipt(receipt, "succeeded", {
|
|
2239
|
+
phase: "complete",
|
|
2240
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2241
|
+
completedDocuments: completed.map((doc) => doc.docPath),
|
|
2242
|
+
documents: receiptDocuments
|
|
2243
|
+
});
|
|
2244
|
+
return {
|
|
2245
|
+
projectId: plan.projectId,
|
|
2246
|
+
direct: plan.direct,
|
|
2247
|
+
totalOps,
|
|
2248
|
+
documents: completed,
|
|
2249
|
+
receiptPath: receipt.path
|
|
2250
|
+
};
|
|
2251
|
+
} catch (error) {
|
|
2252
|
+
const status = failureStatus ?? (unknownMutationOutcome ? "ambiguous" : "failed");
|
|
2253
|
+
receipt = updateReceipt(receipt, status, {
|
|
2254
|
+
phase: status === "ambiguous" ? "mutation_outcome_unknown" : "failed",
|
|
2255
|
+
failedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2256
|
+
error: errorMessage(error),
|
|
2257
|
+
completedDocuments: completed.map((doc) => doc.docPath),
|
|
2258
|
+
documents: receiptDocuments
|
|
2259
|
+
});
|
|
2260
|
+
throw new PushSubmissionError(
|
|
2261
|
+
`${errorMessage(error)} Audit receipt: ${receipt.path}`,
|
|
2262
|
+
receipt.path,
|
|
2263
|
+
status,
|
|
2264
|
+
receiptDocuments,
|
|
2265
|
+
error
|
|
2266
|
+
);
|
|
2267
|
+
} finally {
|
|
2268
|
+
try {
|
|
2269
|
+
opened?.socket.close();
|
|
2270
|
+
} finally {
|
|
2271
|
+
mutationLock?.release();
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
function printPlan(plan) {
|
|
2276
|
+
console.log(
|
|
2277
|
+
plan.direct ? "Mode: DIRECT \u2014 plain edits (not marked as suggestions)" : "Mode: SUGGESTIONS \u2014 tracked changes for co-authors to accept/reject"
|
|
2278
|
+
);
|
|
2279
|
+
for (const doc of plan.documents) {
|
|
2280
|
+
const ins = doc.ops.filter((op) => op.i != null).length;
|
|
2281
|
+
const del = doc.ops.filter((op) => op.d != null).length;
|
|
2282
|
+
console.log(
|
|
2283
|
+
`
|
|
2284
|
+
${doc.localPath} \u2192 ${doc.docPath} (v${doc.liveVersion}): ${doc.ops.length} op(s), ${ins} ins / ${del} del`
|
|
2285
|
+
);
|
|
2286
|
+
if (!plan.direct) {
|
|
2287
|
+
console.log(
|
|
2288
|
+
` Tracked-range budget: ${doc.activeTrackedRanges.length} existing + ${doc.ops.length} proposed operations / ${MAX_TRACKED_RANGES}. A phrase replacement uses one deletion and one insertion.`
|
|
2289
|
+
);
|
|
2290
|
+
}
|
|
2291
|
+
for (const op of doc.ops.slice(0, 12)) console.log(preview(op));
|
|
2292
|
+
if (doc.ops.length > 12) console.log(` \u2026 and ${doc.ops.length - 12} more`);
|
|
2293
|
+
for (const overlap of doc.commentOverlaps) {
|
|
2294
|
+
console.log(
|
|
2295
|
+
` \u2139\uFE0F touches comment ${overlap.threadId} @ ${overlap.position}: ${JSON.stringify(overlap.anchor)}`
|
|
2296
|
+
);
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
async function push(opts) {
|
|
2301
|
+
validatePushOptions(opts);
|
|
2302
|
+
const { isReviewedManifest, createReviewedFilePlan, applyReviewedFilePlan, REVIEWED_FILE_KIND } = await import("./reviewed-replace-A7C4UNJS.js");
|
|
2303
|
+
if (opts.edits && isReviewedManifest(JSON.parse(readFileSync6(workspaceReadPath(opts.edits), "utf8")))) {
|
|
2304
|
+
await createReviewedFilePlan(opts);
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
2307
|
+
if (opts.plan) {
|
|
2308
|
+
const supplied = typeof opts.plan === "string" ? JSON.parse(readFileSync6(workspaceReadPath(opts.plan), "utf8")) : opts.plan;
|
|
2309
|
+
if (supplied?.kind === REVIEWED_FILE_KIND) {
|
|
2310
|
+
await applyReviewedFilePlan(supplied, opts);
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
const result2 = await submitPlan(opts.plan, {
|
|
2314
|
+
basePath: opts.basePath,
|
|
2315
|
+
receiptsDir: opts.receiptsDir,
|
|
2316
|
+
allowAmbiguousRetry: opts.allowAmbiguousRetry
|
|
2317
|
+
});
|
|
2318
|
+
console.log(
|
|
2319
|
+
`\u2705 Submitted and verified ${result2.totalOps} ${result2.direct ? "direct edit(s)" : "tracked suggestion(s)"} across ${result2.documents.length} file(s).`
|
|
2320
|
+
);
|
|
2321
|
+
console.log(`Audit receipt: ${result2.receiptPath}`);
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
const plan = await createPlan(opts);
|
|
2325
|
+
if (!plan.documents.length) {
|
|
2326
|
+
console.log("Nothing to push \u2014 no unapplied local edits were found.");
|
|
2327
|
+
if (opts.planOut) console.log(`Saved empty plan to ${opts.planOut}.`);
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
printPlan(plan);
|
|
2331
|
+
if (opts.planOut) {
|
|
2332
|
+
console.log(`
|
|
2333
|
+
Saved binding plan to ${opts.planOut}; nothing sent to Overleaf.`);
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
if (opts.dryRun) {
|
|
2337
|
+
console.log("\n(dry run \u2014 nothing sent to Overleaf)");
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
const result = await submitPlan(plan, {
|
|
2341
|
+
basePath: opts.basePath,
|
|
2342
|
+
receiptsDir: opts.receiptsDir,
|
|
2343
|
+
allowAmbiguousRetry: opts.allowAmbiguousRetry
|
|
2344
|
+
});
|
|
2345
|
+
console.log(
|
|
2346
|
+
`
|
|
2347
|
+
\u2705 Pushed and verified ${result.totalOps} ${result.direct ? "direct edit(s)" : "tracked suggestion(s)"} across ${result.documents.length} file(s).`
|
|
2348
|
+
);
|
|
2349
|
+
console.log(`Audit receipt: ${result.receiptPath}`);
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// src/lib/consolidation.ts
|
|
2353
|
+
function checkedSnapshot(value) {
|
|
2354
|
+
const snapshot = value;
|
|
2355
|
+
if (!snapshot || typeof snapshot.projectId !== "string" || !snapshot.projectId || typeof snapshot.docId !== "string" || !snapshot.docId || typeof snapshot.docPath !== "string" || !Number.isSafeInteger(snapshot.version) || snapshot.version < 0 || typeof snapshot.text !== "string" || !Array.isArray(snapshot.ranges?.changes) || !Array.isArray(snapshot.ranges?.comments) || !snapshot.threads || typeof snapshot.threads !== "object" || Array.isArray(snapshot.threads)) throw new Error("Consolidation requires a full document snapshot: projectId, docId, docPath, version, text, ranges and threads.");
|
|
2356
|
+
for (const range of snapshot.ranges.changes) {
|
|
2357
|
+
const op = range?.op;
|
|
2358
|
+
if (typeof range?.id !== "string" || !range.id || !op || !Number.isSafeInteger(op.p) || op.p < 0 || op.p > snapshot.text.length || typeof op.i === "string" === (typeof op.d === "string") || !(op.i ?? op.d)?.length || typeof op.i === "string" && snapshot.text.slice(op.p, op.p + op.i.length) !== op.i) throw new Error("Snapshot contains an invalid or stale tracked range.");
|
|
2359
|
+
}
|
|
2360
|
+
for (const range of snapshot.ranges.comments) {
|
|
2361
|
+
const op = range?.op;
|
|
2362
|
+
if (!op || !Number.isSafeInteger(op.p) || op.p < 0 || op.p > snapshot.text.length || typeof op.c !== "string" || typeof op.t !== "string" || snapshot.text.slice(op.p, op.p + op.c.length) !== op.c) throw new Error("Snapshot contains an invalid or stale comment anchor.");
|
|
2363
|
+
}
|
|
2364
|
+
return snapshot;
|
|
2365
|
+
}
|
|
2366
|
+
function touches(start, end, range) {
|
|
2367
|
+
return start <= range.end && range.start <= end;
|
|
2368
|
+
}
|
|
2369
|
+
function protectedRanges(snapshot, selected) {
|
|
2370
|
+
const ranges = [
|
|
2371
|
+
...snapshot.ranges.changes.filter((range) => !selected.has(range.id)).map((range) => ({
|
|
2372
|
+
start: range.op.p,
|
|
2373
|
+
end: range.op.p + (range.op.i?.length ?? 0),
|
|
2374
|
+
kind: "unselected-change",
|
|
2375
|
+
id: range.id
|
|
2376
|
+
})),
|
|
2377
|
+
...snapshot.ranges.comments.map((range) => ({
|
|
2378
|
+
start: range.op.p,
|
|
2379
|
+
end: range.op.p + range.op.c.length,
|
|
2380
|
+
kind: "comment",
|
|
2381
|
+
id: range.op.t
|
|
2382
|
+
}))
|
|
2383
|
+
];
|
|
2384
|
+
return ranges.map((range) => ({ ...range, originalStart: range.start, originalEnd: range.end }));
|
|
2385
|
+
}
|
|
2386
|
+
function planConsolidation(value, authorId, requestedIds) {
|
|
2387
|
+
const snapshot = structuredClone(checkedSnapshot(value));
|
|
2388
|
+
if (!authorId) throw new Error("Choose the author id whose suggestions should be consolidated.");
|
|
2389
|
+
const selectedIds = [...new Set(requestedIds ?? snapshot.ranges.changes.filter((range) => range.metadata?.user_id === authorId).map((range) => range.id))];
|
|
2390
|
+
if (!selectedIds.length) throw new Error("No tracked changes match the selected author.");
|
|
2391
|
+
const selected = new Set(selectedIds);
|
|
2392
|
+
for (const id of selected) {
|
|
2393
|
+
const fragments = snapshot.ranges.changes.filter((range) => range.id === id);
|
|
2394
|
+
if (!fragments.length) throw new Error(`Tracked change ${id} is absent from the snapshot.`);
|
|
2395
|
+
if (fragments.some((range) => range.metadata?.user_id !== authorId)) {
|
|
2396
|
+
throw new Error(`Tracked change ${id} includes a different or unknown author.`);
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
const undo = buildRejectionPlan(snapshot.text, snapshot.ranges.changes, selectedIds);
|
|
2400
|
+
const protectedState = protectedRanges(snapshot, selected);
|
|
2401
|
+
const blockers = [];
|
|
2402
|
+
for (const op of undo.operations) {
|
|
2403
|
+
for (const range of protectedState) {
|
|
2404
|
+
if (touches(op.p, op.p + ("d" in op ? op.d.length : 0), range)) {
|
|
2405
|
+
blockers.push({ kind: range.kind, id: range.id, phase: "undo" });
|
|
2406
|
+
}
|
|
2407
|
+
const offset = "i" in op ? op.i.length : -op.d.length;
|
|
2408
|
+
if (op.p < range.start) {
|
|
2409
|
+
range.start += offset;
|
|
2410
|
+
range.end += offset;
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
const grouping = { protectedSpans: protectedState };
|
|
2415
|
+
const reapply = buildOps(undo.expectedText, snapshot.text, grouping);
|
|
2416
|
+
const footprint = buildOperationFootprint(undo.expectedText, snapshot.text, grouping);
|
|
2417
|
+
for (const edit of footprint) {
|
|
2418
|
+
for (const range of protectedState) {
|
|
2419
|
+
if (touches(edit.start, edit.end, range)) {
|
|
2420
|
+
blockers.push({ kind: range.kind, id: range.id, phase: "reapply" });
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
if (applyOps(undo.expectedText, reapply) !== snapshot.text) {
|
|
2425
|
+
throw new Error("Grouped revisions do not reconstruct the proposed text.");
|
|
2426
|
+
}
|
|
2427
|
+
const newRanges = [];
|
|
2428
|
+
let delta = 0;
|
|
2429
|
+
for (const [index, edit] of footprint.entries()) {
|
|
2430
|
+
const p = edit.start + delta;
|
|
2431
|
+
const deleted = undo.expectedText.slice(edit.start, edit.end);
|
|
2432
|
+
if (edit.text) newRanges.push({ id: `preview-insert-${index}`, op: { p, i: edit.text } });
|
|
2433
|
+
if (deleted) newRanges.push({ id: `preview-delete-${index}`, op: { p: p + edit.text.length, d: deleted } });
|
|
2434
|
+
delta += edit.text.length - deleted.length;
|
|
2435
|
+
}
|
|
2436
|
+
const reconstructed = buildRejectionPlan(snapshot.text, newRanges, newRanges.map((range) => range.id));
|
|
2437
|
+
if (reconstructed.expectedText !== undo.expectedText) {
|
|
2438
|
+
throw new Error("Grouped revisions do not preserve the text beneath the selected suggestions.");
|
|
2439
|
+
}
|
|
2440
|
+
const beforeCount = snapshot.ranges.changes.length;
|
|
2441
|
+
const projectedCount = beforeCount - undo.fragmentCount + reapply.length;
|
|
2442
|
+
const uniqueBlockers = [...new Map(blockers.map((blocker) => [JSON.stringify(blocker), blocker])).values()];
|
|
2443
|
+
return {
|
|
2444
|
+
kind: "overleaf-review-consolidation-plan",
|
|
2445
|
+
schemaVersion: 2,
|
|
2446
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2447
|
+
status: uniqueBlockers.length ? "blocked" : projectedCount >= beforeCount ? "no-reduction" : "ready",
|
|
2448
|
+
authorId,
|
|
2449
|
+
selectedIds,
|
|
2450
|
+
beforeCount,
|
|
2451
|
+
selectedFragmentCount: undo.fragmentCount,
|
|
2452
|
+
projectedCount,
|
|
2453
|
+
projectedReduction: beforeCount - projectedCount,
|
|
2454
|
+
blockers: uniqueBlockers,
|
|
2455
|
+
binding: {
|
|
2456
|
+
projectId: snapshot.projectId,
|
|
2457
|
+
docId: snapshot.docId,
|
|
2458
|
+
version: snapshot.version,
|
|
2459
|
+
textHash: sha256(snapshot.text),
|
|
2460
|
+
rangeFingerprint: fingerprintRanges(snapshot.ranges),
|
|
2461
|
+
threadsHash: sha256(stableJson(snapshot.threads))
|
|
2462
|
+
},
|
|
2463
|
+
textProof: {
|
|
2464
|
+
proposedHash: sha256(snapshot.text),
|
|
2465
|
+
selectedRejectedHash: sha256(undo.expectedText),
|
|
2466
|
+
selectedRejectedText: undo.expectedText,
|
|
2467
|
+
forwardVerified: true,
|
|
2468
|
+
reverseVerified: true
|
|
2469
|
+
},
|
|
2470
|
+
candidate: uniqueBlockers.length ? null : { undo: undo.operations, reapply },
|
|
2471
|
+
backup: snapshot,
|
|
2472
|
+
limitations: [
|
|
2473
|
+
"Only review consolidate --apply accepts this artifact; review submit does not.",
|
|
2474
|
+
"Preserves the current pending proposal, not an earlier historical review state.",
|
|
2475
|
+
"Projected count assumes isolated ranges; server transformations need sandbox verification.",
|
|
2476
|
+
"Consolidation would create new change IDs and timestamps for the selected author."
|
|
2477
|
+
]
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
function validateConsolidationPlan(value) {
|
|
2481
|
+
const plan = value;
|
|
2482
|
+
if (!plan || plan.kind !== "overleaf-review-consolidation-plan" || plan.schemaVersion !== 2 || !Array.isArray(plan.selectedIds) || !plan.selectedIds.every((id) => typeof id === "string") || typeof plan.authorId !== "string" || typeof plan.createdAt !== "string") {
|
|
2483
|
+
throw new Error("Unsupported consolidation plan; create a new plan.");
|
|
2484
|
+
}
|
|
2485
|
+
const expected = planConsolidation(plan.backup, plan.authorId, plan.selectedIds);
|
|
2486
|
+
if (stableJson({ ...plan, createdAt: "" }) !== stableJson({ ...expected, createdAt: "" })) {
|
|
2487
|
+
throw new Error("Consolidation plan was altered or cannot be reproduced from its backup.");
|
|
2488
|
+
}
|
|
2489
|
+
if (plan.status !== "ready" || !plan.candidate || plan.projectedCount > MAX_TRACKED_RANGES) {
|
|
2490
|
+
throw new Error("Consolidation is blocked, does not reduce ranges, or exceeds the range budget.");
|
|
2491
|
+
}
|
|
2492
|
+
rejectedText(plan.backup);
|
|
2493
|
+
return plan;
|
|
2494
|
+
}
|
|
2495
|
+
function rejectedText(snapshot) {
|
|
2496
|
+
return buildRejectionPlan(
|
|
2497
|
+
snapshot.text,
|
|
2498
|
+
snapshot.ranges.changes,
|
|
2499
|
+
snapshot.ranges.changes.map((range) => range.id)
|
|
2500
|
+
).expectedText;
|
|
2501
|
+
}
|
|
2502
|
+
function assertConsolidationBinding(plan, live) {
|
|
2503
|
+
checkedSnapshot(live);
|
|
2504
|
+
if (live.projectId !== plan.backup.projectId || live.docId !== plan.backup.docId || live.docPath !== plan.backup.docPath || live.version !== plan.backup.version || live.text !== plan.backup.text || stableJson(live.ranges) !== stableJson(plan.backup.ranges) || stableJson(live.threads) !== stableJson(plan.backup.threads)) {
|
|
2505
|
+
throw new Error("Document, version, review ranges or threads changed after consolidation planning; re-plan.");
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
function verifyConsolidation(plan, value, seed) {
|
|
2509
|
+
const after = checkedSnapshot(value);
|
|
2510
|
+
const before = plan.backup;
|
|
2511
|
+
if (!/^[0-9a-f]{18}$/.test(seed) || after.projectId !== before.projectId || after.docId !== before.docId || after.docPath !== before.docPath || after.version !== before.version + 1 || after.text !== before.text) {
|
|
2512
|
+
throw new Error("Consolidation text, identity or document version failed verification.");
|
|
2513
|
+
}
|
|
2514
|
+
const selected = new Set(plan.selectedIds);
|
|
2515
|
+
if (after.ranges.changes.some((range) => selected.has(range.id))) throw new Error("Old tracked-change IDs remain after consolidation.");
|
|
2516
|
+
const originalOther = before.ranges.changes.filter((range) => !selected.has(range.id));
|
|
2517
|
+
const otherIds = new Set(originalOther.map((range) => range.id));
|
|
2518
|
+
const actualOther = after.ranges.changes.filter((range) => otherIds.has(range.id));
|
|
2519
|
+
const fresh = after.ranges.changes.filter((range) => !otherIds.has(range.id));
|
|
2520
|
+
const canonical = (ranges) => stableJson(ranges.map((range) => stableJson(range)).sort());
|
|
2521
|
+
if (canonical(originalOther) !== canonical(actualOther) || canonical(before.ranges.comments) !== canonical(after.ranges.comments) || stableJson(before.threads) !== stableJson(after.threads)) {
|
|
2522
|
+
throw new Error("Comments, threads or unselected suggestions changed during consolidation.");
|
|
2523
|
+
}
|
|
2524
|
+
if (fresh.some((range) => !new RegExp(`^${seed}[0-9a-f]{6}$`).test(range.id) || range.metadata?.user_id !== plan.authorId) || after.ranges.changes.length > plan.projectedCount || after.ranges.changes.length >= plan.beforeCount) {
|
|
2525
|
+
throw new Error("Consolidated range count or author attribution failed verification.");
|
|
2526
|
+
}
|
|
2527
|
+
const changeIds = [...new Set(fresh.map((range) => range.id))];
|
|
2528
|
+
if (buildRejectionPlan(after.text, after.ranges.changes, changeIds).expectedText !== plan.textProof.selectedRejectedText || rejectedText(after) !== rejectedText(before)) {
|
|
2529
|
+
throw new Error("Consolidation changed the text beneath pending suggestions.");
|
|
2530
|
+
}
|
|
2531
|
+
return { changeIds, rangeCount: after.ranges.changes.length };
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
// src/lib/consolidation-submit.ts
|
|
2535
|
+
async function submitConsolidation(value, transport) {
|
|
2536
|
+
const plan = validateConsolidationPlan(value);
|
|
2537
|
+
const planHash = sha256(stableJson(plan));
|
|
2538
|
+
const options = { receiptsDir: transport.receiptsDir };
|
|
2539
|
+
const relevant = readReceipts(transport.receiptsDir).filter((handle) => handle.receipt.operation === "consolidate" && handle.receipt.details.projectId === plan.binding.projectId && handle.receipt.details.docId === plan.binding.docId);
|
|
2540
|
+
const uncertain = relevant.find((handle) => handle.receipt.status === "ambiguous" || handle.receipt.status === "in_progress");
|
|
2541
|
+
if (uncertain) {
|
|
2542
|
+
throw new Error(`Earlier consolidation has an uncertain outcome. Inspect and reconcile ${uncertain.path}; no automatic retry was sent.`);
|
|
2543
|
+
}
|
|
2544
|
+
const prior = relevant.find((handle) => handle.receipt.status === "succeeded" && handle.receipt.details.planHash === planHash);
|
|
2545
|
+
if (prior) return { receiptPath: prior.path, alreadyApplied: true };
|
|
2546
|
+
let receipt = beginReceipt("consolidate", {
|
|
2547
|
+
projectId: plan.binding.projectId,
|
|
2548
|
+
docId: plan.binding.docId,
|
|
2549
|
+
docPath: plan.backup.docPath,
|
|
2550
|
+
planHash,
|
|
2551
|
+
plan,
|
|
2552
|
+
phase: "preflight"
|
|
2553
|
+
}, options);
|
|
2554
|
+
let attempted = false;
|
|
2555
|
+
try {
|
|
2556
|
+
if (await transport.accountId() !== plan.authorId) {
|
|
2557
|
+
throw new Error("Consolidation can only reapply suggestions belonging to the authenticated account.");
|
|
2558
|
+
}
|
|
2559
|
+
const before = await transport.snapshot();
|
|
2560
|
+
assertConsolidationBinding(plan, before);
|
|
2561
|
+
const seed = createTrackedChangeSeed();
|
|
2562
|
+
if (before.ranges.changes.some((range) => range.id.startsWith(seed))) {
|
|
2563
|
+
throw new Error("Tracked-change seed collision; create a fresh consolidation attempt.");
|
|
2564
|
+
}
|
|
2565
|
+
const update = {
|
|
2566
|
+
doc: before.docId,
|
|
2567
|
+
v: before.version,
|
|
2568
|
+
op: [...plan.candidate.undo, ...plan.candidate.reapply],
|
|
2569
|
+
meta: { tc: seed },
|
|
2570
|
+
hash: overleafSnapshotHash(before.text)
|
|
2571
|
+
};
|
|
2572
|
+
receipt = updateReceipt(receipt, "in_progress", {
|
|
2573
|
+
phase: "sending",
|
|
2574
|
+
before,
|
|
2575
|
+
seed,
|
|
2576
|
+
update,
|
|
2577
|
+
mutationStartedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2578
|
+
});
|
|
2579
|
+
attempted = true;
|
|
2580
|
+
await transport.send(update);
|
|
2581
|
+
const after = await transport.snapshot();
|
|
2582
|
+
receipt = updateReceipt(receipt, "in_progress", { phase: "verifying", after });
|
|
2583
|
+
const result = verifyConsolidation(plan, after, seed);
|
|
2584
|
+
receipt = updateReceipt(receipt, "succeeded", { phase: "complete", result });
|
|
2585
|
+
return { receiptPath: receipt.path, alreadyApplied: false, ...result };
|
|
2586
|
+
} catch (error) {
|
|
2587
|
+
receipt = updateReceipt(receipt, attempted ? "ambiguous" : "failed", {
|
|
2588
|
+
phase: attempted ? "outcome_unknown" : "preflight_failed",
|
|
2589
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2590
|
+
});
|
|
2591
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}. Receipt: ${receipt.path}. ` + (attempted ? "Do not retry or auto-rollback; inspect live review state and the saved backup first." : "Nothing sent."), { cause: error });
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
// src/commands/consolidate.ts
|
|
2596
|
+
async function readConsolidationSnapshot(opened, doc) {
|
|
2597
|
+
if (String(opened.project?._id) !== config.projectId) throw new Error("Connected project identity could not be verified.");
|
|
2598
|
+
const before = await joinDoc(opened.socket, doc._id);
|
|
2599
|
+
const threads = await getThreads();
|
|
2600
|
+
const after = await joinDoc(opened.socket, doc._id);
|
|
2601
|
+
const afterThreads = await getThreads();
|
|
2602
|
+
if (before.version !== after.version || before.lines.join("\n") !== after.lines.join("\n") || stableJson(before.ranges) !== stableJson(after.ranges) || stableJson(threads) !== stableJson(afterThreads)) {
|
|
2603
|
+
throw new Error("The document or review state changed during snapshot capture.");
|
|
2604
|
+
}
|
|
2605
|
+
return {
|
|
2606
|
+
projectId: config.projectId,
|
|
2607
|
+
docId: doc._id,
|
|
2608
|
+
docPath: doc.path,
|
|
2609
|
+
version: after.version,
|
|
2610
|
+
text: after.lines.join("\n"),
|
|
2611
|
+
ranges: { changes: after.ranges.changes ?? [], comments: after.ranges.comments ?? [] },
|
|
2612
|
+
threads: afterThreads
|
|
2613
|
+
};
|
|
2614
|
+
}
|
|
2615
|
+
async function consolidateApply(path) {
|
|
2616
|
+
const plan = validateConsolidationPlan(JSON.parse(readFileSync7(workspaceReadPath(path), "utf8")));
|
|
2617
|
+
if (config.projectId !== plan.binding.projectId) throw new Error("Consolidation plan belongs to a different project.");
|
|
2618
|
+
const lock = acquireMutationLock(config.projectId);
|
|
2619
|
+
let opened;
|
|
2620
|
+
try {
|
|
2621
|
+
opened = await openProject();
|
|
2622
|
+
const project = opened;
|
|
2623
|
+
const doc = project.docs.find((doc2) => doc2._id === plan.binding.docId && doc2.path === plan.backup.docPath);
|
|
2624
|
+
if (!doc) throw new Error("Planned consolidation document is absent or renamed.");
|
|
2625
|
+
const result = await submitConsolidation(plan, {
|
|
2626
|
+
accountId: getAuthenticatedUserId,
|
|
2627
|
+
snapshot: () => readConsolidationSnapshot(project, doc),
|
|
2628
|
+
send: (update) => applyOtUpdateAndWait(project.socket, doc._id, update)
|
|
2629
|
+
});
|
|
2630
|
+
console.log(result.alreadyApplied ? "This consolidation plan was already applied; nothing resent." : `Verified consolidation: ${plan.beforeCount} \u2192 ${result.rangeCount} tracked ranges. Both text views and protected review state preserved.`);
|
|
2631
|
+
console.log(`Audit receipt and backup: ${result.receiptPath}`);
|
|
2632
|
+
} finally {
|
|
2633
|
+
try {
|
|
2634
|
+
opened?.socket.close();
|
|
2635
|
+
} finally {
|
|
2636
|
+
lock.release();
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
async function consolidatePreview(options) {
|
|
2641
|
+
let snapshot;
|
|
2642
|
+
if (options.snapshot) {
|
|
2643
|
+
snapshot = JSON.parse(readFileSync7(workspaceReadPath(options.snapshot), "utf8"));
|
|
2644
|
+
} else {
|
|
2645
|
+
if (!options.doc) throw new Error("Consolidation requires --doc or --snapshot.");
|
|
2646
|
+
const projectId = config.projectId;
|
|
2647
|
+
const lock = acquireMutationLock(projectId);
|
|
2648
|
+
let opened;
|
|
2649
|
+
try {
|
|
2650
|
+
opened = await openProject();
|
|
2651
|
+
const doc = matchDocument(options.doc, opened.docs);
|
|
2652
|
+
if (!doc) throw new Error(`Document not found: ${options.doc}`);
|
|
2653
|
+
snapshot = await readConsolidationSnapshot(opened, doc);
|
|
2654
|
+
} finally {
|
|
2655
|
+
try {
|
|
2656
|
+
opened?.socket.close();
|
|
2657
|
+
} finally {
|
|
2658
|
+
lock.release();
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
const plan = planConsolidation(snapshot, options.author, options.changeIds);
|
|
2663
|
+
writeJsonAtomic(workspaceWritePath(options.out), plan);
|
|
2664
|
+
console.log(`Consolidation dry run for ${snapshot.docPath}: ${plan.status}`);
|
|
2665
|
+
console.log(`Tracked ranges: ${plan.beforeCount} \u2192 ${plan.projectedCount} projected (${plan.selectedFragmentCount} selected).`);
|
|
2666
|
+
for (const blocker of plan.blockers) {
|
|
2667
|
+
console.log(` Blocked by ${blocker.kind} ${blocker.id} during ${blocker.phase}.`);
|
|
2668
|
+
}
|
|
2669
|
+
console.log(`Full backup and text proofs saved to ${options.out}.`);
|
|
2670
|
+
console.log("Nothing sent to Overleaf. Inspect the plan before review consolidate --apply --plan <file>.");
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
export {
|
|
2674
|
+
saveCredentials,
|
|
2675
|
+
saveProjectConfig,
|
|
2676
|
+
config,
|
|
2677
|
+
openProject,
|
|
2678
|
+
joinDoc,
|
|
2679
|
+
applyOtUpdateAndWait,
|
|
2680
|
+
getCsrfToken,
|
|
2681
|
+
getThreads,
|
|
2682
|
+
RestRequestError,
|
|
2683
|
+
threadMessages,
|
|
2684
|
+
threadMessageId,
|
|
2685
|
+
findRecentIdenticalMessage,
|
|
2686
|
+
observePostedThreadMessage,
|
|
2687
|
+
postThreadMessageDetailed,
|
|
2688
|
+
setThreadResolved,
|
|
2689
|
+
acceptChanges,
|
|
2690
|
+
uploadFile,
|
|
2691
|
+
deleteMessage,
|
|
2692
|
+
deleteThread,
|
|
2693
|
+
validateSession,
|
|
2694
|
+
getAuthenticatedUserId,
|
|
2695
|
+
workspaceRelativePath,
|
|
2696
|
+
workspaceReadPath,
|
|
2697
|
+
workspaceWritePath,
|
|
2698
|
+
acquireMutationLock,
|
|
2699
|
+
parseReplacementManifest,
|
|
2700
|
+
bindExplicitEdits,
|
|
2701
|
+
matchDocument,
|
|
2702
|
+
writeJsonAtomic,
|
|
2703
|
+
beginReceipt,
|
|
2704
|
+
updateReceipt,
|
|
2705
|
+
readReceipts,
|
|
2706
|
+
BASE_STATE_PATH,
|
|
2707
|
+
sha256,
|
|
2708
|
+
stableJson,
|
|
2709
|
+
fingerprintRanges,
|
|
2710
|
+
loadBaseState,
|
|
2711
|
+
mergeBaseDocuments,
|
|
2712
|
+
createTrackedChangeSeed,
|
|
2713
|
+
TrackedChangeMutationError,
|
|
2714
|
+
uniqueChangeIds,
|
|
2715
|
+
remainingChangeIds,
|
|
2716
|
+
buildRejectionPlan,
|
|
2717
|
+
SNAPSHOTS_DIR,
|
|
2718
|
+
snapshotTimestamp,
|
|
2719
|
+
snapshotRelativePath,
|
|
2720
|
+
checkedSnapshot,
|
|
2721
|
+
readConsolidationSnapshot,
|
|
2722
|
+
consolidateApply,
|
|
2723
|
+
consolidatePreview,
|
|
2724
|
+
applyOps,
|
|
2725
|
+
overleafSnapshotHash,
|
|
2726
|
+
MAX_TRACKED_RANGES,
|
|
2727
|
+
synchronizeLocalAfterRemote,
|
|
2728
|
+
push
|
|
2729
|
+
};
|