overleaf-review 0.6.2 → 0.7.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
CHANGED
|
@@ -136,6 +136,9 @@ first safe push. If an upgraded repository already has unpushed edits, copy or s
|
|
|
136
136
|
| `push [--file <f>] [--doc <name>] [--direct] [--dry-run] [--plan-out <path>] [--plan <path>] [--allow-overlap] [--unsafe-no-base] [--acknowledge-ambiguous]` | Safely merge and send local edits (all changed `.tex` if no `--file`) |
|
|
137
137
|
| `comment --anchor <text> --message <text> [--doc <name>] [--nth <n>] [--force]` | Add an anchored comment; recent identical retries are skipped unless forced |
|
|
138
138
|
| `reply --thread <id> --message <text> [--force]` | Reply to a thread; recent identical retries are skipped unless forced |
|
|
139
|
+
| `edit-message --thread <id> --message-id <id> --before <text> --message <text> --out <plan.json>` | Plan an in-place edit of your own existing message; sends nothing |
|
|
140
|
+
| `edit-message --manifest <edits.json> --out <plan.json>` | Preview a batch of exact before/after message edits |
|
|
141
|
+
| `edit-message --plan <plan.json>` | Apply the saved message-edit plan with readback and per-message receipts |
|
|
139
142
|
| `resolve --thread <id> [--reopen]` | Resolve (or reopen) a comment thread |
|
|
140
143
|
| `delete-comment --thread <id>` | Delete a whole comment thread |
|
|
141
144
|
| `delete-message --message-id <id> [--thread <id>]` | Delete a single message within a thread |
|
|
@@ -144,6 +147,70 @@ first safe push. If an upgraded repository already has unpushed edits, copy or s
|
|
|
144
147
|
|
|
145
148
|
Thread and change ids are listed by `pull` (in `.overleaf/reviews.md`).
|
|
146
149
|
|
|
150
|
+
### Editing existing replies in place (v0.7.0)
|
|
151
|
+
|
|
152
|
+
Use `edit-message` for correcting your own existing comments or replies without deleting and
|
|
153
|
+
reposting them. This uses Overleaf's native edit endpoint: message IDs, authorship and original
|
|
154
|
+
posting times stay intact; Overleaf may add/update `edited_at`. It sends no manuscript operations,
|
|
155
|
+
does not move comment anchors, and does not resolve/reopen threads. Other authors' messages cannot
|
|
156
|
+
be edited. A material change to an answer someone has already responded to may be clearer as a
|
|
157
|
+
new follow-up message instead.
|
|
158
|
+
|
|
159
|
+
For one message, supply its exact current text and proposed replacement:
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
overleaf-review edit-message --thread <thread-id> --message-id <message-id> \
|
|
163
|
+
--before 'Original reply.' --message 'Corrected reply.' --out message-plan.json
|
|
164
|
+
overleaf-review edit-message --plan message-plan.json
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
For longer replies or batches, prepare `message-edits.json` using exact IDs from `reviews.json`:
|
|
168
|
+
|
|
169
|
+
```json
|
|
170
|
+
{
|
|
171
|
+
"edits": [
|
|
172
|
+
{
|
|
173
|
+
"threadId": "0123456789abcdef01234567",
|
|
174
|
+
"messageId": "0123456789abcdef01234568",
|
|
175
|
+
"before": "Original reply.",
|
|
176
|
+
"after": "Corrected reply.\nA second paragraph, if needed."
|
|
177
|
+
}
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
overleaf-review edit-message --manifest message-edits.json --out message-plan.json
|
|
184
|
+
# Inspect all before/after edits before applying:
|
|
185
|
+
overleaf-review edit-message --plan message-plan.json
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Planning is read-only on Overleaf. It binds the server, project, authenticated author, exact
|
|
189
|
+
message IDs/text and full targeted threads. All targets are checked before the first write and
|
|
190
|
+
again before each subsequent write. Changes to a targeted thread—including new replies, edits,
|
|
191
|
+
or resolution changes—stop the batch; activity in unrelated threads does not block it. Apply
|
|
192
|
+
accepts only the saved plan, without content overrides or a force flag.
|
|
193
|
+
|
|
194
|
+
Every edit is verified by reading back the same message ID and replacement content, with unchanged
|
|
195
|
+
authorship, posting time, other messages and thread metadata. A receipt in `.overleaf/receipts/`
|
|
196
|
+
retains the plan's complete original threads, before/after text, and per-message progress.
|
|
197
|
+
|
|
198
|
+
Batches are sequential, **not atomic**. If a later request is definitively rejected, resubmitting
|
|
199
|
+
the same plan rechecks all targets and resumes without repeating verified edits. A lost response
|
|
200
|
+
is recovered only when exact readback proves the intended result. If the outcome stays uncertain,
|
|
201
|
+
there is no blind resend, rollback, or delete-and-repost fallback; even a different plan targeting
|
|
202
|
+
that thread is blocked until the original attempt is reconciled. Keep the original plan/receipts.
|
|
203
|
+
|
|
204
|
+
Use a quiet editing window: the native endpoint has no expected-content/version condition, so the
|
|
205
|
+
preflight cannot prevent an edit racing between the last read and the POST. Verification detects
|
|
206
|
+
observable discrepancies but cannot undo an already-sent overwrite or prove that no transient
|
|
207
|
+
concurrent edit was overwritten. No server-side compare-and-swap or atomic batch is claimed.
|
|
208
|
+
|
|
209
|
+
`npm run probe:message-edit-live -- --project <test-id> --confirm-test-project` is an opt-in live
|
|
210
|
+
check using the built CLI. It creates a new scratch document, posts and edits two test replies,
|
|
211
|
+
verifies retry behavior, and checks that existing documents and threads were unchanged. Never
|
|
212
|
+
run this probe against a manuscript project.
|
|
213
|
+
|
|
147
214
|
### Safe pushes
|
|
148
215
|
|
|
149
216
|
A normal push compares three versions of each document:
|
|
@@ -509,6 +509,25 @@ async function uploadFile(folderId, name, bytes, csrf) {
|
|
|
509
509
|
if (!res.ok) throw new Error(`upload ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
510
510
|
return res.json();
|
|
511
511
|
}
|
|
512
|
+
async function editThreadMessage(threadId, messageId, content, csrf) {
|
|
513
|
+
if (![threadId, messageId].every((id) => /^[0-9a-f]{24}$/i.test(id)) || typeof content !== "string" || !content.trim()) {
|
|
514
|
+
throw new Error("Message editing requires valid thread/message IDs and nonempty content.");
|
|
515
|
+
}
|
|
516
|
+
const res = await fetch(
|
|
517
|
+
`${config.baseUrl}/project/${config.projectId}/thread/${threadId}/messages/${messageId}/edit`,
|
|
518
|
+
{
|
|
519
|
+
method: "POST",
|
|
520
|
+
headers: headers({ "Content-Type": "application/json", "X-CSRF-Token": csrf }),
|
|
521
|
+
body: JSON.stringify({ content }),
|
|
522
|
+
redirect: "error",
|
|
523
|
+
signal: AbortSignal.timeout(15e3)
|
|
524
|
+
}
|
|
525
|
+
);
|
|
526
|
+
if (!res.ok) {
|
|
527
|
+
const body = await res.text();
|
|
528
|
+
throw new RestRequestError(`editThreadMessage ${res.status}: ${body.slice(0, 300)}`, res.status, body.slice(0, 1e3));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
512
531
|
async function deleteMessage(threadId, messageId, csrf) {
|
|
513
532
|
const res = await fetch(
|
|
514
533
|
`${config.baseUrl}/project/${config.projectId}/thread/${threadId}/messages/${messageId}`,
|
|
@@ -2308,7 +2327,7 @@ ${doc.localPath} \u2192 ${doc.docPath} (v${doc.liveVersion}): ${doc.ops.length}
|
|
|
2308
2327
|
}
|
|
2309
2328
|
async function push(opts) {
|
|
2310
2329
|
validatePushOptions(opts);
|
|
2311
|
-
const { isReviewedManifest, createReviewedFilePlan, applyReviewedFilePlan, REVIEWED_FILE_KIND } = await import("./reviewed-replace-
|
|
2330
|
+
const { isReviewedManifest, createReviewedFilePlan, applyReviewedFilePlan, REVIEWED_FILE_KIND } = await import("./reviewed-replace-QOIDOJGV.js");
|
|
2312
2331
|
if (opts.edits && isReviewedManifest(JSON.parse(readFileSync6(workspaceReadPath(opts.edits), "utf8")))) {
|
|
2313
2332
|
await createReviewedFilePlan(opts);
|
|
2314
2333
|
return;
|
|
@@ -2697,6 +2716,7 @@ export {
|
|
|
2697
2716
|
setThreadResolved,
|
|
2698
2717
|
acceptChanges,
|
|
2699
2718
|
uploadFile,
|
|
2719
|
+
editThreadMessage,
|
|
2700
2720
|
deleteMessage,
|
|
2701
2721
|
deleteThread,
|
|
2702
2722
|
validateSession,
|
package/dist/cli.js
CHANGED
|
@@ -13,8 +13,10 @@ import {
|
|
|
13
13
|
consolidatePreview,
|
|
14
14
|
deleteMessage,
|
|
15
15
|
deleteThread,
|
|
16
|
+
editThreadMessage,
|
|
16
17
|
findRecentIdenticalMessage,
|
|
17
18
|
fingerprintRanges,
|
|
19
|
+
getAuthenticatedUserId,
|
|
18
20
|
getCsrfToken,
|
|
19
21
|
getThreads,
|
|
20
22
|
joinDoc,
|
|
@@ -32,14 +34,17 @@ import {
|
|
|
32
34
|
sha256,
|
|
33
35
|
snapshotRelativePath,
|
|
34
36
|
snapshotTimestamp,
|
|
37
|
+
stableJson,
|
|
35
38
|
threadMessageId,
|
|
36
39
|
threadMessages,
|
|
37
40
|
uniqueChangeIds,
|
|
38
41
|
updateReceipt,
|
|
39
42
|
uploadFile,
|
|
40
43
|
validateSession,
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
workspaceReadPath,
|
|
45
|
+
workspaceWritePath,
|
|
46
|
+
writeJsonAtomic
|
|
47
|
+
} from "./chunk-3VDCWUZ7.js";
|
|
43
48
|
|
|
44
49
|
// src/commands/pull.ts
|
|
45
50
|
import { writeFileSync, mkdirSync } from "fs";
|
|
@@ -948,6 +953,248 @@ async function reply(threadId, message, options = {}) {
|
|
|
948
953
|
}
|
|
949
954
|
}
|
|
950
955
|
|
|
956
|
+
// src/commands/edit-message.ts
|
|
957
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
958
|
+
|
|
959
|
+
// src/lib/message-edit.ts
|
|
960
|
+
var MESSAGE_EDIT_KIND = "overleaf-review-message-edit-plan";
|
|
961
|
+
var idPattern = /^[0-9a-f]{24}$/i;
|
|
962
|
+
function messageAuthor(message) {
|
|
963
|
+
const user = message.user;
|
|
964
|
+
const ids = [message.user_id, user?.id, user?._id].filter((id) => id !== void 0);
|
|
965
|
+
if (new Set(ids).size > 1) throw new Error("Conflicting message author IDs.");
|
|
966
|
+
return ids[0];
|
|
967
|
+
}
|
|
968
|
+
function parseMessageEdits(value) {
|
|
969
|
+
const manifest = value;
|
|
970
|
+
if (!manifest || Object.keys(manifest).some((key) => key !== "edits") || !Array.isArray(manifest.edits) || !manifest.edits.length) {
|
|
971
|
+
throw new Error("Message edit manifest requires only a nonempty edits array.");
|
|
972
|
+
}
|
|
973
|
+
const seen = /* @__PURE__ */ new Set();
|
|
974
|
+
return manifest.edits.map((edit) => {
|
|
975
|
+
if (!edit || Object.keys(edit).some((key) => !["threadId", "messageId", "before", "after"].includes(key)) || !idPattern.test(edit.threadId) || !idPattern.test(edit.messageId) || typeof edit.before !== "string" || typeof edit.after !== "string" || !edit.after.trim() || edit.before === edit.after || seen.has(edit.messageId)) throw new Error("Each edit needs unique exact IDs, before text, and different nonempty after text.");
|
|
976
|
+
seen.add(edit.messageId);
|
|
977
|
+
return { threadId: edit.threadId, messageId: edit.messageId, before: edit.before, after: edit.after };
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
function checkedThread(value, threadId) {
|
|
981
|
+
const thread = value;
|
|
982
|
+
if (!thread || typeof thread !== "object" || Array.isArray(thread) || !Array.isArray(thread.messages)) throw new Error(`Thread ${threadId} is absent or invalid.`);
|
|
983
|
+
const ids = /* @__PURE__ */ new Set();
|
|
984
|
+
for (const message of thread.messages) {
|
|
985
|
+
const id = threadMessageId(message);
|
|
986
|
+
if (!message || !id || !idPattern.test(id) || typeof message.content !== "string" || ids.has(id) || message.id !== void 0 && message._id !== void 0 && message.id !== message._id) throw new Error(`Thread ${threadId} contains invalid or duplicate message IDs.`);
|
|
987
|
+
ids.add(id);
|
|
988
|
+
}
|
|
989
|
+
return structuredClone(thread);
|
|
990
|
+
}
|
|
991
|
+
function createMessageEditPlan(baseUrl, projectId, authorId, allThreads, input) {
|
|
992
|
+
if (!idPattern.test(projectId) || !idPattern.test(authorId) || !/^https?:\/\//.test(baseUrl) || new URL(baseUrl).origin !== baseUrl) throw new Error("Message edit plan requires exact server origin, project and authenticated author IDs.");
|
|
993
|
+
const edits = parseMessageEdits(input);
|
|
994
|
+
const threads = {};
|
|
995
|
+
for (const edit of edits) {
|
|
996
|
+
const thread = threads[edit.threadId] ??= checkedThread(allThreads[edit.threadId], edit.threadId);
|
|
997
|
+
const message = thread.messages.find((message2) => threadMessageId(message2) === edit.messageId);
|
|
998
|
+
if (!message) throw new Error(`Message ${edit.messageId} is absent from thread ${edit.threadId}.`);
|
|
999
|
+
if (messageAuthor(message) !== authorId) throw new Error(`Message ${edit.messageId} is not owned by the authenticated account.`);
|
|
1000
|
+
if (message.content !== edit.before) throw new Error(`Message ${edit.messageId} differs from the expected before text; refresh and re-plan.`);
|
|
1001
|
+
}
|
|
1002
|
+
return { kind: MESSAGE_EDIT_KIND, schemaVersion: 1, baseUrl, projectId, authorId, edits, threads };
|
|
1003
|
+
}
|
|
1004
|
+
function validateMessageEditPlan(value) {
|
|
1005
|
+
const plan = value;
|
|
1006
|
+
if (!plan || plan.kind !== MESSAGE_EDIT_KIND || plan.schemaVersion !== 1 || !plan.threads) throw new Error("Invalid message-edit plan.");
|
|
1007
|
+
const expected = createMessageEditPlan(plan.baseUrl, plan.projectId, plan.authorId, plan.threads, { edits: plan.edits });
|
|
1008
|
+
if (stableJson(plan) !== stableJson(expected)) throw new Error("Message-edit plan is malformed or inconsistent.");
|
|
1009
|
+
return plan;
|
|
1010
|
+
}
|
|
1011
|
+
function expectedThreads(plan, progress) {
|
|
1012
|
+
const threads = structuredClone(plan.threads);
|
|
1013
|
+
progress.forEach((item, index) => {
|
|
1014
|
+
if (item.status === "verified") {
|
|
1015
|
+
if (!item.observed) throw new Error("Verified edit has no saved readback; inspect receipt.");
|
|
1016
|
+
const edit = plan.edits[index];
|
|
1017
|
+
const messages = threads[edit.threadId].messages;
|
|
1018
|
+
const target = messages.findIndex((message) => threadMessageId(message) === edit.messageId);
|
|
1019
|
+
assertEditedMessage(messages[target], item.observed, edit);
|
|
1020
|
+
messages[target] = structuredClone(item.observed);
|
|
1021
|
+
}
|
|
1022
|
+
});
|
|
1023
|
+
return threads;
|
|
1024
|
+
}
|
|
1025
|
+
function assertEditedMessage(original, observed, edit) {
|
|
1026
|
+
if (observed.edited_at !== void 0 && !(typeof observed.edited_at === "number" && Number.isFinite(observed.edited_at) && observed.edited_at > 0) && !(typeof observed.edited_at === "string" && Number.isFinite(Date.parse(observed.edited_at)))) throw new Error("Invalid edited_at on message readback.");
|
|
1027
|
+
const allowed = { ...original, content: edit.after };
|
|
1028
|
+
if (observed.edited_at === void 0) delete allowed.edited_at;
|
|
1029
|
+
else allowed.edited_at = observed.edited_at;
|
|
1030
|
+
if (stableJson(allowed) !== stableJson(observed)) throw new Error(`Edited message ${edit.messageId} identity, author, timestamp or content failed verification.`);
|
|
1031
|
+
}
|
|
1032
|
+
function assertThreads(expected, live) {
|
|
1033
|
+
for (const [id, thread] of Object.entries(expected)) {
|
|
1034
|
+
if (stableJson(checkedThread(live[id], id)) !== stableJson(thread)) {
|
|
1035
|
+
throw new Error(`Thread ${id} or one of its messages changed; no further edits sent.`);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
function editedReadback(expected, live, edit) {
|
|
1040
|
+
const thread = checkedThread(live[edit.threadId], edit.threadId);
|
|
1041
|
+
const message = thread.messages.find((message2) => threadMessageId(message2) === edit.messageId);
|
|
1042
|
+
if (!message || message.content !== edit.after) throw new Error(`Edited message ${edit.messageId} not visible on readback.`);
|
|
1043
|
+
const allowed = structuredClone(expected);
|
|
1044
|
+
const index = allowed[edit.threadId].messages.findIndex((candidate) => threadMessageId(candidate) === edit.messageId);
|
|
1045
|
+
assertEditedMessage(allowed[edit.threadId].messages[index], message, edit);
|
|
1046
|
+
allowed[edit.threadId].messages[index] = message;
|
|
1047
|
+
assertThreads(allowed, live);
|
|
1048
|
+
return message;
|
|
1049
|
+
}
|
|
1050
|
+
async function submitMessageEditPlan(value, transport) {
|
|
1051
|
+
const plan = validateMessageEditPlan(value);
|
|
1052
|
+
if (transport.baseUrl !== plan.baseUrl || transport.projectId !== plan.projectId || await transport.accountId() !== plan.authorId) {
|
|
1053
|
+
throw new Error("Message-edit server, project or authenticated account differs from the plan.");
|
|
1054
|
+
}
|
|
1055
|
+
const planHash = sha256(stableJson(plan));
|
|
1056
|
+
const history = readReceipts(transport.receiptsDir).filter((handle) => handle.receipt.operation === "edit-message" && handle.receipt.details.baseUrl === plan.baseUrl && handle.receipt.details.projectId === plan.projectId);
|
|
1057
|
+
const conflict = history.find((handle) => handle.receipt.details.planHash !== planHash && handle.receipt.details.progress?.some((item, index) => item.status === "sending" && plan.edits.some((edit) => edit.threadId === handle.receipt.details.plan?.edits[index]?.threadId)));
|
|
1058
|
+
if (conflict) throw new Error(`An earlier edit has an uncertain outcome: ${conflict.path}. Reconcile that plan first.`);
|
|
1059
|
+
const prior = history.find((handle) => handle.receipt.details.planHash === planHash);
|
|
1060
|
+
if (prior && stableJson(prior.receipt.details.plan) !== stableJson(plan)) throw new Error(`Receipt plan mismatch; inspect ${prior.path}.`);
|
|
1061
|
+
let receipt = prior ?? beginReceipt("edit-message", {
|
|
1062
|
+
baseUrl: plan.baseUrl,
|
|
1063
|
+
projectId: plan.projectId,
|
|
1064
|
+
authorId: plan.authorId,
|
|
1065
|
+
planHash,
|
|
1066
|
+
plan,
|
|
1067
|
+
progress: plan.edits.map(() => ({ status: "pending" }))
|
|
1068
|
+
}, { receiptsDir: transport.receiptsDir });
|
|
1069
|
+
const progress = structuredClone(receipt.receipt.details.progress);
|
|
1070
|
+
if (!Array.isArray(progress) || progress.length !== plan.edits.length || progress.some((item) => !item || !["pending", "sending", "verified", "failed"].includes(item.status))) throw new Error(`Invalid edit progress; inspect ${receipt.path}.`);
|
|
1071
|
+
const alreadyApplied = receipt.receipt.status === "succeeded";
|
|
1072
|
+
let expected = expectedThreads(plan, progress);
|
|
1073
|
+
const verify = async (edit) => {
|
|
1074
|
+
let error;
|
|
1075
|
+
const attempts = Math.max(1, Math.min(20, transport.verificationAttempts ?? 3));
|
|
1076
|
+
for (let i = 0; i < attempts; i++) {
|
|
1077
|
+
try {
|
|
1078
|
+
return editedReadback(expected, await transport.threads(), edit);
|
|
1079
|
+
} catch (e) {
|
|
1080
|
+
error = e;
|
|
1081
|
+
}
|
|
1082
|
+
if (i + 1 < attempts) await new Promise((resolve2) => setTimeout(resolve2, transport.verificationIntervalMs ?? 200));
|
|
1083
|
+
}
|
|
1084
|
+
throw error;
|
|
1085
|
+
};
|
|
1086
|
+
try {
|
|
1087
|
+
for (const [index, item] of progress.entries()) {
|
|
1088
|
+
if (item.status !== "sending") continue;
|
|
1089
|
+
item.observed = await verify(plan.edits[index]);
|
|
1090
|
+
item.status = "verified";
|
|
1091
|
+
receipt = updateReceipt(receipt, "in_progress", { progress });
|
|
1092
|
+
expected = expectedThreads(plan, progress);
|
|
1093
|
+
}
|
|
1094
|
+
assertThreads(expected, await transport.threads());
|
|
1095
|
+
for (const [index, edit] of plan.edits.entries()) {
|
|
1096
|
+
const item = progress[index];
|
|
1097
|
+
if (item.status === "verified") continue;
|
|
1098
|
+
assertThreads(expected, await transport.threads());
|
|
1099
|
+
item.status = "sending";
|
|
1100
|
+
receipt = updateReceipt(receipt, "in_progress", { progress, phase: "editing", currentMessageId: edit.messageId });
|
|
1101
|
+
let sendError;
|
|
1102
|
+
try {
|
|
1103
|
+
await transport.edit(edit);
|
|
1104
|
+
} catch (error) {
|
|
1105
|
+
sendError = error;
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
item.observed = await verify(edit);
|
|
1109
|
+
} catch (error) {
|
|
1110
|
+
if (sendError instanceof RestRequestError && sendError.status >= 400 && sendError.status < 500 && sendError.status !== 408) {
|
|
1111
|
+
item.status = "failed";
|
|
1112
|
+
}
|
|
1113
|
+
throw sendError ?? error;
|
|
1114
|
+
}
|
|
1115
|
+
item.status = "verified";
|
|
1116
|
+
receipt = updateReceipt(receipt, "in_progress", { progress, ...sendError ? { recoveredTransportError: String(sendError) } : {} });
|
|
1117
|
+
expected = expectedThreads(plan, progress);
|
|
1118
|
+
}
|
|
1119
|
+
assertThreads(expected, await transport.threads());
|
|
1120
|
+
receipt = updateReceipt(receipt, "succeeded", { progress, phase: "complete", verifiedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
1121
|
+
return { receiptPath: receipt.path, alreadyApplied, verifiedCount: progress.filter((item) => item.status === "verified").length };
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
receipt = updateReceipt(receipt, progress.some((item) => item.status === "sending") ? "ambiguous" : "failed", {
|
|
1124
|
+
progress,
|
|
1125
|
+
phase: "stopped",
|
|
1126
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1127
|
+
});
|
|
1128
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)} Inspect receipt: ${receipt.path}. No automatic rollback or blind retry.`, { cause: error });
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// src/commands/edit-message.ts
|
|
1133
|
+
function parseEditMessageArgs(args) {
|
|
1134
|
+
const allowed = ["--thread", "--message-id", "--before", "--message", "--manifest", "--out", "--plan"];
|
|
1135
|
+
const flags = {};
|
|
1136
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
1137
|
+
const key = args[i];
|
|
1138
|
+
const value = args[i + 1];
|
|
1139
|
+
if (!allowed.includes(key) || Object.hasOwn(flags, key) || value === void 0 || value.startsWith("--")) {
|
|
1140
|
+
throw new Error("Invalid edit-message arguments: only explicit planning flags or --plan <file> are supported; no force/delete fallback.");
|
|
1141
|
+
}
|
|
1142
|
+
flags[key] = value;
|
|
1143
|
+
}
|
|
1144
|
+
if (Object.hasOwn(flags, "--plan")) {
|
|
1145
|
+
if (Object.keys(flags).length !== 1 || !flags["--plan"]) throw new Error("Apply accepts only --plan <file>; no overrides.");
|
|
1146
|
+
return { plan: flags["--plan"] };
|
|
1147
|
+
}
|
|
1148
|
+
if (!flags["--out"]) throw new Error("Plan first: edit-message requires --out <plan.json>; apply separately with --plan.");
|
|
1149
|
+
if (Object.hasOwn(flags, "--manifest")) {
|
|
1150
|
+
if (Object.keys(flags).length !== 2 || !flags["--manifest"]) throw new Error("Batch planning accepts only --manifest <file> --out <plan.json>.");
|
|
1151
|
+
return { manifest: flags["--manifest"], out: flags["--out"] };
|
|
1152
|
+
}
|
|
1153
|
+
if (Object.keys(flags).length !== 5 || !["--thread", "--message-id", "--before", "--message"].every((key) => Object.hasOwn(flags, key))) {
|
|
1154
|
+
throw new Error("Single-message planning requires --thread, --message-id, --before, --message and --out.");
|
|
1155
|
+
}
|
|
1156
|
+
return { out: flags["--out"], edits: { edits: [{ threadId: flags["--thread"], messageId: flags["--message-id"], before: flags["--before"], after: flags["--message"] }] } };
|
|
1157
|
+
}
|
|
1158
|
+
async function editMessageCommand(args) {
|
|
1159
|
+
const options = parseEditMessageArgs(args);
|
|
1160
|
+
const lock = acquireMutationLock(config.projectId);
|
|
1161
|
+
try {
|
|
1162
|
+
if ("plan" in options) {
|
|
1163
|
+
const plan = validateMessageEditPlan(JSON.parse(readFileSync3(workspaceReadPath(options.plan), "utf8")));
|
|
1164
|
+
if (plan.baseUrl !== config.baseUrl || plan.projectId !== config.projectId) throw new Error("Message edit plan belongs to another server or project.");
|
|
1165
|
+
const csrf = await getCsrfToken();
|
|
1166
|
+
const result = await submitMessageEditPlan(plan, {
|
|
1167
|
+
baseUrl: config.baseUrl,
|
|
1168
|
+
projectId: config.projectId,
|
|
1169
|
+
accountId: getAuthenticatedUserId,
|
|
1170
|
+
threads: getThreads,
|
|
1171
|
+
edit: (edit) => editThreadMessage(edit.threadId, edit.messageId, edit.after, csrf)
|
|
1172
|
+
});
|
|
1173
|
+
console.log(`${result.alreadyApplied ? "Already completed; nothing resent." : "Verified in-place edits."} ${result.verifiedCount} message(s).`);
|
|
1174
|
+
console.log(`Before/after audit receipt: ${result.receiptPath}`);
|
|
1175
|
+
} else {
|
|
1176
|
+
const inputPath = options.manifest ? workspaceReadPath(options.manifest) : void 0;
|
|
1177
|
+
const outputPath = workspaceWritePath(options.out);
|
|
1178
|
+
if (inputPath === outputPath) throw new Error("Plan output must not overwrite its input manifest.");
|
|
1179
|
+
const edits = parseMessageEdits(inputPath ? JSON.parse(readFileSync3(inputPath, "utf8")) : options.edits);
|
|
1180
|
+
const authorId = await getAuthenticatedUserId();
|
|
1181
|
+
const plan = createMessageEditPlan(config.baseUrl, config.projectId, authorId, await getThreads(), { edits });
|
|
1182
|
+
writeJsonAtomic(outputPath, plan);
|
|
1183
|
+
console.log(`Message-edit plan saved to ${options.out}; nothing sent.`);
|
|
1184
|
+
for (const edit of plan.edits) {
|
|
1185
|
+
console.log(`
|
|
1186
|
+
Thread ${edit.threadId}, message ${edit.messageId}
|
|
1187
|
+
Before: ${JSON.stringify(edit.before)}
|
|
1188
|
+
After: ${JSON.stringify(edit.after)}`);
|
|
1189
|
+
}
|
|
1190
|
+
console.log("Native in-place edits only. No manuscript, anchors, thread resolution, deletion or reposting.");
|
|
1191
|
+
console.log("Apply during a quiet editing window; a batch is sequential, not atomic.");
|
|
1192
|
+
}
|
|
1193
|
+
} finally {
|
|
1194
|
+
lock.release();
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
951
1198
|
// src/commands/resolve.ts
|
|
952
1199
|
async function resolve(threadId, reopen = false) {
|
|
953
1200
|
const mutationLock = acquireMutationLock(config.projectId);
|
|
@@ -1547,6 +1794,9 @@ function usage() {
|
|
|
1547
1794
|
console.log("Comments:");
|
|
1548
1795
|
console.log(" comment --anchor <text> --message <text> [--doc <name>] [--nth <n>] [--force]");
|
|
1549
1796
|
console.log(" reply --thread <id> --message <text> [--force]");
|
|
1797
|
+
console.log(" edit-message --thread <id> --message-id <id> --before <text> --message <text> --out <plan.json>");
|
|
1798
|
+
console.log(" --manifest <edits.json> --out <plan.json> Plan a batch instead");
|
|
1799
|
+
console.log(" edit-message --plan <plan.json> Apply verified in-place edits");
|
|
1550
1800
|
console.log(" resolve --thread <id> [--reopen] Resolve/reopen a thread");
|
|
1551
1801
|
console.log(" delete-comment --thread <id> Delete a whole thread");
|
|
1552
1802
|
console.log(" delete-message --message-id <id> Delete a single message\n");
|
|
@@ -1736,6 +1986,9 @@ async function main() {
|
|
|
1736
1986
|
await reply(thread, message, { force: process.argv.includes("--force") });
|
|
1737
1987
|
break;
|
|
1738
1988
|
}
|
|
1989
|
+
case "edit-message":
|
|
1990
|
+
await editMessageCommand(process.argv.slice(3));
|
|
1991
|
+
break;
|
|
1739
1992
|
case "resolve": {
|
|
1740
1993
|
const thread = getFlag("thread");
|
|
1741
1994
|
if (!thread) fail("resolve requires --thread <id>");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "overleaf-review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"probe:reviewed-model": "tsx src/probes/06-reviewed-model.ts",
|
|
46
46
|
"probe:reviewed-live": "tsx src/probes/07-reviewed-live.ts",
|
|
47
47
|
"probe:rejection-model": "tsx src/probes/08-rejection-model.ts",
|
|
48
|
+
"probe:message-edit-live": "tsx src/probes/09-message-edit-live.ts",
|
|
48
49
|
"typecheck": "tsc --noEmit",
|
|
49
50
|
"test": "tsx --test test/*.test.ts",
|
|
50
51
|
"check": "npm run typecheck && npm test && npm run build"
|