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.
- package/README.md +36 -6
- package/dist/cli.js +154 -7
- 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
|
-
|
|
40
|
-
|
|
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
|
-
| `
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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]
|
|
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({
|
|
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