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.
- package/README.md +32 -3
- package/dist/cli.js +117 -4
- 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
|
-
| `
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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]
|
|
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({
|
|
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