overleaf-review 0.1.1 β 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.
- package/README.md +13 -2
- package/dist/cli.js +172 -42
- 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` / `
|
|
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
|
@@ -87,6 +87,9 @@ var config = {
|
|
|
87
87
|
|
|
88
88
|
// src/overleaf-socket.ts
|
|
89
89
|
import WebSocket from "ws";
|
|
90
|
+
function utf8Decode(binary) {
|
|
91
|
+
return Buffer.from(binary, "latin1").toString("utf8");
|
|
92
|
+
}
|
|
90
93
|
var OverleafSocket = class {
|
|
91
94
|
constructor(baseUrl, cookie, opts = {}) {
|
|
92
95
|
this.baseUrl = baseUrl;
|
|
@@ -146,15 +149,15 @@ var OverleafSocket = class {
|
|
|
146
149
|
const wsUrl = `${this.baseUrl.replace(/^http/, "ws")}/socket.io/1/websocket/${sid}`;
|
|
147
150
|
if (this.debug) console.log(`[socket] transports=${transports}; ws=${wsUrl}`);
|
|
148
151
|
this.ws = new WebSocket(wsUrl, { headers: this.browserHeaders });
|
|
149
|
-
await new Promise((resolve2,
|
|
152
|
+
await new Promise((resolve2, reject2) => {
|
|
150
153
|
this.ws.once("open", () => resolve2());
|
|
151
|
-
this.ws.once("error",
|
|
154
|
+
this.ws.once("error", reject2);
|
|
152
155
|
this.ws.once("unexpected-response", (_req, response) => {
|
|
153
156
|
let errBody = "";
|
|
154
157
|
response.on("data", (chunk) => errBody += chunk.toString());
|
|
155
158
|
response.on(
|
|
156
159
|
"end",
|
|
157
|
-
() =>
|
|
160
|
+
() => reject2(
|
|
158
161
|
new Error(
|
|
159
162
|
`ws upgrade rejected: ${response.statusCode} ${response.statusMessage}
|
|
160
163
|
headers: ${JSON.stringify(response.headers)}
|
|
@@ -164,7 +167,7 @@ var OverleafSocket = class {
|
|
|
164
167
|
);
|
|
165
168
|
});
|
|
166
169
|
});
|
|
167
|
-
this.ws.on("message", (data) => this.onFrame(data.toString()));
|
|
170
|
+
this.ws.on("message", (data) => this.onFrame(utf8Decode(data.toString())));
|
|
168
171
|
const intervalMs = (Number(hbTimeout) || 30) * 800;
|
|
169
172
|
this.heartbeat = setInterval(() => this.send("2::"), intervalMs);
|
|
170
173
|
if (this.debug) console.log("[socket] websocket open");
|
|
@@ -172,10 +175,10 @@ var OverleafSocket = class {
|
|
|
172
175
|
/** Emit an event and resolve with the server's ack payload (array of args). */
|
|
173
176
|
emit(name, args, timeoutMs = 15e3) {
|
|
174
177
|
const id = this.ackId++;
|
|
175
|
-
return new Promise((resolve2,
|
|
178
|
+
return new Promise((resolve2, reject2) => {
|
|
176
179
|
const timer = setTimeout(() => {
|
|
177
180
|
this.pendingAcks.delete(id);
|
|
178
|
-
|
|
181
|
+
reject2(new Error(`emit('${name}') timed out after ${timeoutMs}ms with no ack`));
|
|
179
182
|
}, timeoutMs);
|
|
180
183
|
this.pendingAcks.set(id, (payload) => {
|
|
181
184
|
clearTimeout(timer);
|
|
@@ -312,6 +315,26 @@ async function setThreadResolved(docId, threadId, reopen, csrf) {
|
|
|
312
315
|
}
|
|
313
316
|
return res.status;
|
|
314
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
|
+
}
|
|
315
338
|
async function validateSession(baseUrl, session2) {
|
|
316
339
|
const res = await fetch(`${baseUrl}/project`, {
|
|
317
340
|
headers: { Cookie: `overleaf_session2=${session2}`, "User-Agent": UA },
|
|
@@ -388,6 +411,7 @@ async function pull(outDir = ".overleaf") {
|
|
|
388
411
|
const line = offsetToLine(state.lines, ch.op.p);
|
|
389
412
|
changes.push({
|
|
390
413
|
doc: doc.path,
|
|
414
|
+
id: ch.id,
|
|
391
415
|
type: isInsert ? "insert" : "delete",
|
|
392
416
|
text: isInsert ? ch.op.i : ch.op.d,
|
|
393
417
|
line: line + 1,
|
|
@@ -415,6 +439,10 @@ function renderMarkdown(d) {
|
|
|
415
439
|
L.push(`# Overleaf review \u2014 ${d.project}`, "");
|
|
416
440
|
L.push(`_Pulled ${d.pulledAt} from project \`${d.projectId}\`._`, "");
|
|
417
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
|
+
);
|
|
418
446
|
const byDoc = (items) => {
|
|
419
447
|
const m = /* @__PURE__ */ new Map();
|
|
420
448
|
for (const it of items) (m.get(it.doc) ?? m.set(it.doc, []).get(it.doc)).push(it);
|
|
@@ -427,6 +455,7 @@ function renderMarkdown(d) {
|
|
|
427
455
|
for (const c of items) {
|
|
428
456
|
const flag = c.resolved ? " \u2014 \u2705 resolved" : "";
|
|
429
457
|
L.push(`#### \u{1F4AC} \u201C${c.anchor}\u201D \xB7 line ${c.line}${flag}`);
|
|
458
|
+
L.push(`\`thread ${c.threadId}\``);
|
|
430
459
|
L.push("```", c.context, "```");
|
|
431
460
|
for (const m of c.messages) {
|
|
432
461
|
L.push(`- **${m.author}**: ${m.content}`);
|
|
@@ -442,7 +471,9 @@ function renderMarkdown(d) {
|
|
|
442
471
|
L.push(`### ${doc}`, "");
|
|
443
472
|
for (const ch of items) {
|
|
444
473
|
const verb = ch.type === "insert" ? "inserted" : "deleted";
|
|
445
|
-
L.push(
|
|
474
|
+
L.push(
|
|
475
|
+
`- **${verb}** at line ${ch.line} by ${ch.author} \xB7 \`change ${ch.id}\`: \`${ch.text.replace(/\n/g, "\u23CE")}\``
|
|
476
|
+
);
|
|
446
477
|
L.push(" ```", ch.context.replace(/^/gm, " "), " ```");
|
|
447
478
|
}
|
|
448
479
|
L.push("");
|
|
@@ -600,6 +631,13 @@ async function comment(opts) {
|
|
|
600
631
|
console.log(`\u2705 Commented on "${opts.anchor}" in ${doc.name} (thread ${threadId})`);
|
|
601
632
|
}
|
|
602
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
|
+
|
|
603
641
|
// src/commands/resolve.ts
|
|
604
642
|
async function resolve(threadId, reopen = false) {
|
|
605
643
|
const { socket, docs } = await openProject();
|
|
@@ -622,6 +660,77 @@ async function resolve(threadId, reopen = false) {
|
|
|
622
660
|
console.log(`\u2705 Thread ${threadId} ${reopen ? "reopened" : "resolved"}`);
|
|
623
661
|
}
|
|
624
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
|
+
|
|
625
734
|
// src/commands/login.ts
|
|
626
735
|
import { createInterface } from "readline/promises";
|
|
627
736
|
async function login(opts) {
|
|
@@ -681,40 +790,44 @@ function getFlag(name) {
|
|
|
681
790
|
const i = process.argv.indexOf(`--${name}`);
|
|
682
791
|
return i >= 0 ? process.argv[i + 1] : void 0;
|
|
683
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
|
+
}
|
|
684
800
|
function usage() {
|
|
685
801
|
console.log("overleaf-review \u2014 sync Overleaf review data with git\n");
|
|
686
802
|
console.log("Setup:");
|
|
687
|
-
console.log("
|
|
688
|
-
console.log("
|
|
689
|
-
console.log("
|
|
690
|
-
console.log("
|
|
691
|
-
console.log("
|
|
692
|
-
console.log("
|
|
693
|
-
console.log("
|
|
694
|
-
console.log("
|
|
695
|
-
console.log("
|
|
696
|
-
console.log("
|
|
697
|
-
console.log("
|
|
698
|
-
console.log("
|
|
699
|
-
console.log("
|
|
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)");
|
|
700
817
|
}
|
|
701
818
|
async function main() {
|
|
702
819
|
const cmd = process.argv[2];
|
|
703
820
|
switch (cmd) {
|
|
704
|
-
case "login":
|
|
821
|
+
case "login":
|
|
705
822
|
await login({
|
|
706
823
|
cookie: getFlag("cookie"),
|
|
707
824
|
baseUrl: getFlag("base-url"),
|
|
708
825
|
browser: process.argv.includes("--browser")
|
|
709
826
|
});
|
|
710
827
|
break;
|
|
711
|
-
}
|
|
712
828
|
case "link": {
|
|
713
829
|
const project = getFlag("project");
|
|
714
|
-
if (!project)
|
|
715
|
-
console.error("link requires --project <id>");
|
|
716
|
-
process.exit(1);
|
|
717
|
-
}
|
|
830
|
+
if (!project) fail("link requires --project <id>");
|
|
718
831
|
link(project, getFlag("base-url"));
|
|
719
832
|
break;
|
|
720
833
|
}
|
|
@@ -726,40 +839,57 @@ async function main() {
|
|
|
726
839
|
);
|
|
727
840
|
break;
|
|
728
841
|
}
|
|
729
|
-
case "push":
|
|
842
|
+
case "push":
|
|
730
843
|
await push({ file: getFlag("file"), docName: getFlag("doc"), dryRun: process.argv.includes("--dry-run") });
|
|
731
844
|
break;
|
|
732
|
-
}
|
|
733
845
|
case "comment": {
|
|
734
846
|
const anchor = getFlag("anchor");
|
|
735
847
|
const message = getFlag("message");
|
|
736
|
-
if (!anchor || !message)
|
|
737
|
-
console.error("comment requires --anchor <text> and --message <text>");
|
|
738
|
-
process.exit(1);
|
|
739
|
-
}
|
|
848
|
+
if (!anchor || !message) fail("comment requires --anchor <text> and --message <text>");
|
|
740
849
|
const nthRaw = getFlag("nth");
|
|
741
|
-
await comment({
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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);
|
|
747
858
|
break;
|
|
748
859
|
}
|
|
749
860
|
case "resolve": {
|
|
750
861
|
const thread = getFlag("thread");
|
|
751
|
-
if (!thread)
|
|
752
|
-
console.error("resolve requires --thread <id>");
|
|
753
|
-
process.exit(1);
|
|
754
|
-
}
|
|
862
|
+
if (!thread) fail("resolve requires --thread <id>");
|
|
755
863
|
await resolve(thread, process.argv.includes("--reopen"));
|
|
756
864
|
break;
|
|
757
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
|
+
}
|
|
758
884
|
default:
|
|
759
885
|
usage();
|
|
760
886
|
process.exit(cmd ? 1 : 0);
|
|
761
887
|
}
|
|
762
888
|
}
|
|
889
|
+
function fail(msg) {
|
|
890
|
+
console.error(msg);
|
|
891
|
+
process.exit(1);
|
|
892
|
+
}
|
|
763
893
|
main().then(() => process.exit(0)).catch((e) => {
|
|
764
894
|
console.error(e instanceof Error ? e.message : e);
|
|
765
895
|
process.exit(1);
|
package/package.json
CHANGED