From 4909da76596829fd278228584f0365e27e9ac2f5 Mon Sep 17 00:00:00 2001 From: Darrow8 Date: Sun, 4 Oct 2026 19:46:03 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20upload=20images=20and=20PDF?= =?UTF-8?q?s=20to=20a=20project=20for=20\includegraphics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add uploadFile to RestClient/ProjectSession/OverleafClient, a `claudeleaf upload` CLI command and an `overleaf_upload_file` MCP tool (local path or base64). Allows png/jpg/gif/svg/pdf/eps up to 50 MB; a same-named file in the folder is replaced. Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 11 ++++++-- README.md | 2 +- src/cli.ts | 16 +++++++++++- src/client.ts | 9 +++++++ src/mcpServer.ts | 24 ++++++++++++++++++ src/rest.ts | 37 ++++++++++++++++++++++++--- src/session.ts | 54 ++++++++++++++++++++++++++++++++++++++++ test/clientOps.test.ts | 52 ++++++++++++++++++++++++++++++++++++++ test/integration.test.ts | 23 +++++++++++++++++ 9 files changed, 221 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e28b530..13536e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,6 +139,13 @@ symbols); non-BMP/astral characters (emoji) are corrupted by Overleaf's own stor parent_folder_id}`, `DELETE /project//{doc|file|folder}/`, `POST ...// /rename` — all need the session cookie and the `ol-csrfToken` from the project HTML. +**Uploading files** (images for `\includegraphics`) is HTTP: `POST /project//upload?folder_id=` +with `multipart/form-data` (`relativePath=null`, `name`, `type`, `qqfile`) + cookie + CSRF → +`{success, entity_id, entity_type:"file"}`. A same-named file in the folder is replaced. +`ProjectSession.uploadFile` takes a local path or a Buffer, allows png/jpg/gif/svg/pdf/eps up to +50 MB, and registers the file in the local tree. `RestClient.request` sends `FormData` bodies +without a `Content-Type` (fetch adds the boundary). + **Compiling** is HTTP (CSRF + cookie), not realtime. `POST /project//compile?auto_compile=true` with body `{rootDoc_id, draft, check:"silent", incrementalCompilesEnabled, stopOnFirstError}` → `{status, outputFiles:[{path,type,build,url}], clsiServerId, ...}`. `status` is @@ -196,7 +203,7 @@ state; instead: `destructiveHint` explicitly (it would otherwise default to `true` whenever `readOnlyHint` is `false`): `true` for overwrite/remove tools (`replace_text`, `replace_range`, `delete_range`, `delete_lines`, `set_document`, `delete_document`), `false` for the purely - additive ones (`insert_text`, `append_text`, `create_document`, and `rename_document` — + additive ones (`insert_text`, `append_text`, `create_document`, `upload_file`, and `rename_document` — which changes a name, losing no content). ## Configuration (optional environment variables) @@ -220,7 +227,7 @@ No configuration is required. Overrides (mainly for self-hosted / tuning): the first argument and are `async`: `projectInfo`, `listDocuments`, `readDocument`, `getLines`, `documentVersion`, `search`, `insertAt`, `insert`, `append`, `deleteRange`, `replaceRange`, `replaceText`, `setText`, `deleteLines`, `setCursor`, `getConnectedUsers`, `createDocument`, -`createFolder`, `deleteDocument`, `renameDocument`, `resync`, `compile`. `ProjectSession` +`uploadFile`, `createFolder`, `deleteDocument`, `renameDocument`, `resync`, `compile`. `ProjectSession` exposes the same methods without the leading `project` argument. Paths resolve by full path, then id, then unique basename; `line`/`column` are 0-based and offsets are character indices into the `\n`-joined text — **except** compile-log line numbers (`CompileResult.errors`/ diff --git a/README.md b/README.md index 5220157..1499f68 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Bring Claude (and any agent) into Overleaf. Claudeleaf is a reliable TypeScript - **Real editing, not git.** Edits go through Overleaf's live collaboration channel (ShareJS over Socket.IO), so they appear instantly to everyone in the project, attributed to your user. - **All your projects, one instance.** List every project you can access and edit any of them by id or name — no project configured up front. -- **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, and create/delete/rename files. +- **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. - **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 000c36f..4e28bd7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,7 @@ import { OverleafClient } from "./client.js"; import { Config } from "./config.js"; import { ClaudeleafError } from "./errors.js"; -const VALUE_FLAGS = new Set(["count"]); +const VALUE_FLAGS = new Set(["count", "folder", "name"]); function parseArgs(args: string[]): { positionals: string[]; flags: Record } { const positionals: string[] = []; @@ -68,6 +68,8 @@ Usage: claudeleaf [args] replace [--count N] set replace whole document with stdin search find text + upload [--folder DIR] [--name NAME] + upload an image/PDF for \includegraphics compile [--draft] [--stop-on-first-error] [--log] [--warnings] mcp run the MCP server over stdio @@ -184,6 +186,18 @@ async function run(argv: string[]): Promise { } return 0; } + case "upload": { + const file = positionals[1]; + if (!file) { + console.error("error: 'upload' needs a local file path"); + return 1; + } + const folder = typeof flags.folder === "string" ? flags.folder : undefined; + const name = typeof flags.name === "string" ? flags.name : undefined; + const res = await client.uploadFile(project, file, folder, name); + console.log(`Uploaded ${res.path} (id ${res._id ?? "?"})`); + return 0; + } case "compile": { const result = await client.compile(project, { draft: Boolean(flags.draft), diff --git a/src/client.ts b/src/client.ts index e8abf52..47c2f49 100644 --- a/src/client.ts +++ b/src/client.ts @@ -217,6 +217,15 @@ export class OverleafClient { return (await this.project(project)).createDocument(name, folder); } + async uploadFile( + project: string, + source: string | Uint8Array, + folder?: string, + name?: string, + ): Promise<{ _id?: string; path: string }> { + return (await this.project(project)).uploadFile(source, folder, name); + } + async createFolder(project: string, name: string, folder?: string): Promise<{ _id?: string }> { return (await this.project(project)).createFolder(name, folder); } diff --git a/src/mcpServer.ts b/src/mcpServer.ts index c12bb31..4a18eb9 100644 --- a/src/mcpServer.ts +++ b/src/mcpServer.ts @@ -186,6 +186,30 @@ export function createServer(): McpServer { }, ); + server.registerTool( + "overleaf_upload_file", + { + description: + "Upload an image/figure (png, jpg, gif, svg, pdf, eps; max 50 MB) so LaTeX can \\includegraphics it. " + + "Give `path` (a local file path) or `base64` + `name`. `folder` defaults to the project root. " + + "A same-named file in the folder is replaced.", + inputSchema: { + project: z.string(), + path: z.string().optional(), + base64: z.string().optional(), + name: z.string().optional(), + folder: z.string().optional(), + }, + annotations: { readOnlyHint: false, destructiveHint: false }, + }, + async ({ project, path, base64, name, folder }) => { + if (!path === !base64) throw new Error("provide exactly one of `path` or `base64`"); + const source = path ?? Buffer.from(base64 as string, "base64"); + const res = await getClient().uploadFile(project, source, folder, name); + return result({ ok: true, path: res.path, id: res._id }); + }, + ); + server.registerTool( "overleaf_delete_document", { diff --git a/src/rest.ts b/src/rest.ts index 13b4c18..a26118e 100644 --- a/src/rest.ts +++ b/src/rest.ts @@ -94,9 +94,10 @@ export class RestClient { private async request( method: "POST" | "DELETE", path: string, - payload?: object, + payload?: object | FormData, timeoutMs = this.config.requestTimeout * 1000, ): Promise { + const isForm = payload instanceof FormData; const url = `${this.config.baseUrl}${path}`; let last: Response | undefined; for (let attempt = 0; attempt < 4; attempt++) { @@ -108,15 +109,21 @@ export class RestClient { "User-Agent": this.config.userAgent, Referer: this.config.projectUrl(this.projectId), "X-CSRF-Token": await this.ensureCsrf(refresh), - "Content-Type": "application/json", Accept: "application/json", }; + // For multipart bodies fetch sets Content-Type itself (it needs the boundary). + if (!isForm) headers["Content-Type"] = "application/json"; let res: Response; try { res = await fetch(url, { method, headers, - body: method === "POST" ? JSON.stringify(payload ?? {}) : undefined, + body: + method === "POST" + ? isForm + ? (payload as FormData) + : JSON.stringify(payload ?? {}) + : undefined, signal: AbortSignal.timeout(timeoutMs), // a stalled socket aborts and retries }); } catch (e) { @@ -153,6 +160,30 @@ export class RestClient { }) as Promise<{ _id?: string }>; } + /** Upload a binary file (image, PDF, ...) into a folder; replaces a same-named file. */ + async uploadFile( + name: string, + parentFolderId: string, + data: Uint8Array, + mime: string, + ): Promise<{ _id?: string }> { + const form = new FormData(); + form.append("relativePath", "null"); + form.append("name", name); + form.append("type", mime); + form.append("qqfile", new Blob([data], { type: mime }), name); + const res = (await this.request( + "POST", + `/project/${this.projectId}/upload?folder_id=${encodeURIComponent(parentFolderId)}`, + form, + Math.max(this.config.requestTimeout, 120) * 1000, + )) as { success?: boolean; entity_id?: string; error?: string }; + if (res.success === false || !res.entity_id) { + throw new ClaudeleafError(`upload of ${name} failed: ${res.error ?? "no entity id returned"}`); + } + return { _id: res.entity_id }; + } + createFolder(name: string, parentFolderId: string): Promise<{ _id?: string }> { return this.request("POST", `/project/${this.projectId}/folder`, { name, diff --git a/src/session.ts b/src/session.ts index d523ad9..55af8c1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -16,6 +16,9 @@ * in-flight edit). */ +import { readFile } from "node:fs/promises"; +import { basename, extname } from "node:path"; + import { SessionManager } from "./auth.js"; import type { Config } from "./config.js"; import { Document } from "./document.js"; @@ -292,6 +295,46 @@ export class ProjectSession { return result; } + /** + * Upload a binary file (e.g. an image for `\includegraphics`) into the project. + * `source` is a local file path or a Buffer (then `name` is required). An existing file + * with the same name in the folder is replaced. + */ + async uploadFile( + source: string | Uint8Array, + folder?: string, + name?: string, + ): Promise<{ _id?: string; path: string }> { + let data: Uint8Array; + let fileName = name; + if (typeof source === "string") { + data = await readFile(source); + fileName ??= basename(source); + } else { + if (!fileName) throw new ClaudeleafError("a name is required when uploading raw bytes"); + data = source; + } + if (/[\\/]/.test(fileName)) throw new ClaudeleafError(`invalid file name: ${fileName}`); + const mime = UPLOAD_TYPES[extname(fileName).toLowerCase()]; + if (!mime) { + throw new ClaudeleafError( + `unsupported file type for ${fileName} (allowed: ${Object.keys(UPLOAD_TYPES).join(", ")})`, + ); + } + if (data.byteLength === 0) throw new ClaudeleafError(`${fileName} is empty`); + if (data.byteLength > MAX_UPLOAD_BYTES) { + throw new ClaudeleafError(`${fileName} exceeds the ${MAX_UPLOAD_BYTES / 1024 / 1024} MB upload limit`); + } + const folderId = this.folderId(folder); + const result = await this.rest.uploadFile(fileName, folderId, data, mime); + const path = joinPath(folder, fileName); + this.entities = this.entities.filter((e) => !(e.path === path && e.type === "file")); + if (result._id) { + this.entities.push({ id: result._id, name: fileName, path, type: "file", folderId }); + } + return { ...result, path }; + } + async createFolder(name: string, folder?: string): Promise<{ _id?: string }> { return this.rest.createFolder(name, this.folderId(folder)); } @@ -731,6 +774,17 @@ function validateRange(doc: Document, start: number, end: number): [number, numb return [s, e]; } +const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; +const UPLOAD_TYPES: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".pdf": "application/pdf", + ".eps": "application/postscript", +}; + function joinPath(folder: string | undefined, name: string): string { if (!folder || folder === "/" || folder === "") return name; return folder.trim().replace(/^\/+|\/+$/g, "") + "/" + name; diff --git a/test/clientOps.test.ts b/test/clientOps.test.ts index 7e7493d..393e621 100644 --- a/test/clientOps.test.ts +++ b/test/clientOps.test.ts @@ -220,3 +220,55 @@ describe("account project resolution", () => { await expect(resolve(client, "Dup")).rejects.toBeInstanceOf(ProjectNotFoundError); }); }); + +describe("uploadFile", () => { + const PNG = Buffer.from("iVBORw0KGgo=", "base64"); + + function withFakeRest(session: ProjectSession) { + const calls: { name: string; folderId: string; size: number; mime: string }[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (session as any).rest = { + uploadFile: async (name: string, folderId: string, data: Uint8Array, mime: string) => { + calls.push({ name, folderId, size: data.byteLength, mime }); + return { _id: "file1" }; + }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (session as any).rootFolderId = "root"; + return calls; + } + + it("uploads bytes, registers the file and replaces a same-named one", async () => { + const { session } = makeSession("x"); + const calls = withFakeRest(session); + const res = await session.uploadFile(PNG, undefined, "plot.png"); + expect(res.path).toBe("plot.png"); + expect(calls).toEqual([{ name: "plot.png", folderId: "root", size: PNG.length, mime: "image/png" }]); + await session.uploadFile(PNG, undefined, "plot.png"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const files = (session as any).entities.filter((e: { type: string }) => e.type === "file"); + expect(files).toHaveLength(1); + }); + + it("reads a local file and uses its basename", async () => { + const { session } = makeSession("x"); + const calls = withFakeRest(session); + const { mkdtempSync, writeFileSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const file = join(mkdtempSync(join(tmpdir(), "cl-")), "wandb_loss.png"); + writeFileSync(file, PNG); + const res = await session.uploadFile(file); + expect(res.path).toBe("wandb_loss.png"); + expect(calls[0].mime).toBe("image/png"); + }); + + it("rejects unsupported types, empty data, missing names and bad names", async () => { + const { session } = makeSession("x"); + withFakeRest(session); + await expect(session.uploadFile(PNG, undefined, "a.exe")).rejects.toThrow(/unsupported/); + await expect(session.uploadFile(Buffer.alloc(0), undefined, "a.png")).rejects.toThrow(/empty/); + await expect(session.uploadFile(PNG)).rejects.toThrow(/name is required/); + await expect(session.uploadFile(PNG, undefined, "../a.png")).rejects.toThrow(/invalid file name/); + }); +}); diff --git a/test/integration.test.ts b/test/integration.test.ts index 9f67f60..4053111 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -209,6 +209,29 @@ describe.skipIf(!hasSession)("integration", () => { } }); + // -- image upload -- + it("uploads an image, lists it, uses it in a compile, and deletes it", async () => { + // 1x1 red PNG + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", + "base64", + ); + const NAME = "claudeleaf_vitest.png"; + const res = await client.uploadFile(project, png, undefined, NAME); + try { + expect(res._id).toBeTruthy(); + const files = await client.listDocuments(project, true); + expect(files.some((f) => f.path === NAME)).toBe(true); + await client.append(project, SCRATCH, `\\includegraphics{${NAME}}\n`); + // re-upload replaces rather than duplicating + await client.uploadFile(project, png, undefined, NAME); + const again = await client.listDocuments(project, true); + expect(again.filter((f) => f.path === NAME)).toHaveLength(1); + } finally { + await client.deleteDocument(project, NAME).catch(() => {}); + } + }); + // -- compile -- it("compiles and returns status, log and output files", async () => { const r = await client.compile(project);