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