overleaf-review 0.1.2 β†’ 0.2.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 +13 -2
  2. package/dist/cli.js +168 -41
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -36,7 +36,9 @@ workflow and carries the review layer Git can't represent.
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
38
  docs by path (one file, or every changed `.tex` at once). `--dry-run` previews the exact ops.
39
- - πŸ’¬ **`comment` / `resolve`** β€” anchor a comment on any text, and resolve/reopen threads.
39
+ - πŸ’¬ **`comment` / `reply` / `resolve` / `delete-comment`** β€” full comment-thread control:
40
+ start a thread, reply, resolve/reopen, or delete.
41
+ - βœ… **`accept` / `reject`** β€” act on your collaborators' tracked changes from the CLI.
40
42
  - πŸ”‘ **`login`** β€” validated auth stored outside your repo (chmod 600); `--browser` mode is
41
43
  institutional-SSO friendly.
42
44
 
@@ -73,7 +75,13 @@ overleaf-review resolve --thread <id> # thread ids come from `pull`
73
75
  | `pull [--out <dir>]` | Read comments + tracked changes into a sidecar |
74
76
  | `push [--file <f>] [--doc <name>] [--dry-run]` | Send local edits as tracked-change suggestions (all changed `.tex` if no `--file`) |
75
77
  | `comment --anchor <text> --message <text> [--doc <name>] [--nth <n>]` | Add a comment anchored on the given text |
78
+ | `reply --thread <id> --message <text>` | Reply to an existing comment thread |
76
79
  | `resolve --thread <id> [--reopen]` | Resolve (or reopen) a comment thread |
80
+ | `delete-comment --thread <id>` | Delete a comment thread |
81
+ | `accept --change <id> …` | Accept collaborators' tracked change(s) |
82
+ | `reject --change <id> …` | Reject collaborators' tracked change(s) |
83
+
84
+ Thread and change ids are listed by `pull` (in `.overleaf/reviews.md`).
77
85
 
78
86
  ## 🧠 How it works
79
87
 
@@ -96,10 +104,13 @@ own account and projects. Use at your own risk.
96
104
 
97
105
  ## πŸ—ΊοΈ Roadmap
98
106
 
99
- - Reply-to-thread and delete-comment
100
107
  - A `pull` that also writes doc content (not just the review sidecar)
101
108
  - Trusted-publishing CI
102
109
 
