overleaf-review 0.6.0 → 0.6.2

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
@@ -212,6 +212,13 @@ Grouping applies to new pushes; it does not consolidate or accept suggestions al
212
212
  ### Holistic paragraph rewrites over reviewed suggestions and comments
213
213
 
214
214
  Use this when a paragraph rewrite intentionally incorporates or supersedes existing suggestions.
215
+
216
+ To regroup existing fragmented suggestions **without changing their proposed wording**, set
217
+ `before` and `after` to the same paragraph text and explicitly list the suggestion IDs in
218
+ `supersedes`. Keep the local file unchanged for that block. This is supported from v0.6.1;
219
+ the proposal must still differ from the underlying text obtained by rejecting those suggestions.
220
+ Affected comments still require explicit anchor mappings, and all stale-plan and verification
221
+ checks still apply. Ordinary replacements without selected suggestions cannot be no-ops.
215
222
  It differs from consolidation: the proposed text may change, and selected older suggestion cards
216
223
  are replaced by a new pending revision under the authenticated account.
217
224
 
@@ -332,6 +339,17 @@ project, verifies consolidation and protected review state, and checks that exis
332
339
  were unchanged. It does not require or spoof another account. Foreign-author preservation is also
333
340
  covered by the upstream model check. Never run this probe against a manuscript project.
334
341
 
342
+ From v0.6.2, rejection and underlying-text reconstruction account for insertions and deletions
343
+ at the same position, including multiple co-located deletions. The undo first restores selected
344
+ deletions, then removes insertions at their adjusted positions; exact-text and range verification
345
+ remain mandatory. Recreate unsubmitted plans from a fresh snapshot after upgrading, since the
346
+ operation ordering has changed. Do not re-plan to bypass an uncertain earlier submission.
347
+
348
+ `npm run probe:rejection-model` checks this ordering against pinned upstream range logic in both
349
+ tracking modes, including consolidation and reviewed paragraph rewrites. Add `-- --backup <file>`
350
+ to check a saved full snapshot (or a plan containing `backup`) offline. The backup stays local;
351
+ the probe fetches only public upstream code and never connects to an Overleaf project.
352
+
335
353
  ### Push overrides and submission
336
354
 
337
355
  Use `--allow-overlap` only after inspecting those tracked changes. `--unsafe-no-base` is a deliberate
@@ -924,11 +924,11 @@ function buildReviewEdits(source, target, options = {}) {
924
924
  }
925
925
  return groups;
926
926
  }
