overleaf-review 0.6.1 → 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:
@@ -339,6 +406,17 @@ project, verifies consolidation and protected review state, and checks that exis
339
406
  were unchanged. It does not require or spoof another account. Foreign-author preservation is also
340
407
  covered by the upstream model check. Never run this probe against a manuscript project.
341
408
 
409
+ From v0.6.2, rejection and underlying-text reconstruction account for insertions and deletions
410
+ at the same position, including multiple co-located deletions. The undo first restores selected
411
+ deletions, then removes insertions at their adjusted positions; exact-text and range verification
412
+ remain mandatory. Recreate unsubmitted plans from a fresh snapshot after upgrading, since the
413
+ operation ordering has changed. Do not re-plan to bypass an uncertain earlier submission.
414
+
415
+ `npm run probe:rejection-model` checks this ordering against pinned upstream range logic in both
416
+ tracking modes, including consolidation and reviewed paragraph rewrites. Add `-- --backup <file>`
417
+ to check a saved full snapshot (or a plan containing `backup`) offline. The backup stays local;
418
+ the probe fetches only public upstream code and never connects to an Overleaf project.
419
+
342
420
  ### Push overrides and submission
343
421
 
344
422
  Use `--allow-overlap` only after inspecting those tracked changes. `--unsafe-no-base` is a deliberate
@@ -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}`,
@@ -1285,17 +1304,26 @@ function applyUndo(text, op, changeId) {
1285
1304
  }
1286
1305
  function buildRejectionPlan(currentText, ranges, requestedIds) {
1287
1306
  const requested = new Set(uniqueChangeIds(requestedIds));
1288
- const fragments = ranges.filter((range) => requested.has(range.id));
1289
- fragments.sort((a, b) => b.op.p - a.op.p);
1307
+ const fragments = ranges.map((range, index) => ({ range, index })).filter(({ range }) => requested.has(range.id)).map((fragment) => ({ ...fragment, inverse: inverseOf(fragment.range) }));
1308
+ fragments.sort((a, b) => Number(b.range.op.d !== void 0) - Number(a.range.op.d !== void 0) || b.range.op.p - a.range.op.p || a.index - b.index);
1309
+ const restorations = fragments.filter((fragment) => "i" in fragment.inverse);
1290
1310
  const operations = [];
1291
1311
  let expectedText = currentText;
1292
- for (const range of fragments) {
1293
- const inverse = inverseOf(range);
1312
+ let restorePosition = -1;
1313
+ let restoredAtPosition = 0;
1314
+ for (const { range, inverse: originalInverse } of fragments) {
1315
+ if ("i" in originalInverse && originalInverse.p !== restorePosition) {
1316
+ restorePosition = originalInverse.p;
1317
+ restoredAtPosition = 0;
1318
+ }
1319
+ const shift = "d" in originalInverse ? restorations.reduce((sum, restored) => sum + (restored.range.op.p <= range.op.p ? restored.range.op.d.length : 0), 0) : restoredAtPosition;
1320
+ const inverse = { ...originalInverse, p: originalInverse.p + shift };
1294
1321
  expectedText = applyUndo(expectedText, inverse, range.id);
1295
1322
  operations.push(inverse);
1323
+ if ("i" in inverse) restoredAtPosition += inverse.i.length;
1296
1324
  }
1297
1325
  return {
1298
- changeIds: changeIdsInRanges(fragments),
1326
+ changeIds: changeIdsInRanges(fragments.map((fragment) => fragment.range)),
1299
1327
  fragmentCount: fragments.length,
1300
1328
  operations,
1301
1329
  expectedText
@@ -2299,7 +2327,7 @@ ${doc.localPath} \u2192 ${doc.docPath} (v${doc.liveVersion}): ${doc.ops.length}
2299
2327
  }
2300
2328
  async function push(opts) {
2301
2329
  validatePushOptions(opts);
2302
- const { isReviewedManifest, createReviewedFilePlan, applyReviewedFilePlan, REVIEWED_FILE_KIND } = await import("./reviewed-replace-4PCULETF.js");
2330
+ const { isReviewedManifest, createReviewedFilePlan, applyReviewedFilePlan, REVIEWED_FILE_KIND } = await import("./reviewed-replace-QOIDOJGV.js");
2303
2331
  if (opts.edits && isReviewedManifest(JSON.parse(readFileSync6(workspaceReadPath(opts.edits), "utf8")))) {
2304
2332
  await createReviewedFilePlan(opts);
2305
2333
  return;
@@ -2688,6 +2716,7 @@ export {
2688
2716
  setThreadResolved,
2689
2717
  acceptChanges,
2690
2718
  uploadFile,
2719
+ editThreadMessage,
2691
2720
  deleteMessage,
2692
2721
  deleteThread,
2693
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
- workspaceWritePath
42
- } from "./chunk-JRXROCS6.js";
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>");
@@ -34,7 +34,7 @@ import {
34
34
  workspaceRelativePath,
35
35
  workspaceWritePath,
36
36
  writeJsonAtomic
37
- } from "./chunk-JRXROCS6.js";
37
+ } from "./chunk-3VDCWUZ7.js";
38
38
 
39
39
  // src/commands/reviewed-replace.ts
40
40
  import { readFileSync } from "fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "overleaf-review",
3
- "version": "0.6.1",
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",
@@ -44,6 +44,8 @@
44
44
  "probe:review-live": "tsx src/probes/05-review-live.ts",
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
+ "probe:rejection-model": "tsx src/probes/08-rejection-model.ts",
48
+ "probe:message-edit-live": "tsx src/probes/09-message-edit-live.ts",
47
49
  "typecheck": "tsc --noEmit",
48
50
  "test": "tsx --test test/*.test.ts",
49
51
  "check": "npm run typecheck && npm test && npm run build"