overleaf-review 0.2.0 β†’ 0.3.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 (3) hide show
  1. package/README.md +36 -6
  2. package/dist/cli.js +154 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -35,13 +35,40 @@ workflow and carries the review layer Git can't represent.
35
35
  - πŸ“₯ **`pull`** β€” read comments + tracked changes (with anchors) into a Git-friendly sidecar
36
36
  (`.overleaf/reviews.md` + `.json`), so your tools have every co-author note in context.
37
37
  - πŸ“€ **`push`** β€” turn local edits into **tracked-change suggestions**, mapping files to Overleaf
38
- docs by path (one file, or every changed `.tex` at once). `--dry-run` previews the exact ops.
39
- - πŸ’¬ **`comment` / `reply` / `resolve` / `delete-comment`** β€” full comment-thread control:
40
- start a thread, reply, resolve/reopen, or delete.
38
+ docs by path (one file, or every changed `.tex` at once). `--dry-run` previews the exact ops;
39
+ `--direct` sends plain edits instead of suggestions.
40
+ - πŸ”„ **`fetch`** β€” write Overleaf's current text back down into your repo (read-only on Overleaf, so
41
+ it cannot disturb a single comment or tracked change).
42
+ - πŸ–ΌοΈ **`upload`** β€” push figures, PDFs, or new files into Overleaf.
43
+ - πŸ’¬ **`comment` / `reply` / `resolve` / `delete-comment` / `delete-message`** β€” full comment
44
+ control: start a thread, reply, resolve/reopen, delete a whole thread or a single message.
41
45
  - βœ… **`accept` / `reject`** β€” act on your collaborators' tracked changes from the CLI.
42
46
  - πŸ”‘ **`login`** β€” validated auth stored outside your repo (chmod 600); `--browser` mode is
43
47
  institutional-SSO friendly.
44
48
 
