overleaf-review 0.2.1 β†’ 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 +32 -3
  2. package/dist/cli.js +117 -4
  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.
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.
39
43
  - πŸ’¬ **`comment` / `reply` / `resolve` / `delete-comment` / `delete-message`** β€” full comment
40
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,7 +100,9 @@ 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 |
@@ -105,7 +134,7 @@ own account and projects. Use at your own risk.
105
134
 
106
135
  ## πŸ—ΊοΈ Roadmap
107
136
 
108
- - A `pull` that also writes doc content (not just the review sidecar)
137
+ - File rename / delete (currently done in the Overleaf UI)
109
138
  - Trusted-publishing CI
110
139
 
111
140
  ## πŸ“ Changelog
package/dist/cli.js CHANGED
@@ -327,6 +327,19 @@ 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
+ }
330
343
  async function deleteMessage(threadId, messageId, csrf) {
331
344
  const res = await fetch(
332
345
  `${config.baseUrl}/project/${config.projectId}/thread/${threadId}/messages/${messageId}`,
@@ -567,6 +580,9 @@ async function push(opts) {
567
580
  socket.close();
568
581
  return;
569
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
+ );
570
586
  for (const pl of plans) {
571
587
  const ins = pl.ops.filter((o) => o.i != null).length;
572
588
  const del = pl.ops.filter((o) => o.d != null).length;
@@ -582,7 +598,8 @@ ${pl.file} \u2192 ${pl.doc.path} (v${pl.version}): ${pl.ops.length} op(s), ${ins
582
598
  }
583
599
  socket.on("otUpdateError", (a) => console.log("!! otUpdateError:", JSON.stringify(a)));
584
600
  for (const pl of plans) {
585
- 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 };
586
603
  const ack = await socket.emit("applyOtUpdate", [pl.doc._id, update], 2e4);
587
604
  if (ack?.[0]) {
588
605
  socket.close();
@@ -596,12 +613,81 @@ ${pl.file} \u2192 ${pl.doc.path} (v${pl.version}): ${pl.ops.length} op(s), ${ins
596
613
  }
597
614
  socket.close();
598
615
  const totalOps = plans.reduce((n, p) => n + p.ops.length, 0);
616
+ const mode = opts.direct ? "direct edit(s)" : "tracked suggestion(s)";
599
617
  console.log(
600
618
  `
601
- \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.`
602
657
  );
603
658
  }
604
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
+
605
691
  // src/commands/comment.ts
606
692
  import { randomBytes as randomBytes2 } from "crypto";
607
693
  async function comment(opts) {
@@ -831,6 +917,9 @@ function usage() {
831
917
  console.log(" link --project <id> Link this repo to an Overleaf project\n");
832
918
  console.log("Read:");
833
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");
834
923
  console.log("Comments:");
835
924
  console.log(" comment --anchor <text> --message <text> [--doc <name>] [--nth <n>]");
836
925
  console.log(" reply --thread <id> --message <text> Reply to an existing thread");
@@ -838,7 +927,8 @@ function usage() {
838
927
  console.log(" delete-comment --thread <id> Delete a whole thread");
839
928
  console.log(" delete-message --message-id <id> Delete a single message\n");
840
929
  console.log("Tracked changes:");
841
- 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)");
842
932
  console.log(" accept --change <id> [--change <id> \u2026] Accept collaborators\u2019 changes");
843
933
  console.log(" reject --change <id> [--change <id> \u2026] Reject collaborators\u2019 changes");
844
934
  console.log("\n(thread/change ids come from `pull`; --change accepts comma-separated lists too)");
@@ -868,8 +958,31 @@ async function main() {
868
958
  break;
869
959
  }
870
960
  case "push":
871
- 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") });
872
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
+ }
873
986
  case "comment": {
874
987
  const anchor = getFlag("anchor");
875
988
  const message = getFlag("message");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "overleaf-review",
3
- "version": "0.2.1",
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",