From dce457892f83d02cd1b3428d2c19487e7eb68e11 Mon Sep 17 00:00:00 2001 From: JohannKaspar <63501587+JohannKaspar@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:40:26 +0100 Subject: [PATCH 1/2] List tracked changes and refuse edits that would touch one Edits that change text inside a collaborator's pending suggestion silently rewrite or remove it, and Overleaf mails the author that the suggestion was rejected. joinDoc already returns ranges.changes; parse them, expose them (listTrackedChanges, CLI 'changes', MCP overleaf_list_tracked_changes), and check every text edit against fresh ranges before sending. The check throws TrackedChangeConflictError; SDK/CLI can skip it with overwriteTrackedChanges, the MCP server has no override so an agent asks a human. --- CLAUDE.md | 24 ++++++- README.md | 1 + src/cli.ts | 23 +++++- src/client.ts | 6 ++ src/errors.ts | 17 +++++ src/index.ts | 2 + src/mcpServer.ts | 18 ++++- src/session.ts | 80 ++++++++++++++++++++- src/trackedChanges.ts | 136 ++++++++++++++++++++++++++++++++++++ src/types.ts | 27 +++++++ test/clientOps.test.ts | 85 +++++++++++++++++++++- test/trackedChanges.test.ts | 66 +++++++++++++++++ 12 files changed, 477 insertions(+), 8 deletions(-) create mode 100644 src/trackedChanges.ts create mode 100644 test/trackedChanges.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index ce6bf64..431a3ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,7 @@ src/ protocol.ts Socket.IO 0.9 frame encode/decode (pure) ot.ts ShareJS ops, offset<->rowcol, diff, wire encoding, git-blob hash (pure) logParser.ts parse pdfTeX/LaTeX output.log into errors/warnings/bad-boxes (pure) + trackedChanges.ts parse ranges.changes; find the suggestions an op would touch (pure) document.ts Document: local mirror of one open doc (text + version + generation) types.ts shared types (Entity, ProjectSummary, ProjectInfo, CompileResult, ...) realtime.ts RealtimeConnection(projectId): WS transport, acks, heartbeat, reconnect @@ -150,6 +151,24 @@ UTF-8 (do not decode). The resolve/reopen/delete thread routes return 404 on ove Claudeleaf ships only create + list. Exposed as `addComment`/`listComments` (SDK) and `overleaf_add_comment`/`overleaf_list_comments` (MCP). +**Reading tracked changes and the overwrite guard.** `ranges.changes` entries are +`{id, op:{p,i}|{p,d}, metadata:{user_id,ts}}`: a tracked insertion is snapshot text at +`[p, p+i.length)`, a tracked deletion is text no longer in the snapshot, anchored at the point +`p`; the text is the realtime byte-view (decode it). `trackedChanges.ts` (pure) parses them and +`findConflicts(op, changes)` returns the ones an op would touch, carrying positions through the +op's components. Rules: an insert conflicts strictly inside an insertion or exactly on a +deletion anchor; a delete conflicts when it overlaps an insertion or has a deletion anchor +strictly inside it; edges are allowed so the neighbouring word stays editable (per Overleaf's +change tracker an edge insert shifts or leaves a suggestion and merges only into a tracked +insert of the same user - not yet verified live); comment ops never conflict. `submit` re-joins +the doc before every attempt unless `EditOptions.overwriteTrackedChanges` is set (the mirror +tracks text, not ranges, so earlier ranges may be stale) and throws `TrackedChangeConflictError` +(with `.changes`) before sending. Why: an edit inside a co-author's pending suggestion silently +rewrites or removes it, and Overleaf mails its author that the suggestion was rejected. +`listTrackedChanges` names authors from the project's owner/members. The MCP exposes +`overleaf_list_tracked_changes` but deliberately no override, so an agent asks a human instead; +the CLI has `changes` and `--overwrite-tracked-changes`. + **Hash.** Overleaf's optional ShareJS hash is git-blob SHA-1 (`ot.gitBlobHash`). We **omit it**: the server treats it as optional, and a mismatch is fatal (`otUpdateError "Invalid hash"` + disconnect), so omitting it is strictly more reliable. @@ -233,7 +252,8 @@ state; instead: - **MCP server stays minimal** — every tool is a one-line wrapper; the server stays alive until the client disconnects (stdin close), then exits. Each tool carries MCP `annotations`. `readOnlyHint` is `true` for the reads (`list_projects`, `project_info`, - `list_documents`, `read_document`, `search`, `connected_users`, `list_comments`, `compile`) + `list_documents`, `read_document`, `search`, `connected_users`, `list_comments`, + `list_tracked_changes`, `compile`) and `false` for the writes. `compile` is read-only (it never mutates project content) but also `idempotentHint: false`, since each call kicks off a fresh build. The writes set `destructiveHint` explicitly (it would otherwise default to `true` whenever `readOnlyHint` @@ -265,7 +285,7 @@ No configuration is required. Overrides (mainly for self-hosted / tuning): `project(idOrName)` → `ProjectSession`. All document methods take a `project` (id or name) as the first argument and are `async`: `projectInfo`, `listDocuments`, `readDocument`, `getLines`, `documentVersion`, `search`, `insertAt`, `insert`, `append`, `deleteRange`, `replaceRange`, -`replaceText`, `setText`, `deleteLines`, `setCursor`, `getConnectedUsers`, `createDocument`, +`replaceText`, `setText`, `deleteLines`, `setCursor`, `getConnectedUsers`, `listTrackedChanges`, `createDocument`, `uploadFile`, `createFolder`, `deleteDocument`, `renameDocument`, `resync`, `compile`. `ProjectSession` exposes the same methods without the leading `project` argument. Every edit method takes an optional trailing `EditOptions` (`{trackChanges?}`) — set `trackChanges` to submit the edit as diff --git a/README.md b/README.md index 53b1720..9fb4285 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Bring Claude (and any agent) into Overleaf. Claudeleaf is a reliable TypeScript - **Full document control** — list documents, read text, insert at a position or line/column, replace ranges or literal text, delete ranges or lines, replace a whole document, create/delete/rename files, and **upload images** (e.g. a local W&B plot PNG) for `\includegraphics`: `claudeleaf upload plot.png --folder figures` or the `overleaf_upload_file` MCP tool. - **Review mode (track changes).** Submit any edit as a tracked change (a suggestion) with `trackChanges` instead of a direct edit, so a human accepts or rejects it in Overleaf. Requires track changes on the project (a premium feature). - **Comments.** Anchor a comment to a span of text (like selecting text and commenting in the editor) and list a document's comments with their threads. +- **Collaborators' suggestions are protected.** List a document's pending tracked changes with their authors (`claudeleaf changes `, `overleaf_list_tracked_changes`). Any edit that would change text inside someone's pending suggestion is refused before it is sent, instead of silently rewriting it (which Overleaf reports to the author as a rejected suggestion). Override in the SDK/CLI with `overwriteTrackedChanges` / `--overwrite-tracked-changes`; the MCP server has no override, so an agent asks a human. - **Compile & error logs** — trigger a LaTeX recompile and get back the status plus the parsed errors and warnings (with file and line), or the full raw log. - **Reliable by design** — automatic reconnect with document re-join, per-document edit serialization, and server-confirmed edits (no silent loss). - **Zero config, easy sign-in.** No credentials stored and nothing to configure: run `claudeleaf login` once, sign in by hand in the browser that opens (handles reCAPTCHA / SSO / 2FA), and the session is cached. diff --git a/src/cli.ts b/src/cli.ts index b150935..8e79d9d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -80,6 +80,7 @@ Usage: claudeleaf [args] comment [message|-] comment on the [start,end) span comments list a document's comments + changes list a document's pending tracked changes upload [--folder DIR] [--name NAME] upload an image/PDF for \includegraphics compile [--draft] [--stop-on-first-error] [--log] [--warnings] @@ -89,7 +90,9 @@ project is an id or name. line/column are 0-based; start/end are character offse The editing commands (append, insert, replace, set) accept --track-changes to submit the edit as a review-mode suggestion instead of a direct edit (requires -track changes on the project, a premium feature).`; +track changes on the project, a premium feature). They refuse, sending nothing, +an edit that would touch a pending tracked change; --overwrite-tracked-changes +skips that check.`; async function run(argv: string[]): Promise { const command = argv[0]; @@ -149,7 +152,10 @@ async function run(argv: string[]): Promise { return 1; } // Editing commands share this: submit as a review-mode tracked change instead of a direct edit. - const editOpts = { trackChanges: boolFlag(flags["track-changes"]) }; + const editOpts = { + trackChanges: boolFlag(flags["track-changes"]), + overwriteTrackedChanges: boolFlag(flags["overwrite-tracked-changes"]), + }; const editedHow = editOpts.trackChanges ? " (as a tracked change)" : ""; try { switch (command) { @@ -231,6 +237,19 @@ async function run(argv: string[]): Promise { } return 0; } + case "changes": { + const changes = await client.listTrackedChanges(project, positionals[1]); + if (changes.length === 0) { + console.log("No tracked changes."); + return 0; + } + for (const c of changes) { + const who = c.author ?? c.email ?? c.userId ?? "?"; + const when = c.timestamp ? ` ${c.timestamp}` : ""; + console.log(`${c.line}:${c.column} ${c.kind.padEnd(6)} ${who}${when} ${JSON.stringify(c.text)}`); + } + return 0; + } case "upload": { const file = positionals[1]; if (!file) { diff --git a/src/client.ts b/src/client.ts index a2b57e5..5c74ead 100644 --- a/src/client.ts +++ b/src/client.ts @@ -27,6 +27,7 @@ import type { Entity, ProjectInfo, ProjectSummary, + TrackedChange, } from "./types.js"; import { Mutex } from "./util.js"; @@ -214,6 +215,11 @@ export class OverleafClient { return (await this.project(project)).listComments(path); } + // -- tracked changes -------------------------------------------------- + async listTrackedChanges(project: string, path: string): Promise { + return (await this.project(project)).listTrackedChanges(path); + } + // -- presence & structure --------------------------------------------- async setCursor(project: string, path: string, line: number, column: number): Promise { await (await this.project(project)).setCursor(path, line, column); diff --git a/src/errors.ts b/src/errors.ts index 2789363..df936d2 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -4,6 +4,8 @@ * All errors derive from {@link ClaudeleafError}, so callers can catch the whole family. */ +import type { TrackedChange } from "./types.js"; + export class ClaudeleafError extends Error { constructor(message: string) { super(message); @@ -47,3 +49,18 @@ export class EditError extends ClaudeleafError {} * in most cases; this surfaces only when retries are exhausted. */ export class EditConflictError extends EditError {} + +/** + * The edit would change text inside or at the edge of a pending tracked change (a review-mode + * suggestion), which would silently alter or remove that suggestion. Nothing was sent. Ask the + * suggestion's author to accept or reject it first, or retry with `overwriteTrackedChanges`. + */ +export class TrackedChangeConflictError extends EditError { + constructor( + message: string, + /** The suggestions the edit would have touched. */ + readonly changes: TrackedChange[], + ) { + super(message); + } +} diff --git a/src/index.ts b/src/index.ts index 04a9609..3ddad7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ export { DocumentNotFoundError, EditError, EditConflictError, + TrackedChangeConflictError, } from "./errors.js"; export type { Cookies, @@ -35,6 +36,7 @@ export type { ConnectedUser, Comment, CommentMessage, + TrackedChange, OutputFile, LogLevel, LogEntry, diff --git a/src/mcpServer.ts b/src/mcpServer.ts index c83c9d8..7782331 100644 --- a/src/mcpServer.ts +++ b/src/mcpServer.ts @@ -35,7 +35,9 @@ const trackChanges = z .describe( "Submit as a tracked change (review-mode suggestion) instead of a direct edit, for a " + "human to accept or reject. Inserts become tracked insertions; deletions keep the removed " + - "text struck through. Requires track changes to be available on the project (premium).", + "text struck through. Requires track changes to be available on the project (premium). " + + "Every text edit is refused, with nothing sent, if it would touch a pending suggestion " + + "(see overleaf_list_tracked_changes); then ask the human instead of working around it.", ); export function createServer(): McpServer { @@ -285,6 +287,20 @@ export function createServer(): McpServer { async ({ project, path }) => result(await getClient().listComments(project, path)), ); + server.registerTool( + "overleaf_list_tracked_changes", + { + description: + "List a document's pending tracked changes (review-mode suggestions) — each with kind " + + "(insert: text in the document; delete: text removed but kept as a suggestion), the text, " + + "position (offset/line/column), author and time. Edits that would touch one are refused, " + + "so check here before editing text a collaborator may have suggested changes to.", + inputSchema: { project: z.string(), path: z.string() }, + annotations: { readOnlyHint: true }, + }, + async ({ project, path }) => result(await getClient().listTrackedChanges(project, path)), + ); + server.registerTool( "overleaf_compile", { diff --git a/src/session.ts b/src/session.ts index a81581c..89408cb 100644 --- a/src/session.ts +++ b/src/session.ts @@ -30,6 +30,7 @@ import { EditConflictError, EditError, NotTransmittedError, + TrackedChangeConflictError, } from "./errors.js"; import { parseLatexLog } from "./logParser.js"; import * as ot from "./ot.js"; @@ -43,7 +44,9 @@ import type { Entity, OutputFile, ProjectInfo, + TrackedChange, } from "./types.js"; +import { findConflicts, parseChanges, type ParsedChange } from "./trackedChanges.js"; import { Deferred, Mutex, sleep } from "./util.js"; const TREE_EVENTS = new Set([ @@ -68,6 +71,14 @@ type ConfirmResult = { status: "confirmed"; version?: number } | { status: "drop */ export interface EditOptions { trackChanges?: boolean; + /** + * Allow the edit to change text inside or at the edge of a pending tracked change. By + * default an edit that would touch someone's suggestion is refused with a + * {@link TrackedChangeConflictError} before anything is sent, because it would silently + * alter or remove that suggestion (Overleaf reports a removed insertion to its author as + * "rejected"). The check re-joins the document before each edit to read fresh ranges. + */ + overwriteTrackedChanges?: boolean; } interface PendingEdit { @@ -84,7 +95,7 @@ interface RawCommentRange { /** The ranges a `joinDoc {encodeRanges:true}` ack carries alongside the document. */ interface DocRanges { comments?: RawCommentRange[]; - changes?: unknown[]; + changes?: unknown; } export class ProjectSession { @@ -366,6 +377,46 @@ export class ProjectSession { }); } + // -- tracked changes -------------------------------------------------- + /** + * List a document's pending tracked changes (review-mode suggestions), in document order, + * with their author where the author is a member of the project. Read-only. + */ + async listTrackedChanges(path: string): Promise { + const doc = await this.openDoc(path); + // Through joinDoc for the same reasons as listComments: a failed join raises, and the + // offsets are mapped against the text of this same join. + const ranges = await this.mutex(doc.id).runExclusive(() => this.joinDoc(doc)); + return parseChanges(ranges.changes) + .sort((a, b) => a.offset - b.offset) + .map((c) => this.describeChange(doc, c)); + } + + private describeChange(doc: Document, c: ParsedChange): TrackedChange { + const [line, column] = doc.offsetToRowcol(c.offset); + const user = c.userId ? this.projectUser(c.userId) : undefined; + const name = user ? [user.first_name, user.last_name].filter(Boolean).join(" ") : ""; + return { + id: c.id, + kind: c.kind, + text: c.text, + offset: c.offset, + line, + column, + userId: c.userId, + author: name || undefined, + email: user?.email as string | undefined, + timestamp: c.timestamp, + }; + } + + /** The owner or member with this user id, from the joinProject payload. */ + private projectUser(userId: string): Record | undefined { + const owner = this.project.owner as Record | undefined; + const people = [owner, ...((this.project.members as Record[]) ?? [])]; + return people.find((u) => u && (u._id === userId || u.id === userId)); + } + // -- presence & structure --------------------------------------------- async setCursor(path: string, line: number, column: number): Promise { const doc = await this.openDoc(path); @@ -665,9 +716,20 @@ export class ProjectSession { return this.mutex(doc.id).runExclusive(async () => { let lastError: Error | undefined; for (let attempt = 0; attempt < 4; attempt++) { - await this.ensureJoined(doc); + // Unless overwriting suggestions is allowed, re-join to get the current ranges: the + // local mirror tracks text, not ranges, so ranges from an earlier join may be stale. + let ranges: DocRanges | undefined; + if (opts.overwriteTrackedChanges) await this.ensureJoined(doc); + else ranges = await this.joinDoc(doc); const { op, cursor } = build(doc); if (op.length === 0) return doc; + if (ranges) { + const touched = findConflicts(op, parseChanges(ranges.changes)); + if (touched.length > 0) { + const described = touched.map((c) => this.describeChange(doc, c)); + throw new TrackedChangeConflictError(conflictMessage(doc.path, described), described); + } + } let newText: string; try { @@ -888,6 +950,20 @@ export class ProjectSession { } } +function conflictMessage(path: string, changes: TrackedChange[]): string { + const shown = changes.slice(0, 3).map((c) => { + const who = c.author ?? c.email ?? c.userId ?? "unknown author"; + const text = c.text.length > 60 ? c.text.slice(0, 57) + "..." : c.text; + return `${c.kind === "insert" ? "insertion" : "deletion"} by ${who} at ${c.line}:${c.column} ${JSON.stringify(text)}`; + }); + const more = changes.length > 3 ? ` and ${changes.length - 3} more` : ""; + return ( + `edit to ${path} not sent: it would touch ${changes.length} pending tracked change(s) - ` + + `${shown.join("; ")}${more}. Ask the author to accept or reject the suggestion first ` + + "(or retry with overwriteTrackedChanges)." + ); +} + function clamp(value: number, length: number): number { return Math.max(0, Math.min(value, length)); } diff --git a/src/trackedChanges.ts b/src/trackedChanges.ts new file mode 100644 index 0000000..c532a52 --- /dev/null +++ b/src/trackedChanges.ts @@ -0,0 +1,136 @@ +/** + * Tracked changes (review-mode suggestions) as carried in a doc's ranges, and the check that + * keeps an edit from touching one. + * + * Overleaf sends a document's pending suggestions with every `joinDoc {encodeRanges:true}` + * as `ranges.changes`: `{id, op:{p, i} | {p, d}, metadata:{user_id, ts}}`. A tracked + * **insertion** is text that is in the snapshot at `[p, p + i.length)`; a tracked **deletion** + * is text that is no longer in the snapshot, kept only in the range and anchored at the single + * point `p` (shown struck through in the editor). Text in both is the realtime byte-view. + * + * An edit that changes text inside or at the edge of someone's pending suggestion changes or + * removes that suggestion without the author seeing it as a decision of theirs (Overleaf + * reports a removed insertion to its author as "rejected"). {@link findConflicts} finds the + * suggestions an op would touch, so the edit can be refused instead. + * + * Pure / side-effect free. + */ + +import * as ot from "./ot.js"; + +/** A raw entry of `ranges.changes` as the server sends it. */ +export interface RawChange { + id?: string; + op?: { p?: number; i?: string; d?: string }; + metadata?: { user_id?: string; ts?: string | number }; +} + +/** A pending tracked change in a document, decoded. */ +export interface ParsedChange { + id: string; + kind: "insert" | "delete"; + /** The inserted text (in the snapshot) or the deleted text (no longer in the snapshot). */ + text: string; + /** Character offset in the `\n`-joined snapshot where the change sits. */ + offset: number; + userId?: string; + /** ISO timestamp of the change, as the server sent it (normalised to a string). */ + timestamp?: string; +} + +/** Decode `ranges.changes`, skipping entries whose shape is not understood. */ +export function parseChanges(raw: unknown): ParsedChange[] { + if (!Array.isArray(raw)) return []; + const out: ParsedChange[] = []; + for (const entry of raw as RawChange[]) { + if (!entry || typeof entry !== "object") continue; + const op = entry.op; + const offset = op?.p; + if (!entry.id || typeof offset !== "number" || offset < 0) continue; + let kind: "insert" | "delete"; + let wire: string; + if (typeof op?.i === "string") { + kind = "insert"; + wire = op.i; + } else if (typeof op?.d === "string") { + kind = "delete"; + wire = op.d; + } else { + continue; + } + const ts = entry.metadata?.ts; + out.push({ + id: entry.id, + kind, + text: ot.decodeWireText(wire), + offset, + userId: entry.metadata?.user_id, + timestamp: ts === undefined ? undefined : typeof ts === "number" ? new Date(ts).toISOString() : String(ts), + }); + } + return out; +} + +/** + * Return the pending changes that `op` would touch, in their original order (each at most + * once). Components apply left to right, so the change positions are carried through each + * component before the next one is checked. + * + * The rules lean conservative - a false refusal costs a question to a human, a missed one + * silently rewrites a co-author's suggestion - but leave the text next to a suggestion + * editable, since replacing the neighbouring word is the common case: + * - an insert conflicts with a tracked insertion if it lands strictly inside it (Overleaf's + * change tracker shifts or leaves a suggestion on an edge insert, merging only a tracked + * insert of the same user), and with a tracked deletion if it lands on the deletion's anchor + * point (it would put new text where the suggestion removes text); + * - a delete conflicts with a tracked insertion if the two spans overlap, and with a tracked + * deletion if the anchor lies strictly inside the deleted span. + * Comment components change no text and never conflict. + */ +export function findConflicts(op: ot.Op, changes: ParsedChange[]): ParsedChange[] { + // Working copy of each change's span in the evolving text: [start, end) for an insertion, + // the point start === end for a deletion. + const spans = changes.map((c) => ({ + change: c, + start: c.offset, + end: c.kind === "insert" ? c.offset + c.text.length : c.offset, + })); + const hit = new Set(); + + for (const comp of op) { + if ("c" in comp) continue; // comment marker: no text change + if ("i" in comp) { + const q = comp.p; + const n = comp.i.length; + for (const s of spans) { + const isInsert = s.change.kind === "insert"; + if (isInsert ? s.start < q && q < s.end : q === s.start) hit.add(s.change.id); + // Shift what lies at or after the insert point. (A span the insert lands strictly + // inside has already conflicted; growing it keeps later checks conservative.) + if (q <= s.start) { + s.start += n; + s.end += n; + } else if (q < s.end) { + s.end += n; + } + } + } else { + const a = comp.p; + const n = comp.d.length; + const b = a + n; + for (const s of spans) { + const isInsert = s.change.kind === "insert"; + if (isInsert ? a < s.end && s.start < b : a < s.start && s.start < b) hit.add(s.change.id); + s.start = shiftForDelete(s.start, a, b); + s.end = shiftForDelete(s.end, a, b); + } + } + } + return changes.filter((c) => hit.has(c.id)); +} + +function shiftForDelete(pos: number, a: number, b: number): number { + if (pos <= a) return pos; + if (pos >= b) return pos - (b - a); + return a; // inside the deleted span: collapse onto its start +} diff --git a/src/types.ts b/src/types.ts index 32bf722..8fac22e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -67,6 +67,33 @@ export interface Comment { messages: CommentMessage[]; } +/** + * A pending tracked change (a review-mode suggestion) in a document. An `insert` is text that + * is in the document at `[offset, offset + text.length)`; a `delete` is text that has been + * removed from the document but is kept as a suggestion anchored at `offset` (shown struck + * through until someone accepts or rejects it). + */ +export interface TrackedChange { + /** Overleaf's change id. */ + id: string; + kind: "insert" | "delete"; + /** The suggested inserted text, or the text suggested for deletion. */ + text: string; + /** Character offset in the `\n`-joined document. */ + offset: number; + /** 0-based line of `offset`. */ + line: number; + /** 0-based column of `offset`. */ + column: number; + /** Overleaf user id of the author. */ + userId?: string; + /** Author's name, if the user is a member of the project. */ + author?: string; + email?: string; + /** ISO timestamp of the change. */ + timestamp?: string; +} + /** A file produced by a compile (log, pdf, aux, ...). `url` is absolute and fetchable. */ export interface OutputFile { path: string; diff --git a/test/clientOps.test.ts b/test/clientOps.test.ts index 45c9d26..f55caad 100644 --- a/test/clientOps.test.ts +++ b/test/clientOps.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest"; import { SessionManager } from "../src/auth.js"; import { OverleafClient } from "../src/client.js"; import { Config } from "../src/config.js"; -import { EditConflictError, EditError, NotTransmittedError, ProjectNotFoundError } from "../src/errors.js"; +import { EditConflictError, EditError, NotTransmittedError, ProjectNotFoundError, TrackedChangeConflictError } from "../src/errors.js"; import * as ot from "../src/ot.js"; import { ProjectSession } from "../src/session.js"; @@ -446,3 +446,86 @@ describe("uploadFile", () => { await expect(session.uploadFile(PNG, undefined, "../a.png")).rejects.toThrow(/invalid file name/); }); }); + +describe("tracked changes: listing and the overwrite guard", () => { + // "hello brave world" with Kenny's pending insertion "brave " at [6, 12). + function withSuggestion() { + const m = makeSession("hello brave world"); + m.fake.ranges = { + changes: [{ id: "c1", op: { p: 6, i: "brave " }, metadata: { user_id: "u-kenny", ts: "2026-11-08T09:00:00.000Z" } }], + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (m.session as any).project = { + owner: { _id: "u-owner", first_name: "Ada", email: "ada@example.org" }, + members: [{ _id: "u-kenny", first_name: "Kenny", last_name: "S", email: "kenny@example.org" }], + }; + return m; + } + + it("listTrackedChanges decodes changes and names the author", async () => { + const { session } = withSuggestion(); + expect(await session.listTrackedChanges("main.tex")).toEqual([ + { + id: "c1", + kind: "insert", + text: "brave ", + offset: 6, + line: 0, + column: 6, + userId: "u-kenny", + author: "Kenny S", + email: "kenny@example.org", + timestamp: "2026-11-08T09:00:00.000Z", + }, + ]); + }); + + it("listTrackedChanges is empty when the doc has no ranges", async () => { + const { session } = makeSession("plain"); + expect(await session.listTrackedChanges("main.tex")).toEqual([]); + }); + + it("refuses an edit that touches a suggestion and sends nothing", async () => { + const { session, fake } = withSuggestion(); + const err = await session.replaceText("main.tex", "brave", "bold").catch((e) => e); + expect(err).toBeInstanceOf(TrackedChangeConflictError); + expect(err.message).toMatch(/insertion by Kenny S at 0:6/); + expect(err.changes.map((c: { id: string }) => c.id)).toEqual(["c1"]); + expect(fake.applies).toBe(0); + expect(fake.text).toBe("hello brave world"); + }); + + it("refuses for tracked edits too (a suggestion on a suggestion)", async () => { + const { session, fake } = withSuggestion(); + await expect(session.insertAt("main.tex", 8, "x", { trackChanges: true })).rejects.toBeInstanceOf(TrackedChangeConflictError); + expect(fake.applies).toBe(0); + }); + + it("lets edits elsewhere through", async () => { + const { session, fake } = withSuggestion(); + await session.replaceText("main.tex", "world", "there"); + expect(fake.text).toBe("hello brave there"); + }); + + it("reads fresh ranges: a suggestion made after the doc was opened still blocks", async () => { + const { session, fake } = makeSession("hello brave world"); + await session.readDocument("main.tex"); // opened while there were no suggestions + fake.ranges = { changes: [{ id: "late", op: { p: 6, i: "brave " } }] }; + await expect(session.deleteRange("main.tex", 6, 11)).rejects.toBeInstanceOf(TrackedChangeConflictError); + }); + + it("overwriteTrackedChanges skips the check", async () => { + const { session, fake } = withSuggestion(); + await session.replaceText("main.tex", "brave", "bold", 0, { overwriteTrackedChanges: true }); + expect(fake.text).toBe("hello bold world"); + }); + + it("does not block comments on suggested text", async () => { + const { session, fake } = withSuggestion(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (session as any).rest = { postThreadMessage: async () => ({}) }; + const c = await session.addComment("main.tex", 6, 11, "nice"); + expect(c.text).toBe("brave"); + expect(fake.applies).toBe(1); + }); +}); diff --git a/test/trackedChanges.test.ts b/test/trackedChanges.test.ts new file mode 100644 index 0000000..76d4eb5 --- /dev/null +++ b/test/trackedChanges.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; + +import * as ot from "../src/ot.js"; +import { findConflicts, parseChanges, type ParsedChange } from "../src/trackedChanges.js"; + +// "hello brave world": a pending insertion "brave " at [6, 12), a pending deletion anchored at 12. +const ins: ParsedChange = { id: "ins", kind: "insert", text: "brave ", offset: 6 }; +const del: ParsedChange = { id: "del", kind: "delete", text: "big ", offset: 12 }; +const ids = (op: ot.Op, changes = [ins, del]) => findConflicts(op, changes).map((c) => c.id); + +describe("parseChanges", () => { + it("decodes inserts and deletes, byte-view text and numeric timestamps", () => { + const wire = Buffer.from("é", "utf8").toString("latin1"); + const parsed = parseChanges([ + { id: "a", op: { p: 3, i: wire }, metadata: { user_id: "u1", ts: "2026-11-09T10:00:00.000Z" } }, + { id: "b", op: { p: 5, d: "gone" }, metadata: { user_id: "u2", ts: 0 } }, + ]); + expect(parsed).toEqual([ + { id: "a", kind: "insert", text: "é", offset: 3, userId: "u1", timestamp: "2026-11-09T10:00:00.000Z" }, + { id: "b", kind: "delete", text: "gone", offset: 5, userId: "u2", timestamp: "1970-01-01T00:00:00.000Z" }, + ]); + }); + it("skips entries it cannot interpret and non-arrays", () => { + expect(parseChanges(undefined)).toEqual([]); + expect(parseChanges({})).toEqual([]); + expect(parseChanges([null, { op: { p: 1, i: "x" } }, { id: "x", op: { i: "x" } }, { id: "y", op: { p: 1 } }])).toEqual([]); + }); +}); + +describe("findConflicts", () => { + it("allows edits away from any suggestion", () => { + expect(ids([ot.insert(0, "Oh, ")])).toEqual([]); + expect(ids([ot.del(0, "hello")])).toEqual([]); + expect(ids([ot.del(13, "orld")])).toEqual([]); + }); + it("refuses an insert strictly inside a tracked insertion, allows one at its edges", () => { + expect(ids([ot.insert(8, "x")])).toEqual(["ins"]); + expect(ids([ot.insert(6, "x")], [ins])).toEqual([]); + expect(ids([ot.insert(12, "x")], [ins])).toEqual([]); + }); + it("refuses a delete that overlaps a tracked insertion, allows one that only touches it", () => { + expect(ids([ot.del(4, "o b")])).toEqual(["ins"]); + expect(ids([ot.del(0, "hello ")], [ins])).toEqual([]); + }); + it("refuses an insert on a deletion anchor and a delete spanning it, allows one touching it", () => { + expect(ids([ot.insert(12, "x")], [del])).toEqual(["del"]); + expect(ids([ot.del(10, "e w")], [del])).toEqual(["del"]); + expect(ids([ot.del(12, "w")], [del])).toEqual([]); + expect(ids([ot.del(11, " ")], [del])).toEqual([]); + }); + it("still refuses replacing the word right after a deletion (the insert lands on the anchor)", () => { + expect(ids([ot.del(12, "world"), ot.insert(12, "there")], [del])).toEqual(["del"]); + }); + it("carries positions through earlier components of the same op", () => { + // Deleting "hello " first moves the insertion to [0, 6); an insert at 3 then lands inside it. + expect(ids([ot.del(0, "hello "), ot.insert(3, "x")], [ins])).toEqual(["ins"]); + // Inserting 4 chars at 0 moves the deletion anchor to 16; deleting at 12 no longer reaches it. + expect(ids([ot.insert(0, "Oh, "), ot.del(12, "a")], [del])).toEqual([]); + }); + it("ignores comment components", () => { + expect(ids([ot.comment(6, "brave ", "t1")])).toEqual([]); + }); + it("reports each change once, in input order", () => { + expect(ids([ot.del(4, "o brave w")])).toEqual(["ins", "del"]); + }); +}); From 6941022843e8dde8429de96f003e0557456e7dd3 Mon Sep 17 00:00:00 2001 From: JohannKaspar <63501587+JohannKaspar@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:52:40 +0100 Subject: [PATCH 2/2] Skip the break-and-restore compile test when main.tex has suggestions Restoring the whole document cannot recreate tracked changes, so the test would drop them; the overwrite guard refuses it. Record the live check of edge inserts in CLAUDE.md. --- CLAUDE.md | 5 ++++- test/integration.test.ts | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 431a3ad..c0124b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,7 +160,10 @@ op's components. Rules: an insert conflicts strictly inside an insertion or exac deletion anchor; a delete conflicts when it overlaps an insertion or has a deletion anchor strictly inside it; edges are allowed so the neighbouring word stays editable (per Overleaf's change tracker an edge insert shifts or leaves a suggestion and merges only into a tracked -insert of the same user - not yet verified live); comment ops never conflict. `submit` re-joins +insert of the same user; verified live that direct inserts at both edges of a tracked insertion +leave its text unchanged, with suggestion and edit from one account - a second author not yet +tested); comment ops never conflict. The live compile test skips itself when `main.tex` has +pending suggestions, since its break-and-restore would drop them. `submit` re-joins the doc before every attempt unless `EditOptions.overwriteTrackedChanges` is set (the mirror tracks text, not ranges, so earlier ranges may be stale) and throws `TrackedChangeConflictError` (with `.changes`) before sending. Why: an edit inside a co-author's pending suggestion silently diff --git a/test/integration.test.ts b/test/integration.test.ts index 4053111..82c208d 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -242,7 +242,12 @@ describe.skipIf(!hasSession)("integration", () => { expect(r.pdfUrl).toContain("output.pdf"); }); - it("surfaces LaTeX errors with line numbers (break + restore main.tex)", async () => { + it("surfaces LaTeX errors with line numbers (break + restore main.tex)", async (ctx) => { + // Replacing and restoring the whole document would drop any pending suggestion in it (a + // restore cannot recreate tracked changes), and the overwrite guard refuses it anyway. + if ((await client.listTrackedChanges(project, "main.tex")).length > 0) { + ctx.skip("main.tex has pending tracked changes"); + } const original = await client.readDocument(project, "main.tex"); const broken = "\\documentclass{article}\n\\begin{document}\nok\n\\undefinedcommandhere\n\\end{document}\n"; try {