49
+ ## ⚠️ Don't mix this with Overleaf's Git/GitHub sync
50
+
51
+ **Overleaf's Git integration writes documents by wholesale content replacement.** The review layer
52
+ is stored separately, anchored by character offsets β€” so a bulk overwrite orphans or displaces your
53
+ comments and tracked changes. (Overleaf's own docs advise against combining Git with track changes.)
54
+ This isn't something a tool can patch around; it's inherent to how the bridge writes.
55
+
56
+ `overleaf-review` writes through Overleaf's **real-time OT API** instead β€” incremental insert/delete
57
+ ops that Overleaf *transforms the review ranges against*, so comments and tracked changes survive.
58
+
59
+ **Recommended setup: unlink Overleaf's Git/GitHub sync and let `overleaf-review` be the only bridge.**
60
+
61
+ | Need | Command |
62
+ | --- | --- |
63
+ | Overleaf text β†’ your repo | `fetch` |
64
+ | Your edits β†’ Overleaf, as suggestions | `push` |
65
+ | Your edits β†’ Overleaf, directly | `push --direct` |
66
+ | Figures / new files β†’ Overleaf | `upload` |
67
+
68
+ Your Git repo stays a completely normal repo β€” commit whatever you like, `.tex` included β€” and
69
+ nothing bidirectional exists that can clobber the review record. (Renaming/deleting files is still
70
+ done in the Overleaf UI.)
71
+
45
72
  ## πŸ“¦ Install
46
73
 
47
74
  ```bash
@@ -73,11 +100,14 @@ overleaf-review resolve --thread <id> # thread ids come from `pull`
73
100
  | `login [--cookie <v>] [--browser]` | Authenticate and store your session (SSO-friendly `--browser`) |
74
101
  | `link --project <id>` | Link this repo to an Overleaf project (`.overleaf/config.json`) |
75
102
  | `pull [--out <dir>]` | Read comments + tracked changes into a sidecar |
76
- | `push [--file <f>] [--doc <name>] [--dry-run]` | Send local edits as tracked-change suggestions (all changed `.tex` if no `--file`) |
103
+ | `fetch [--file <f>] [--dry-run]` | Write Overleaf's text down into local files (read-only on Overleaf) |
104
+ | `upload <path…> [--folder <name>]` | Upload figures / new files into Overleaf |
105
+ | `push [--file <f>] [--doc <name>] [--direct] [--dry-run]` | Send local edits as tracked suggestions (all changed `.tex` if no `--file`); `--direct` for plain edits |
77
106
  | `comment --anchor <text> --message <text> [--doc <name>] [--nth <n>]` | Add a comment anchored on the given text |
78
107
  | `reply --thread <id> --message <text>` | Reply to an existing comment thread |
79
108
  | `resolve --thread <id> [--reopen]` | Resolve (or reopen) a comment thread |
80
- | `delete-comment --thread <id>` | Delete a comment thread |
109
+ | `delete-comment --thread <id>` | Delete a whole comment thread |
110
+ | `delete-message --message-id <id> [--thread <id>]` | Delete a single message within a thread |
81
111
  | `accept --change <id> …` | Accept collaborators' tracked change(s) |
82
112
  | `reject --change <id> …` | Reject collaborators' tracked change(s) |
83
113
 
@@ -104,7 +134,7 @@ own account and projects. Use at your own risk.
104
134
 
105
135
  ## πŸ—ΊοΈ Roadmap
106
136
 
107
- - A `pull` that also writes doc content (not just the review sidecar)
137
+ - File rename / delete (currently done in the Overleaf UI)
108
138
  - Trusted-publishing CI
109
139
 
110
140
  ## πŸ“ Changelog
package/dist/cli.js CHANGED
@@ -327,6 +327,27 @@ async function acceptChanges(docId, changeIds, csrf) {
327
327
  if (!res.ok) throw new Error(`acceptChanges ${res.status}: ${(await res.text()).slice(0, 200)}`);
328
328
  return res.status;
329
329
  }
330
+ async function uploadFile(folderId, name, bytes, csrf) {
331
+ const form = new FormData();
332
+ form.append("qqfile", new Blob([new Uint8Array(bytes)]), name);
333
+ form.append("name", name);
334
+ form.append("relativePath", "null");
335
+ const res = await fetch(
336
+ `${config.baseUrl}/project/${config.projectId}/upload?folder_id=${folderId}`,
337
+ // Deliberately no Content-Type β€” fetch sets the multipart boundary itself.
338
+ { method: "POST", headers: headers({ "X-CSRF-Token": csrf }), body: form }
339
+ );
340
+ if (!res.ok) throw new Error(`upload ${res.status}: ${(await res.text()).slice(0, 200)}`);
341
+ return res.json();
342
+ }
343
+ async function deleteMessage(threadId, messageId, csrf) {
344
+ const res = await fetch(
345
+ `${config.baseUrl}/project/${config.projectId}/thread/${threadId}/messages/${messageId}`,
346
+ { method: "DELETE", headers: headers({ "X-CSRF-Token": csrf }) }
347
+ );
348
+ if (!res.ok) throw new Error(`deleteMessage ${res.status}: ${(await res.text()).slice(0, 200)}`);
349
+ return res.status;
350
+ }
330
351
  async function deleteThread(docId, threadId, csrf) {
331
352
  const res = await fetch(
332
353
  `${config.baseUrl}/project/${config.projectId}/doc/${docId}/thread/${threadId}`,
@@ -399,6 +420,7 @@ async function pull(outDir = ".overleaf") {
399
420
  context: lineContext(state.lines, line),
400
421
  resolved: Boolean(thread.resolved),
401
422
  messages: (thread.messages ?? []).map((m) => ({
423
+ id: m.id,
402
424
  author: m.user?.first_name ?? members[m.user_id] ?? m.user_id,
403
425
  email: m.user?.email,
404
426
  content: m.content,
@@ -440,7 +462,7 @@ function renderMarkdown(d) {
440
462
  L.push(`_Pulled ${d.pulledAt} from project \`${d.projectId}\`._`, "");
