pindeck 2.9.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -21
- package/cli.mjs +2 -1
- package/lib/capabilities.mjs +13 -7
- package/lib/deck-files.mjs +38 -0
- package/lib/eids.mjs +101 -8
- package/lib/frameworks.mjs +59 -0
- package/lib/instructions.mjs +74 -0
- package/lib/pptx-render.mjs +78 -9
- package/lib/system-prompt.md +5 -3
- package/lib/waveform.mjs +9 -3
- package/package.json +1 -1
- package/public/bridge.js +248 -69
- package/public/editor.css +10 -0
- package/public/editor.js +41 -11
- package/public/index.html +5 -1
- package/server.mjs +222 -63
package/public/index.html
CHANGED
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
double-click to change the text. A selected element is what Claude takes “this” to mean.</p>
|
|
96
96
|
<p>Add figures, audio, a PDF or a screenshot with <strong>+</strong>, or drag and drop or paste a file; Claude can put it on a slide
|
|
97
97
|
or, for a paper or report, take the details from it.</p>
|
|
98
|
-
<p>Claude edits <code>index.html</code> and <code>theme.css</code>, checks the layout, and the slide updates here.
|
|
98
|
+
<p>Claude edits <code id="deck-file">index.html</code> and <code>theme.css</code>, checks the layout, and the slide updates here.
|
|
99
99
|
<code>brand.css</code> is locked.</p>
|
|
100
100
|
</div>
|
|
101
101
|
</div>
|
|
@@ -155,6 +155,10 @@
|
|
|
155
155
|
</form>
|
|
156
156
|
</dialog>
|
|
157
157
|
|
|
158
|
+
<dialog id="shot-dialog" class="shot-dialog" aria-label="Screenshot">
|
|
159
|
+
<img id="shot-img" alt="Screenshot of the slide">
|
|
160
|
+
</dialog>
|
|
161
|
+
|
|
158
162
|
<dialog id="diff-dialog" class="diff-dialog">
|
|
159
163
|
<div class="dialog-head"><span id="diff-title">Changes</span><button class="small" id="diff-close">Close</button></div>
|
|
160
164
|
<pre id="diff-body" class="diff"></pre>
|
package/server.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import path from "node:path";
|
|
|
24
24
|
import crypto from "node:crypto";
|
|
25
25
|
import { spawn, execFile } from "node:child_process";
|
|
26
26
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
27
|
-
import { assignEids, parse, findSlides, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
|
|
27
|
+
import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
|
|
28
28
|
import { unifiedDiff } from "./lib/diff.mjs";
|
|
29
29
|
import { ClaudeSession } from "./lib/claude.mjs";
|
|
30
30
|
import { formatLayout, formatPin } from "./lib/layout.mjs";
|
|
@@ -33,21 +33,29 @@ import { readPptx } from "./lib/pptx.mjs";
|
|
|
33
33
|
import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
|
|
34
34
|
import { buildImport, planSlides } from "./lib/pptx-import.mjs";
|
|
35
35
|
import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, findExecutable } from "./lib/capabilities.mjs";
|
|
36
|
-
import {
|
|
36
|
+
import { unsupportedMessage } from "./lib/frameworks.mjs";
|
|
37
|
+
import { readInstructions } from "./lib/instructions.mjs";
|
|
38
|
+
import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
|
|
37
39
|
|
|
38
40
|
/* ---- Config ------------------------------------------------------------------ */
|
|
39
|
-
const USAGE = `Usage: pindeck [deck-folder] [--port 5180] [--model <name>]
|
|
40
|
-
pindeck login [deck-folder]
|
|
41
|
+
const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--model <name>] [--asset-root <folder>]...
|
|
42
|
+
pindeck login [deck-folder | deck-file.html]
|
|
41
43
|
|
|
42
|
-
Opens
|
|
43
|
-
|
|
44
|
+
Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
|
|
45
|
+
folder), which then opens its index.html. A deck made otherwise names its slides in its <head> with
|
|
46
|
+
<meta name="pindeck:slides" content="<CSS selector>"> and <meta name="pindeck:size" content="1440x1080">.
|
|
44
47
|
login prints a new one-time login link for the PinDeck running for that deck
|
|
45
|
-
(without a
|
|
48
|
+
(without a deck: for the PinDeck started last).
|
|
49
|
+
--asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
|
|
50
|
+
../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
|
|
51
|
+
"assetRoots" in ~/.pindeck/config.json.`;
|
|
46
52
|
const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
47
53
|
const argv = process.argv.slice(2);
|
|
48
54
|
if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
|
|
49
55
|
const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
|
|
50
56
|
const portArg = opt("--port", process.env.PORT || 5180);
|
|
57
|
+
const assetArgs = [];
|
|
58
|
+
for (let a; (a = opt("--asset-root")) !== undefined;) assetArgs.push(a);
|
|
51
59
|
let PORT = parsePort(portArg); // the port actually bound once listening (--port 0 picks a free one)
|
|
52
60
|
if (PORT === null) { console.error(`Invalid port "${portArg}": use a number from 1 to 65535.\n\n${USAGE}`); process.exit(2); }
|
|
53
61
|
if (!isSupportedPlatform(process.platform)) {
|
|
@@ -58,15 +66,24 @@ if (!isSupportedPlatform(process.platform)) {
|
|
|
58
66
|
const LOGIN = argv[0] === "login";
|
|
59
67
|
if (LOGIN) argv.shift();
|
|
60
68
|
const MODEL = opt("--model", process.env.CLAUDE_MODEL || "");
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
69
|
+
/* The deck: a file (talk.html), or a folder, which means its index.html. A path ending in .html or .htm
|
|
70
|
+
that is not a folder is a file, so a missing file gets a clear message. */
|
|
71
|
+
const TARGET = path.resolve(argv[0] || process.cwd());
|
|
72
|
+
const isFolder = (() => { try { return fs.statSync(TARGET).isDirectory(); } catch { return false; } })();
|
|
73
|
+
const DECK_DIR = isFolder || !/\.html?$/i.test(TARGET) ? TARGET : path.dirname(TARGET);
|
|
74
|
+
const DECK_FILE = DECK_DIR === TARGET ? "index.html" : path.basename(TARGET);
|
|
75
|
+
const NAMED = DECK_FILE !== "index.html"; // a deck file with its own name; several can share a folder
|
|
76
|
+
const DECK_NAME = NAMED ? DECK_FILE.replace(/\.html?$/i, "") : path.basename(DECK_DIR); // for exports and titles
|
|
77
|
+
const EDITABLE = [DECK_FILE, "theme.css"]; // Claude may edit these
|
|
64
78
|
const LOCKED = ["brand.css"]; // never, enforced twice
|
|
65
79
|
const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck");
|
|
66
|
-
|
|
67
|
-
|
|
80
|
+
/* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
|
|
81
|
+
histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
|
|
82
|
+
const deckKey = (NAMED ? `${path.basename(DECK_DIR)}-${DECK_NAME}` : path.basename(DECK_DIR)).replace(/[^\w.-]+/g, "_") + "-" +
|
|
83
|
+
crypto.createHash("sha1").update(NAMED ? path.join(DECK_DIR, DECK_FILE) : DECK_DIR).digest("hex").slice(0, 8);
|
|
68
84
|
const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
|
|
69
85
|
const SHOT_DIR = path.join(STATE_DIR, "shots");
|
|
86
|
+
const SHOT_NAME = /^[\w-]+\.png$/; // what takeShot writes, and all /shots/ serves
|
|
70
87
|
const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
|
|
71
88
|
/* pindeck login: a new one-time login link from the running server, asked with the token in its server
|
|
72
89
|
file (readable only by this user) */
|
|
@@ -107,6 +124,13 @@ if (deckProblem) {
|
|
|
107
124
|
console.error(`${deckProblem}\n\n${USAGE}`);
|
|
108
125
|
process.exit(1);
|
|
109
126
|
}
|
|
127
|
+
// reveal.js, or a deck that names its slides: another framework's deck is left exactly as it is (no ids, no state)
|
|
128
|
+
const deckAtStart = fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8");
|
|
129
|
+
const frameworkProblem = unsupportedMessage(deckAtStart, DECK_FILE);
|
|
130
|
+
if (frameworkProblem) {
|
|
131
|
+
console.error(frameworkProblem);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
110
134
|
/* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
|
|
111
135
|
port is bound (see Start), so a failed start leaves no state behind. */
|
|
112
136
|
const SERVER_FILES = [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")]; // the second: the CLI's fallback
|
|
@@ -134,6 +158,22 @@ const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8",
|
|
|
134
158
|
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
|
|
135
159
|
const hash = (s) => crypto.createHash("sha1").update(s).digest("hex");
|
|
136
160
|
const readDeck = (f) => { try { return fs.readFileSync(path.join(DECK_DIR, f), "utf8"); } catch { return null; } };
|
|
161
|
+
/* A deck made without reveal.js names its slides with <meta name="pindeck:slides"> (null for reveal.js) and
|
|
162
|
+
their size with <meta name="pindeck:size">. Read from the deck as it is now, so a changed tag counts from
|
|
163
|
+
the next headless run or Claude session. Headless Chrome opens such a deck at the slide size, so a
|
|
164
|
+
screenshot is the slide itself. */
|
|
165
|
+
function deckSlides() {
|
|
166
|
+
const tree = parse(readDeck(DECK_FILE) || "");
|
|
167
|
+
const selector = slideSelector(tree);
|
|
168
|
+
return { selector, size: (selector && deckSize(tree)) || { width: 1280, height: 720 } };
|
|
169
|
+
}
|
|
170
|
+
/* Why the deck no longer names slides PinDeck can use (a tag changed while PinDeck runs), or null: the
|
|
171
|
+
start-up check's message, which the editor shows and Claude is told */
|
|
172
|
+
function deckProblemNow() {
|
|
173
|
+
const html = readDeck(DECK_FILE);
|
|
174
|
+
return (html != null && unsupportedMessage(html, DECK_FILE)?.replace(/ Nothing was changed\.$/, "")) || null;
|
|
175
|
+
}
|
|
176
|
+
let deckNow = { selector: deckSlides().selector, problem: null }; // for the editor; kept current by scheduleFlush
|
|
137
177
|
const ourWrites = new Map(); // file -> { hash, manual } of what we wrote, to tell our writes from others
|
|
138
178
|
function writeDeck(f, content, manual = false) {
|
|
139
179
|
ourWrites.set(f, { hash: hash(content), manual });
|
|
@@ -147,6 +187,25 @@ function writeDeckFile(rel, data, manual = true) {
|
|
|
147
187
|
writeAtomic(abs, data);
|
|
148
188
|
}
|
|
149
189
|
const snapshot = (files) => Object.fromEntries(files.map((f) => [f, readDeck(f)]));
|
|
190
|
+
/* A change (a Claude turn or a hand edit) keeps each file it touched as it was before and after: text as
|
|
191
|
+
a string, or as a Buffer from a scan of the whole deck (null: the file did not exist). */
|
|
192
|
+
const changeFiles = (t) => [...new Set([...Object.keys(t.before), ...Object.keys(t.after)])];
|
|
193
|
+
const isText = (v) => v == null || typeof v === "string" || !v.includes(0);
|
|
194
|
+
const asText = (v) => (v == null ? "" : String(v));
|
|
195
|
+
const isAs = (rel, v) => sameContent(path.join(DECK_DIR, rel), v ?? null);
|
|
196
|
+
function putDeckFile(rel, v) { // undo and redo: back to v (null: remove the file)
|
|
197
|
+
if (v == null) return fs.rmSync(path.join(DECK_DIR, rel), { force: true });
|
|
198
|
+
fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
|
|
199
|
+
writeDeck(rel, v);
|
|
200
|
+
}
|
|
201
|
+
function fileChange(rel, before, after) {
|
|
202
|
+
if (!isText(before) || !isText(after)) return { file: rel, added: 0, removed: 0, binary: true };
|
|
203
|
+
const d = unifiedDiff(asText(before), asText(after), rel);
|
|
204
|
+
return { file: rel, added: d.added, removed: d.removed };
|
|
205
|
+
}
|
|
206
|
+
const fileDiff = (rel, before, after) => (!isText(before) || !isText(after)
|
|
207
|
+
? `Binary file ${rel} ${before == null ? "added" : after == null ? "deleted" : "changed"}`
|
|
208
|
+
: unifiedDiff(asText(before), asText(after), rel).text);
|
|
150
209
|
/* The revision of the editable files: sent with every API answer, checked on the editor's changes */
|
|
151
210
|
const deckRev = () => revision(DECK_DIR, EDITABLE);
|
|
152
211
|
const json = (res, code, obj) => {
|
|
@@ -239,15 +298,25 @@ const CHROME = CAPS.chromePath; // null when Chrome features are unavailable (
|
|
|
239
298
|
/* Headless Chrome on macOS often lingers long after it has written its output, so we
|
|
240
299
|
watch the output and stop Chrome as soon as `done(stdout, stderr)` says we have it. */
|
|
241
300
|
let chromeSeq = 0;
|
|
301
|
+
/* The page gets the window's size too. Chrome lays it out about 87 px shorter than the window (room for
|
|
302
|
+
a toolbar it does not draw) and grows it only for the screenshot itself, too late for some versions
|
|
303
|
+
to lay the slide out again, so shot and dump lay the deck out for this size (see bridge.js). */
|
|
304
|
+
function sized(url, size) {
|
|
305
|
+
const u = new URL(url);
|
|
306
|
+
u.searchParams.set("width", size.width);
|
|
307
|
+
u.searchParams.set("height", size.height);
|
|
308
|
+
return u.href;
|
|
309
|
+
}
|
|
242
310
|
function chrome(args, done) {
|
|
243
311
|
if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
|
|
244
312
|
return new Promise((resolve, reject) => {
|
|
245
313
|
// a profile per run: concurrent runs must not share one
|
|
246
314
|
const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
|
|
315
|
+
const { size } = deckSlides();
|
|
247
316
|
const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
|
|
248
317
|
"--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
|
|
249
|
-
|
|
250
|
-
...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, a) : a)]);
|
|
318
|
+
`--window-size=${size.width},${size.height}`, "--force-device-scale-factor=1", "--virtual-time-budget=5000",
|
|
319
|
+
...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, sized(a, size)) : a)]);
|
|
251
320
|
let out = "", err = "", finished = false;
|
|
252
321
|
const finish = (fn, v) => {
|
|
253
322
|
if (finished) return;
|
|
@@ -267,7 +336,7 @@ function chrome(args, done) {
|
|
|
267
336
|
});
|
|
268
337
|
}
|
|
269
338
|
const deckUrl = (mode, params = {}) =>
|
|
270
|
-
`${ORIGIN}
|
|
339
|
+
`${ORIGIN}${DECK_PAGE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
|
|
271
340
|
async function takeShot(slide) {
|
|
272
341
|
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 }));
|
|
273
342
|
const out = path.join(SHOT_DIR, `${slide.eid.replace(/[^\w-]/g, "_")}-${Date.now()}.png`);
|
|
@@ -311,21 +380,21 @@ const measure = (slide) => measureInEditor(slide).catch((e) => {
|
|
|
311
380
|
presentation: one page per build step (a slide with two clicks gives three pages).
|
|
312
381
|
Files go to <deck>/export/. One export at a time. */
|
|
313
382
|
let exportQueue = Promise.resolve(), snapSeq = 0;
|
|
314
|
-
const snapshots = new Map(); // id -> static print layout, served
|
|
383
|
+
const snapshots = new Map(); // id -> static print layout, served next to the deck page as __pindeck-print-<id>.html
|
|
315
384
|
function exportPdf(kind, includeHidden = false) {
|
|
316
385
|
if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
|
|
317
386
|
const split = kind === "presentation";
|
|
318
|
-
const name = `${
|
|
387
|
+
const name = `${DECK_NAME}-${split ? "presentation" : "handout"}.pdf`;
|
|
319
388
|
const out = path.join(DECK_DIR, "export", name);
|
|
320
389
|
const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
|
|
321
390
|
const run = async () => {
|
|
322
391
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
323
392
|
const layoutProfile = profile("layout"), printProfile = profile("print");
|
|
324
393
|
const html = await printLayout(CHROME, chromeStart(layoutProfile,
|
|
325
|
-
`${ORIGIN}
|
|
394
|
+
`${ORIGIN}${DECK_PAGE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
|
|
326
395
|
const id = `${process.pid}-${++snapSeq}`;
|
|
327
396
|
snapshots.set(id, html);
|
|
328
|
-
try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}
|
|
397
|
+
try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
|
|
329
398
|
finally { snapshots.delete(id); }
|
|
330
399
|
return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
|
|
331
400
|
url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
|
|
@@ -381,6 +450,37 @@ if (MODELS.includes(fileConfig.model)) settings.model = fileConfig.model;
|
|
|
381
450
|
else if (fileConfig.model !== undefined) console.warn(`config.json: unknown model "${fileConfig.model}" (use ${MODELS.join(" or ")}); using ${DEFAULTS.model}.`);
|
|
382
451
|
if (EFFORTS.includes(fileConfig.effort)) settings.effort = fileConfig.effort;
|
|
383
452
|
else if (fileConfig.effort !== undefined) console.warn(`config.json: unknown effort "${fileConfig.effort}" (use ${EFFORTS.join(", ")}); using ${DEFAULTS.effort}.`);
|
|
453
|
+
/* Asset roots (--asset-root, and "assetRoots" in config.json): folders outside the deck whose files the
|
|
454
|
+
deck may use. The outermost one that contains the deck is served at /files/, with the deck page inside
|
|
455
|
+
it, so a relative path such as ../shared/style.css reaches its file. Every file served, from the deck
|
|
456
|
+
or from /files/, must really lie (after following symlinks) inside the deck folder or an asset root:
|
|
457
|
+
a symlink that leads anywhere else is refused. */
|
|
458
|
+
const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
|
|
459
|
+
const ASSET_ROOTS = [...assetArgs, ...(Array.isArray(fileConfig.assetRoots) ? fileConfig.assetRoots : [])].map((r) => {
|
|
460
|
+
const abs = path.resolve(expandHome(String(r)));
|
|
461
|
+
let st = null;
|
|
462
|
+
try { st = fs.statSync(abs); } catch {}
|
|
463
|
+
if (!st?.isDirectory()) { console.error(`Asset root ${abs} is not a folder.\n\n${USAGE}`); process.exit(2); }
|
|
464
|
+
return abs;
|
|
465
|
+
});
|
|
466
|
+
const within = (dir, p) => p === dir || p.startsWith(dir + path.sep);
|
|
467
|
+
const realOr = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
|
|
468
|
+
const SERVE_ROOT = ASSET_ROOTS.filter((r) => within(r, DECK_DIR)).sort((a, b) => a.length - b.length)[0] || null;
|
|
469
|
+
const ALLOWED_REAL = [DECK_DIR, ...ASSET_ROOTS].map(realOr);
|
|
470
|
+
const encPath = (rel) => rel.split(path.sep).filter(Boolean).map(encodeURIComponent).join("/");
|
|
471
|
+
/* The deck page's URL, and the folder URL its relative paths resolve against */
|
|
472
|
+
const DECK_PAGE = SERVE_ROOT ? `/files/${[encPath(path.relative(SERVE_ROOT, DECK_DIR)), encodeURIComponent(DECK_FILE)].filter(Boolean).join("/")}`
|
|
473
|
+
: `/deck/${encodeURIComponent(DECK_FILE)}`;
|
|
474
|
+
const DECK_BASE = DECK_PAGE.slice(0, DECK_PAGE.lastIndexOf("/") + 1);
|
|
475
|
+
/* A file to serve: inside its base by path, and inside the deck or an asset root once symlinks are
|
|
476
|
+
followed. null: refuse. A missing file passes, and is answered 404. */
|
|
477
|
+
function servable(base, rel) {
|
|
478
|
+
const f = safeJoin(base, rel);
|
|
479
|
+
if (!f) return null;
|
|
480
|
+
let real;
|
|
481
|
+
try { real = fs.realpathSync(f); } catch { return f; }
|
|
482
|
+
return ALLOWED_REAL.some((d) => within(d, real)) ? f : null;
|
|
483
|
+
}
|
|
384
484
|
const saveSettings = () => fs.writeFile(CONFIG_FILE, JSON.stringify({ ...fileConfig, model: settings.model, effort: settings.effort }, null, 2) + "\n", () => {});
|
|
385
485
|
const completeSettings = () => { // create or complete the file (at start, once the port is bound)
|
|
386
486
|
if (fileConfig.model !== settings.model || fileConfig.effort !== settings.effort) saveSettings();
|
|
@@ -389,22 +489,46 @@ if (MODEL) settings.model = MODEL; // --model on the command line: this run on
|
|
|
389
489
|
|
|
390
490
|
const status = () => ({ busy: state.busy, model: state.model, sessionId: state.sessionId, settings: { ...settings },
|
|
391
491
|
running: session.running, deck: DECK_DIR, deckFile: DECK_FILE, chrome: CAPS.screenshot.available,
|
|
492
|
+
slideSelector: deckNow.selector, deckProblem: deckNow.problem,
|
|
392
493
|
capabilities: publicCapabilities(CAPS), rev: deckRev(),
|
|
393
494
|
undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
|
|
394
495
|
const pushStatus = () => broadcast("status", status());
|
|
395
496
|
|
|
396
497
|
const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
|
|
397
|
-
.replaceAll("{{CLI}}", CLI_CMD) + capabilityPrompt(CAPS, CLI_CMD)
|
|
498
|
+
.replaceAll("{{CLI}}", CLI_CMD).replaceAll("{{DECK}}", DECK_FILE) + capabilityPrompt(CAPS, CLI_CMD) +
|
|
499
|
+
// a template's own notes usually say index.html; other decks may share this folder
|
|
500
|
+
(NAMED ? `\n\n## This deck's file\nThe slides are in ${DECK_FILE}. Where the deck's notes below say index.html, they mean ` +
|
|
501
|
+
`${DECK_FILE}. Other .html files in this folder are other decks: do not edit them.\n` : "") +
|
|
502
|
+
(ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
|
|
503
|
+
`them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
|
|
504
|
+
`other decks may use them too.\n` : "");
|
|
505
|
+
/* For a deck made without reveal.js: what its slides are, from its selector as it is at each session start */
|
|
506
|
+
function slidesInstructions() {
|
|
507
|
+
const { selector } = deckSlides();
|
|
508
|
+
return selector ? `\n\n## Slides of this deck\nThis deck is not made with reveal.js: its slides are the elements matching the ` +
|
|
509
|
+
`CSS selector \`${selector}\` (<meta name="pindeck:slides">), and the deck's own script shows them. Where these ` +
|
|
510
|
+
`instructions say <section> or reveal.js, read an element matching that selector, and that script. A new slide is ` +
|
|
511
|
+
`a new such element next to the others; keep the pindeck meta tags. Hidden slides (data-visibility="hidden") are ` +
|
|
512
|
+
`left out of PinDeck's PDF exports; whether presenting skips them depends on the deck's script.\n` : "";
|
|
513
|
+
}
|
|
514
|
+
/* What Claude may never change, whatever the edit rules allow: the locked brand files, vendored libraries,
|
|
515
|
+
git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
|
|
516
|
+
win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
|
|
517
|
+
function lockedPaths() {
|
|
518
|
+
let others = [];
|
|
519
|
+
try { others = fs.readdirSync(DECK_DIR).filter((f) => /\.html?$/i.test(f) && f !== DECK_FILE); } catch {}
|
|
520
|
+
return [...LOCKED, "vendor/**", ".git/**", ...others];
|
|
521
|
+
}
|
|
398
522
|
/* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
|
|
399
523
|
or a parent repo's settings would let Claude run more than PinDeck grants (and the user's hooks and
|
|
400
|
-
MCP servers have no place in a slide editor). That also skips
|
|
401
|
-
the system prompt instead, read afresh at each session start
|
|
402
|
-
|
|
403
|
-
|
|
524
|
+
MCP servers have no place in a slide editor). That also skips CLAUDE.md files, so the deck's own go
|
|
525
|
+
into the system prompt instead, read afresh at each session start: CLAUDE.md and AGENTS.md (the name
|
|
526
|
+
other coding agents read), with their @path imports (lib/instructions.mjs). Parent folders' files are
|
|
527
|
+
not loaded; a deck imports shared rules explicitly. Decks copied from the example call the CLI as
|
|
528
|
+
`node ../cli.mjs`, which is right only next to PinDeck: that becomes this server's command. */
|
|
404
529
|
function deckInstructions() {
|
|
405
530
|
return ["CLAUDE.md", "AGENTS.md"].map((name) => {
|
|
406
|
-
|
|
407
|
-
try { s = fs.readFileSync(path.join(DECK_DIR, name), "utf8").trim(); } catch { return ""; }
|
|
531
|
+
const s = readInstructions(path.join(DECK_DIR, name));
|
|
408
532
|
return s ? `\n\n## The deck's ${name}\n${s.replace(/node \.\.\/cli\.mjs\b/g, () => CLI_CMD)}\n` : "";
|
|
409
533
|
}).join("");
|
|
410
534
|
}
|
|
@@ -413,11 +537,12 @@ const session = new ClaudeSession({
|
|
|
413
537
|
cwd: DECK_DIR,
|
|
414
538
|
env: () => ({ DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN }),
|
|
415
539
|
args: () => [
|
|
416
|
-
"--append-system-prompt", systemPrompt + deckInstructions(),
|
|
540
|
+
"--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
|
|
417
541
|
"--setting-sources", "", "--strict-mcp-config", // no settings files, no MCP servers or connectors
|
|
418
542
|
"--permission-mode", "default",
|
|
419
|
-
|
|
420
|
-
"--
|
|
543
|
+
// any file in the deck folder (Edit rules cover Write too; Write is named as well, to be explicit)
|
|
544
|
+
"--allowedTools", "Read", "Glob", "Grep", "Edit(./**)", "Write(./**)", `Bash(${CLI_CMD}:*)`,
|
|
545
|
+
"--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
|
|
421
546
|
...(settings.model ? ["--model", settings.model] : []),
|
|
422
547
|
...(settings.effort ? ["--effort", settings.effort] : []),
|
|
423
548
|
],
|
|
@@ -447,7 +572,12 @@ function toolSummary(name, input = {}) {
|
|
|
447
572
|
return !r || r.startsWith("..") ? path.basename(p) : r;
|
|
448
573
|
};
|
|
449
574
|
switch (name) {
|
|
450
|
-
case "Read":
|
|
575
|
+
case "Read": {
|
|
576
|
+
// a screenshot PinDeck took: the chat shows it, by its name in the shots folder
|
|
577
|
+
const f = input.file_path && path.resolve(DECK_DIR, input.file_path);
|
|
578
|
+
const shot = f && path.dirname(f) === SHOT_DIR && SHOT_NAME.test(path.basename(f)) ? path.basename(f) : undefined;
|
|
579
|
+
return { name: "Read", detail: base(input.file_path), shot };
|
|
580
|
+
}
|
|
451
581
|
case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
|
|
452
582
|
case "Bash": {
|
|
453
583
|
// a PinDeck command only when the whole command is one: a script that ends in `cli.mjs layout`
|
|
@@ -492,7 +622,9 @@ function handleEvent(ev) {
|
|
|
492
622
|
}
|
|
493
623
|
|
|
494
624
|
function startTurn(promptText, userEntry) {
|
|
495
|
-
|
|
625
|
+
// every deck file as it is now: Claude may change any of them (not brand.css or vendor/), and undo
|
|
626
|
+
// takes back all it changed
|
|
627
|
+
state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]), scan: scanFiles(DECK_DIR) };
|
|
496
628
|
state.busy = true;
|
|
497
629
|
pushStatus();
|
|
498
630
|
ensureSession();
|
|
@@ -516,11 +648,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
516
648
|
}
|
|
517
649
|
toolEntries.clear();
|
|
518
650
|
const eid = runEidPass();
|
|
519
|
-
const
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
651
|
+
const { changed, tooLarge } = scanChanges(turn.scan, scanFiles(DECK_DIR));
|
|
652
|
+
const before = {}, after = {};
|
|
653
|
+
for (const [rel, c] of Object.entries(changed)) { before[rel] = c.before; after[rel] = c.after; }
|
|
654
|
+
// the deck file and theme.css first, then the rest by name
|
|
655
|
+
const order = (rel) => (EDITABLE.includes(rel) ? EDITABLE.indexOf(rel) : EDITABLE.length);
|
|
656
|
+
const files = Object.keys(changed).sort((a, b) => order(a) - order(b) || a.localeCompare(b))
|
|
657
|
+
.map((rel) => fileChange(rel, before[rel], after[rel]));
|
|
524
658
|
// after a hand-off: imported pictures that Claude replaced (nothing refers to them any more) are
|
|
525
659
|
// deleted; undo brings them back with the turn
|
|
526
660
|
const deletedFiles = [];
|
|
@@ -530,11 +664,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
530
664
|
deletedFiles.push({ rel, data: fs.readFileSync(abs) });
|
|
531
665
|
fs.rmSync(abs, { force: true });
|
|
532
666
|
}
|
|
533
|
-
const t = { id: turn.id, kind: "claude", before
|
|
667
|
+
const t = { id: turn.id, kind: "claude", before, after, files, deletedFiles, undone: false };
|
|
534
668
|
if (files.length) { state.turns.push(t); state.redo = []; }
|
|
535
669
|
addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
|
|
536
670
|
denials, restored, newIds: eid.assigned.length });
|
|
537
671
|
if (deletedFiles.length) addEntry({ kind: "notice", text: `Removed ${deletedFiles.map((f) => f.rel).join(", ")}: replaced, and no longer used.` });
|
|
672
|
+
if (tooLarge.length) addEntry({ kind: "notice", text: `${tooLarge.join(", ")} changed in this turn but ${tooLarge.length > 1 ? "are" : "is"} ` +
|
|
673
|
+
`too large to keep a copy of (over 2 MB): Undo leaves ${tooLarge.length > 1 ? "them" : "it"} as ${tooLarge.length > 1 ? "they are" : "it is"}.` });
|
|
538
674
|
pushStatus();
|
|
539
675
|
}
|
|
540
676
|
|
|
@@ -593,7 +729,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
|
|
|
593
729
|
for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
|
|
594
730
|
if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide} with the pin markers: ${screenshot.path} (Read it).`);
|
|
595
731
|
if (attachments.length) ctx.push("Attached files (the user added them to this message; they are saved in the deck folder, " +
|
|
596
|
-
|
|
732
|
+
`use these paths relative to ${DECK_FILE}):\n` + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
|
|
597
733
|
return (ctx.length ? `<editor-context>\n${ctx.join("\n\n")}\n</editor-context>\n\n` : "") + userText;
|
|
598
734
|
}
|
|
599
735
|
|
|
@@ -620,7 +756,7 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
|
|
|
620
756
|
if (out && fs.existsSync(path.join(DECK_DIR, out)) && !state.pdfFigures.has(out)) return bad(`${out} exists already; choose another name.`, 409);
|
|
621
757
|
const tmp = path.join(SHOT_DIR, `pdf-${slug(path.basename(abs, ".pdf"))}-p${n}-${Date.now()}.png`);
|
|
622
758
|
await renderJobs(abs, [{ page: n, out: tmp, box: pts, scale }], STATE_DIR, { slideWidth: pw });
|
|
623
|
-
if (!fs.existsSync(tmp)) return bad("
|
|
759
|
+
if (!fs.existsSync(tmp)) return bad("The PDF page could not be rendered.", 500);
|
|
624
760
|
const size = `${Math.round(pts[2] * scale)} × ${Math.round(pts[3] * scale)} px`;
|
|
625
761
|
if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
|
|
626
762
|
const buf = fs.readFileSync(tmp);
|
|
@@ -670,6 +806,14 @@ function scheduleFlush() {
|
|
|
670
806
|
};
|
|
671
807
|
eidTimer = setTimeout(pass, 800);
|
|
672
808
|
}
|
|
809
|
+
if (files.includes(DECK_FILE)) { // the slide tags, by hand or by Claude: tell both when they stop working
|
|
810
|
+
const now = { selector: deckSlides().selector, problem: deckProblemNow() };
|
|
811
|
+
if (now.selector !== deckNow.selector || now.problem !== deckNow.problem) {
|
|
812
|
+
if (now.problem) state.notes = [...new Set([...state.notes, `PinDeck cannot use the deck's slides now: ${now.problem}`])];
|
|
813
|
+
deckNow = now;
|
|
814
|
+
pushStatus();
|
|
815
|
+
}
|
|
816
|
+
}
|
|
673
817
|
// manual: only drag/resize writes, which the browser already shows; no reload of the visible slide
|
|
674
818
|
broadcast("files", { files, manual, rev: deckRev() });
|
|
675
819
|
}, 120);
|
|
@@ -768,7 +912,7 @@ function deletePlan(eid) {
|
|
|
768
912
|
Then nothing is written and the answer is 409 with the files that differ. */
|
|
769
913
|
function undoConflicts(t, direction) {
|
|
770
914
|
const expect = direction === "undo" ? t.after : t.before;
|
|
771
|
-
const files =
|
|
915
|
+
const files = changeFiles(t).filter((rel) => !isAs(rel, expect[rel]));
|
|
772
916
|
// undo brings deleted files back (their place must be free, or hold the same bytes) and removes added
|
|
773
917
|
// ones (which must be unchanged); redo the other way round
|
|
774
918
|
const comeBack = direction === "undo" ? t.deletedFiles || [] : t.addedFiles || [];
|
|
@@ -794,11 +938,7 @@ function undo(res) {
|
|
|
794
938
|
if (!t) return json(res, 409, { error: "Nothing to undo." });
|
|
795
939
|
const diverged = undoConflicts(t, "undo");
|
|
796
940
|
if (diverged.length) return conflict(res, "undo", diverged);
|
|
797
|
-
for (const
|
|
798
|
-
if (readDeck(f) === t.before[f]) continue;
|
|
799
|
-
if (t.before[f] != null) writeDeck(f, t.before[f]);
|
|
800
|
-
else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // the change created the file
|
|
801
|
-
}
|
|
941
|
+
for (const rel of changeFiles(t)) if (!isAs(rel, t.before[rel])) putDeckFile(rel, t.before[rel] ?? null); // null: the change created it
|
|
802
942
|
for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
|
|
803
943
|
for (const { rel } of t.addedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true }); // imported figures go
|
|
804
944
|
t.undone = true;
|
|
@@ -1037,23 +1177,41 @@ const server = http.createServer(async (req, res) => {
|
|
|
1037
1177
|
const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
|
|
1038
1178
|
return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
|
|
1039
1179
|
}
|
|
1040
|
-
if (p.startsWith("/deck/") && !ok) return text(res, 403, "Not logged in");
|
|
1180
|
+
if ((p.startsWith("/deck/") || p.startsWith("/files/") || p.startsWith("/shots/")) && !ok) return text(res, 403, "Not logged in");
|
|
1181
|
+
// /shots/: the screenshots of this deck, for the chat; only PNG files directly in its shots folder
|
|
1182
|
+
if (p.startsWith("/shots/")) {
|
|
1183
|
+
const name = decodeURIComponent(p.slice(7));
|
|
1184
|
+
if (req.method !== "GET" || !SHOT_NAME.test(name)) return text(res, 404, "Not found");
|
|
1185
|
+
return serveFile(res, path.join(SHOT_DIR, name), null, req);
|
|
1186
|
+
}
|
|
1041
1187
|
let snap;
|
|
1042
|
-
if (req.method === "GET" && (snap = p.match(/^\/deck\/__pindeck-print-([\w-]+)\.html$/))) {
|
|
1188
|
+
if (req.method === "GET" && (snap = p.match(/^\/(?:deck|files)\/(?:.*\/)?__pindeck-print-([\w-]+)\.html$/))) {
|
|
1043
1189
|
const html = snapshots.get(snap[1]);
|
|
1044
1190
|
if (!html) return text(res, 404, "Not found");
|
|
1045
1191
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1046
1192
|
return res.end(html);
|
|
1047
1193
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1194
|
+
// /deck/: the deck folder; /deck/ alone is the deck page. With an asset root the page lives under
|
|
1195
|
+
// /files/, so that ../ paths reach the root: /deck/ alone redirects there.
|
|
1196
|
+
if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
|
|
1197
|
+
res.writeHead(302, { location: DECK_PAGE + url.search, "cache-control": "no-store" });
|
|
1198
|
+
return res.end();
|
|
1199
|
+
}
|
|
1200
|
+
const deckPath = req.method === "GET" && (p.startsWith("/deck/") ? servable(DECK_DIR, p.slice(6) || DECK_FILE)
|
|
1201
|
+
: p.startsWith("/files/") && SERVE_ROOT ? servable(SERVE_ROOT, p.slice(7)) : undefined);
|
|
1202
|
+
if (deckPath === null) return text(res, 403, "Forbidden");
|
|
1203
|
+
if (deckPath) {
|
|
1204
|
+
const f = deckPath;
|
|
1051
1205
|
const mode = url.searchParams.get("editor");
|
|
1052
|
-
if (!mode ||
|
|
1206
|
+
if (!mode || !/\.html?$/i.test(f)) return serveFile(res, f, null, req);
|
|
1053
1207
|
return serveFile(res, f, (s) => {
|
|
1054
1208
|
// headless runs must not stream media, or Chrome never finishes the page
|
|
1055
1209
|
if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
|
|
1056
|
-
if (mode === "pdf")
|
|
1210
|
+
if (mode === "pdf") {
|
|
1211
|
+
s = s.replace(/<\/head>/i, PDF_HEAD_TAG).replace(/<\/body>/i, PDF_PAGE_TAG);
|
|
1212
|
+
// a deck that names its slides (in the file as it is now) is laid out by the bridge; reveal.js lays out its own
|
|
1213
|
+
return slideSelector(parse(s)) ? s.replace(/<\/body>/i, BRIDGE_TAG) : s;
|
|
1214
|
+
}
|
|
1057
1215
|
return s.replace(/<\/body>/i, BRIDGE_TAG);
|
|
1058
1216
|
});
|
|
1059
1217
|
}
|
|
@@ -1108,7 +1266,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1108
1266
|
// only files PinDeck itself stored can be attached
|
|
1109
1267
|
b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
|
|
1110
1268
|
b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
|
|
1111
|
-
|
|
1269
|
+
// the file's name, so the chat can show it (true in chats from before 2.11)
|
|
1270
|
+
const entry = addEntry({ kind: "user", text: b.text, screenshot: screenshot ? path.basename(screenshot.path) : false,
|
|
1112
1271
|
attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
|
|
1113
1272
|
selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
|
|
1114
1273
|
pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
|
|
@@ -1170,14 +1329,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
1170
1329
|
const b = await body(req);
|
|
1171
1330
|
if (staleEdit(res, b)) return;
|
|
1172
1331
|
const before = snapshot(EDITABLE);
|
|
1173
|
-
const
|
|
1332
|
+
const tree = parse(before[DECK_FILE]), slides = findSlides(tree);
|
|
1174
1333
|
const index = slides.findIndex((s) => s.attrs["data-eid"]?.value === b.eid);
|
|
1175
1334
|
if (index < 0) return json(res, 404, { error: "Slide not found in the file." });
|
|
1176
1335
|
const r = setAttr(before[DECK_FILE], b.eid, "data-visibility", b.hidden ? "hidden" : null);
|
|
1177
1336
|
if (!r.changed) return json(res, 200, { unchanged: true });
|
|
1337
|
+
const skipped = slideSelector(tree) ? "left out of PDF exports (whether presenting skips them depends on the deck's script)"
|
|
1338
|
+
: "skipped when presenting and in PDF exports";
|
|
1178
1339
|
const turnId = recordHandEdit(before, r.html, `${b.hidden ? "Hid" : "Showed"} slide ${index + 1}`,
|
|
1179
1340
|
`The user ${b.hidden ? "hid" : "showed again"} slide ${index + 1} (${b.eid}). Hidden slides carry ` +
|
|
1180
|
-
`data-visibility="hidden": they stay in the file but are skipped
|
|
1341
|
+
`data-visibility="hidden": they stay in the file but are ${skipped}.`,
|
|
1181
1342
|
{ manual: true, chat: false }); // the editor reloads the slide itself; undo is in the toolbar
|
|
1182
1343
|
return json(res, 200, { turnId, index });
|
|
1183
1344
|
}
|
|
@@ -1269,7 +1430,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1269
1430
|
if ((m = p.match(/^\/api\/turn\/(\d+)\/diff$/))) {
|
|
1270
1431
|
const t = state.turns.find((t) => t.id === +m[1]);
|
|
1271
1432
|
if (!t) return text(res, 404, "No such change");
|
|
1272
|
-
return text(res, 200,
|
|
1433
|
+
return text(res, 200, changeFiles(t).map((rel) => fileDiff(rel, t.before[rel] ?? null, t.after[rel] ?? null)).filter(Boolean).join("\n\n"));
|
|
1273
1434
|
}
|
|
1274
1435
|
if ((m = p.match(/^\/api\/turn\/(\d+)\/undo$/)) && req.method === "POST") {
|
|
1275
1436
|
const t = liveTurns().at(-1);
|
|
@@ -1284,11 +1445,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1284
1445
|
const diverged = undoConflicts(t, "redo");
|
|
1285
1446
|
if (diverged.length) return conflict(res, "redo", diverged);
|
|
1286
1447
|
state.redo.pop();
|
|
1287
|
-
for (const
|
|
1288
|
-
if (readDeck(f) === t.after[f]) continue;
|
|
1289
|
-
if (t.after[f] != null) writeDeck(f, t.after[f]);
|
|
1290
|
-
else fs.rmSync(path.join(DECK_DIR, f), { force: true });
|
|
1291
|
-
}
|
|
1448
|
+
for (const rel of changeFiles(t)) if (!isAs(rel, t.after[rel])) putDeckFile(rel, t.after[rel] ?? null);
|
|
1292
1449
|
for (const { rel } of t.deletedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true });
|
|
1293
1450
|
for (const { rel, data } of t.addedFiles || []) writeDeckFile(rel, data, false);
|
|
1294
1451
|
t.undone = false;
|
|
@@ -1422,11 +1579,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1422
1579
|
return text(res, r.status || 200, r.text);
|
|
1423
1580
|
}
|
|
1424
1581
|
if (p === "/api/slides") {
|
|
1425
|
-
|
|
1582
|
+
const list = slideList(), why = !list.length && deckProblemNow();
|
|
1583
|
+
if (why) return text(res, 409, why);
|
|
1584
|
+
return text(res, 200, list.map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
|
|
1426
1585
|
}
|
|
1427
1586
|
if (p === "/api/layout" || p === "/api/shot") {
|
|
1428
1587
|
const slide = resolveSlide(url.searchParams.get("slide"));
|
|
1429
|
-
if (!slide) return text(res, 404, `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
|
|
1588
|
+
if (!slide) return text(res, 404, deckProblemNow() || `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
|
|
1430
1589
|
if (!slide.eid) runEidPass();
|
|
1431
1590
|
const s2 = slide.eid ? slide : resolveSlide(String(slide.index + 1));
|
|
1432
1591
|
if (p === "/api/shot") {
|