pindeck 0.0.0-stage → 2.8.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/server.mjs ADDED
@@ -0,0 +1,1163 @@
1
+ #!/usr/bin/env node
2
+ /* PinDeck: local server.
3
+
4
+ pindeck [deck-dir] [--port 5180] [--model <name>] (or: node server.mjs ...)
5
+
6
+ - serves the editor UI and the deck (with a bridge script injected in edit mode)
7
+ - pushes file changes to the browser (server-sent events) for live reload
8
+ - runs one long-lived Claude Code process for the chat sidebar
9
+ - snapshots files around every Claude turn for diffs and undo
10
+ - answers `cli.mjs layout|shot|slides|pdf|pdf-figure` for Claude's self-checks, exports and PDF figures
11
+ - exports the deck as a PDF handout or a PDF presentation (build steps on separate pages)
12
+ - stores figures and audio attached to chat messages in <deck>/figures/ for Claude to use, and attached
13
+ PDFs (papers, reports, figures) in ~/.pindeck, outside the deck
14
+ - imports slides from PowerPoint files in the deck's own layouts
15
+ Listens on 127.0.0.1 only; every API call needs the per-run token.
16
+ Runtime files (chat, screenshots, backups) live in ~/.pindeck, never in the deck or the package. */
17
+
18
+ import http from "node:http";
19
+ import fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import crypto from "node:crypto";
23
+ import { spawn, execFile } from "node:child_process";
24
+ import { fileURLToPath } from "node:url";
25
+ import { assignEids, parse, findSlides, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
26
+ import { unifiedDiff } from "./lib/diff.mjs";
27
+ import { ClaudeSession } from "./lib/claude.mjs";
28
+ import { formatLayout, formatPin } from "./lib/layout.mjs";
29
+ import { printLayout, printPdf, PDF_HEAD_TAG, PDF_PAGE_TAG } from "./lib/pdf.mjs";
30
+ import { readPptx } from "./lib/pptx.mjs";
31
+ import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
32
+ import { buildImport, planSlides } from "./lib/pptx-import.mjs";
33
+
34
+ /* ---- Config ------------------------------------------------------------------ */
35
+ const USAGE = `Usage: pindeck [deck-folder] [--port 5180] [--model <name>]
36
+
37
+ Opens the reveal.js deck in deck-folder (default: the current folder) in PinDeck.
38
+ The folder must contain index.html.`;
39
+ const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
40
+ const argv = process.argv.slice(2);
41
+ if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
42
+ const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
43
+ const PORT = +opt("--port", process.env.PORT || 5180);
44
+ const MODEL = opt("--model", process.env.CLAUDE_MODEL || "");
45
+ const DECK_DIR = path.resolve(argv[0] || process.cwd());
46
+ const DECK_FILE = "index.html";
47
+ const EDITABLE = ["index.html", "theme.css"]; // Claude may edit these
48
+ const LOCKED = ["brand.css"]; // never, enforced twice
49
+ const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck");
50
+ const deckKey = path.basename(DECK_DIR).replace(/[^\w.-]+/g, "_") + "-" +
51
+ crypto.createHash("sha1").update(DECK_DIR).digest("hex").slice(0, 8);
52
+ const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
53
+ const SHOT_DIR = path.join(STATE_DIR, "shots");
54
+ const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
55
+ const TOKEN = crypto.randomBytes(16).toString("hex");
56
+ const ORIGIN = `http://127.0.0.1:${PORT}`;
57
+ const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(path.sep).join("/");
58
+ const CLI_CMD = /\s/.test(CLI_REL) ? `node "${CLI_REL}"` : `node ${CLI_REL}`;
59
+
60
+ if (!fs.existsSync(path.join(DECK_DIR, DECK_FILE))) {
61
+ console.error(`No ${DECK_FILE} in ${DECK_DIR}\n\n${USAGE}`);
62
+ process.exit(1);
63
+ }
64
+ fs.mkdirSync(SHOT_DIR, { recursive: true });
65
+ const serverInfo = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
66
+ fs.writeFileSync(path.join(STATE_DIR, "server.json"), serverInfo);
67
+ fs.writeFileSync(path.join(HOME_DIR, "server.json"), serverInfo); // the CLI's fallback: most recent server
68
+
69
+ /* ---- Small helpers --------------------------------------------------------------- */
70
+ const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8",
71
+ mjs: "text/javascript; charset=utf-8", json: "application/json", svg: "image/svg+xml", png: "image/png",
72
+ jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", ico: "image/x-icon",
73
+ woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", wav: "audio/wav", mp3: "audio/mpeg",
74
+ mp4: "video/mp4", webm: "video/webm", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
75
+ const hash = (s) => crypto.createHash("sha1").update(s).digest("hex");
76
+ const readDeck = (f) => { try { return fs.readFileSync(path.join(DECK_DIR, f), "utf8"); } catch { return null; } };
77
+ const ourWrites = new Map(); // file -> { hash, manual } of what we wrote, to tell our writes from others
78
+ function writeDeck(f, content, manual = false) {
79
+ ourWrites.set(f, { hash: hash(content), manual });
80
+ fs.writeFileSync(path.join(DECK_DIR, f), content);
81
+ }
82
+ const snapshot = (files) => Object.fromEntries(files.map((f) => [f, readDeck(f)]));
83
+ const json = (res, code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
84
+ const text = (res, code, s) => { res.writeHead(code, { "content-type": "text/plain; charset=utf-8" }); res.end(s); };
85
+ async function body(req) {
86
+ let s = "";
87
+ for await (const c of req) { s += c; if (s.length > 5e6) throw new Error("body too large"); }
88
+ return s ? JSON.parse(s) : {};
89
+ }
90
+ function safeJoin(base, rel) {
91
+ const p = path.resolve(base, "." + path.posix.normalize("/" + decodeURIComponent(rel)));
92
+ return p === base || p.startsWith(base + path.sep) ? p : null;
93
+ }
94
+
95
+ /* ---- Server-sent events ------------------------------------------------------------ */
96
+ const clients = new Set();
97
+ function broadcast(type, data) {
98
+ const msg = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
99
+ for (const res of clients) res.write(msg);
100
+ }
101
+ setInterval(() => { for (const res of clients) res.write(": ping\n\n"); }, 20000);
102
+
103
+ /* ---- Element ids ----------------------------------------------------------------------- */
104
+ let backedUp = false;
105
+ function runEidPass() {
106
+ const html = readDeck(DECK_FILE);
107
+ const r = assignEids(html);
108
+ if (r.changed) {
109
+ if (!backedUp) {
110
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
111
+ fs.mkdirSync(path.join(STATE_DIR, "backups"), { recursive: true });
112
+ fs.writeFileSync(path.join(STATE_DIR, "backups", `index-${ts}.html`), html);
113
+ backedUp = true;
114
+ }
115
+ writeDeck(DECK_FILE, r.html);
116
+ refreshGit();
117
+ }
118
+ return r;
119
+ }
120
+
121
+ /* With the "pindeck-ids" git filter (`node cli.mjs git-setup`) git strips the ids from commits and
122
+ diffs. But git lists a file as modified whenever its size differs from what it recorded, without
123
+ comparing contents, and adding ids changes the size. So after adding ids PinDeck re-records the file
124
+ in git (`git add`), but only when that changes nothing: its content without ids must equal what git
125
+ already has staged. A file with real edits is left alone and shows as modified, as it should. */
126
+ let gitFilter = null; // null: not checked yet
127
+ const git = (args, cb) => execFile("git", ["-C", DECK_DIR, ...args], { encoding: "utf8" }, (err, out) => cb(err, (out || "").trim()));
128
+ function refreshGit() {
129
+ const run = () => git(["hash-object", `--path=${DECK_FILE}`, DECK_FILE], (e1, cleaned) => {
130
+ if (e1 || !cleaned) return;
131
+ git(["ls-files", "-s", "--", DECK_FILE], (e2, entry) => {
132
+ const staged = entry.split(/\s+/)[1];
133
+ if (!e2 && staged === cleaned) git(["add", "--", DECK_FILE], () => {});
134
+ });
135
+ });
136
+ if (gitFilter === null) {
137
+ git(["check-attr", "filter", "--", DECK_FILE], (err, out) => {
138
+ gitFilter = !err && /: filter: pindeck-ids$/.test(out);
139
+ if (gitFilter) run();
140
+ });
141
+ } else if (gitFilter) run();
142
+ }
143
+ function slideList() {
144
+ const html = readDeck(DECK_FILE);
145
+ return findSlides(parse(html)).map((s, index) => ({ index, eid: s.attrs["data-eid"]?.value || null }));
146
+ }
147
+ function resolveSlide(spec) {
148
+ const slides = slideList();
149
+ if (!spec || spec === "current") return slides[state.current?.index ?? 0] || slides[0];
150
+ if (/^\d+$/.test(spec)) return slides[+spec - 1];
151
+ return slides.find((s) => s.eid === spec) || slides.find((s) => s.eid && s.eid.startsWith(spec));
152
+ }
153
+
154
+ /* ---- Headless Chrome (screenshots, and layout when no editor tab is open) --------------- */
155
+ const CHROME = [process.env.CHROME_BIN,
156
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
157
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
158
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
159
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
160
+ "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"].find((p) => p && fs.existsSync(p));
161
+ /* Headless Chrome on macOS often lingers long after it has written its output, so we
162
+ watch the output and stop Chrome as soon as `done(stdout, stderr)` says we have it. */
163
+ let chromeSeq = 0;
164
+ function chrome(args, done) {
165
+ if (!CHROME) return Promise.reject(new Error("No Chrome/Chromium found (set CHROME_BIN)."));
166
+ return new Promise((resolve, reject) => {
167
+ // a profile per run: concurrent runs must not share one
168
+ const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
169
+ const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
170
+ "--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
171
+ "--window-size=1280,720", "--force-device-scale-factor=1", "--virtual-time-budget=5000", ...args]);
172
+ let out = "", err = "", finished = false;
173
+ const finish = (fn, v) => {
174
+ if (finished) return;
175
+ finished = true;
176
+ clearTimeout(timer);
177
+ proc.kill("SIGKILL");
178
+ setTimeout(() => fs.rm(profile, { recursive: true, force: true }, () => {}), 500);
179
+ fn(v);
180
+ };
181
+ const check = () => { if (done(out, err)) finish(resolve, out); };
182
+ proc.stdout.on("data", (d) => { out += d; check(); });
183
+ proc.stderr.on("data", (d) => { err += d; check(); });
184
+ proc.on("error", (e) => finish(reject, e));
185
+ proc.on("exit", () => (done(out, err) ? finish(resolve, out) : finish(reject, new Error("Chrome exited without output."))));
186
+ const timer = setTimeout(() => finish(reject, new Error("Chrome timed out.")), 25000);
187
+ });
188
+ }
189
+ const deckUrl = (mode, params = {}) =>
190
+ `${ORIGIN}/deck/${DECK_FILE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
191
+ async function takeShot(slide) {
192
+ const pins = state.pins.filter((p) => p.slide.eid === slide.eid).map((p) => ({ n: p.n, eid: p.eid, at: p.at, point: p.point }));
193
+ const out = path.join(SHOT_DIR, `${slide.eid.replace(/[^\w-]/g, "_")}-${Date.now()}.png`);
194
+ await chrome([`--screenshot=${out}`, deckUrl("shot", { slide: slide.eid, pins: JSON.stringify(pins) })],
195
+ (_o, err) => /bytes written to file/.test(err) && fs.existsSync(out));
196
+ if (!fs.existsSync(out)) throw new Error("Chrome did not write the screenshot.");
197
+ // keep the folder small
198
+ const old = fs.readdirSync(SHOT_DIR).map((f) => path.join(SHOT_DIR, f))
199
+ .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs).slice(40);
200
+ old.forEach((f) => fs.rmSync(f, { force: true }));
201
+ return out;
202
+ }
203
+ async function measureHeadless(slide) {
204
+ const dom = await chrome(["--dump-dom", deckUrl("dump", { slide: slide.eid })], (o) => /<\/html>\s*$/i.test(o));
205
+ const m = /<script id="__editor_layout" type="application\/json">([\s\S]*?)<\/script>/.exec(dom);
206
+ if (!m) throw new Error("Headless layout measurement failed.");
207
+ const r = JSON.parse(m[1]);
208
+ if (r.error) throw new Error(r.error);
209
+ return r;
210
+ }
211
+
212
+ /* Measuring in the editor tab is fast and is exactly what the user sees. */
213
+ const pending = new Map();
214
+ let measureSeq = 0;
215
+ function measureInEditor(slide) {
216
+ if (!clients.size) return Promise.reject(new Error("no editor tab"));
217
+ const id = ++measureSeq;
218
+ broadcast("measure", { id, index: slide.index, eid: slide.eid });
219
+ return new Promise((resolve, reject) => {
220
+ const t = setTimeout(() => { pending.delete(id); reject(new Error("editor did not answer")); }, 8000);
221
+ pending.set(id, { resolve, reject, t });
222
+ });
223
+ }
224
+ const measure = (slide) => measureInEditor(slide).catch(() => measureHeadless(slide));
225
+
226
+ /* PDF export. handout: one page per slide, build steps in their final state.
227
+ presentation: one page per build step (a slide with two clicks gives three pages).
228
+ Files go to <deck>/export/. One export at a time. */
229
+ let exportQueue = Promise.resolve(), snapSeq = 0;
230
+ const snapshots = new Map(); // id -> static print layout, served at /deck/__pindeck-print-<id>.html
231
+ function exportPdf(kind, includeHidden = false) {
232
+ if (!CHROME) return Promise.reject(new Error("PDF export needs Chrome or Chromium (set CHROME_BIN)."));
233
+ const split = kind === "presentation";
234
+ const name = `${path.basename(DECK_DIR)}-${split ? "presentation" : "handout"}.pdf`;
235
+ const out = path.join(DECK_DIR, "export", name);
236
+ const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
237
+ const run = async () => {
238
+ fs.mkdirSync(path.dirname(out), { recursive: true });
239
+ const html = await printLayout(CHROME,
240
+ `${ORIGIN}/deck/${DECK_FILE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`, profile("layout"));
241
+ const id = `${process.pid}-${++snapSeq}`;
242
+ snapshots.set(id, html);
243
+ try { await printPdf(CHROME, `${ORIGIN}/deck/__pindeck-print-${id}.html`, out, profile("print")); }
244
+ finally { snapshots.delete(id); }
245
+ return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
246
+ url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
247
+ };
248
+ const job = exportQueue.then(run, run);
249
+ exportQueue = job.catch(() => {});
250
+ return job;
251
+ }
252
+
253
+ /* ---- Chat state and the Claude Code session --------------------------------------------- */
254
+ const CHAT_FILE = path.join(STATE_DIR, "chat.json");
255
+ const state = {
256
+ chat: [], seq: 0, busy: false, model: MODEL || null, sessionId: null,
257
+ turns: [], redo: [], turn: null, notes: [], pins: [], current: null,
258
+ uploads: new Set(), // files uploaded for the next message; removable until it is sent
259
+ pdfFigures: new Set(), // figures/ files written by pdf-figure, which it may overwrite
260
+ };
261
+ try {
262
+ const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
263
+ Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null });
264
+ } catch {}
265
+ const saveChat = () => fs.writeFile(CHAT_FILE,
266
+ JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId }), () => {});
267
+ function addEntry(entry) {
268
+ entry.id = ++state.seq;
269
+ entry.time = Date.now();
270
+ state.chat.push(entry);
271
+ broadcast("chat", entry);
272
+ saveChat();
273
+ return entry;
274
+ }
275
+ const liveTurns = () => state.turns.filter((t) => !t.undone);
276
+ const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "Claude's change") : null);
277
+ /* Model and effort for the Claude Code session. ~/.pindeck/config.json holds them: PinDeck creates it
278
+ with the defaults below, you can edit it, and a choice in the chat header's menu is saved there too,
279
+ so the file always shows what PinDeck uses. --model on the command line applies to one run only. */
280
+ const MODELS = ["opus", "sonnet"];
281
+ const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
282
+ const DEFAULTS = { model: "sonnet", effort: "medium" };
283
+ const CONFIG_FILE = path.join(HOME_DIR, "config.json");
284
+ const settings = { ...DEFAULTS };
285
+ let fileConfig = {};
286
+ try { fileConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); }
287
+ catch (e) { if (e.code !== "ENOENT") console.warn(`${CONFIG_FILE} is not valid JSON; using the defaults.`); }
288
+ if (MODELS.includes(fileConfig.model)) settings.model = fileConfig.model;
289
+ else if (fileConfig.model !== undefined) console.warn(`config.json: unknown model "${fileConfig.model}" (use ${MODELS.join(" or ")}); using ${DEFAULTS.model}.`);
290
+ if (EFFORTS.includes(fileConfig.effort)) settings.effort = fileConfig.effort;
291
+ else if (fileConfig.effort !== undefined) console.warn(`config.json: unknown effort "${fileConfig.effort}" (use ${EFFORTS.join(", ")}); using ${DEFAULTS.effort}.`);
292
+ const saveSettings = () => fs.writeFile(CONFIG_FILE, JSON.stringify({ ...fileConfig, model: settings.model, effort: settings.effort }, null, 2) + "\n", () => {});
293
+ if (fileConfig.model !== settings.model || fileConfig.effort !== settings.effort) saveSettings(); // create or complete the file
294
+ if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
295
+
296
+ const status = () => ({ busy: state.busy, model: state.model, sessionId: state.sessionId, settings: { ...settings },
297
+ running: session.running, deck: DECK_DIR, deckFile: DECK_FILE, chrome: !!CHROME,
298
+ undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
299
+ const pushStatus = () => broadcast("status", status());
300
+
301
+ const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
302
+ .replaceAll("{{CLI}}", CLI_CMD);
303
+ /* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
304
+ or a parent repo's settings would let Claude run more than PinDeck grants (and the user's hooks and
305
+ MCP servers have no place in a slide editor). That also skips the deck's CLAUDE.md, so it goes into
306
+ the system prompt instead, read afresh at each session start. */
307
+ function deckInstructions() {
308
+ try { return `\n\n## The deck's CLAUDE.md\n${fs.readFileSync(path.join(DECK_DIR, "CLAUDE.md"), "utf8").trim()}\n`; }
309
+ catch { return ""; }
310
+ }
311
+ let stderrTail = "";
312
+ const session = new ClaudeSession({
313
+ cwd: DECK_DIR,
314
+ env: { DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN },
315
+ args: () => [
316
+ "--append-system-prompt", systemPrompt + deckInstructions(),
317
+ "--setting-sources", "", "--strict-mcp-config", // no settings files, no MCP servers or connectors
318
+ "--permission-mode", "default",
319
+ "--allowedTools", "Read", "Glob", "Grep", ...EDITABLE.map((f) => `Edit(./${f})`), `Bash(${CLI_CMD}:*)`,
320
+ "--disallowedTools", ...LOCKED.flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
321
+ ...(settings.model ? ["--model", settings.model] : []),
322
+ ...(settings.effort ? ["--effort", settings.effort] : []),
323
+ ],
324
+ onStderr: (s) => { stderrTail = (stderrTail + s).slice(-2000); },
325
+ onEvent: handleEvent,
326
+ onExit: (code, signal, { stale } = {}) => {
327
+ if (state.busy && state.turn && !stale) {
328
+ const why = code == null && signal == null ? "could not start" : `stopped (${signal || "exit " + code})`;
329
+ const hint = /ENOENT/.test(stderrTail) ? " Is the claude command installed and on the PATH (or set CLAUDE_BIN)?" : "";
330
+ finishTurn({ error: `Claude Code ${why}. ${stderrTail.trim().split("\n").pop() || ""}${hint}` });
331
+ }
332
+ pushStatus();
333
+ },
334
+ });
335
+ function ensureSession() {
336
+ if (!session.running) { stderrTail = ""; session.start(state.sessionId); pushStatus(); }
337
+ }
338
+
339
+ function toolSummary(name, input = {}) {
340
+ // files outside the deck (screenshots, attached PDFs) by their name only
341
+ const base = (p) => {
342
+ if (!p) return "";
343
+ const r = path.relative(DECK_DIR, p);
344
+ return !r || r.startsWith("..") ? path.basename(p) : r;
345
+ };
346
+ switch (name) {
347
+ case "Read": return { name: "Read", detail: base(input.file_path) };
348
+ case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
349
+ case "Bash": {
350
+ // a PinDeck command only when the whole command is one: a script that ends in `cli.mjs layout`
351
+ // must show as the script, or a refused script looks like a refused layout check
352
+ const cmd = String(input.command || "").trim();
353
+ const m = cmd.startsWith(CLI_CMD + " ") && cmd.slice(CLI_CMD.length).match(/^\s+([\w-]+)\s*([^\n;&|<>`$]*)$/);
354
+ const names = { shot: "Screenshot", layout: "Check layout", "pdf-figure": "PDF figure" };
355
+ return m ? { name: names[m[1]] || m[1], detail: m[1] === "pdf-figure" ? m[2].trim().replace(/^\S*\//, "") : m[2].trim() }
356
+ : { name: "Run", detail: cmd.split("\n")[0].slice(0, 80) };
357
+ }
358
+ case "Glob": case "Grep": return { name, detail: input.pattern || "" };
359
+ default: return { name, detail: "" };
360
+ }
361
+ }
362
+ const toolEntries = new Map(); // tool_use_id -> entry
363
+ function handleEvent(ev) {
364
+ if (ev.type === "system" && ev.subtype === "init") {
365
+ state.model = ev.model || state.model;
366
+ state.sessionId = ev.session_id;
367
+ saveChat();
368
+ pushStatus();
369
+ } else if (ev.type === "assistant" && state.turn) {
370
+ for (const b of ev.message?.content || []) {
371
+ if (b.type === "text" && b.text.trim()) addEntry({ kind: "assistant", text: b.text.trim() });
372
+ if (b.type === "tool_use") toolEntries.set(b.id, addEntry({ kind: "tool", ...toolSummary(b.name, b.input) }));
373
+ }
374
+ } else if (ev.type === "user" && state.turn) {
375
+ for (const b of ev.message?.content || []) {
376
+ if (b.type === "tool_result" && b.is_error) {
377
+ const e = toolEntries.get(b.tool_use_id);
378
+ const msg = (Array.isArray(b.content) ? b.content.map((c) => c.text || "").join(" ") : String(b.content || "")).slice(0, 200);
379
+ if (e) { e.error = msg; broadcast("chat-update", e); }
380
+ }
381
+ }
382
+ } else if (ev.type === "result" && state.turn) {
383
+ finishTurn({
384
+ error: ev.is_error && ev.subtype !== "success" ? (ev.subtype === "error_during_execution" ? "Stopped." : ev.result || ev.subtype) : null,
385
+ cost: ev.total_cost_usd, duration: ev.duration_ms,
386
+ denials: (ev.permission_denials || []).map((d) => toolSummary(d.tool_name, d.tool_input)),
387
+ });
388
+ }
389
+ }
390
+
391
+ function startTurn(promptText, userEntry) {
392
+ state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]) };
393
+ state.busy = true;
394
+ pushStatus();
395
+ ensureSession();
396
+ session.send([{ type: "text", text: promptText }]);
397
+ }
398
+
399
+ function finishTurn({ error, cost, duration, denials = [] }) {
400
+ const turn = state.turn;
401
+ state.turn = null;
402
+ state.busy = false;
403
+ if (!turn) return pushStatus();
404
+ // Lock 2: restore any locked file that changed during the turn
405
+ const restored = [];
406
+ for (const f of LOCKED) {
407
+ const now = readDeck(f);
408
+ if (now === turn.before[f]) continue;
409
+ if (turn.before[f] != null) writeDeck(f, turn.before[f]);
410
+ else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // a locked file that Claude created
411
+ restored.push(f);
412
+ }
413
+ toolEntries.clear();
414
+ const eid = runEidPass();
415
+ const after = snapshot(EDITABLE);
416
+ const files = EDITABLE.map((f) => {
417
+ const d = unifiedDiff(turn.before[f] ?? "", after[f] ?? "", f);
418
+ return { file: f, added: d.added, removed: d.removed };
419
+ }).filter((f) => f.added || f.removed);
420
+ // after a hand-off: imported pictures that Claude replaced (nothing refers to them any more) are
421
+ // deleted; undo brings them back with the turn
422
+ const deletedFiles = [];
423
+ for (const rel of turn.importedFiles || []) {
424
+ const abs = path.join(DECK_DIR, rel);
425
+ if (!fs.existsSync(abs) || EDITABLE.some((f) => (after[f] || "").includes(rel))) continue;
426
+ deletedFiles.push({ rel, data: fs.readFileSync(abs) });
427
+ fs.rmSync(abs, { force: true });
428
+ }
429
+ const t = { id: turn.id, kind: "claude", before: turn.before, after, files, deletedFiles, undone: false };
430
+ if (files.length) { state.turns.push(t); state.redo = []; }
431
+ addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
432
+ denials, restored, newIds: eid.assigned.length });
433
+ if (deletedFiles.length) addEntry({ kind: "notice", text: `Removed ${deletedFiles.map((f) => f.rel).join(", ")}: replaced, and no longer used.` });
434
+ pushStatus();
435
+ }
436
+
437
+ /* ---- Attachments: figures, audio and PDFs added to a chat message ------------------------------------
438
+ Figures and audio go into <deck>/figures/ (paths relative to the deck). PDFs are reading material for
439
+ Claude (a paper, a report, a figure or table to take over) and stay outside the deck, in PDF_DIR
440
+ (absolute paths); `cli.mjs pdf-figure` renders a page or part of one into figures/ when it is needed. */
441
+ const ATTACH_TYPES = {
442
+ image: ["png", "jpg", "jpeg", "gif", "webp", "svg", "avif"],
443
+ audio: ["wav", "mp3", "ogg", "oga", "m4a", "aac", "flac", "opus"],
444
+ pdf: ["pdf"],
445
+ };
446
+ const uploadFile = (p) => (path.isAbsolute(p) ? p : path.join(DECK_DIR, p));
447
+ const ATTACH_MAX = 100 * 1024 * 1024;
448
+ const attachKind = (name) => {
449
+ const ext = path.extname(name).slice(1).toLowerCase();
450
+ return Object.keys(ATTACH_TYPES).find((k) => ATTACH_TYPES[k].includes(ext)) || null;
451
+ };
452
+ /* A clean, unused file name in figures/ (or in dir): "My Figure (2).PNG" -> "figures/my-figure-2.png" */
453
+ const slug = (name) => name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
454
+ .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "file";
455
+ function attachPath(original, dir = "figures") {
456
+ const ext = path.extname(original).toLowerCase();
457
+ const base = slug(path.basename(original, path.extname(original)));
458
+ let rel = `${dir}/${base}${ext}`, k = 2;
459
+ while (fs.existsSync(uploadFile(rel))) rel = `${dir}/${base}-${k++}${ext}`;
460
+ return rel;
461
+ }
462
+ const kb = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} kB`);
463
+ function formatAttachment(a) {
464
+ if (a.kind === "pdf")
465
+ return `- ${a.path}: PDF, ${a.pages ? `${a.pages} page${a.pages > 1 ? "s" : ""}${a.pageSize ? ` (${a.pageSize[0]} × ${a.pageSize[1]} pt)` : ""}, ` : ""}${kb(a.bytes)}. ` +
466
+ `Read it directly${a.pages > 10 ? ' with pages (e.g. "1-5"), at most 20 per Read' : ""}; for a figure or table from it, see "Attached PDFs".`;
467
+ if (a.kind === "image")
468
+ return `- ${a.path}: image${a.width ? `, ${a.width} × ${a.height} px` : ""}, ${kb(a.bytes)}` +
469
+ (/\.(png|jpe?g|gif|webp)$/i.test(a.path) ? ". Read it directly to see it (no resizing needed)." : a.path.endsWith(".svg") ? " (SVG, readable as text)." : ".");
470
+ const meta = [a.duration ? `${a.duration.toFixed(1)} s` : "", a.sampleRate ? `${Math.round(a.sampleRate / 1000)} kHz` : "",
471
+ a.channels ? (a.channels === 1 ? "mono" : `${a.channels} channels`) : "", kb(a.bytes)].filter(Boolean).join(", ");
472
+ return `- ${a.path}: audio, ${meta}` + (a.waveform ? `; waveform image ${a.waveform} (bars, viewBox 1000 × 120) for an audio player with waveform.` : ".");
473
+ }
474
+ const formatDrop = (a) => (a.droppedAt?.slide ? ` Dropped onto slide ${a.droppedAt.slide.index + 1} (${a.droppedAt.slide.eid}) at ` +
475
+ `x=${a.droppedAt.point.x} y=${a.droppedAt.point.y}${a.droppedAt.eid ? `, over ${a.droppedAt.eid}` : ""}: probably where the user wants it.` : "");
476
+
477
+ function buildPrompt({ text: userText, pins = [], selection, layouts = [], current, screenshot, attachments = [] }) {
478
+ const ctx = [];
479
+ if (current) ctx.push(`The user is looking at slide ${current.index + 1} (${current.eid}).`);
480
+ if (state.notes.length) {
481
+ const notes = state.notes.slice(-15);
482
+ if (state.notes.length > notes.length) ctx.push(`Note: ${state.notes.length - notes.length} earlier changes are not listed; re-read the files.`);
483
+ ctx.push(...notes.map((n) => "Note: " + n));
484
+ state.notes = [];
485
+ }
486
+ if (selection) ctx.push(`Selected element (what "this" or "it" most likely refers to): ${selection.eid} (${selection.tag}), ` +
487
+ `box x=${selection.box.x} y=${selection.box.y} w=${selection.box.w} h=${selection.box.h} on slide ${selection.slide.index + 1}.`);
488
+ if (pins.length) ctx.push("Pins (numbered clicks; the user refers to them by number):\n" + pins.map(formatPin).join("\n"));
489
+ for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
490
+ if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide} with the pin markers: ${screenshot.path} (Read it).`);
491
+ if (attachments.length) ctx.push("Attached files (the user added them to this message; they are saved in the deck folder, " +
492
+ "use these paths relative to index.html):\n" + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
493
+ return (ctx.length ? `<editor-context>\n${ctx.join("\n\n")}\n</editor-context>\n\n` : "") + userText;
494
+ }
495
+
496
+ /* `cli.mjs pdf-figure <pdf> <page> [x,y,w,h] [figures/name.png] [--width px]`: renders a PDF page, or a box
497
+ of it given as fractions of the page from the top left, to PNG. Without a figures/ name it goes to the
498
+ screenshots folder, for Claude to look at before choosing; with one, into the deck. */
499
+ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "" }) {
500
+ const bad = (t, status = 400) => ({ status, text: t });
501
+ if (process.platform !== "darwin") return bad("Rendering PDF pages needs macOS (PDFKit).", 501);
502
+ const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
503
+ .map((f) => path.resolve(f)).find((f) => /\.pdf$/i.test(f) && fs.existsSync(f) &&
504
+ [DECK_DIR, PDF_DIR].some((d) => f.startsWith(d + path.sep)));
505
+ if (!abs) return bad(`No PDF "${file}": use the path from the attached files, or a .pdf in the deck folder.`, 404);
506
+ const info = await pdfInfo(abs, STATE_DIR);
507
+ const n = Number(page);
508
+ if (!(Number.isInteger(n) && n >= 1 && n <= info.pages)) return bad(`No page ${page}: the PDF has ${info.pages} page${info.pages > 1 ? "s" : ""}.`);
509
+ const f = box ? box.split(",").map(Number) : [0, 0, 1, 1];
510
+ if (f.length !== 4 || f.some((v) => !(v >= 0 && v <= 1)) || !f[2] || !f[3] || f[0] + f[2] > 1.001 || f[1] + f[3] > 1.001)
511
+ return bad("The box is x,y,w,h as fractions of the page (0 to 1, from the top left), e.g. 0.1,0.25,0.8,0.3.");
512
+ const [pw, ph] = info.sizes[n - 1];
513
+ const pts = [f[0] * pw, f[1] * ph, Math.min(f[2], 1 - f[0]) * pw, Math.min(f[3], 1 - f[1]) * ph];
514
+ const scale = Math.min(8, Math.max(100, Math.min(4000, Number(width) || 1600)) / pts[2]); // output px per point
515
+ if (out && !/^figures\/[a-z0-9][a-z0-9._-]*\.png$/i.test(out)) return bad("The output is a PNG in figures/, e.g. figures/paper-fig3.png.");
516
+ if (out && fs.existsSync(path.join(DECK_DIR, out)) && !state.pdfFigures.has(out)) return bad(`${out} exists already; choose another name.`, 409);
517
+ const tmp = path.join(SHOT_DIR, `pdf-${slug(path.basename(abs, ".pdf"))}-p${n}-${Date.now()}.png`);
518
+ await renderJobs(abs, [{ page: n, out: tmp, box: pts, scale }], STATE_DIR, { slideWidth: pw });
519
+ if (!fs.existsSync(tmp)) return bad("PDFKit did not write the picture.", 500);
520
+ const size = `${Math.round(pts[2] * scale)} × ${Math.round(pts[3] * scale)} px`;
521
+ if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
522
+ const buf = fs.readFileSync(tmp);
523
+ fs.rmSync(tmp, { force: true });
524
+ fs.mkdirSync(path.join(DECK_DIR, "figures"), { recursive: true });
525
+ ourWrites.set(out, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
526
+ fs.writeFileSync(path.join(DECK_DIR, out), buf);
527
+ state.pdfFigures.add(out);
528
+ return { text: `${out} (${size})` };
529
+ }
530
+
531
+ /* ---- File watching -------------------------------------------------------------------------- */
532
+ let changed = new Set(), flush = null, eidTimer = null;
533
+ fs.watch(DECK_DIR, { recursive: true }, (_ev, file) => {
534
+ if (!file || /(^|\/)\.|\.bak$|~$|^export[\/]/.test(file)) return;
535
+ changed.add(file.split(path.sep).join("/"));
536
+ clearTimeout(flush);
537
+ flush = setTimeout(() => {
538
+ const files = [...changed];
539
+ changed = new Set();
540
+ let external = false, manual = true;
541
+ for (const f of files) {
542
+ const c = readDeck(f);
543
+ const w = ourWrites.get(f);
544
+ if (c != null && w && w.hash === hash(c)) { if (!w.manual) manual = false; continue; }
545
+ manual = false;
546
+ if (EDITABLE.includes(f) && !state.busy) external = true;
547
+ }
548
+ if (external) {
549
+ state.notes = [...new Set([...state.notes,
550
+ "The deck files changed outside this chat since your last turn (edited by hand or by another tool). Re-read before editing."])];
551
+ // new elements from a hand edit get ids once the file is quiet
552
+ clearTimeout(eidTimer);
553
+ eidTimer = setTimeout(() => { if (!state.busy) runEidPass(); }, 800);
554
+ }
555
+ // manual: only drag/resize writes, which the browser already shows; no reload of the visible slide
556
+ broadcast("files", { files, manual });
557
+ }, 120);
558
+ });
559
+
560
+ /* ---- Hand edits ----------------------------------------------------------------------------------------- */
561
+ function recordHandEdit(before, html, label, note, { manual = true, deletedFiles = [], addedFiles = [], chat = true } = {}) {
562
+ writeDeck(DECK_FILE, html, manual);
563
+ const after = snapshot(EDITABLE);
564
+ const d = unifiedDiff(before[DECK_FILE], after[DECK_FILE], DECK_FILE);
565
+ const t = { id: state.seq + 1, kind: "manual", label, before, after, deletedFiles, addedFiles,
566
+ files: [{ file: DECK_FILE, added: d.added, removed: d.removed }], undone: false };
567
+ state.turns.push(t);
568
+ state.redo = [];
569
+ // chat: false keeps the edit out of the chat (it stays in the undo history and Claude is still told)
570
+ if (chat) addEntry({ kind: "edit", turnId: t.id, label, files: t.files });
571
+ else state.seq++; // keep turn ids unique: they share the counter with chat entries
572
+ state.notes.push(note);
573
+ pushStatus();
574
+ return t.id;
575
+ }
576
+
577
+ /* What deleting an element would remove, and which of its files nothing else uses. Files outside the
578
+ deck folder, in vendor/, or still referenced elsewhere (index.html, theme.css) are never offered. */
579
+ function deletePlan(eid) {
580
+ const html = readDeck(DECK_FILE);
581
+ const target = deleteTarget(html, eid);
582
+ if (!target) return null;
583
+ const rest = removeElement(html, target.eid);
584
+ const css = (readDeck("theme.css") || "") + (readDeck("brand.css") || "");
585
+ const files = fileRefs(target.outer).flatMap((ref) => {
586
+ const abs = path.resolve(DECK_DIR, ref);
587
+ const rel = path.relative(DECK_DIR, abs).split(path.sep).join("/");
588
+ if (rel.startsWith("..") || rel.startsWith("vendor/") || !fs.existsSync(abs) || !fs.statSync(abs).isFile()) return [];
589
+ const usedElsewhere = fileRefs(rest).some((r) => path.resolve(DECK_DIR, r) === abs) || css.includes(ref) || css.includes(rel);
590
+ return [{ rel, bytes: fs.statSync(abs).size, usedElsewhere }];
591
+ });
592
+ return { ...target, html: rest, files };
593
+ }
594
+
595
+ /* ---- Undo (Claude's turns and hand edits share one history) ---------------------------------------- */
596
+ function undo(res) {
597
+ if (state.busy) return json(res, 409, { error: "Wait until Claude has finished." });
598
+ const t = liveTurns().at(-1);
599
+ if (!t) return json(res, 409, { error: "Nothing to undo." });
600
+ const edited = EDITABLE.some((f) => readDeck(f) !== t.after[f]);
601
+ for (const f of EDITABLE) {
602
+ if (readDeck(f) === t.before[f]) continue;
603
+ if (t.before[f] != null) writeDeck(f, t.before[f]);
604
+ else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // the change created the file
605
+ }
606
+ for (const { rel, data } of t.deletedFiles || []) { // files deleted with an element come back too
607
+ fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
608
+ fs.writeFileSync(path.join(DECK_DIR, rel), data);
609
+ }
610
+ for (const { rel } of t.addedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true }); // imported figures go
611
+ t.undone = true;
612
+ state.redo.push(t);
613
+ state.notes.push(t.kind === "manual"
614
+ ? `The user undid their hand edit (${t.label}); ${DECK_FILE} is back to the state before it.`
615
+ : `The user undid your change from turn ${t.id}; ${t.files.map((f) => f.file).join(" and ")} are back to the state before it. Re-read before editing.`);
616
+ broadcast("undone", { turnId: t.id, undone: true });
617
+ if (edited) addEntry({ kind: "notice", text: "Undone. Edits made after that change were reverted too." });
618
+ pushStatus();
619
+ return json(res, 200, {});
620
+ }
621
+
622
+ /* ---- PowerPoint import -----------------------------------------------------------------------------
623
+ Upload: the file is read at once (slide list for the dialog); in the background PowerPoint or Keynote
624
+ makes a PDF of it, from which come the thumbnails and, on import, the pictures of diagrams.
625
+ Only the latest upload is kept, in ~/.pindeck/decks/<deck>/imports/. */
626
+ const IMPORT_DIR = path.join(STATE_DIR, "imports");
627
+ const IMPORT_MAX = 500 * 1024 * 1024;
628
+ let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
629
+ fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
630
+
631
+ function startImport(name, buf) {
632
+ const model = readPptx(buf); // first: a file that cannot be read leaves the previous import as it was
633
+ if (lastImport) fs.rm(lastImport.dir, { recursive: true, force: true }, () => {});
634
+ const id = crypto.randomBytes(6).toString("hex");
635
+ const dir = path.join(IMPORT_DIR, id);
636
+ fs.mkdirSync(dir, { recursive: true });
637
+ const file = path.join(dir, "deck.pptx");
638
+ fs.writeFileSync(file, buf);
639
+ const imp = { id, name, dir, model, renderError: null };
640
+ imp.render = !renderAvailable() ? Promise.resolve(null) : (async () => {
641
+ try {
642
+ const { pdf, app } = await pptxToPdf(file, dir);
643
+ await renderJobs(pdf, model.slides.map((s) => ({ page: s.n, out: path.join(dir, `thumb-${s.n}.png`), width: 320 })), dir);
644
+ if (lastImport === imp) broadcast("import", { id, thumbs: true, app });
645
+ return pdf;
646
+ } catch (e) {
647
+ imp.renderError = String(e.message || e);
648
+ if (lastImport === imp) broadcast("import", { id, error: imp.renderError });
649
+ return null;
650
+ }
651
+ })();
652
+ lastImport = imp;
653
+ return imp;
654
+ }
655
+
656
+ async function applyImport(imp, numbers, afterEid, refine = false) {
657
+ const pdf = await imp.render;
658
+ const before = snapshot(EDITABLE);
659
+ const html0 = before[DECK_FILE];
660
+ const slides = findSlides(parse(html0));
661
+ if (!slides.length) throw new Error("The deck has no slides to add to.");
662
+ const found = slides.findIndex((s) => s.attrs["data-eid"]?.value === afterEid);
663
+ const afterIdx = found < 0 ? slides.length - 1 : found; // no such slide (any more): at the end
664
+ const after = slides[afterIdx];
665
+ const css = (readDeck("theme.css") || "") + (readDeck("brand.css") || "");
666
+ const prefix = path.basename(imp.name, path.extname(imp.name)).normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
667
+ .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").split("-").slice(0, 3).join("-") || "import";
668
+ const r = await buildImport({ model: imp.model, numbers, pdf, workDir: imp.dir, deckDir: DECK_DIR, deckHtml: html0, css, after: afterIdx, prefix });
669
+ // building takes a while (pictures are cut from the slides): a change made meanwhile must not be overwritten
670
+ if (EDITABLE.some((f) => readDeck(f) !== before[f])) throw new Error("The deck changed during the import. Import again.");
671
+ // the new slides go after the current one, at its indentation
672
+ const end = elementEnd(html0, after);
673
+ const ls = html0.lastIndexOf("\n", after.start - 1) + 1;
674
+ const indent = /^\s*$/.test(html0.slice(ls, after.start)) ? html0.slice(ls, after.start) : "";
675
+ const html = assignEids(html0.slice(0, end) + r.fragments.map((f) => `\n\n${indent}` + f).join("") + html0.slice(end)).html;
676
+ for (const f of r.files) {
677
+ const abs = path.join(DECK_DIR, f.rel);
678
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
679
+ ourWrites.set(f.rel, { hash: hash(f.data.toString("utf8")), manual: true }); // no reload for each new file
680
+ fs.writeFileSync(abs, f.data);
681
+ }
682
+ const all = findSlides(parse(html));
683
+ const first = afterIdx + 1, last = afterIdx + r.fragments.length;
684
+ const eids = all.slice(first, last + 1).map((s) => s.attrs["data-eid"]?.value);
685
+ const range = first === last ? `slide ${first + 1}` : `slides ${first + 1}–${last + 1}`;
686
+ const cut = r.plans.flatMap((p) => p.figures.filter((f) => f.crop && !f.missing).map((f) => f.file));
687
+ const lost = r.plans.reduce((n, p) => n + (p.lostSteps || 0), 0);
688
+ const turnId = recordHandEdit(before, html, `Imported ${r.fragments.length} slide${r.fragments.length > 1 ? "s" : ""} from ${imp.name} (${range})`,
689
+ `The user imported ${r.fragments.length} slide(s) from the PowerPoint file "${imp.name}" as ${range} (${eids.join(", ")}). ` +
690
+ `Text and tables became HTML in the deck's layouts, build steps fragments, audio the deck's audio player; ` +
691
+ (cut.length ? `diagrams, charts and other drawings are pictures cut from PowerPoint's rendering of the slide (${cut.join(", ")}). ` : "") +
692
+ (lost ? `${lost} build step(s) on parts of a picture could not be kept. ` : "") +
693
+ `Hidden PowerPoint slides are hidden here too. The layout is a first approximation: check it before relying on it.`,
694
+ { manual: true, addedFiles: r.files });
695
+ const refining = refine && !state.busy ? await refineImport(imp, pdf, r.plans, eids, first) : 0;
696
+ return { turnId, index: first, eid: eids[0], count: r.fragments.length, refining,
697
+ missing: r.plans.flatMap((p) => p.figures.filter((f) => f.missing)).length };
698
+ }
699
+
700
+ /* The hand-off: Claude gets the slides the import could only approximate, each with a picture of the
701
+ original slide, and improves them in the deck's style. Returns the number of slides handed over. */
702
+ async function refineImport(imp, pdf, plans, eids, first) {
703
+ const todo = plans.map((p, i) => ({ p, index: first + i, eid: eids[i] })).filter((x) => x.p.complex.length);
704
+ if (!todo.length) return 0;
705
+ fs.mkdirSync(SHOT_DIR, { recursive: true });
706
+ const original = (n) => path.join(SHOT_DIR, `import-${imp.id}-${n}.png`);
707
+ if (pdf) {
708
+ try { await renderJobs(pdf, todo.map((x) => ({ page: x.p.n, out: original(x.p.n), width: 1280 })), imp.dir); }
709
+ catch (e) { console.error(e); }
710
+ }
711
+ const nums = todo.map((x) => x.index + 1);
712
+ const list = nums.length === 1 ? `slide ${nums[0]}` : `slides ${nums.slice(0, -1).join(", ")} and ${nums.at(-1)}`;
713
+ const visible = `Improve the imported ${list}: compare ${todo.length === 1 ? "it" : "each"} with the original PowerPoint slide and rebuild what the import could only approximate.`;
714
+ const details = todo.map(({ p, index, eid }) => {
715
+ const pics = p.figures.filter((f) => f.crop && !f.missing && f.labels?.length)
716
+ .map((f) => ` ${f.file} (${f.table ? "table" : `${f.shapes} shapes`}); its texts: ${f.labels.map((l) => JSON.stringify(l)).join(", ")}`);
717
+ return `- Slide ${index + 1} (${eid}), PowerPoint slide ${p.n}: ${p.complex.join("; ")}.` +
718
+ (fs.existsSync(original(p.n)) ? `\n Original slide: ${original(p.n)} (Read it).` : "") + (pics.length ? "\n" + pics.join("\n") : "");
719
+ }).join("\n");
720
+ const text = visible + "\n\n" +
721
+ `How to work, slide by slide: Read the original picture, look at the imported slide (\`${CLI_CMD} shot <n>\`), then improve it ` +
722
+ `in this deck's own style (theme.css):\n` +
723
+ `- Diagrams of boxes, arrows and text that became a picture: rebuild them as inline SVG in the deck's diagram style, ` +
724
+ `using the texts listed. Keep a picture if it is a plot, a photo or too intricate to redraw well. Do not delete a picture ` +
725
+ `you replaced: PinDeck removes it after your turn once nothing refers to it.\n` +
726
+ `- Equations that are pictures: write them as TeX (\\( \\) or \\[ \\]) if you can read them.\n` +
727
+ `- Tables kept as pictures: make them HTML tables in the deck's table style, marking highlighted rows as the deck does.\n` +
728
+ `- Build steps lost on parts of a diagram: add them back as fragments when you rebuild it.\n` +
729
+ `- Layout: nothing may overflow or overlap; check with \`${CLI_CMD} layout ${nums.join(" ")} --issues\`.\n` +
730
+ `Keep the wording of the text and the speaker notes.\n\n${details}`;
731
+ const entry = addEntry({ kind: "user", text: visible });
732
+ startTurn(buildPrompt({ text }), entry);
733
+ state.turn.importedFiles = todo.flatMap(({ p }) => p.figures.filter((f) => f.crop && !f.missing).map((f) => f.file));
734
+ return todo.length;
735
+ }
736
+
737
+ /* ---- HTTP ------------------------------------------------------------------------------------- */
738
+ const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
739
+ const HOSTS = new Set([`127.0.0.1:${PORT}`, `localhost:${PORT}`]);
740
+
741
+ function serveFile(res, file, transform) {
742
+ fs.readFile(file, (err, data) => {
743
+ if (err) return text(res, 404, "Not found");
744
+ const ext = path.extname(file).slice(1).toLowerCase();
745
+ res.writeHead(200, { "content-type": MIME[ext] || "application/octet-stream", "cache-control": "no-store" });
746
+ res.end(transform ? transform(String(data)) : data);
747
+ });
748
+ }
749
+
750
+ const server = http.createServer(async (req, res) => {
751
+ // only pages opened as 127.0.0.1 or localhost: another site's name pointed at 127.0.0.1 (DNS rebinding)
752
+ // must not read the editor page, its token or the deck
753
+ if (!HOSTS.has(String(req.headers.host || "").toLowerCase())) return text(res, 403, "Bad host");
754
+ const url = new URL(req.url, ORIGIN);
755
+ const p = url.pathname;
756
+ try {
757
+ // UI and deck files (read-only)
758
+ if (req.method === "GET" && (p === "/" || p === "/index.html"))
759
+ return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), (s) => s.replace("%%TOKEN%%", TOKEN));
760
+ if (req.method === "GET" && p.startsWith("/editor/")) {
761
+ const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
762
+ return f ? serveFile(res, f) : text(res, 403, "Forbidden");
763
+ }
764
+ let snap;
765
+ if (req.method === "GET" && (snap = p.match(/^\/deck\/__pindeck-print-([\w-]+)\.html$/))) {
766
+ const html = snapshots.get(snap[1]);
767
+ if (!html) return text(res, 404, "Not found");
768
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
769
+ return res.end(html);
770
+ }
771
+ if (req.method === "GET" && p.startsWith("/deck/")) {
772
+ const f = safeJoin(DECK_DIR, p.slice(6) || DECK_FILE);
773
+ if (!f) return text(res, 403, "Forbidden");
774
+ const mode = url.searchParams.get("editor");
775
+ if (!mode || !f.endsWith(".html")) return serveFile(res, f);
776
+ return serveFile(res, f, (s) => {
777
+ // headless runs must not stream media, or Chrome never finishes the page
778
+ if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
779
+ if (mode === "pdf") return s.replace(/<\/head>/i, PDF_HEAD_TAG).replace(/<\/body>/i, PDF_PAGE_TAG);
780
+ return s.replace(/<\/body>/i, BRIDGE_TAG);
781
+ });
782
+ }
783
+
784
+ // API: token required; POSTs must come from the editor page itself
785
+ if (!p.startsWith("/api/")) return text(res, 404, "Not found");
786
+ const tok = req.headers["x-editor-token"] || url.searchParams.get("t");
787
+ if (tok !== TOKEN) return text(res, 403, "Bad token");
788
+ const origin = req.headers.origin;
789
+ if (req.method === "POST" && origin && origin !== ORIGIN && origin !== `http://localhost:${PORT}`)
790
+ return text(res, 403, "Bad origin");
791
+
792
+ if (p === "/api/events") {
793
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
794
+ res.write(`event: hello\ndata: ${JSON.stringify({ chat: state.chat, status: status(),
795
+ turns: state.turns.map((t) => ({ id: t.id, undone: t.undone })) })}\n\n`);
796
+ clients.add(res);
797
+ req.on("close", () => clients.delete(res));
798
+ return;
799
+ }
800
+ if (p === "/api/ping") return json(res, 200, { ok: true });
801
+ if (p === "/api/current" && req.method === "POST") { state.current = await body(req); return json(res, 200, {}); }
802
+ if (p === "/api/pins" && req.method === "POST") { state.pins = (await body(req)).pins || []; return json(res, 200, {}); }
803
+ if (p === "/api/measure-result" && req.method === "POST") {
804
+ const b = await body(req);
805
+ const w = pending.get(b.id);
806
+ if (w) { pending.delete(b.id); clearTimeout(w.t); b.error ? w.reject(new Error(b.error)) : w.resolve(b.result); }
807
+ return json(res, 200, {});
808
+ }
809
+
810
+ if (p === "/api/message" && req.method === "POST") {
811
+ if (state.busy) return json(res, 409, { error: "Claude is still working." });
812
+ const b = await body(req);
813
+ if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Empty message." });
814
+ // what the prompt reads from pins and the selection must be there
815
+ const placed = (x) => x && typeof x.eid === "string" && x.slide && Number.isInteger(x.slide.index) && x.box;
816
+ b.pins = (Array.isArray(b.pins) ? b.pins : []).filter((p) => placed(p) && p.point && p.at);
817
+ if (!placed(b.selection)) b.selection = null;
818
+ if (!Array.isArray(b.layouts)) b.layouts = [];
819
+ if (b.current && !Number.isInteger(b.current.index)) b.current = null;
820
+ let screenshot = null;
821
+ const shotSlide = typeof b.screenshot === "object" && b.screenshot?.eid ? b.screenshot : b.screenshot ? b.current : null;
822
+ if (shotSlide) {
823
+ try { screenshot = { slide: shotSlide.index + 1, path: await takeShot(shotSlide) }; }
824
+ catch (e) { addEntry({ kind: "notice", text: "Screenshot failed: " + e.message }); }
825
+ }
826
+ const label = (p) => (p.tag?.startsWith("footer") ? "footer" : p.eid === p.slide.eid ? "slide" : p.eid.replace(/^[^/]+\//, ""));
827
+ // only files PinDeck itself stored can be attached
828
+ b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
829
+ b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
830
+ const entry = addEntry({ kind: "user", text: b.text, screenshot: !!screenshot,
831
+ attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
832
+ selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
833
+ pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
834
+ startTurn(buildPrompt({ ...b, screenshot }), entry);
835
+ return json(res, 200, { id: entry.id });
836
+ }
837
+ if (p === "/api/upload" && req.method === "POST") {
838
+ const original = decodeURIComponent(String(req.headers["x-filename"] || "file"));
839
+ const kind = attachKind(original);
840
+ if (!kind) return json(res, 415, { error: `Only images (${ATTACH_TYPES.image.join(", ")}), audio (${ATTACH_TYPES.audio.join(", ")}) and PDFs can be attached.` });
841
+ const chunks = [];
842
+ let size = 0;
843
+ for await (const c of req) {
844
+ size += c.length;
845
+ if (size > ATTACH_MAX) return json(res, 413, { error: `${original} is larger than ${kb(ATTACH_MAX)}.` });
846
+ chunks.push(c);
847
+ }
848
+ const buf = Buffer.concat(chunks);
849
+ if (kind === "pdf") {
850
+ if (buf.subarray(0, 5).toString() !== "%PDF-") return json(res, 415, { error: `${original} is not a PDF.` });
851
+ fs.mkdirSync(PDF_DIR, { recursive: true });
852
+ const abs = attachPath(original, PDF_DIR);
853
+ fs.writeFileSync(abs, buf);
854
+ state.uploads.add(abs);
855
+ let info = null;
856
+ if (process.platform === "darwin") { try { info = await pdfInfo(abs, STATE_DIR); } catch {} }
857
+ return json(res, 200, { path: abs, kind, bytes: buf.length, pages: info?.pages, pageSize: info?.sizes[0] });
858
+ }
859
+ const rel = attachPath(original);
860
+ fs.mkdirSync(path.join(DECK_DIR, "figures"), { recursive: true });
861
+ ourWrites.set(rel, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
862
+ fs.writeFileSync(path.join(DECK_DIR, rel), buf);
863
+ state.uploads.add(rel);
864
+ return json(res, 200, { path: rel, kind, bytes: buf.length, url: `/deck/${rel}` });
865
+ }
866
+ if (p === "/api/upload-remove" && req.method === "POST") {
867
+ const b = await body(req);
868
+ for (const rel of [b.path, b.waveform].filter(Boolean)) {
869
+ if (!state.uploads.has(rel)) continue; // only unsent uploads, never other deck files
870
+ state.uploads.delete(rel);
871
+ fs.rmSync(uploadFile(rel), { force: true });
872
+ }
873
+ return json(res, 200, {});
874
+ }
875
+ if (p === "/api/settings" && req.method === "POST") {
876
+ const b = await body(req);
877
+ const model = b.model ?? settings.model, effort = b.effort ?? settings.effort;
878
+ if (!MODELS.includes(model) || !EFFORTS.includes(effort)) return json(res, 400, { error: "Unknown model or effort." });
879
+ if (model === settings.model && effort === settings.effort) return json(res, 200, { settings });
880
+ if (state.busy) return json(res, 409, { error: "Wait until Claude has finished, then change the model or effort." });
881
+ Object.assign(settings, { model, effort });
882
+ saveSettings();
883
+ // restart the session with the new flags; --resume keeps the conversation
884
+ if (session.running) { session.stop(); setTimeout(ensureSession, 300); }
885
+ addEntry({ kind: "notice", text: `Model: ${model[0].toUpperCase() + model.slice(1)}, effort: ${effort}. The conversation continues.` });
886
+ pushStatus();
887
+ return json(res, 200, { settings });
888
+ }
889
+ if (p === "/api/slide-hidden" && req.method === "POST") {
890
+ if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
891
+ const b = await body(req);
892
+ const before = snapshot(EDITABLE);
893
+ const slides = findSlides(parse(before[DECK_FILE]));
894
+ const index = slides.findIndex((s) => s.attrs["data-eid"]?.value === b.eid);
895
+ if (index < 0) return json(res, 404, { error: "Slide not found in the file." });
896
+ const r = setAttr(before[DECK_FILE], b.eid, "data-visibility", b.hidden ? "hidden" : null);
897
+ if (!r.changed) return json(res, 200, { unchanged: true });
898
+ const turnId = recordHandEdit(before, r.html, `${b.hidden ? "Hid" : "Showed"} slide ${index + 1}`,
899
+ `The user ${b.hidden ? "hid" : "showed again"} slide ${index + 1} (${b.eid}). Hidden slides carry ` +
900
+ `data-visibility="hidden": they stay in the file but are skipped when presenting and in PDF exports.`,
901
+ { manual: true, chat: false }); // the editor reloads the slide itself; undo is in the toolbar
902
+ return json(res, 200, { turnId, index });
903
+ }
904
+ if (p === "/api/move-slide" && req.method === "POST") {
905
+ if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
906
+ const b = await body(req);
907
+ const before = snapshot(EDITABLE);
908
+ const r = moveSlide(before[DECK_FILE], b.eid, b.before ?? null);
909
+ if (r.error) return json(res, 409, { error: r.error });
910
+ if (!r.changed) return json(res, 200, { unchanged: true, index: r.from });
911
+ const turnId = recordHandEdit(before, r.html, `Moved slide ${r.from + 1} to ${r.index + 1}`,
912
+ `The user moved slide ${r.from + 1} (${b.eid}) to position ${r.index + 1}; the slides in between changed numbers.`,
913
+ { manual: true, chat: false }); // the editor reloads itself on the moved slide; undo is in the toolbar
914
+ return json(res, 200, { turnId, index: r.index });
915
+ }
916
+ if (p === "/api/add-slide" && req.method === "POST") {
917
+ if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
918
+ const b = await body(req);
919
+ if (!["content", "section", "duplicate"].includes(b.kind)) return json(res, 400, { error: "Unknown slide kind." });
920
+ const before = snapshot(EDITABLE);
921
+ const r = insertSlide(before[DECK_FILE], b.kind, b.after);
922
+ if (r.error) return json(res, 409, { error: r.error });
923
+ const html = assignEids(r.html).html;
924
+ const slide = findSlides(parse(html))[r.index];
925
+ const eid = slide.attrs["data-eid"].value;
926
+ const what = { content: "content slide", section: "section divider", duplicate: "copy of the slide" }[b.kind];
927
+ const turnId = recordHandEdit(before, html, `Added a ${what} (slide ${r.index + 1})`,
928
+ `The user added a ${what} as slide ${r.index + 1} (${eid})` + (b.kind === "duplicate" ? `, a copy of ${b.after}.` : " with placeholder text."),
929
+ { manual: true }); // the editor reloads itself and opens the new slide; no second reload from the watcher
930
+ return json(res, 200, { turnId, index: r.index, eid });
931
+ }
932
+ if (p === "/api/import" && req.method === "POST") {
933
+ const name = path.basename(decodeURIComponent(String(req.headers["x-filename"] || "slides.pptx")));
934
+ if (!/\.pptx$/i.test(name)) return json(res, 415, { error: "Choose a PowerPoint file (.pptx). Save older .ppt files as .pptx first." });
935
+ const chunks = [];
936
+ let size = 0;
937
+ for await (const c of req) {
938
+ size += c.length;
939
+ if (size > IMPORT_MAX) return json(res, 413, { error: `${name} is larger than ${kb(IMPORT_MAX)}.` });
940
+ chunks.push(c);
941
+ }
942
+ let imp;
943
+ try { imp = startImport(name, Buffer.concat(chunks)); }
944
+ catch (e) { return json(res, 400, { error: `${name} could not be read: ${e.message}` }); }
945
+ // what the import can only approximate, so the dialog can offer Claude's help with it
946
+ const complex = new Map(planSlides(imp.model, imp.model.slides.map((s) => s.n), { drawings: renderAvailable() }).map((p) => [p.n, p.complex]));
947
+ return json(res, 200, { id: imp.id, name, render: renderAvailable(),
948
+ slides: imp.model.slides.map((s) => ({ n: s.n, title: s.title, subtitle: s.subtitle, hidden: s.hidden, kind: s.kind, complex: complex.get(s.n) || [] })) });
949
+ }
950
+ if (p === "/api/import-thumb") {
951
+ const imp = lastImport, n = +url.searchParams.get("n");
952
+ if (!imp || imp.id !== url.searchParams.get("id") || !Number.isInteger(n)) return text(res, 404, "Not found");
953
+ return serveFile(res, path.join(imp.dir, `thumb-${n}.png`));
954
+ }
955
+ if (p === "/api/import-apply" && req.method === "POST") {
956
+ if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
957
+ const b = await body(req);
958
+ const imp = lastImport;
959
+ if (!imp || imp.id !== b.id) return json(res, 410, { error: "This import has expired. Choose the file again." });
960
+ const numbers = (b.slides || []).filter((n) => imp.model.slides.some((s) => s.n === n));
961
+ if (!numbers.length) return json(res, 400, { error: "Choose at least one slide." });
962
+ try { return json(res, 200, await applyImport(imp, numbers, b.after, !!b.refine)); }
963
+ catch (e) { console.error(e); return json(res, 500, { error: String(e.message || e) }); }
964
+ }
965
+ if (p === "/api/stop" && req.method === "POST") {
966
+ if (state.busy && session.running) {
967
+ session.interrupt();
968
+ setTimeout(() => { if (state.busy) { session.stop(); } }, 5000); // force if it does not answer
969
+ }
970
+ return json(res, 200, {});
971
+ }
972
+ if (p === "/api/new-chat" && req.method === "POST") {
973
+ session.stop();
974
+ Object.assign(state, { chat: [], sessionId: null, turns: [], redo: [], turn: null, busy: false, notes: [] });
975
+ saveChat();
976
+ broadcast("reset", { chat: [] });
977
+ ensureSession();
978
+ pushStatus();
979
+ return json(res, 200, {});
980
+ }
981
+ let m;
982
+ if ((m = p.match(/^\/api\/turn\/(\d+)\/diff$/))) {
983
+ const t = state.turns.find((t) => t.id === +m[1]);
984
+ if (!t) return text(res, 404, "No such change");
985
+ return text(res, 200, EDITABLE.map((f) => unifiedDiff(t.before[f] ?? "", t.after[f] ?? "", f).text).filter(Boolean).join("\n\n"));
986
+ }
987
+ if ((m = p.match(/^\/api\/turn\/(\d+)\/undo$/)) && req.method === "POST") {
988
+ const t = liveTurns().at(-1);
989
+ if (t && t.id !== +m[1]) return json(res, 409, { error: "Only the most recent change can be undone." });
990
+ return undo(res);
991
+ }
992
+ if (p === "/api/undo" && req.method === "POST") return undo(res);
993
+ if (p === "/api/redo" && req.method === "POST") {
994
+ if (state.busy) return json(res, 409, { error: "Wait until Claude has finished." });
995
+ const t = state.redo.at(-1);
996
+ if (!t) return json(res, 409, { error: "Nothing to redo." });
997
+ if (EDITABLE.some((f) => (readDeck(f) ?? null) !== (t.before[f] ?? null))) {
998
+ state.redo = [];
999
+ pushStatus();
1000
+ return json(res, 409, { error: "The files changed since the undo, so it cannot be redone." });
1001
+ }
1002
+ state.redo.pop();
1003
+ for (const f of EDITABLE) if (t.after[f] != null) writeDeck(f, t.after[f]);
1004
+ for (const { rel } of t.deletedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true });
1005
+ for (const { rel, data } of t.addedFiles || []) {
1006
+ fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
1007
+ fs.writeFileSync(path.join(DECK_DIR, rel), data);
1008
+ }
1009
+ t.undone = false;
1010
+ state.notes.push(t.kind === "manual" ? `The user redid their hand edit: ${t.label}.`
1011
+ : `The user redid your change from turn ${t.id} after undoing it.`);
1012
+ broadcast("undone", { turnId: t.id, undone: false });
1013
+ pushStatus();
1014
+ return json(res, 200, {});
1015
+ }
1016
+
1017
+ // Delete an element (and, if the user agrees, the files only it uses)
1018
+ if (p === "/api/delete-info") {
1019
+ const plan = deletePlan(url.searchParams.get("eid"));
1020
+ if (!plan) return json(res, 404, { error: "Element not found in the file." });
1021
+ return json(res, 200, { eid: plan.eid, tag: plan.tag, files: plan.files });
1022
+ }
1023
+ if (p === "/api/delete" && req.method === "POST") {
1024
+ if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1025
+ const b = await body(req);
1026
+ const plan = deletePlan(b.eid);
1027
+ if (!plan) return json(res, 404, { error: "Element not found in the file." });
1028
+ const before = snapshot(EDITABLE);
1029
+ const wanted = new Set(b.files || []);
1030
+ const deletedFiles = plan.files.filter((f) => wanted.has(f.rel) && !f.usedElsewhere)
1031
+ .map((f) => ({ rel: f.rel, data: fs.readFileSync(path.join(DECK_DIR, f.rel)) }));
1032
+ const short = plan.eid.replace(/^[^/]+\//, "/");
1033
+ const label = `Deleted ${short}` + (deletedFiles.length ? ` and ${deletedFiles.map((f) => f.rel).join(", ")}` : "");
1034
+ const turnId = recordHandEdit(before, plan.html, label,
1035
+ `The user deleted ${plan.eid} (${plan.tag}) by hand` + (deletedFiles.length ? `, together with the file(s) ${deletedFiles.map((f) => f.rel).join(", ")}` : "") + ".",
1036
+ { manual: false, deletedFiles });
1037
+ for (const f of deletedFiles) fs.rmSync(path.join(DECK_DIR, f.rel), { force: true });
1038
+ return json(res, 200, { turnId, deleted: plan.eid, files: deletedFiles.map((f) => f.rel) });
1039
+ }
1040
+
1041
+ // Hand edits from the editor: drag/resize (inline styles) and text
1042
+ if (p === "/api/edit" && req.method === "POST") {
1043
+ if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1044
+ const b = await body(req);
1045
+ const before = snapshot(EDITABLE);
1046
+ const r = setStyles(before[DECK_FILE], b.ops || []);
1047
+ if (r.missing.length) return json(res, 404, { error: `No element ${r.missing.join(", ")} in ${DECK_FILE}.` });
1048
+ if (!r.changed) return json(res, 200, { unchanged: true });
1049
+ const label = String(b.label || "Edited layout").slice(0, 200);
1050
+ const turnId = recordHandEdit(before, r.html, label,
1051
+ `The user edited the layout by hand: ${label}. This is an inline style on the element in ${DECK_FILE}.`);
1052
+ return json(res, 200, { turnId });
1053
+ }
1054
+ if (p === "/api/source") {
1055
+ const html = getInner(readDeck(DECK_FILE), url.searchParams.get("eid"), JSON.parse(url.searchParams.get("path") || "[]"));
1056
+ return html == null ? json(res, 404, { error: "Element not found in the file." }) : json(res, 200, { html });
1057
+ }
1058
+ // Speaker notes of a slide: read for the notes field, saved as a hand edit
1059
+ if (p === "/api/notes" && req.method === "GET") {
1060
+ const n = getNotes(readDeck(DECK_FILE), url.searchParams.get("eid"));
1061
+ return n ? json(res, 200, n) : json(res, 404, { error: "Slide not found in the file." });
1062
+ }
1063
+ if (p === "/api/notes" && req.method === "POST") {
1064
+ if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1065
+ const b = await body(req);
1066
+ const before = snapshot(EDITABLE);
1067
+ const r = setNotes(before[DECK_FILE], b.eid, b.text, !!b.html);
1068
+ if (!r) return json(res, 404, { error: "Slide not found in the file." });
1069
+ if (!r.changed) return json(res, 200, { unchanged: true });
1070
+ const n = findSlides(parse(before[DECK_FILE])).findIndex((s) => s.attrs["data-eid"]?.value === b.eid) + 1;
1071
+ const text = String(b.text || "").trim();
1072
+ const turnId = recordHandEdit(before, r.html, text ? `Edited the notes of slide ${n}` : `Removed the notes of slide ${n}`,
1073
+ text ? `The user edited the speaker notes of slide ${n} (${b.eid}) by hand. They now read: "${text.slice(0, 300)}".`
1074
+ : `The user removed the speaker notes of slide ${n} (${b.eid}).`); // manual: the slide itself does not change
1075
+ return json(res, 200, { turnId });
1076
+ }
1077
+ if (p === "/api/text" && req.method === "POST") {
1078
+ if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1079
+ const b = await body(req);
1080
+ const before = snapshot(EDITABLE);
1081
+ const r = setInner(before[DECK_FILE], b.eid, b.path || [], String(b.html ?? ""));
1082
+ if (!r) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
1083
+ if (!r.changed) return json(res, 200, { unchanged: true });
1084
+ const label = String(b.label || "Edited text").slice(0, 200);
1085
+ const where = b.eid + (b.path?.length ? ` (cell ${b.path.map((i) => i + 1).join("/")})` : "");
1086
+ const turnId = recordHandEdit(before, r.html, label,
1087
+ `The user edited the text of ${where} by hand. It now reads: "${String(b.text || "").slice(0, 300)}".`);
1088
+ return json(res, 200, { turnId });
1089
+ }
1090
+
1091
+ // Enter in a bullet: the bullet keeps the text before the caret, a new bullet after it gets the rest
1092
+ if (p === "/api/split" && req.method === "POST") {
1093
+ if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1094
+ const b = await body(req);
1095
+ const before = snapshot(EDITABLE);
1096
+ const r1 = setInner(before[DECK_FILE], b.eid, [], String(b.html ?? ""));
1097
+ if (!r1) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
1098
+ const r2 = insertAfter(r1.html, b.eid, { tag: "li", cls: b.cls || "", inner: String(b.newHtml ?? ""), newEid: b.newEid });
1099
+ const short = (e) => e.replace(/^[^/]+\//, "/");
1100
+ const turnId = recordHandEdit(before, r2.html, `New bullet after ${short(b.eid)}`,
1101
+ `The user added a bullet (${r2.eid}) after ${b.eid} by pressing Enter while editing; ${b.eid} now reads "${String(b.text || "").slice(0, 200)}".`);
1102
+ return json(res, 200, { turnId, eid: r2.eid });
1103
+ }
1104
+ if (p === "/api/remove" && req.method === "POST") {
1105
+ if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1106
+ const b = await body(req);
1107
+ const before = snapshot(EDITABLE);
1108
+ const html = removeElement(before[DECK_FILE], b.eid);
1109
+ if (html == null) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
1110
+ const turnId = recordHandEdit(before, html, `Removed empty bullet ${b.eid.replace(/^[^/]+\//, "/")}`,
1111
+ `The user removed the empty bullet ${b.eid}.`);
1112
+ return json(res, 200, { turnId });
1113
+ }
1114
+
1115
+ if (p === "/api/export" && req.method === "POST") {
1116
+ const b = await body(req);
1117
+ try { return json(res, 200, await exportPdf(b.kind, b.includeHidden)); }
1118
+ catch (e) { return json(res, 500, { error: String(e.message || e) }); }
1119
+ }
1120
+
1121
+ // CLI endpoints (used by Claude Code via cli.mjs)
1122
+ if (p === "/api/pdf") {
1123
+ const kind = url.searchParams.get("slide") || url.searchParams.get("kind") || "handout";
1124
+ if (!["handout", "presentation"].includes(kind)) return text(res, 400, 'Use "handout" or "presentation".');
1125
+ const r = await exportPdf(kind, url.searchParams.get("all") === "1");
1126
+ return text(res, 200, r.path);
1127
+ }
1128
+ if (p === "/api/pdf-figure") {
1129
+ const r = await pdfFigure(Object.fromEntries(url.searchParams));
1130
+ return text(res, r.status || 200, r.text);
1131
+ }
1132
+ if (p === "/api/slides") {
1133
+ return text(res, 200, slideList().map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
1134
+ }
1135
+ if (p === "/api/layout" || p === "/api/shot") {
1136
+ const slide = resolveSlide(url.searchParams.get("slide"));
1137
+ if (!slide) return text(res, 404, `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
1138
+ if (!slide.eid) runEidPass();
1139
+ const s2 = slide.eid ? slide : resolveSlide(String(slide.index + 1));
1140
+ if (p === "/api/shot") return text(res, 200, await takeShot(s2));
1141
+ return text(res, 200, formatLayout(await measure(s2)));
1142
+ }
1143
+ return text(res, 404, "Not found");
1144
+ } catch (e) {
1145
+ // malformed JSON or %-encoding in a request is the caller's mistake, not a server error
1146
+ const bad = e instanceof SyntaxError || e instanceof URIError || e.message === "body too large";
1147
+ if (!bad) console.error(e);
1148
+ if (!res.headersSent) text(res, bad ? 400 : 500, String(e.message || e));
1149
+ }
1150
+ });
1151
+
1152
+ /* ---- Start ------------------------------------------------------------------------------------- */
1153
+ const first = runEidPass();
1154
+ if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
1155
+ server.listen(PORT, "127.0.0.1", () => {
1156
+ console.log(`PinDeck: ${ORIGIN}/`);
1157
+ console.log(`Deck: ${DECK_DIR}`);
1158
+ if (!CHROME) console.log("No Chrome found: screenshots are disabled (set CHROME_BIN).");
1159
+ ensureSession();
1160
+ });
1161
+ const quit = () => { session.stop(); setTimeout(() => process.exit(0), 300); };
1162
+ process.on("SIGINT", quit);
1163
+ process.on("SIGTERM", quit);