overleaf-review 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +78 -0
  3. package/dist/cli.js +766 -0
  4. package/package.json +58 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Miguel Castellano
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # overleaf-review
2
+
3
+ **The missing review layer for Overleaf's Git bridge.**
4
+
5
+ Overleaf's Git integration syncs your `.tex` source — but silently drops the entire
6
+ collaborative review layer: co-authors' **comments** (with their text anchors) and
7
+ **tracked changes** live in Overleaf's own data model and never reach your repo. So if you
8
+ draft in your editor (e.g. VSCode + an AI assistant) and sync via Git, you can't see the
9
+ feedback your co-authors leave in Overleaf, and you can't push suggestions back.
10
+
11
+ `overleaf-review` bridges that gap, **both directions**:
12
+
13
+ - **`pull`** — read comments + tracked changes into a Git-friendly sidecar (`.overleaf/reviews.md`
14
+ and `.json`) so your tools have every co-author note in context.
15
+ - **`push`** — turn your local edits into tracked-change suggestions in Overleaf, mapping files
16
+ to Overleaf docs by path (push one with `--file`, or every changed `.tex` at once). `--dry-run`
17
+ previews the exact ops first.
18
+ - **`comment` / `resolve`** — create a comment anchored on any text, and resolve/reopen threads.
19
+
20
+ > ⚠️ **Unofficial.** Overleaf has no public API for comments or tracked changes. This tool
21
+ > talks to the same real-time and thread endpoints the web editor uses. It is **not affiliated
22
+ > with or endorsed by Overleaf**, may break when Overleaf changes internals, and should be used
23
+ > on your own account and projects. MIT licensed — use at your own risk.
24
+
25
+ ## Status
26
+
27
+ Early but functional. The full read/write path is **proven end-to-end against live overleaf.com**
28
+ (see [`src/probes/`](src/probes/)). Working commands: **`login`**, **`link`**, **`pull`**,
29
+ **`push`** (with `--dry-run`), **`comment`**, and **`resolve`** — comments *and* tracked changes,
30
+ both directions. Next: multi-file mapping and `npm publish`.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ npm install -g overleaf-review
36
+ ```
37
+
38
+ Then run `overleaf-review <command>` anywhere. For local development, clone the repo and use
39
+ `npx tsx src/cli.ts <command>` (as shown below).
40
+
41
+ ## Getting started
42
+
43
+ ```bash
44
+ npm install
45
+
46
+ # 1. Authenticate (stored in ~/.config/overleaf-review/, chmod 600 — never in the repo)
47
+ npx tsx src/cli.ts login # paste your overleaf_session2 cookie, or:
48
+ npx tsx src/cli.ts login --browser # opens your Chrome, log in normally (SSO works)
49
+
50
+ # 2. Link this repo to an Overleaf project (id from the project URL)
51
+ npx tsx src/cli.ts link --project <projectId>
52
+
53
+ # 3. Sync the review layer
54
+ npx tsx src/cli.ts pull # comments + changes → .overleaf/
55
+ npx tsx src/cli.ts push --dry-run # preview ALL changed .tex as suggestions
56
+ npx tsx src/cli.ts push # send them as tracked changes
57
+ npx tsx src/cli.ts push --file sections/intro.tex # or just one file
58
+ npx tsx src/cli.ts comment --anchor "Introduction" --message "Expand this section"
59
+ npx tsx src/cli.ts resolve --thread <id> # thread ids come from `pull`
60
+ ```
61
+
62
+ Auth grants full account access, so it's treated like a password: `login` stores it in
63
+ `~/.config/overleaf-review/credentials.json` (chmod 600), never in the repo. `--browser` needs
64
+ Playwright (`npm i -D playwright`) and drives your installed Chrome — no extra download — and
65
+ works with institutional SSO. For dev you can instead put `OVERLEAF_SESSION2` and
66
+ `OVERLEAF_PROJECT_ID` in a gitignored `.env` (env vars take priority).
67
+
68
+ ## How it works
69
+
70
+ overleaf.com's editor speaks an old **socket.io 0.9** protocol over a WebSocket. The client
71
+ ([`src/overleaf-socket.ts`](src/overleaf-socket.ts)) joins the project, reads each doc's
72
+ `ranges` (comments + tracked changes), and — to write — sends `applyOtUpdate` ops:
73
+ a tracked change is an insert/delete op with a `meta.tc` flag; a comment is a `c` op plus a
74
+ REST post of the message text. Comment message bodies come from the project's threads endpoint.
75
+
76
+ ## License
77
+
78
+ MIT © Miguel Castellano
package/dist/cli.js ADDED
@@ -0,0 +1,766 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/commands/pull.ts
4
+ import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "fs";
5
+ import { join as join3 } 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";
13
+ 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 } 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
+ writeFileSync2(REPO_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
49
+ return REPO_CONFIG_PATH;
50
+ }
51
+
52
+ // src/config.ts
53
+ function resolveBaseUrl() {
54
+ return process.env.OVERLEAF_BASE_URL ?? loadProjectConfig().baseUrl ?? loadCredentials().baseUrl ?? "https://www.overleaf.com";
55
+ }
56
+ function resolveSession2() {
57
+ const value = process.env.OVERLEAF_SESSION2 || loadCredentials().session2;
58
+ if (!value) {
59
+ throw new Error("Not authenticated \u2014 run `overleaf-review login` (or set OVERLEAF_SESSION2).");
60
+ }
61
+ return value;
62
+ }
63
+ function resolveProjectId() {
64
+ const value = process.env.OVERLEAF_PROJECT_ID || loadProjectConfig().projectId;
65
+ if (!value) {
66
+ throw new Error(
67
+ "No project linked \u2014 run `overleaf-review link --project <id>` (or set OVERLEAF_PROJECT_ID)."
68
+ );
69
+ }
70
+ return value;
71
+ }
72
+ var config = {
73
+ get baseUrl() {
74
+ return resolveBaseUrl();
75
+ },
76
+ get session2() {
77
+ return resolveSession2();
78
+ },
79
+ get projectId() {
80
+ return resolveProjectId();
81
+ },
82
+ /** Cookie header value used for both HTTP and the websocket handshake. */
83
+ get cookie() {
84
+ return `overleaf_session2=${this.session2}`;
85
+ }
86
+ };
87
+
88
+ // src/overleaf-socket.ts
89
+ import WebSocket from "ws";
90
+ var OverleafSocket = class {
91
+ constructor(baseUrl, cookie, opts = {}) {
92
+ this.baseUrl = baseUrl;
93
+ this.debug = opts.debug ?? true;
94
+ for (const part of cookie.split(";")) {
95
+ const eq = part.indexOf("=");
96
+ if (eq > 0) this.cookies.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
97
+ }
98
+ }
99
+ baseUrl;
100
+ ws;
101
+ ackId = 1;
102
+ pendingAcks = /* @__PURE__ */ new Map();
103
+ handlers = /* @__PURE__ */ new Map();
104
+ heartbeat;
105
+ debug;
106
+ cookies = /* @__PURE__ */ new Map();
107
+ /** Browser-like headers — Overleaf's proxy can 502 non-browser upgrades. */
108
+ get browserHeaders() {
109
+ return {
110
+ Cookie: this.cookieHeader,
111
+ Origin: this.baseUrl,
112
+ "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"
113
+ };
114
+ }
115
+ get cookieHeader() {
116
+ return [...this.cookies].map(([k, v]) => `${k}=${v}`).join("; ");
117
+ }
118
+ /** Merge Set-Cookie values — notably the GCLB load-balancer affinity cookie. */
119
+ absorbCookies(setCookies) {
120
+ for (const sc of setCookies) {
121
+ const pair = sc.split(";", 1)[0];
122
+ const eq = pair.indexOf("=");
123
+ if (eq > 0) this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
124
+ }
125
+ }
126
+ /** Subscribe to a server-pushed event (e.g. otUpdateApplied). */
127
+ on(event, handler) {
128
+ const list = this.handlers.get(event) ?? [];
129
+ list.push(handler);
130
+ this.handlers.set(event, list);
131
+ }
132
+ async connect(projectId) {
133
+ const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${projectId}&t=${Date.now()}`;
134
+ const res = await fetch(handshakeUrl, { headers: this.browserHeaders });
135
+ const setCookies = res.headers.getSetCookie?.() ?? [];
136
+ this.absorbCookies(setCookies);
137
+ if (this.debug) console.log(`[socket] cookies now: ${[...this.cookies.keys()].join(", ")}`);
138
+ if (!res.ok) {
139
+ throw new Error(
140
+ `socket.io handshake failed: ${res.status} ${res.statusText}. Check the session cookie and that the account is logged in.`
141
+ );
142
+ }
143
+ const body = await res.text();
144
+ const [sid, hbTimeout, , transports] = body.split(":");
145
+ if (this.debug) console.log(`[socket] handshake ok: ${body}`);
146
+ const wsUrl = `${this.baseUrl.replace(/^http/, "ws")}/socket.io/1/websocket/${sid}`;
147
+ if (this.debug) console.log(`[socket] transports=${transports}; ws=${wsUrl}`);
148
+ this.ws = new WebSocket(wsUrl, { headers: this.browserHeaders });
149
+ await new Promise((resolve2, reject) => {
150
+ this.ws.once("open", () => resolve2());
151
+ this.ws.once("error", reject);
152
+ this.ws.once("unexpected-response", (_req, response) => {
153
+ let errBody = "";
154
+ response.on("data", (chunk) => errBody += chunk.toString());
155
+ response.on(
156
+ "end",
157
+ () => reject(
158
+ new Error(
159
+ `ws upgrade rejected: ${response.statusCode} ${response.statusMessage}
160
+ headers: ${JSON.stringify(response.headers)}
161
+ body: ${errBody.slice(0, 800)}`
162
+ )
163
+ )
164
+ );
165
+ });
166
+ });
167
+ this.ws.on("message", (data) => this.onFrame(data.toString()));
168
+ const intervalMs = (Number(hbTimeout) || 30) * 800;
169
+ this.heartbeat = setInterval(() => this.send("2::"), intervalMs);
170
+ if (this.debug) console.log("[socket] websocket open");
171
+ }
172
+ /** Emit an event and resolve with the server's ack payload (array of args). */
173
+ emit(name, args, timeoutMs = 15e3) {
174
+ const id = this.ackId++;
175
+ return new Promise((resolve2, reject) => {
176
+ const timer = setTimeout(() => {
177
+ this.pendingAcks.delete(id);
178
+ reject(new Error(`emit('${name}') timed out after ${timeoutMs}ms with no ack`));
179
+ }, timeoutMs);
180
+ this.pendingAcks.set(id, (payload) => {
181
+ clearTimeout(timer);
182
+ resolve2(payload);
183
+ });
184
+ this.send(`5:${id}+::${JSON.stringify({ name, args })}`);
185
+ });
186
+ }
187
+ onFrame(frame) {
188
+ if (this.debug) console.log("[socket] <-", frame.slice(0, 200));
189
+ const type = frame[0];
190
+ const c1 = frame.indexOf(":");
191
+ const c2 = frame.indexOf(":", c1 + 1);
192
+ const c3 = frame.indexOf(":", c2 + 1);
193
+ const data = c3 === -1 ? "" : frame.slice(c3 + 1);
194
+ switch (type) {
195
+ case "2":
196
+ this.send("2::");
197
+ break;
198
+ case "5": {
199
+ try {
200
+ const { name, args } = JSON.parse(data);
201
+ for (const h of this.handlers.get(name) ?? []) h(args);
202
+ } catch {
203
+ }
204
+ break;
205
+ }
206
+ case "6": {
207
+ const plus = data.indexOf("+");
208
+ const ackId = Number(plus === -1 ? data : data.slice(0, plus));
209
+ const payload = plus === -1 ? [] : JSON.parse(data.slice(plus + 1));
210
+ this.pendingAcks.get(ackId)?.(payload);
211
+ this.pendingAcks.delete(ackId);
212
+ break;
213
+ }
214
+ }
215
+ }
216
+ send(frame) {
217
+ if (this.debug && !frame.startsWith("2")) console.log("[socket] ->", frame.slice(0, 200));
218
+ this.ws.send(frame);
219
+ }
220
+ close() {
221
+ if (this.heartbeat) clearInterval(this.heartbeat);
222
+ this.ws?.close();
223
+ }
224
+ };
225
+
226
+ // src/lib/session.ts
227
+ function collectDocs(rootFolder) {
228
+ const out = [];
229
+ const walk = (folders, prefix) => {
230
+ for (const f of folders ?? []) {
231
+ const dir = f.name && f.name !== "rootFolder" ? prefix ? `${prefix}/${f.name}` : f.name : prefix;
232
+ for (const d of f.docs ?? []) {
233
+ out.push({ _id: d._id, name: d.name, path: dir ? `${dir}/${d.name}` : d.name });
234
+ }
235
+ walk(f.folders ?? [], dir);
236
+ }
237
+ };
238
+ walk(rootFolder ?? [], "");
239
+ return out;
240
+ }
241
+ async function openProject(opts = {}) {
242
+ const socket = new OverleafSocket(config.baseUrl, config.cookie, { debug: opts.debug ?? false });
243
+ const pushed = new Promise((resolve2) => socket.on("joinProjectResponse", resolve2));
244
+ await socket.connect(config.projectId);
245
+ const first = await Promise.race([
246
+ pushed,
247
+ new Promise((r) => setTimeout(() => r(null), 6e3))
248
+ ]);
249
+ let publicId = "";
250
+ let project;
251
+ if (first) {
252
+ publicId = first[0]?.publicId ?? "";
253
+ project = first[0]?.project;
254
+ } else {
255
+ const r = await socket.emit("joinProject", [{ project_id: config.projectId }]);
256
+ project = r[1];
257
+ }
258
+ return { socket, publicId, project, docs: collectDocs(project?.rootFolder) };
259
+ }
260
+ async function joinDoc(socket, docId) {
261
+ const res = await socket.emit("joinDoc", [docId, { encodeRanges: true }]);
262
+ const [err, lines, version, , ranges] = res;
263
+ if (err) throw new Error(`joinDoc error: ${JSON.stringify(err)}`);
264
+ return { version, lines: lines ?? [], ranges: ranges ?? {} };
265
+ }
266
+
267
+ // src/lib/rest.ts
268
+ 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";
269
+ function headers(extra = {}) {
270
+ return { Cookie: config.cookie, "User-Agent": UA, Origin: config.baseUrl, ...extra };
271
+ }
272
+ async function getCsrfToken() {
273
+ const res = await fetch(`${config.baseUrl}/project/${config.projectId}`, { headers: headers() });
274
+ const html = await res.text();
275
+ const m = html.match(/<meta\s+name="ol-csrfToken"\s+content="([^"]+)"/) ?? html.match(/"csrfToken"\s*:\s*"([^"]+)"/) ?? html.match(/csrfToken\s*[:=]\s*["']([^"']+)["']/);
276
+ if (!m) {
277
+ throw new Error(
278
+ `CSRF token not found (HTTP ${res.status}). If this is a login page, the session cookie is invalid/expired.`
279
+ );
280
+ }
281
+ return m[1];
282
+ }
283
+ async function getThreads() {
284
+ const res = await fetch(`${config.baseUrl}/project/${config.projectId}/threads`, {
285
+ headers: headers()
286
+ });
287
+ if (!res.ok) throw new Error(`getThreads ${res.status}: ${(await res.text()).slice(0, 200)}`);
288
+ return res.json();
289
+ }
290
+ async function postThreadMessage(threadId, content, csrf) {
291
+ const res = await fetch(
292
+ `${config.baseUrl}/project/${config.projectId}/thread/${threadId}/messages`,
293
+ {
294
+ method: "POST",
295
+ headers: headers({ "Content-Type": "application/json", "X-CSRF-Token": csrf }),
296
+ body: JSON.stringify({ content })
297
+ }
298
+ );
299
+ if (!res.ok) {
300
+ throw new Error(`postThreadMessage ${res.status}: ${(await res.text()).slice(0, 300)}`);
301
+ }
302
+ return res.status;
303
+ }
304
+ async function setThreadResolved(docId, threadId, reopen, csrf) {
305
+ const action = reopen ? "reopen" : "resolve";
306
+ const res = await fetch(
307
+ `${config.baseUrl}/project/${config.projectId}/doc/${docId}/thread/${threadId}/${action}`,
308
+ { method: "POST", headers: headers({ "X-CSRF-Token": csrf }) }
309
+ );
310
+ if (!res.ok) {
311
+ throw new Error(`${action} thread ${res.status}: ${(await res.text()).slice(0, 200)}`);
312
+ }
313
+ return res.status;
314
+ }
315
+ async function validateSession(baseUrl, session2) {
316
+ const res = await fetch(`${baseUrl}/project`, {
317
+ headers: { Cookie: `overleaf_session2=${session2}`, "User-Agent": UA },
318
+ redirect: "follow"
319
+ });
320
+ const html = await res.text();
321
+ const looksLikeLogin = res.url.includes("/login") || /name="ol-page"\s+content="login"/.test(html) || html.includes('id="loginForm"');
322
+ if (looksLikeLogin) {
323
+ throw new Error("Session cookie is invalid or expired (got the login page).");
324
+ }
325
+ const m = html.match(/name="ol-usersEmail"\s+content="([^"]+)"/) ?? html.match(/"email":"([^"@]+@[^"]+)"/);
326
+ return m ? m[1] : "your Overleaf account";
327
+ }
328
+
329
+ // src/lib/anchors.ts
330
+ function offsetToLine(lines, offset) {
331
+ let remaining = offset;
332
+ for (let i = 0; i < lines.length; i++) {
333
+ const len = lines[i].length;
334
+ if (remaining <= len) return i;
335
+ remaining -= len + 1;
336
+ }
337
+ return Math.max(0, lines.length - 1);
338
+ }
339
+ function lineContext(lines, line, radius = 1) {
340
+ const start = Math.max(0, line - radius);
341
+ const end = Math.min(lines.length - 1, line + radius);
342
+ const out = [];
343
+ for (let i = start; i <= end; i++) {
344
+ out.push(`${i === line ? ">" : " "} ${String(i + 1).padStart(3)} ${lines[i]}`);
345
+ }
346
+ return out.join("\n");
347
+ }
348
+
349
+ // src/commands/pull.ts
350
+ function buildMemberMap(project) {
351
+ const map = {};
352
+ const add = (u) => {
353
+ if (!u?._id) return;
354
+ map[u._id] = [u.first_name, u.last_name].filter(Boolean).join(" ") || u.email || u._id;
355
+ };
356
+ add(project?.owner);
357
+ for (const m of project?.members ?? []) add(m);
358
+ return map;
359
+ }
360
+ async function pull(outDir = ".overleaf") {
361
+ const { socket, project, docs } = await openProject();
362
+ const members = buildMemberMap(project);
363
+ const threads = await getThreads();
364
+ const comments = [];
365
+ const changes = [];
366
+ for (const doc of docs) {
367
+ const state = await joinDoc(socket, doc._id);
368
+ for (const c of state.ranges.comments ?? []) {
369
+ const line = offsetToLine(state.lines, c.op.p);
370
+ const thread = threads[c.op.t] ?? {};
371
+ comments.push({
372
+ doc: doc.path,
373
+ threadId: c.op.t,
374
+ anchor: c.op.c,
375
+ line: line + 1,
376
+ context: lineContext(state.lines, line),
377
+ resolved: Boolean(thread.resolved),
378
+ messages: (thread.messages ?? []).map((m) => ({
379
+ author: m.user?.first_name ?? members[m.user_id] ?? m.user_id,
380
+ email: m.user?.email,
381
+ content: m.content,
382
+ timestamp: m.timestamp
383
+ }))
384
+ });
385
+ }
386
+ for (const ch of state.ranges.changes ?? []) {
387
+ const isInsert = typeof ch.op.i === "string";
388
+ const line = offsetToLine(state.lines, ch.op.p);
389
+ changes.push({
390
+ doc: doc.path,
391
+ type: isInsert ? "insert" : "delete",
392
+ text: isInsert ? ch.op.i : ch.op.d,
393
+ line: line + 1,
394
+ context: lineContext(state.lines, line),
395
+ author: members[ch.metadata?.user_id] ?? ch.metadata?.user_id ?? "unknown",
396
+ ts: ch.metadata?.ts
397
+ });
398
+ }
399
+ }
400
+ socket.close();
401
+ const data = {
402
+ project: project?.name ?? "(unknown)",
403
+ projectId: config.projectId,
404
+ pulledAt: (/* @__PURE__ */ new Date()).toISOString(),
405
+ comments,
406
+ changes
407
+ };
408
+ mkdirSync3(outDir, { recursive: true });
409
+ writeFileSync3(join3(outDir, "reviews.json"), JSON.stringify(data, null, 2) + "\n");
410
+ writeFileSync3(join3(outDir, "reviews.md"), renderMarkdown(data));
411
+ return data;
412
+ }
413
+ function renderMarkdown(d) {
414
+ const L = [];
415
+ L.push(`# Overleaf review \u2014 ${d.project}`, "");
416
+ L.push(`_Pulled ${d.pulledAt} from project \`${d.projectId}\`._`, "");
417
+ L.push(`**${d.comments.length}** comment(s), **${d.changes.length}** tracked change(s).`, "");
418
+ const byDoc = (items) => {
419
+ const m = /* @__PURE__ */ new Map();
420
+ for (const it of items) (m.get(it.doc) ?? m.set(it.doc, []).get(it.doc)).push(it);
421
+ return m;
422
+ };
423
+ if (d.comments.length) {
424
+ L.push("## Comments", "");
425
+ for (const [doc, items] of byDoc(d.comments)) {
426
+ L.push(`### ${doc}`, "");
427
+ for (const c of items) {
428
+ const flag = c.resolved ? " \u2014 \u2705 resolved" : "";
429
+ L.push(`#### \u{1F4AC} \u201C${c.anchor}\u201D \xB7 line ${c.line}${flag}`);
430
+ L.push("```", c.context, "```");
431
+ for (const m of c.messages) {
432
+ L.push(`- **${m.author}**: ${m.content}`);
433
+ }
434
+ if (!c.messages.length) L.push("- _(no message text)_");
435
+ L.push("");
436
+ }
437
+ }
438
+ }
439
+ if (d.changes.length) {
440
+ L.push("## Tracked changes", "");
441
+ for (const [doc, items] of byDoc(d.changes)) {
442
+ L.push(`### ${doc}`, "");
443
+ for (const ch of items) {
444
+ const verb = ch.type === "insert" ? "inserted" : "deleted";
445
+ L.push(`- **${verb}** at line ${ch.line} by ${ch.author}: \`${ch.text.replace(/\n/g, "\u23CE")}\``);
446
+ L.push(" ```", ch.context.replace(/^/gm, " "), " ```");
447
+ }
448
+ L.push("");
449
+ }
450
+ }
451
+ return L.join("\n");
452
+ }
453
+
454
+ // src/commands/push.ts
455
+ import { readFileSync as readFileSync3, readdirSync } from "fs";
456
+ import { relative, resolve as resolvePath, sep } from "path";
457
+ import { randomBytes } from "crypto";
458
+ import { diffChars } from "diff";
459
+ function buildOps(remote, local) {
460
+ const ops = [];
461
+ let p = 0;
462
+ for (const part of diffChars(remote, local)) {
463
+ if (part.added) {
464
+ ops.push({ p, i: part.value });
465
+ p += part.value.length;
466
+ } else if (part.removed) {
467
+ ops.push({ p, d: part.value });
468
+ } else {
469
+ p += part.value.length;
470
+ }
471
+ }
472
+ return ops;
473
+ }
474
+ function preview(op) {
475
+ const kind = op.i != null ? "insert" : "delete";
476
+ const text = (op.i ?? op.d ?? "").replace(/\n/g, "\u23CE");
477
+ const clip = text.length > 60 ? text.slice(0, 60) + "\u2026" : text;
478
+ return ` ${kind.padEnd(6)} @ ${String(op.p).padStart(5)} "${clip}"`;
479
+ }
480
+ function toOverleafPath(file) {
481
+ return relative(process.cwd(), resolvePath(file)).split(sep).join("/");
482
+ }
483
+ var IGNORE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".overleaf", "tmp", "dist"]);
484
+ function discoverLocalTex(dir = process.cwd(), acc = []) {
485
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
486
+ if (entry.name.startsWith(".") || IGNORE_DIRS.has(entry.name)) continue;
487
+ const full = resolvePath(dir, entry.name);
488
+ if (entry.isDirectory()) discoverLocalTex(full, acc);
489
+ else if (entry.name.endsWith(".tex")) acc.push(toOverleafPath(full));
490
+ }
491
+ return acc;
492
+ }
493
+ function pickDoc(file, docName, docs, rootDocId, allowRootFallback) {
494
+ if (docName) return docs.find((d) => d.path === docName || d.name === docName);
495
+ const rel = toOverleafPath(file);
496
+ const base = rel.split("/").pop();
497
+ return docs.find((d) => d.path === rel) ?? docs.find((d) => d.name === base) ?? (allowRootFallback ? docs.find((d) => d._id === rootDocId) ?? docs[0] : void 0);
498
+ }
499
+ async function push(opts) {
500
+ const { socket, project, docs } = await openProject();
501
+ const files = opts.file ? [opts.file] : discoverLocalTex();
502
+ if (!files.length) {
503
+ console.log("No local .tex files found to push.");
504
+ socket.close();
505
+ return;
506
+ }
507
+ const plans = [];
508
+ for (const file of files) {
509
+ let local;
510
+ try {
511
+ local = readFileSync3(file, "utf8");
512
+ } catch {
513
+ console.log(`- ${file}: cannot read, skipped`);
514
+ continue;
515
+ }
516
+ const doc = pickDoc(file, opts.docName, docs, project.rootDoc_id, Boolean(opts.file));
517
+ if (!doc) {
518
+ console.log(`- ${file}: no matching Overleaf doc, skipped`);
519
+ continue;
520
+ }
521
+ const state = await joinDoc(socket, doc._id);
522
+ const ops = buildOps(state.lines.join("\n"), local);
523
+ if (ops.length) plans.push({ file, doc, ops, version: state.version });
524
+ }
525
+ if (!plans.length) {
526
+ console.log("Nothing to push \u2014 local files already match Overleaf.");
527
+ socket.close();
528
+ return;
529
+ }
530
+ for (const pl of plans) {
531
+ const ins = pl.ops.filter((o) => o.i != null).length;
532
+ const del = pl.ops.filter((o) => o.d != null).length;
533
+ console.log(`
534
+ ${pl.file} \u2192 ${pl.doc.path} (v${pl.version}): ${pl.ops.length} op(s), ${ins} ins / ${del} del`);
535
+ for (const op of pl.ops.slice(0, 12)) console.log(preview(op));
536
+ if (pl.ops.length > 12) console.log(` \u2026 and ${pl.ops.length - 12} more`);
537
+ }
538
+ if (opts.dryRun) {
539
+ console.log("\n(dry run \u2014 nothing sent to Overleaf)");
540
+ socket.close();
541
+ return;
542
+ }
543
+ socket.on("otUpdateError", (a) => console.log("!! otUpdateError:", JSON.stringify(a)));
544
+ for (const pl of plans) {
545
+ const update = { doc: pl.doc._id, op: pl.ops, v: pl.version, meta: { tc: randomBytes(12).toString("hex") } };
546
+ const ack = await socket.emit("applyOtUpdate", [pl.doc._id, update], 2e4);
547
+ if (ack?.[0]) {
548
+ socket.close();
549
+ throw new Error(`Overleaf rejected ${pl.file}: ${JSON.stringify(ack[0])}`);
550
+ }
551
+ }
552
+ let allMatch = true;
553
+ for (const pl of plans) {
554
+ const after = await joinDoc(socket, pl.doc._id);
555
+ if (after.lines.join("\n") !== readFileSync3(pl.file, "utf8")) allMatch = false;
556
+ }
557
+ socket.close();
558
+ const totalOps = plans.reduce((n, p) => n + p.ops.length, 0);
559
+ console.log(
560
+ `
561
+ \u2705 Pushed suggestions to ${plans.length} file(s), ${totalOps} tracked op(s) total \u2014 verified match: ${allMatch ? "yes" : "\u26A0\uFE0F NO, inspect"}`
562
+ );
563
+ }
564
+
565
+ // src/commands/comment.ts
566
+ import { randomBytes as randomBytes2 } from "crypto";
567
+ async function comment(opts) {
568
+ const { socket, project, docs } = await openProject();
569
+ const doc = opts.docName ? docs.find((d) => d.path === opts.docName || d.name === opts.docName) : docs.find((d) => d._id === project.rootDoc_id) ?? docs[0];
570
+ if (!doc) {
571
+ socket.close();
572
+ throw new Error(`doc not found: ${opts.docName ?? "(root)"}`);
573
+ }
574
+ const state = await joinDoc(socket, doc._id);
575
+ const flat = state.lines.join("\n");
576
+ const nth = Math.max(1, opts.occurrence ?? 1);
577
+ let p = -1;
578
+ let from = 0;
579
+ for (let i = 0; i < nth; i++) {
580
+ p = flat.indexOf(opts.anchor, from);
581
+ if (p < 0) break;
582
+ from = p + 1;
583
+ }
584
+ if (p < 0) {
585
+ socket.close();
586
+ throw new Error(`anchor text not found in ${doc.name}: "${opts.anchor}"`);
587
+ }
588
+ const threadId = randomBytes2(12).toString("hex");
589
+ const update = {
590
+ doc: doc._id,
591
+ op: [{ p, c: opts.anchor, t: threadId }],
592
+ v: state.version,
593
+ meta: {}
594
+ };
595
+ const ack = await socket.emit("applyOtUpdate", [doc._id, update], 15e3);
596
+ socket.close();
597
+ if (ack?.[0]) throw new Error(`Overleaf rejected the comment op: ${JSON.stringify(ack[0])}`);
598
+ const csrf = await getCsrfToken();
599
+ await postThreadMessage(threadId, opts.message, csrf);
600
+ console.log(`\u2705 Commented on "${opts.anchor}" in ${doc.name} (thread ${threadId})`);
601
+ }
602
+
603
+ // src/commands/resolve.ts
604
+ async function resolve(threadId, reopen = false) {
605
+ const { socket, docs } = await openProject();
606
+ let docId;
607
+ for (const doc of docs) {
608
+ const state = await joinDoc(socket, doc._id);
609
+ if ((state.ranges.comments ?? []).some((c) => c.op?.t === threadId)) {
610
+ docId = doc._id;
611
+ break;
612
+ }
613
+ }
614
+ socket.close();
615
+ if (!docId) {
616
+ throw new Error(
617
+ `thread ${threadId} not found in any doc's active comments (already resolved threads may not be locatable this way)`
618
+ );
619
+ }
620
+ const csrf = await getCsrfToken();
621
+ await setThreadResolved(docId, threadId, reopen, csrf);
622
+ console.log(`\u2705 Thread ${threadId} ${reopen ? "reopened" : "resolved"}`);
623
+ }
624
+
625
+ // src/commands/login.ts
626
+ import { createInterface } from "readline/promises";
627
+ async function login(opts) {
628
+ const baseUrl = opts.baseUrl ?? "https://www.overleaf.com";
629
+ let cookie = opts.cookie ?? process.env.OVERLEAF_SESSION2;
630
+ if (opts.browser) {
631
+ cookie = await captureCookieViaBrowser(baseUrl);
632
+ } else if (!cookie) {
633
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
634
+ console.log("Log into Overleaf in your browser (institutional SSO is fine), then copy the");
635
+ console.log("`overleaf_session2` cookie value:");
636
+ console.log(" devtools \u2192 Application \u2192 Cookies \u2192 your Overleaf host \u2192 overleaf_session2\n");
637
+ cookie = (await rl.question("Paste overleaf_session2: ")).trim();
638
+ rl.close();
639
+ }
640
+ if (!cookie) throw new Error("No cookie provided.");
641
+ const account = await validateSession(baseUrl, cookie);
642
+ const path = saveCredentials({ baseUrl, session2: cookie });
643
+ console.log(`
644
+ \u2705 Logged in as ${account}. Saved to ${path} (chmod 600).`);
645
+ }
646
+ async function captureCookieViaBrowser(baseUrl) {
647
+ let chromium;
648
+ try {
649
+ const specifier = "playwright";
650
+ chromium = (await import(specifier)).chromium;
651
+ } catch {
652
+ throw new Error(
653
+ "Browser login needs Playwright. Install it with:\n npm i -D playwright\nIt will drive your installed Chrome (no extra download)."
654
+ );
655
+ }
656
+ const browser = await chromium.launch({ headless: false, channel: "chrome" });
657
+ try {
658
+ const ctx = await browser.newContext();
659
+ const page = await ctx.newPage();
660
+ await page.goto(`${baseUrl}/login`);
661
+ console.log("A browser opened \u2014 log into Overleaf (SSO is fine). Waiting for the dashboard\u2026");
662
+ await page.waitForURL((url) => url.pathname.startsWith("/project"), { timeout: 3e5 });
663
+ const cookies = await ctx.cookies();
664
+ const found = cookies.find((c) => c.name === "overleaf_session2");
665
+ if (!found) throw new Error("Logged in, but no overleaf_session2 cookie was found.");
666
+ return found.value;
667
+ } finally {
668
+ await browser.close();
669
+ }
670
+ }
671
+
672
+ // src/commands/link.ts
673
+ function link(projectId, baseUrl) {
674
+ const path = saveProjectConfig({ projectId, ...baseUrl ? { baseUrl } : {} });
675
+ console.log(`\u2705 Linked this repo to Overleaf project ${projectId} \u2192 ${path}`);
676
+ console.log(" (safe to commit \u2014 it contains no secrets.)");
677
+ }
678
+
679
+ // src/cli.ts
680
+ function getFlag(name) {
681
+ const i = process.argv.indexOf(`--${name}`);
682
+ return i >= 0 ? process.argv[i + 1] : void 0;
683
+ }
684
+ function usage() {
685
+ console.log("overleaf-review \u2014 sync Overleaf review data with git\n");
686
+ console.log("Setup:");
687
+ console.log(" overleaf-review login [--cookie <val>] [--browser]");
688
+ console.log(" Authenticate and store your session (--browser is SSO-friendly)");
689
+ console.log(" overleaf-review link --project <id>");
690
+ console.log(" Link this repo to an Overleaf project (writes .overleaf/config.json)\n");
691
+ console.log("Review:");
692
+ console.log(" overleaf-review pull [--out <dir>]");
693
+ console.log(" Read comments + tracked changes into a sidecar (.overleaf/)");
694
+ console.log(" overleaf-review push [--file <f>] [--doc <name>] [--dry-run]");
695
+ console.log(" Send local edits as tracked-change suggestions (all changed .tex if no --file)");
696
+ console.log(" overleaf-review comment --anchor <text> --message <text> [--doc <name>] [--nth <n>]");
697
+ console.log(" Add a comment anchored on the given text");
698
+ console.log(" overleaf-review resolve --thread <id> [--reopen]");
699
+ console.log(" Resolve (or reopen) a comment thread (ids come from `pull`)");
700
+ }
701
+ async function main() {
702
+ const cmd = process.argv[2];
703
+ switch (cmd) {
704
+ case "login": {
705
+ await login({
706
+ cookie: getFlag("cookie"),
707
+ baseUrl: getFlag("base-url"),
708
+ browser: process.argv.includes("--browser")
709
+ });
710
+ break;
711
+ }
712
+ case "link": {
713
+ const project = getFlag("project");
714
+ if (!project) {
715
+ console.error("link requires --project <id>");
716
+ process.exit(1);
717
+ }
718
+ link(project, getFlag("base-url"));
719
+ break;
720
+ }
721
+ case "pull": {
722
+ const out = getFlag("out") ?? ".overleaf";
723
+ const data = await pull(out);
724
+ console.log(
725
+ `Pulled ${data.comments.length} comment(s) and ${data.changes.length} tracked change(s) from "${data.project}" \u2192 ${out}/`
726
+ );
727
+ break;
728
+ }
729
+ case "push": {
730
+ await push({ file: getFlag("file"), docName: getFlag("doc"), dryRun: process.argv.includes("--dry-run") });
731
+ break;
732
+ }
733
+ case "comment": {
734
+ const anchor = getFlag("anchor");
735
+ const message = getFlag("message");
736
+ if (!anchor || !message) {
737
+ console.error("comment requires --anchor <text> and --message <text>");
738
+ process.exit(1);
739
+ }
740
+ const nthRaw = getFlag("nth");
741
+ await comment({
742
+ docName: getFlag("doc"),
743
+ anchor,
744
+ message,
745
+ occurrence: nthRaw ? Number(nthRaw) : void 0
746
+ });
747
+ break;
748
+ }
749
+ case "resolve": {
750
+ const thread = getFlag("thread");
751
+ if (!thread) {
752
+ console.error("resolve requires --thread <id>");
753
+ process.exit(1);
754
+ }
755
+ await resolve(thread, process.argv.includes("--reopen"));
756
+ break;
757
+ }
758
+ default:
759
+ usage();
760
+ process.exit(cmd ? 1 : 0);
761
+ }
762
+ }
763
+ main().then(() => process.exit(0)).catch((e) => {
764
+ console.error(e instanceof Error ? e.message : e);
765
+ process.exit(1);
766
+ });
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "overleaf-review",
3
+ "version": "0.1.0",
4
+ "description": "The missing review layer for Overleaf's Git bridge — sync comments and tracked changes between Overleaf and your local repo.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Miguel Castellano",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/michu5696/overleaf-review.git"
11
+ },
12
+ "homepage": "https://github.com/michu5696/overleaf-review#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/michu5696/overleaf-review/issues"
15
+ },
16
+ "keywords": [
17
+ "overleaf",
18
+ "latex",
19
+ "git",
20
+ "comments",
21
+ "track-changes",
22
+ "review",
23
+ "cli"
24
+ ],
25
+ "bin": {
26
+ "overleaf-review": "dist/cli.js"
27
+ },
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "scripts": {
35
+ "build": "tsup",
36
+ "prepublishOnly": "npm run build",
37
+ "start": "node dist/cli.js",
38
+ "pull": "tsx src/cli.ts pull",
39
+ "push": "tsx src/cli.ts push",
40
+ "probe:read": "tsx src/probes/01-read-ranges.ts",
41
+ "probe:comment": "tsx src/probes/02-write-comment.ts",
42
+ "probe:track": "tsx src/probes/03-write-track-change.ts",
43
+ "typecheck": "tsc --noEmit"
44
+ },
45
+ "dependencies": {
46
+ "diff": "^9.0.0",
47
+ "dotenv": "^16.4.5",
48
+ "ws": "^8.18.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/diff": "^7.0.2",
52
+ "@types/node": "^22.10.0",
53
+ "@types/ws": "^8.5.13",
54
+ "tsup": "^8.3.5",
55
+ "tsx": "^4.19.2",
56
+ "typescript": "^5.7.2"
57
+ }
58
+ }