110
+ ## πŸ“ Changelog
111
+
112
+ Version history and notes are on the [Releases page](https://github.com/michu5696/overleaf-review/releases).
113
+
103
114
  ## πŸ“„ License
104
115
 
105
116
  MIT Β© [Miguel Castellano](https://github.com/michu5696)
package/dist/cli.js CHANGED
@@ -149,15 +149,15 @@ var OverleafSocket = class {
149
149
  const wsUrl = `${this.baseUrl.replace(/^http/, "ws")}/socket.io/1/websocket/${sid}`;
150
150
  if (this.debug) console.log(`[socket] transports=${transports}; ws=${wsUrl}`);
151
151
  this.ws = new WebSocket(wsUrl, { headers: this.browserHeaders });
152
- await new Promise((resolve2, reject) => {
152
+ await new Promise((resolve2, reject2) => {
153
153
  this.ws.once("open", () => resolve2());
154
- this.ws.once("error", reject);
154
+ this.ws.once("error", reject2);
155
155
  this.ws.once("unexpected-response", (_req, response) => {
156
156
  let errBody = "";
157
157
  response.on("data", (chunk) => errBody += chunk.toString());
158
158
  response.on(
159
159
  "end",
160
- () => reject(
160
+ () => reject2(
161
161
  new Error(
162
162
  `ws upgrade rejected: ${response.statusCode} ${response.statusMessage}
163
163
  headers: ${JSON.stringify(response.headers)}
@@ -175,10 +175,10 @@ var OverleafSocket = class {
175
175
  /** Emit an event and resolve with the server's ack payload (array of args). */
176
176
  emit(name, args, timeoutMs = 15e3) {
177
177
  const id = this.ackId++;
178
- return new Promise((resolve2, reject) => {
178
+ return new Promise((resolve2, reject2) => {
179
179
  const timer = setTimeout(() => {
180
180
  this.pendingAcks.delete(id);
181
- reject(new Error(`emit('${name}') timed out after ${timeoutMs}ms with no ack`));
181
+ reject2(new Error(`emit('${name}') timed out after ${timeoutMs}ms with no ack`));
182
182
  }, timeoutMs);
183
183
  this.pendingAcks.set(id, (payload) => {
184
184
  clearTimeout(timer);
@@ -315,6 +315,26 @@ async function setThreadResolved(docId, threadId, reopen, csrf) {
315
315
  }
316
316
  return res.status;
317
317
  }
318
+ async function acceptChanges(docId, changeIds, csrf) {
319
+ const res = await fetch(
320
+ `${config.baseUrl}/project/${config.projectId}/doc/${docId}/changes/accept`,
321
+ {
322
+ method: "POST",
323
+ headers: headers({ "X-CSRF-Token": csrf, "Content-Type": "application/json" }),
324
+ body: JSON.stringify({ change_ids: changeIds })
325
+ }
326
+ );
327
+ if (!res.ok) throw new Error(`acceptChanges ${res.status}: ${(await res.text()).slice(0, 200)}`);
328
+ return res.status;
329
+ }
330
+ async function deleteThread(docId, threadId, csrf) {
331
+ const res = await fetch(
332
+ `${config.baseUrl}/project/${config.projectId}/doc/${docId}/thread/${threadId}`,
333
+ { method: "DELETE", headers: headers({ "X-CSRF-Token": csrf }) }
334
+ );
335
+ if (!res.ok) throw new Error(`deleteThread ${res.status}: ${(await res.text()).slice(0, 200)}`);
336
+ return res.status;
337
+ }
318
338
  async function validateSession(baseUrl, session2) {
319
339
  const res = await fetch(`${baseUrl}/project`, {
320
340
  headers: { Cookie: `overleaf_session2=${session2}`, "User-Agent": UA },
@@ -391,6 +411,7 @@ async function pull(outDir = ".overleaf") {
391
411
  const line = offsetToLine(state.lines, ch.op.p);
392
412
  changes.push({
393
413
  doc: doc.path,
414
+ id: ch.id,
394
415
  type: isInsert ? "insert" : "delete",
395
416
  text: isInsert ? ch.op.i : ch.op.d,
396
417
  line: line + 1,
@@ -418,6 +439,10 @@ function renderMarkdown(d) {
418
439
  L.push(`# Overleaf review \u2014 ${d.project}`, "");
419
440
  L.push(`_Pulled ${d.pulledAt} from project \`${d.projectId}\`._`, "");
420
441
  L.push(`**${d.comments.length}** comment(s), **${d.changes.length}** tracked change(s).`, "");
442
+ 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>`.",
444
+ ""
445
+ );
421
446
  const byDoc = (items) => {
422
447
  const m = /* @__PURE__ */ new Map();
423
448
  for (const it of items) (m.get(it.doc) ?? m.set(it.doc, []).get(it.doc)).push(it);
@@ -430,6 +455,7 @@ function renderMarkdown(d) {
430
455
  for (const c of items) {
431
456
  const flag = c.resolved ? " \u2014 \u2705 resolved" : "";
432
457
  L.push(`#### \u{1F4AC} \u201C${c.anchor}\u201D \xB7 line ${c.line}${flag}`);
458
+ L.push(`\`thread ${c.threadId}\``);
433
459
  L.push("```", c.context, "```");
434
460
  for (const m of c.messages) {
435
461
  L.push(`- **${m.author}**: ${m.content}`);
@@ -445,7 +471,9 @@ function renderMarkdown(d) {
445
471
  L.push(`### ${doc}`, "");
446
472
  for (const ch of items) {
447
473
  const verb = ch.type === "insert" ? "inserted" : "deleted";
448
- L.push(`- **${verb}** at line ${ch.line} by ${ch.author}: \`${ch.text.replace(/\n/g, "\u23CE")}\``);
474
+ L.push(
475
+ `- **${verb}** at line ${ch.line} by ${ch.author} \xB7 \`change ${ch.id}\`: \`${ch.text.replace(/\n/g, "\u23CE")}\``
476
+ );
449
477
  L.push(" ```", ch.context.replace(/^/gm, " "), " ```");
450
478
  }
451
479
  L.push("");
@@ -603,6 +631,13 @@ async function comment(opts) {
603
631
  console.log(`\u2705 Commented on "${opts.anchor}" in ${doc.name} (thread ${threadId})`);
604
632
  }
605
633
 
634
+ // src/commands/reply.ts
635
+ async function reply(threadId, message) {
636
+ const csrf = await getCsrfToken();
637
+ await postThreadMessage(threadId, message, csrf);
638
+ console.log(`\u2705 Replied to thread ${threadId}`);
639
+ }
640
+
606
641
  // src/commands/resolve.ts
607
642
  async function resolve(threadId, reopen = false) {
608
643
  const { socket, docs } = await openProject();
@@ -625,6 +660,77 @@ async function resolve(threadId, reopen = false) {
625
660
  console.log(`\u2705 Thread ${threadId} ${reopen ? "reopened" : "resolved"}`);
626
661
  }
627
662
 
663
+ // src/commands/delete-comment.ts
664
+ async function deleteComment(threadId) {
665
+ const { socket, docs } = await openProject();
666
+ let docId;
667
+ for (const doc of docs) {
668
+ const state = await joinDoc(socket, doc._id);
669
+ if ((state.ranges.comments ?? []).some((c) => c.op?.t === threadId)) {
670
+ docId = doc._id;
671
+ break;
672
+ }
673
+ }
674
+ socket.close();
675
+ if (!docId) throw new Error(`thread ${threadId} not found in any doc's comments`);
676
+ const csrf = await getCsrfToken();
677
+ await deleteThread(docId, threadId, csrf);
678
+ console.log(`\u2705 Deleted comment thread ${threadId}`);
679
+ }
680
+
681
+ // src/commands/accept.ts
682
+ async function accept(changeIds) {
683
+ const { socket, docs } = await openProject();
684
+ const byDoc = /* @__PURE__ */ new Map();
685
+ for (const doc of docs) {
686
+ const state = await joinDoc(socket, doc._id);
687
+ const here = (state.ranges.changes ?? []).filter((c) => changeIds.includes(c.id)).map((c) => c.id);
688
+ if (here.length) byDoc.set(doc._id, here);
689
+ }
690
+ socket.close();
691
+ const found = [...byDoc.values()].flat();
692
+ const missing = changeIds.filter((id) => !found.includes(id));
693
+ if (missing.length) console.log(`\u26A0\uFE0F not found (already accepted/rejected?): ${missing.join(", ")}`);
694
+ if (!found.length) throw new Error("no matching tracked changes found");
695
+ const csrf = await getCsrfToken();
696
+ for (const [docId, ids] of byDoc) await acceptChanges(docId, ids, csrf);
697
+ console.log(`\u2705 Accepted ${found.length} tracked change(s)`);
698
+ }
699
+
700
+ // src/commands/reject.ts
701
+ async function reject(changeIds) {
702
+ const { socket, docs } = await openProject();
703
+ const docOf = /* @__PURE__ */ new Map();
704
+ for (const doc of docs) {
705
+ const state = await joinDoc(socket, doc._id);
706
+ for (const c of state.ranges.changes ?? []) {
707
+ if (changeIds.includes(c.id)) docOf.set(c.id, doc._id);
708
+ }
709
+ }
710
+ let rejected = 0;
711
+ for (const id of changeIds) {
712
+ const docId = docOf.get(id);
713
+ if (!docId) {
714
+ console.log(`\u26A0\uFE0F not found (already accepted/rejected?): ${id}`);
715
+ continue;
716
+ }
717
+ const state = await joinDoc(socket, docId);
718
+ const c = (state.ranges.changes ?? []).find((x) => x.id === id);
719
+ if (!c) continue;
720
+ const op = typeof c.op?.i === "string" ? { p: c.op.p, d: c.op.i } : { p: c.op.p, i: c.op.d };
721
+ const update = { doc: docId, op: [op], v: state.version, meta: {} };
722
+ const ack = await socket.emit("applyOtUpdate", [docId, update], 15e3);
723
+ if (ack?.[0]) {
724
+ socket.close();
725
+ throw new Error(`Overleaf rejected the inverse op for ${id}: ${JSON.stringify(ack[0])}`);
726
+ }
727
+ rejected++;
728
+ }
729
+ socket.close();
730
+ if (!rejected) throw new Error("no matching tracked changes found");
731
+ console.log(`\u2705 Rejected ${rejected} tracked change(s)`);
732
+ }
733
+
628
734
  // src/commands/login.ts
629
735
  import { createInterface } from "readline/promises";
630
736
  async function login(opts) {
@@ -684,40 +790,44 @@ function getFlag(name) {
684
790
  const i = process.argv.indexOf(`--${name}`);
685
791
  return i >= 0 ? process.argv[i + 1] : void 0;
686
792
  }
793
+ function getAll(name) {
794
+ const out = [];
795
+ process.argv.forEach((a, i) => {
796
+ if (a === `--${name}` && process.argv[i + 1]) out.push(process.argv[i + 1]);
797
+ });
798
+ return out.flatMap((v) => v.split(",")).map((s) => s.trim()).filter(Boolean);
799
+ }
687
800
  function usage() {
688
801
  console.log("overleaf-review \u2014 sync Overleaf review data with git\n");
689
802
  console.log("Setup:");
690
- console.log(" overleaf-review login [--cookie <val>] [--browser]");
691
- console.log(" Authenticate and store your session (--browser is SSO-friendly)");
692
- console.log(" overleaf-review link --project <id>");
693
- console.log(" Link this repo to an Overleaf project (writes .overleaf/config.json)\n");
694
- console.log("Review:");
695
- console.log(" overleaf-review pull [--out <dir>]");
696
- console.log(" Read comments + tracked changes into a sidecar (.overleaf/)");
697
- console.log(" overleaf-review push [--file <f>] [--doc <name>] [--dry-run]");
698
- console.log(" Send local edits as tracked-change suggestions (all changed .tex if no --file)");
699
- console.log(" overleaf-review comment --anchor <text> --message <text> [--doc <name>] [--nth <n>]");
700
- console.log(" Add a comment anchored on the given text");
701
- console.log(" overleaf-review resolve --thread <id> [--reopen]");
702
- console.log(" Resolve (or reopen) a comment thread (ids come from `pull`)");
803
+ console.log(" login [--cookie <val>] [--browser] Authenticate (SSO-friendly --browser)");
804
+ console.log(" link --project <id> Link this repo to an Overleaf project\n");
805
+ console.log("Read:");
806
+ console.log(" pull [--out <dir>] Comments + tracked changes \u2192 sidecar\n");
807
+ console.log("Comments:");
808
+ console.log(" comment --anchor <text> --message <text> [--doc <name>] [--nth <n>]");
809
+ console.log(" reply --thread <id> --message <text> Reply to an existing thread");
810
+ console.log(" resolve --thread <id> [--reopen] Resolve/reopen a thread");
811
+ console.log(" delete-comment --thread <id> Delete a thread\n");
812
+ console.log("Tracked changes:");
813
+ console.log(" push [--file <f>] [--doc <name>] [--dry-run] Send local edits as suggestions");
814
+ console.log(" accept --change <id> [--change <id> \u2026] Accept collaborators\u2019 changes");
815
+ console.log(" reject --change <id> [--change <id> \u2026] Reject collaborators\u2019 changes");
816
+ console.log("\n(thread/change ids come from `pull`; --change accepts comma-separated lists too)");
703
817
  }
704
818
  async function main() {
705
819
  const cmd = process.argv[2];
706
820
  switch (cmd) {
707
- case "login": {
821
+ case "login":
708
822
  await login({
709
823
  cookie: getFlag("cookie"),
710
824
  baseUrl: getFlag("base-url"),
711
825
  browser: process.argv.includes("--browser")
712
826
  });
713
827
  break;
714
- }
715
828
  case "link": {
716
829
  const project = getFlag("project");
717
- if (!project) {
718
- console.error("link requires --project <id>");
719
- process.exit(1);
720
- }
830
+ if (!project) fail("link requires --project <id>");
721
831
  link(project, getFlag("base-url"));
722
832
  break;
723
833
  }
@@ -729,40 +839,57 @@ async function main() {
729
839
  );
730
840
  break;
731
841
  }
732
- case "push": {
842
+ case "push":
733
843
  await push({ file: getFlag("file"), docName: getFlag("doc"), dryRun: process.argv.includes("--dry-run") });
734
844
  break;
735
- }
736
845
  case "comment": {
737
846
  const anchor = getFlag("anchor");
738
847
  const message = getFlag("message");
739
- if (!anchor || !message) {
740
- console.error("comment requires --anchor <text> and --message <text>");
741
- process.exit(1);
742
- }
848
+ if (!anchor || !message) fail("comment requires --anchor <text> and --message <text>");
743
849
  const nthRaw = getFlag("nth");
744
- await comment({
745
- docName: getFlag("doc"),
746
- anchor,
747
- message,
748
- occurrence: nthRaw ? Number(nthRaw) : void 0
749
- });
850
+ await comment({ docName: getFlag("doc"), anchor, message, occurrence: nthRaw ? Number(nthRaw) : void 0 });
851
+ break;
852
+ }
853
+ case "reply": {
854
+ const thread = getFlag("thread");
855
+ const message = getFlag("message");
856
+ if (!thread || !message) fail("reply requires --thread <id> and --message <text>");
857
+ await reply(thread, message);
750
858
  break;
751
859
  }
752
860
  case "resolve": {
753
861
  const thread = getFlag("thread");
754
- if (!thread) {
755
- console.error("resolve requires --thread <id>");
756
- process.exit(1);
757
- }
862
+ if (!thread) fail("resolve requires --thread <id>");
758
863
  await resolve(thread, process.argv.includes("--reopen"));
759
864
  break;
760
865
  }
866
+ case "delete-comment": {
867
+ const thread = getFlag("thread");
868
+ if (!thread) fail("delete-comment requires --thread <id>");
869
+ await deleteComment(thread);
870
+ break;
871
+ }
872
+ case "accept": {
873
+ const ids = getAll("change");
874
+ if (!ids.length) fail("accept requires --change <id>");
875
+ await accept(ids);
876
+ break;
877
+ }
878
+ case "reject": {
879
+ const ids = getAll("change");
880
+ if (!ids.length) fail("reject requires --change <id>");
881
+ await reject(ids);
882
+ break;
883
+ }
761
884
  default:
762
885
  usage();
763
886
  process.exit(cmd ? 1 : 0);
764
887
  }
765
888
  }
889
+ function fail(msg) {
890
+ console.error(msg);
891
+ process.exit(1);
892
+ }
766
893
  main().then(() => process.exit(0)).catch((e) => {
767
894
  console.error(e instanceof Error ? e.message : e);
768
895
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "overleaf-review",
3
- "version": "0.1.2",
3
+ "version": "0.2.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",