441
463
  L.push(`**${d.comments.length}** comment(s), **${d.changes.length}** tracked change(s).`, "");
442
464
  L.push(
443
- "_Act on these:_ `reply --thread <id> --message <text>` \xB7 `resolve --thread <id>` \xB7 `delete-comment --thread <id>` \xB7 `accept --change <id>` \xB7 `reject --change <id>`.",
465
+ "_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>`.",
444
466
  ""
445
467
  );
446
468
  const byDoc = (items) => {
@@ -458,7 +480,7 @@ function renderMarkdown(d) {
458
480
  L.push(`\`thread ${c.threadId}\``);
459
481
  L.push("```", c.context, "```");
460
482
  for (const m of c.messages) {
461
- L.push(`- **${m.author}**: ${m.content}`);
483
+ L.push(`- **${m.author}**: ${m.content} \`msg ${m.id}\``);
462
484
  }
463
485
  if (!c.messages.length) L.push("- _(no message text)_");
464
486
  L.push("");
@@ -558,6 +580,9 @@ async function push(opts) {
558
580
  socket.close();
559
581
  return;
560
582
  }
583
+ console.log(
584
+ opts.direct ? "Mode: DIRECT \u2014 plain edits (not marked as suggestions)" : "Mode: SUGGESTIONS \u2014 tracked changes for co-authors to accept/reject"
585
+ );
561
586
  for (const pl of plans) {
562
587
  const ins = pl.ops.filter((o) => o.i != null).length;
563
588
  const del = pl.ops.filter((o) => o.d != null).length;
@@ -573,7 +598,8 @@ ${pl.file} \u2192 ${pl.doc.path} (v${pl.version}): ${pl.ops.length} op(s), ${ins
573
598
  }
574
599
  socket.on("otUpdateError", (a) => console.log("!! otUpdateError:", JSON.stringify(a)));
575
600
  for (const pl of plans) {
576
- const update = { doc: pl.doc._id, op: pl.ops, v: pl.version, meta: { tc: randomBytes(12).toString("hex") } };
601
+ const meta = opts.direct ? {} : { tc: randomBytes(12).toString("hex") };
602
+ const update = { doc: pl.doc._id, op: pl.ops, v: pl.version, meta };
577
603
  const ack = await socket.emit("applyOtUpdate", [pl.doc._id, update], 2e4);
578
604
  if (ack?.[0]) {
579
605
  socket.close();
@@ -587,12 +613,81 @@ ${pl.file} \u2192 ${pl.doc.path} (v${pl.version}): ${pl.ops.length} op(s), ${ins
587
613
  }
588
614
  socket.close();
589
615
  const totalOps = plans.reduce((n, p) => n + p.ops.length, 0);
616
+ const mode = opts.direct ? "direct edit(s)" : "tracked suggestion(s)";
590
617
  console.log(
591
618
  `
592
- \u2705 Pushed suggestions to ${plans.length} file(s), ${totalOps} tracked op(s) total \u2014 verified match: ${allMatch ? "yes" : "\u26A0\uFE0F NO, inspect"}`
619
+ \u2705 Pushed ${totalOps} ${mode} across ${plans.length} file(s) \u2014 verified match: ${allMatch ? "yes" : "\u26A0\uFE0F NO, inspect"}`
620
+ );
621
+ }
622
+
623
+ // src/commands/fetch.ts
624
+ import { writeFileSync as writeFileSync4, readFileSync as readFileSync4, mkdirSync as mkdirSync4, existsSync } from "fs";
625
+ import { dirname } from "path";
626
+ async function fetchDocs(opts) {
627
+ const { socket, docs } = await openProject();
628
+ const targets = opts.file ? docs.filter((d) => d.path === opts.file || d.name === opts.file) : docs;
629
+ if (!targets.length) {
630
+ console.log(`No matching doc for "${opts.file}".`);
631
+ socket.close();
632
+ return;
633
+ }
634
+ let changed = 0;
635
+ for (const doc of targets) {
636
+ const state = await joinDoc(socket, doc._id);
637
+ const remote = state.lines.join("\n");
638
+ const local = existsSync(doc.path) ? readFileSync4(doc.path, "utf8") : null;
639
+ if (local === remote) continue;
640
+ changed++;
641
+ const delta = local === null ? "(new file)" : `${local.length} \u2192 ${remote.length} chars`;
642
+ console.log(` ${doc.path} ${delta}`);
643
+ if (!opts.dryRun) {
644
+ mkdirSync4(dirname(doc.path), { recursive: true });
645
+ writeFileSync4(doc.path, remote);
646
+ }
647
+ }
648
+ socket.close();
649
+ if (!changed) {
650
+ console.log("Already up to date \u2014 local files match Overleaf.");
651
+ return;
652
+ }
653
+ console.log(
654
+ opts.dryRun ? `
655
+ (dry run \u2014 ${changed} local file(s) would be overwritten)` : `
656
+ \u2705 Fetched ${changed} file(s) from Overleaf.`
593
657
  );
594
658
  }
595
659
 
660
+ // src/commands/upload.ts
661
+ import { readFileSync as readFileSync5 } from "fs";
662
+ import { basename } from "path";
663
+ function findFolder(folder, wanted, prefix = "") {
664
+ for (const f of folder?.folders ?? []) {
665
+ const path = prefix ? `${prefix}/${f.name}` : f.name;
666
+ if (f.name === wanted || path === wanted) return f;
667
+ const deeper = findFolder(f, wanted, path);
668
+ if (deeper) return deeper;
669
+ }
670
+ return void 0;
671
+ }
672
+ async function upload(paths, folderName) {
673
+ const { socket, project } = await openProject();
674
+ socket.close();
675
+ const root = project?.rootFolder?.[0];
676
+ if (!root?._id) throw new Error("could not resolve the project root folder");
677
+ let folderId = root._id;
678
+ if (folderName) {
679
+ const found = findFolder(root, folderName);
680
+ if (!found) throw new Error(`folder not found in project: ${folderName}`);
681
+ folderId = found._id;
682
+ }
683
+ const csrf = await getCsrfToken();
684
+ for (const path of paths) {
685
+ const bytes = readFileSync5(path);
686
+ const res = await uploadFile(folderId, basename(path), bytes, csrf);
687
+ console.log(`\u2705 Uploaded ${path} \u2192 ${res.entity_type} ${res.entity_id}`);
688
+ }
689
+ }
690
+
596
691
  // src/commands/comment.ts
597
692
  import { randomBytes as randomBytes2 } from "crypto";
598
693
  async function comment(opts) {
@@ -678,6 +773,24 @@ async function deleteComment(threadId) {
678
773
  console.log(`\u2705 Deleted comment thread ${threadId}`);
679
774
  }
680
775
 
776
+ // src/commands/delete-message.ts
777
+ async function deleteThreadMessage(messageId, threadId) {
778
+ const csrf = await getCsrfToken();
779
+ let tid = threadId;
780
+ if (!tid) {
781
+ const threads = await getThreads();
782
+ for (const [t, v] of Object.entries(threads)) {
783
+ if (v.messages?.some((m) => m.id === messageId)) {
784
+ tid = t;
785
+ break;
786
+ }
787
+ }
788
+ }
789
+ if (!tid) throw new Error(`message ${messageId} not found in any thread`);
790
+ await deleteMessage(tid, messageId, csrf);
791
+ console.log(`\u2705 Deleted message ${messageId} from thread ${tid}`);
792
+ }
793
+
681
794
  // src/commands/accept.ts
682
795
  async function accept(changeIds) {
683
796
  const { socket, docs } = await openProject();
@@ -804,13 +917,18 @@ function usage() {
804
917
  console.log(" link --project <id> Link this repo to an Overleaf project\n");
805
918
  console.log("Read:");
806
919
  console.log(" pull [--out <dir>] Comments + tracked changes \u2192 sidecar\n");
920
+ console.log("Content (replaces the git bridge \u2014 review-safe):");
921
+ console.log(" fetch [--file <f>] [--dry-run] Overleaf text \u2192 local files (read-only)");
922
+ console.log(" upload <path\u2026> [--folder <name>] Upload figures / new files to Overleaf\n");
807
923
  console.log("Comments:");
808
924
  console.log(" comment --anchor <text> --message <text> [--doc <name>] [--nth <n>]");
809
925
  console.log(" reply --thread <id> --message <text> Reply to an existing thread");
810
926
  console.log(" resolve --thread <id> [--reopen] Resolve/reopen a thread");
811
- console.log(" delete-comment --thread <id> Delete a thread\n");
927
+ console.log(" delete-comment --thread <id> Delete a whole thread");
928
+ console.log(" delete-message --message-id <id> Delete a single message\n");
812
929
  console.log("Tracked changes:");
813
- console.log(" push [--file <f>] [--doc <name>] [--dry-run] Send local edits as suggestions");
930
+ console.log(" push [--file <f>] [--doc <name>] [--direct] [--dry-run]");
931
+ console.log(" Send local edits as tracked suggestions (--direct = plain edits)");
814
932
  console.log(" accept --change <id> [--change <id> \u2026] Accept collaborators\u2019 changes");
815
933
  console.log(" reject --change <id> [--change <id> \u2026] Reject collaborators\u2019 changes");
816
934
  console.log("\n(thread/change ids come from `pull`; --change accepts comma-separated lists too)");
@@ -840,8 +958,31 @@ async function main() {
840
958
  break;
841
959
  }
842
960
  case "push":
843
- await push({ file: getFlag("file"), docName: getFlag("doc"), dryRun: process.argv.includes("--dry-run") });
961
+ await push({
962
+ file: getFlag("file"),
963
+ docName: getFlag("doc"),
964
+ direct: process.argv.includes("--direct"),
965
+ dryRun: process.argv.includes("--dry-run")
966
+ });
967
+ break;
968
+ case "fetch":
969
+ await fetchDocs({ file: getFlag("file"), dryRun: process.argv.includes("--dry-run") });
844
970
  break;
971
+ case "upload": {
972
+ const argv = process.argv.slice(3);
973
+ const paths = [];
974
+ for (let i = 0; i < argv.length; i++) {
975
+ if (argv[i] === "--folder") {
976
+ i++;
977
+ continue;
978
+ }
979
+ if (argv[i].startsWith("--")) continue;
980
+ paths.push(argv[i]);
981
+ }
982
+ if (!paths.length) fail("upload requires at least one file path");
983
+ await upload(paths, getFlag("folder"));
984
+ break;
985
+ }
845
986
  case "comment": {
846
987
  const anchor = getFlag("anchor");
847
988
  const message = getFlag("message");
@@ -869,6 +1010,12 @@ async function main() {
869
1010
  await deleteComment(thread);
870
1011
  break;
871
1012
  }
1013
+ case "delete-message": {
1014
+ const messageId = getFlag("message-id");
1015
+ if (!messageId) fail("delete-message requires --message-id <id>");
1016
+ await deleteThreadMessage(messageId, getFlag("thread"));
1017
+ break;
1018
+ }
872
1019
  case "accept": {
873
1020
  const ids = getAll("change");
874
1021
  if (!ids.length) fail("accept requires --change <id>");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "overleaf-review",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "The missing review layer for Overleaf's Git bridge β€” sync comments and tracked changes between Overleaf and your local repo.",
5
5
  "type": "module",
6
6
  "license": "MIT",