927
- function validateExplicitEdits(source, value) {
927
+ function validateExplicitEdits(source, value, validation = {}) {
928
928
  if (!Array.isArray(value) || !value.length) throw new Error("Explicit blocks must be a nonempty array.");
929
929
  let previousEnd = -1;
930
930
  return value.map((edit) => {
931
- if (!edit || !Number.isSafeInteger(edit.start) || !Number.isSafeInteger(edit.end) || edit.start < 0 || edit.end < edit.start || edit.end > source.length || typeof edit.text !== "string" || edit.start <= previousEnd || source.slice(edit.start, edit.end) === edit.text) {
931
+ if (!edit || !Number.isSafeInteger(edit.start) || !Number.isSafeInteger(edit.end) || edit.start < 0 || edit.end < edit.start || edit.end > source.length || typeof edit.text !== "string" || edit.start <= previousEnd || !validation.allowUnchanged && source.slice(edit.start, edit.end) === edit.text) {
932
932
  throw new Error("Explicit blocks must be valid, ordered, separated replacements with a text change.");
933
933
  }
934
934
  previousEnd = edit.end;
@@ -941,7 +941,7 @@ function applyTextEdits(source, edits) {
941
941
  }
942
942
  return source;
943
943
  }
944
- function parseReplacementManifest(base, value) {
944
+ function parseReplacementManifest(base, value, validation = {}) {
945
945
  const manifest = value;
946
946
  if (!Array.isArray(manifest?.replacements) || !manifest.replacements.length) {
947
947
  throw new Error("Replacement manifest requires a nonempty replacements array.");
@@ -962,10 +962,10 @@ function parseReplacementManifest(base, value) {
962
962
  if (start === void 0) throw new Error("Replacement occurrence is absent from the saved base.");
963
963
  return { start, end: start + block.before.length, text: block.after };
964
964
  }).sort((a, b) => a.start - b.start);
965
- return validateExplicitEdits(base, edits);
965
+ return validateExplicitEdits(base, edits, validation);
966
966
  }
967
- function bindExplicitEdits(base, local, live, edits, options) {
968
- const checked = validateExplicitEdits(base, edits);
967
+ function bindExplicitEdits(base, local, live, edits, options, validation = {}) {
968
+ const checked = validateExplicitEdits(base, edits, validation);
969
969
  if (applyTextEdits(base, checked) !== local) {
970
970
  throw new Error("Replacement manifest must describe every local change exactly.");
971
971
  }
@@ -1285,17 +1285,26 @@ function applyUndo(text, op, changeId) {
1285
1285
  }
1286
1286
  function buildRejectionPlan(currentText, ranges, requestedIds) {
1287
1287
  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);
1288
+ const fragments = ranges.map((range, index) => ({ range, index })).filter(({ range }) => requested.has(range.id)).map((fragment) => ({ ...fragment, inverse: inverseOf(fragment.range) }));
1289
+ 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);
1290
+ const restorations = fragments.filter((fragment) => "i" in fragment.inverse);
1290
1291
  const operations = [];
1291
1292
  let expectedText = currentText;
1292
- for (const range of fragments) {
1293
- const inverse = inverseOf(range);
1293
+ let restorePosition = -1;
1294
+ let restoredAtPosition = 0;
1295
+ for (const { range, inverse: originalInverse } of fragments) {
1296
+ if ("i" in originalInverse && originalInverse.p !== restorePosition) {
1297
+ restorePosition = originalInverse.p;
1298
+ restoredAtPosition = 0;
1299
+ }
1300
+ 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;
1301
+ const inverse = { ...originalInverse, p: originalInverse.p + shift };
1294
1302
  expectedText = applyUndo(expectedText, inverse, range.id);
1295
1303
  operations.push(inverse);
1304
+ if ("i" in inverse) restoredAtPosition += inverse.i.length;
1296
1305
  }
1297
1306
  return {
1298
- changeIds: changeIdsInRanges(fragments),
1307
+ changeIds: changeIdsInRanges(fragments.map((fragment) => fragment.range)),
1299
1308
  fragmentCount: fragments.length,
1300
1309
  operations,
1301
1310
  expectedText
@@ -2299,7 +2308,7 @@ ${doc.localPath} \u2192 ${doc.docPath} (v${doc.liveVersion}): ${doc.ops.length}
2299
2308
  }
2300
2309
  async function push(opts) {
2301
2310
  validatePushOptions(opts);
2302
- const { isReviewedManifest, createReviewedFilePlan, applyReviewedFilePlan, REVIEWED_FILE_KIND } = await import("./reviewed-replace-A7C4UNJS.js");
2311
+ const { isReviewedManifest, createReviewedFilePlan, applyReviewedFilePlan, REVIEWED_FILE_KIND } = await import("./reviewed-replace-CW447HNG.js");
2303
2312
  if (opts.edits && isReviewedManifest(JSON.parse(readFileSync6(workspaceReadPath(opts.edits), "utf8")))) {
2304
2313
  await createReviewedFilePlan(opts);
2305
2314
  return;
package/dist/cli.js CHANGED
@@ -39,7 +39,7 @@ import {
39
39
  uploadFile,
40
40
  validateSession,
41
41
  workspaceWritePath
42
- } from "./chunk-TWIDV42A.js";
42
+ } from "./chunk-FBISFRYR.js";
43
43
 
44
44
  // src/commands/pull.ts
45
45
  import { writeFileSync, mkdirSync } from "fs";
@@ -34,7 +34,7 @@ import {
34
34
  workspaceRelativePath,
35
35
  workspaceWritePath,
36
36
  writeJsonAtomic
37
- } from "./chunk-TWIDV42A.js";
37
+ } from "./chunk-FBISFRYR.js";
38
38
 
39
39
  // src/commands/reviewed-replace.ts
40
40
  import { readFileSync } from "fs";
@@ -315,10 +315,13 @@ function resolveReviewedBlocks(base, local, live, value) {
315
315
  const manifest = value;
316
316
  if (!Array.isArray(manifest?.replacements) || !manifest.replacements.length) throw new Error("Reviewed manifest requires replacements.");
317
317
  const resolved = manifest.replacements.map((block) => {
318
- const [edit] = parseReplacementManifest(base, { replacements: [{ before: block.before, after: block.after, occurrence: block.occurrence }] });
318
+ const allowUnchanged = Array.isArray(block.supersedes) && block.supersedes.length > 0;
319
+ const [edit] = parseReplacementManifest(base, {
320
+ replacements: [{ before: block.before, after: block.after, occurrence: block.occurrence }]
321
+ }, { allowUnchanged });
319
322
  return { ...edit, supersedes: block.supersedes ?? [], reason: block.reason, comments: block.comments ?? [] };
320
323
  }).sort((a, b) => a.start - b.start);
321
- const mapped = bindExplicitEdits(base, local, live, resolved, {}).explicitEdits;
324
+ const mapped = bindExplicitEdits(base, local, live, resolved, {}, { allowUnchanged: true }).explicitEdits;
322
325
  return resolved.map((block, index) => ({ ...block, ...mapped[index] }));
323
326
  }
324
327
  async function createReviewedFilePlan(opts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "overleaf-review",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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,7 @@
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",
47
48
  "typecheck": "tsc --noEmit",
48
49
  "test": "tsx --test test/*.test.ts",
49
50
  "check": "npm run typecheck && npm test && npm run build"