pindeck 3.4.0 → 4.1.0-beta.1
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/CHANGELOG.md +127 -0
- package/README.md +37 -734
- package/cli.mjs +11 -2
- package/docs/configuration.md +51 -0
- package/docs/how-it-works.md +56 -0
- package/docs/linux-server.md +36 -0
- package/docs/preparing-decks.md +88 -0
- package/docs/using.md +45 -0
- package/lib/agent-tools.mjs +2 -2
- package/lib/deck-list.mjs +5 -3
- package/lib/deck-target.mjs +9 -5
- package/lib/eids.mjs +13 -3
- package/lib/review.mjs +200 -0
- package/lib/servers.mjs +1 -1
- package/lib/system-prompt.md +13 -2
- package/lib/usage.mjs +4 -3
- package/package.json +4 -1
- package/public/bridge.js +94 -51
- package/public/editor.css +36 -3
- package/public/editor.js +223 -49
- package/public/index.html +22 -3
- package/server.mjs +269 -34
package/server.mjs
CHANGED
|
@@ -10,20 +10,20 @@
|
|
|
10
10
|
- answers `cli.mjs layout|shot|slides|pdf|pdf-figure` for the assistant's self-checks, exports and PDF figures
|
|
11
11
|
- exports the deck as a PDF handout or a PDF presentation (build steps on separate pages)
|
|
12
12
|
- stores figures and audio attached to chat messages in <deck>/figures/ for the assistant to use, and attached
|
|
13
|
-
PDFs (papers, reports, figures) in ~/.pindeck-
|
|
13
|
+
PDFs (papers, reports, figures) in ~/.pindeck-v4, outside the deck
|
|
14
14
|
- imports slides from PowerPoint files in the deck's own layouts
|
|
15
15
|
Listens on 127.0.0.1 only; the editor, the deck and every API call need the per-run token (a login
|
|
16
16
|
cookie in the browser, a header for the CLI).
|
|
17
|
-
Runtime files (chat, screenshots, backups) live in ~/.pindeck-
|
|
17
|
+
Runtime files (chat, screenshots, backups) live in ~/.pindeck-v4, never in the deck or the package. */
|
|
18
18
|
|
|
19
19
|
import http from "node:http";
|
|
20
20
|
import { EventEmitter } from "node:events";
|
|
21
21
|
import fs from "node:fs";
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import crypto from "node:crypto";
|
|
24
|
-
import { spawn, execFile } from "node:child_process";
|
|
24
|
+
import { spawn, execFile, execFileSync } from "node:child_process";
|
|
25
25
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
26
|
-
import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd, decode } from "./lib/eids.mjs";
|
|
26
|
+
import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, getOuter, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd, decode } from "./lib/eids.mjs";
|
|
27
27
|
import { unifiedDiff } from "./lib/diff.mjs";
|
|
28
28
|
import { ClaudeSession } from "./lib/claude.mjs";
|
|
29
29
|
import { CodexSession } from "./lib/codex.mjs";
|
|
@@ -44,12 +44,14 @@ import { assetRoots } from "./lib/asset-roots.mjs";
|
|
|
44
44
|
import { registerServer, printLogin, alive } from "./lib/servers.mjs";
|
|
45
45
|
import { goodHost, presentHost, LOGIN_PAGE, createLogin, openOnEnter } from "./lib/login.mjs";
|
|
46
46
|
import { USAGE } from "./lib/usage.mjs";
|
|
47
|
+
import { indexDeck, commentFromPin, locate, loadReview, saveReview, packet, request as commentsRequest, reviewFiles } from "./lib/review.mjs";
|
|
47
48
|
|
|
48
49
|
/* ---- Config ------------------------------------------------------------------ */
|
|
49
50
|
/* Started by the front (pindeck, front.mjs) for one of the decks it serves: the front logs browsers in and
|
|
50
51
|
prints the links; this server tells it its address over IPC. */
|
|
51
52
|
const FRONT = !!process.env.PINDECK_FRONT && !!process.send;
|
|
52
53
|
const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
54
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(EDITOR_DIR, "package.json"), "utf8")).version;
|
|
53
55
|
const argv = process.argv.slice(2);
|
|
54
56
|
if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
|
|
55
57
|
const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
|
|
@@ -70,9 +72,21 @@ const MODEL = opt("--model", "");
|
|
|
70
72
|
if (process.env.CLAUDE_MODEL && !LOGIN && !FRONT) // PinDeck 2 read it; behind the front, the front says so
|
|
71
73
|
console.warn(`CLAUDE_MODEL is not used by PinDeck 3: choose the model in the editor's model menu, or start with --model ${process.env.CLAUDE_MODEL}.`);
|
|
72
74
|
/* The deck: a file (talk.html), or a folder, which means its index.html (see lib/deck-target.mjs) */
|
|
73
|
-
const { dir: DECK_DIR, file: DECK_FILE, named: NAMED, name: DECK_NAME, key: deckKey } = deckTarget(argv[0] || process.cwd());
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
const { dir: DECK_DIR, file: DECK_FILE, named: NAMED, name: DECK_NAME, key: deckKey, source: SOURCE } = deckTarget(argv[0] || process.cwd());
|
|
76
|
+
/* A Quarto deck (SOURCE: talk.qmd): DECK_FILE is what Quarto renders from it, build output. The assistant edits
|
|
77
|
+
the sources; PinDeck renders after every change (see Quarto below) and adds its element ids to the output. */
|
|
78
|
+
const OUTPUTS = SOURCE ? [DECK_FILE, DECK_FILE.replace(/\.html$/i, "_files"), ".quarto"] : [];
|
|
79
|
+
const isOutput = (rel) => OUTPUTS.some((o) => rel === o || rel.startsWith(o + "/"));
|
|
80
|
+
const EDITABLE = SOURCE ? [SOURCE, DECK_FILE] : [DECK_FILE, "theme.css"]; // both backends may edit these (a Quarto deck: its source)
|
|
81
|
+
/* A Quarto deck's format extensions (_extensions/[<org>/]<name>/, e.g. Ocean) carry their own brand.css and vendor/ */
|
|
82
|
+
function extensionBrands() {
|
|
83
|
+
try {
|
|
84
|
+
return fs.readdirSync(path.join(DECK_DIR, "_extensions"), { recursive: true }).map((f) => String(f).split(path.sep).join("/"))
|
|
85
|
+
.filter((f) => /(^|\/)brand\.css$/.test(f) && !f.includes("vendor/")).map((f) => `_extensions/${f}`);
|
|
86
|
+
} catch { return []; }
|
|
87
|
+
}
|
|
88
|
+
const BRANDS = SOURCE ? extensionBrands() : [];
|
|
89
|
+
const LOCKED = ["brand.css", ...BRANDS]; // never, enforced twice
|
|
76
90
|
const HOME_DIR = homeDir();
|
|
77
91
|
const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
|
|
78
92
|
const SHOT_DIR = path.join(STATE_DIR, "shots");
|
|
@@ -92,6 +106,34 @@ const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(
|
|
|
92
106
|
const CLI_CMD = cliCommand(CLI_REL);
|
|
93
107
|
const CAPS = detectCapabilities();
|
|
94
108
|
|
|
109
|
+
/* ---- Quarto ----------------------------------------------------------------------------------------
|
|
110
|
+
`quarto render <source>` makes DECK_FILE. One render at a time; a change to a source while one runs renders
|
|
111
|
+
again when it ends. What the assistant checks (shot, layout, slides, pdf) waits for the render, so it always
|
|
112
|
+
sees its last edit. Quarto's own errors go to the chat, and to the assistant. */
|
|
113
|
+
const QUARTO = SOURCE ? findExecutable(process.env.QUARTO_BIN || "quarto") : null;
|
|
114
|
+
const RENDER_TIMEOUT = 180000;
|
|
115
|
+
const SOURCE_FILE = /\.(qmd|md|scss|css|ya?ml|bib|csl|lua|js|html?|png|jpe?g|gif|svg|webp|pdf|mp4|wav|mp3)$/i; // what a render reads
|
|
116
|
+
/* Quarto's message, without colours and its JavaScript stack: what says what is wrong in the source */
|
|
117
|
+
const quartoError = (stderr) => `Quarto could not render ${SOURCE}:\n` + String(stderr).replace(/\x1b\[[0-9;]*m/g, "")
|
|
118
|
+
.split(/\nStack trace:|\n\s+at /)[0].trim().split("\n").slice(-25).join("\n");
|
|
119
|
+
function renderSync() {
|
|
120
|
+
console.log(`Rendering ${SOURCE} with Quarto…`);
|
|
121
|
+
try { execFileSync(QUARTO.path, ["render", SOURCE], { cwd: DECK_DIR, stdio: ["ignore", "ignore", "pipe"], timeout: RENDER_TIMEOUT }); }
|
|
122
|
+
catch (e) { return quartoError(e.stderr || e.message); }
|
|
123
|
+
return fs.existsSync(path.join(DECK_DIR, DECK_FILE)) ? null
|
|
124
|
+
: `Quarto rendered ${SOURCE}, but not to ${DECK_FILE} beside it (an output-dir in _quarto.yml?). PinDeck needs the HTML next to its source.`;
|
|
125
|
+
}
|
|
126
|
+
let startRenderError = null; // a failed render at start with an older output: PinDeck opens that, the assistant fixes the source
|
|
127
|
+
if (SOURCE && !LOGIN) {
|
|
128
|
+
if (!QUARTO.path) { console.error(`${SOURCE} is a Quarto deck, and Quarto is not installed (${QUARTO.reason}). Install it from https://quarto.org.`); process.exit(1); }
|
|
129
|
+
const mtime = (f) => { try { return fs.statSync(path.join(DECK_DIR, f)).mtimeMs; } catch { return 0; } };
|
|
130
|
+
if (mtime(DECK_FILE) < mtime(SOURCE)) {
|
|
131
|
+
const failed = renderSync();
|
|
132
|
+
if (failed && !fs.existsSync(path.join(DECK_DIR, DECK_FILE))) { console.error(failed); process.exit(1); }
|
|
133
|
+
if (failed) { console.error(failed + `\nOpening the last render of ${SOURCE}.`); startRenderError = failed; }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
95
137
|
/* Checks before anything is written: a deck that cannot be opened or saved is left exactly as it is. */
|
|
96
138
|
function checkDeck() {
|
|
97
139
|
const file = path.join(DECK_DIR, DECK_FILE);
|
|
@@ -304,13 +346,14 @@ function refreshGit() {
|
|
|
304
346
|
}
|
|
305
347
|
function slideList() {
|
|
306
348
|
const html = readDeck(DECK_FILE);
|
|
307
|
-
return findSlides(parse(html)).map((s, index) => ({ index, eid: s.attrs["data-eid"]?.value || null }));
|
|
349
|
+
return findSlides(parse(html)).map((s, index) => ({ index, eid: s.attrs["data-eid"]?.value || null, id: s.attrs.id?.value || null }));
|
|
308
350
|
}
|
|
309
351
|
function resolveSlide(spec) {
|
|
310
352
|
const slides = slideList();
|
|
311
353
|
if (!spec || spec === "current") return slides[state.current?.index ?? 0] || slides[0];
|
|
312
354
|
if (/^\d+$/.test(spec)) return slides[+spec - 1];
|
|
313
|
-
|
|
355
|
+
// or the slide's own id attribute (Quarto's heading anchor, which stays when the title changes)
|
|
356
|
+
return slides.find((s) => s.eid === spec) || slides.find((s) => s.id === spec) || slides.find((s) => s.eid && s.eid.startsWith(spec));
|
|
314
357
|
}
|
|
315
358
|
|
|
316
359
|
/* ---- Headless Chrome (screenshots, and layout when no editor tab is open) --------------- */
|
|
@@ -544,7 +587,8 @@ const status = () => ({ busy: state.busy, switching: state.switching, preparing:
|
|
|
544
587
|
running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
|
|
545
588
|
chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
|
|
546
589
|
capabilities: publicCapabilities(CAPS), rev: deckRev(), view: VIEW.slice(1) + "/",
|
|
547
|
-
undo: changeLabel(liveTurns().at(-1)), undoId: liveTurns().at(-1)?.id ?? null, redo: changeLabel(state.redo.at(-1))
|
|
590
|
+
undo: changeLabel(liveTurns().at(-1)), undoId: liveTurns().at(-1)?.id ?? null, redo: changeLabel(state.redo.at(-1)),
|
|
591
|
+
source: SOURCE, rendering: !!rendering, renderError });
|
|
548
592
|
/* Pushed to the editors on every change. The model lists (both providers' catalogs) go along only when they
|
|
549
593
|
changed since the last push: the editor keeps the last ones it got. */
|
|
550
594
|
let catalogSent = "";
|
|
@@ -563,6 +607,19 @@ const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md
|
|
|
563
607
|
(ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
|
|
564
608
|
`them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
|
|
565
609
|
`other decks may use them too.\n` : "");
|
|
610
|
+
/* A Quarto deck: what to edit, and how its output relates to the source */
|
|
611
|
+
const quartoInstructions = () => SOURCE ? `\n\n## This deck is made with Quarto
|
|
612
|
+
${DECK_FILE} is rendered by Quarto from ${SOURCE}: it is build output, locked, and overwritten by every render. Make every ` +
|
|
613
|
+
`change in ${SOURCE} (Quarto Markdown: \`##\` headings start slides and become the slides' id attributes, \`::: {.notes}\` holds ` +
|
|
614
|
+
`speaker notes, \`::: {.incremental}\` and \`. . .\` make build steps, \`{ojs}\` cells are Observable JS; when you change a ` +
|
|
615
|
+
`slide's title, keep its id by adding \`{#<old id>}\` to the heading, so comments and links still find the slide), or in the files it uses ` +
|
|
616
|
+
`(its .scss theme, figures). Where these instructions say to edit ${DECK_FILE} or theme.css, edit ${SOURCE} or its stylesheet ` +
|
|
617
|
+
`instead. Pins and element ids (data-eid) name elements of the rendered HTML: read ${DECK_FILE} to see what they are, then ` +
|
|
618
|
+
`find the matching text in ${SOURCE}. PinDeck renders after every change you save; ${CLI_CMD} shot, layout and slides wait ` +
|
|
619
|
+
`for that render, and report Quarto's error if it fails. Interactive OJS output is drawn in the browser, so screenshots show it ` +
|
|
620
|
+
`only once its libraries have loaded.\n` + (BRANDS.length ? `The deck's look comes from a Quarto format extension (` +
|
|
621
|
+
BRANDS.map((b) => b.replace(/\/brand\.css$/, "/")).join(", ") + `): read its README.md for the slide layouts and classes. ` +
|
|
622
|
+
`Its brand.css and vendor/ are locked, like brand.css in an HTML deck; its theme.css takes the talk's own styles.\n` : "") : "";
|
|
566
623
|
/* For a deck made without reveal.js: what its slides are, from its selector as it is at each session start */
|
|
567
624
|
function slidesInstructions() {
|
|
568
625
|
const { selector } = deckSlides();
|
|
@@ -577,7 +634,7 @@ function slidesInstructions() {
|
|
|
577
634
|
win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
|
|
578
635
|
function lockedPaths() {
|
|
579
636
|
const { files, folders } = otherDecks();
|
|
580
|
-
return [...LOCKED, "vendor/**", ".git", ".git/**", ...files, ...folders.map((f) => `${f}/**`)];
|
|
637
|
+
return [...LOCKED, "vendor/**", ...BRANDS.map((b) => b.replace(/brand\.css$/, "vendor/**")), ".git", ".git/**", ".pindeck", ".pindeck/**", ...OUTPUTS.flatMap((o) => [o, `${o}/**`]), ...files, ...folders.map((f) => `${f}/**`)];
|
|
581
638
|
}
|
|
582
639
|
/* The other decks in this deck's folder: its other .html files, and the subfolders that hold a deck of their
|
|
583
640
|
own (as the deck menu lists them). Neither the assistant nor this deck's undo touches them. */
|
|
@@ -588,7 +645,7 @@ function otherDecks() {
|
|
|
588
645
|
.map((d) => path.relative(DECK_DIR, d.dir).split(path.sep).join("/")))];
|
|
589
646
|
return { files, folders };
|
|
590
647
|
}
|
|
591
|
-
const scanDeck = () => { const { files, folders } = otherDecks(); return scanFiles(DECK_DIR, { skip: [...files, ...folders] }); };
|
|
648
|
+
const scanDeck = () => { const { files, folders } = otherDecks(); return scanFiles(DECK_DIR, { skip: [...files, ...folders, ...OUTPUTS] }); };
|
|
592
649
|
|
|
593
650
|
/* Decks that share a folder share its files (theme.css, figures/): while the assistant of one works, that of
|
|
594
651
|
another may change them too. Each deck server keeps the times of its turns in a file of its own under
|
|
@@ -642,13 +699,13 @@ const codexInstructions = () => systemPrompt.replace(/## Attached files[\s\S]*?#
|
|
|
642
699
|
Figures and audio are already saved under figures/. Keep their paths and use the deck's existing figure/audio patterns. Do not rename or move attachments.
|
|
643
700
|
Use pindeck_read for text files, images and PDF pages (supply page: 1, then further pages as needed).
|
|
644
701
|
Use pindeck_edit for exact replacements in ${DECK_FILE}, theme.css and the deck's other files, and pindeck_write to create a new file in the deck folder. Never change brand.css.
|
|
645
|
-
Use pindeck_command with separate command and args for layout, shot, slides,
|
|
702
|
+
Use pindeck_command with separate command and args for layout, shot, slides, element (the HTML of data-eid ids), pdf, pdf-figure, resolve or reply.
|
|
646
703
|
Do not use shell commands, native file edits, or other tools to bypass these restrictions.
|
|
647
704
|
When context mentions Read, use pindeck_read. Read screenshots with pindeck_read.
|
|
648
705
|
Read README.md and the deck instructions below for conventions. Use layout after every visual change.
|
|
649
706
|
PDF tables should be reconstructed as HTML; pdf-figure renders figures. Audio is embedded by path.
|
|
650
707
|
|
|
651
|
-
## Hand edits`) + slidesInstructions() + deckInstructions();
|
|
708
|
+
## Hand edits`) + slidesInstructions() + quartoInstructions() + deckInstructions();
|
|
652
709
|
function makeSession(choice) {
|
|
653
710
|
let initial = null;
|
|
654
711
|
const options = {
|
|
@@ -670,11 +727,11 @@ function makeSession(choice) {
|
|
|
670
727
|
instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
|
|
671
728
|
// a tool the rules do not allow comes to PinDeck (--permission-prompt-tool stdio), always refused: with the right command if it was the CLI
|
|
672
729
|
denyMessage: (r) => r?.tool_name === "Bash" ? cliRefusal(r.input?.command, CLI_CMD) : null,
|
|
673
|
-
args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
|
|
730
|
+
args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + quartoInstructions() + deckInstructions(),
|
|
674
731
|
"--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
|
|
675
732
|
"--permission-mode", "default", "--permission-prompt-tool", "stdio", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
|
|
676
733
|
"--allowedTools", ...readRules(), "Edit(./**)", "Write(./**)",
|
|
677
|
-
...["layout", "shot", "slides", "pdf", "pdf-figure"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
|
|
734
|
+
...["layout", "shot", "slides", "element", "pdf", "pdf-figure", "resolve", "reply"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
|
|
678
735
|
"--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
|
|
679
736
|
...(choice.model ? ["--model", choice.model] : []), ...(choice.effort ? ["--effort", choice.effort] : [])],
|
|
680
737
|
});
|
|
@@ -848,7 +905,7 @@ function toolSummary(name, input = {}) {
|
|
|
848
905
|
// must show as the script, or a refused script looks like a refused layout check
|
|
849
906
|
const cmd = String(input.command || "").trim();
|
|
850
907
|
const m = cmd.startsWith(CLI_CMD + " ") && cmd.slice(CLI_CMD.length).match(/^\s+([\w-]+)\s*([^\n;&|<>`$]*)$/);
|
|
851
|
-
const names = { shot: "Screenshot", layout: "Check layout", "pdf-figure": "PDF figure" };
|
|
908
|
+
const names = { shot: "Screenshot", layout: "Check layout", element: "Read element", "pdf-figure": "PDF figure", resolve: "Resolve comment", reply: "Reply to comment" };
|
|
852
909
|
return m ? { name: names[m[1]] || m[1], detail: m[1] === "pdf-figure" ? m[2].trim().replace(/^\S*\//, "") : m[2].trim() }
|
|
853
910
|
: { name: "Run", detail: cmd.split("\n")[0].slice(0, 80) };
|
|
854
911
|
}
|
|
@@ -1034,8 +1091,10 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
|
|
|
1034
1091
|
ctx.push(...notes.map((n) => "Note: " + n));
|
|
1035
1092
|
state.notes = [];
|
|
1036
1093
|
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1094
|
+
const selBox = (s) => `${s.eid} (${s.tag}), box x=${s.box.x} y=${s.box.y} w=${s.box.w} h=${s.box.h}`;
|
|
1095
|
+
if (selection?.also?.length) ctx.push(`Selected elements (what "these", "them" or "this" most likely refer to), on slide ${selection.slide.index + 1}:\n` +
|
|
1096
|
+
[selection, ...selection.also].map((s) => "- " + selBox(s)).join("\n"));
|
|
1097
|
+
else if (selection) ctx.push(`Selected element (what "this" or "it" most likely refers to): ${selBox(selection)} on slide ${selection.slide.index + 1}.`);
|
|
1039
1098
|
if (pins.length) ctx.push("Pins (numbered clicks; the user refers to them by number):\n" + pins.map(formatPin).join("\n"));
|
|
1040
1099
|
for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
|
|
1041
1100
|
if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide}${screenshot.pins ? " with the pin markers" : ""}: ${screenshot.path} (Read it).`);
|
|
@@ -1082,6 +1141,98 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
|
|
|
1082
1141
|
return { text: `${out} (${size})` };
|
|
1083
1142
|
}
|
|
1084
1143
|
|
|
1144
|
+
/* ---- Review comments (lib/review.mjs) ---------------------------------------------------------------
|
|
1145
|
+
Kept in <deck folder>/.pindeck/: never a deck file, never in an undo, locked for the assistant. The packet
|
|
1146
|
+
next to them is written once there are comments, and again whenever they or the deck change. An agent outside
|
|
1147
|
+
PinDeck may set a comment's status in the file; the editor follows. A file that cannot be read is left as it
|
|
1148
|
+
is: comments are then off, with the reason. */
|
|
1149
|
+
const REVIEW = reviewFiles(DECK_DIR, DECK_FILE);
|
|
1150
|
+
let review = null, reviewProblem = null, reviewSaved = null;
|
|
1151
|
+
function readReview() {
|
|
1152
|
+
try { review = loadReview(REVIEW.json); reviewProblem = null; }
|
|
1153
|
+
catch (e) { review = null; reviewProblem = `The comments file ${REVIEW.json} cannot be read (${e.message}): fix or remove it.`; }
|
|
1154
|
+
try { reviewSaved = fs.readFileSync(REVIEW.json, "utf8"); } catch { reviewSaved = null; }
|
|
1155
|
+
}
|
|
1156
|
+
readReview();
|
|
1157
|
+
const deckIndex = () => indexDeck(readDeck(DECK_FILE) || "");
|
|
1158
|
+
// the Quarto source's text, for the source lines of comments (null for an HTML deck)
|
|
1159
|
+
const sourceText = () => { try { return SOURCE ? fs.readFileSync(path.join(DECK_DIR, SOURCE), "utf8") : null; } catch { return null; } };
|
|
1160
|
+
function commentsView(deck = deckIndex()) {
|
|
1161
|
+
if (!review) return { comments: [], problem: reviewProblem };
|
|
1162
|
+
return { file: path.relative(DECK_DIR, REVIEW.json), packet: path.relative(DECK_DIR, REVIEW.packet), problem: null,
|
|
1163
|
+
comments: review.comments.map((c, i) => {
|
|
1164
|
+
const loc = locate(deck, c);
|
|
1165
|
+
return { ...c, n: i + 1, loc: { status: loc.status, index: loc.slide?.index ?? null, slideEid: loc.slide?.eid ?? null, eid: loc.eid, now: loc.now } };
|
|
1166
|
+
}) };
|
|
1167
|
+
}
|
|
1168
|
+
function writePacket(deck = deckIndex()) {
|
|
1169
|
+
if (!review || (!review.comments.length && !fs.existsSync(REVIEW.packet))) return;
|
|
1170
|
+
fs.mkdirSync(path.dirname(REVIEW.packet), { recursive: true });
|
|
1171
|
+
writeAtomic(REVIEW.packet, packet(review, deck, { deckFile: DECK_FILE, source: SOURCE, srcText: sourceText(), commentsFile: path.relative(DECK_DIR, REVIEW.json) }));
|
|
1172
|
+
}
|
|
1173
|
+
function commentsChanged({ save = true } = {}) {
|
|
1174
|
+
const deck = deckIndex();
|
|
1175
|
+
if (save) { saveReview(REVIEW.json, review); reviewSaved = fs.readFileSync(REVIEW.json, "utf8"); }
|
|
1176
|
+
try { writePacket(deck); } catch (e) { console.error(e.message); }
|
|
1177
|
+
const view = commentsView(deck);
|
|
1178
|
+
broadcast("comments", view);
|
|
1179
|
+
return view;
|
|
1180
|
+
}
|
|
1181
|
+
// edits of the file by another program (an agent marks a comment resolved): polled, which survives replaced files
|
|
1182
|
+
function syncReview() {
|
|
1183
|
+
let now = null;
|
|
1184
|
+
try { now = fs.readFileSync(REVIEW.json, "utf8"); } catch {}
|
|
1185
|
+
if (now === reviewSaved) return false;
|
|
1186
|
+
readReview();
|
|
1187
|
+
return true;
|
|
1188
|
+
}
|
|
1189
|
+
fs.watchFile(REVIEW.json, { interval: 1000 }, () => { if (syncReview()) commentsChanged({ save: false }); });
|
|
1190
|
+
|
|
1191
|
+
/* Rendering while PinDeck runs (see Quarto above): started by a change to a source file */
|
|
1192
|
+
let rendering = null, renderAgain = false, renderError = startRenderError, renderTimer = null, renderStarted = Date.now();
|
|
1193
|
+
if (renderError) {
|
|
1194
|
+
addEntry({ kind: "notice", text: renderError + `\nThe deck shown is the last render that worked.` });
|
|
1195
|
+
state.notes.push(renderError + "\nFix the source; PinDeck renders again after every change.");
|
|
1196
|
+
}
|
|
1197
|
+
function render() {
|
|
1198
|
+
clearTimeout(renderTimer);
|
|
1199
|
+
renderTimer = null;
|
|
1200
|
+
if (rendering) { renderAgain = true; return rendering; }
|
|
1201
|
+
renderStarted = Date.now();
|
|
1202
|
+
rendering = new Promise((done) => {
|
|
1203
|
+
execFile(QUARTO.path, ["render", SOURCE], { cwd: DECK_DIR, timeout: RENDER_TIMEOUT, maxBuffer: 1 << 24 }, (err, _out, stderr) => {
|
|
1204
|
+
const was = renderError;
|
|
1205
|
+
renderError = err ? quartoError(stderr || err.message) : null;
|
|
1206
|
+
if (renderError && renderError !== was) {
|
|
1207
|
+
addEntry({ kind: "notice", text: renderError });
|
|
1208
|
+
state.notes.push(renderError + "\nFix the source; PinDeck renders again after every change.");
|
|
1209
|
+
} else if (!renderError && was) addEntry({ kind: "notice", text: `${SOURCE} renders again.` });
|
|
1210
|
+
if (!err) runEidPass(); // the fresh output gets its element ids (and the editor reloads it)
|
|
1211
|
+
done();
|
|
1212
|
+
});
|
|
1213
|
+
}).finally(() => {
|
|
1214
|
+
rendering = null;
|
|
1215
|
+
if (renderAgain) { renderAgain = false; render(); }
|
|
1216
|
+
pushStatus();
|
|
1217
|
+
});
|
|
1218
|
+
pushStatus();
|
|
1219
|
+
return rendering;
|
|
1220
|
+
}
|
|
1221
|
+
function sourceChanged() {
|
|
1222
|
+
clearTimeout(renderTimer);
|
|
1223
|
+
renderTimer = setTimeout(render, 150); // an editor's save is often several events
|
|
1224
|
+
}
|
|
1225
|
+
/* Before the assistant looks at the deck: a pending or running render ends first, and a source saved since the
|
|
1226
|
+
last render began (its file event may not have come yet) is rendered now */
|
|
1227
|
+
async function rendered() {
|
|
1228
|
+
if (!SOURCE) return null;
|
|
1229
|
+
let saved = 0;
|
|
1230
|
+
try { saved = fs.statSync(path.join(DECK_DIR, SOURCE)).mtimeMs; } catch {}
|
|
1231
|
+
if (renderTimer || saved > renderStarted) render();
|
|
1232
|
+
while (rendering) await rendering;
|
|
1233
|
+
return renderError;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1085
1236
|
/* ---- File watching --------------------------------------------------------------------------
|
|
1086
1237
|
Changes are collected for 120 ms and sent to the editor with the deck's revision, so a tab that missed
|
|
1087
1238
|
events (reconnect) can still tell that it shows an old state. Dot files (PinDeck's temporary files,
|
|
@@ -1092,6 +1243,10 @@ function onFileEvent(_ev, file) {
|
|
|
1092
1243
|
if (!file) { changed.add(DECK_FILE); return scheduleFlush(); } // some systems omit the name: assume the deck
|
|
1093
1244
|
file = String(file).split(path.sep).join("/");
|
|
1094
1245
|
if (/(^|\/)\.|\.bak$|~$|^export\//.test(file)) return;
|
|
1246
|
+
if (SOURCE) {
|
|
1247
|
+
if (isOutput(file)) { if (rendering || file !== DECK_FILE) return; } // a render's own writes: its id pass reloads
|
|
1248
|
+
else if (SOURCE_FILE.test(file)) sourceChanged();
|
|
1249
|
+
}
|
|
1095
1250
|
changed.add(file);
|
|
1096
1251
|
scheduleFlush();
|
|
1097
1252
|
}
|
|
@@ -1128,6 +1283,7 @@ function scheduleFlush() {
|
|
|
1128
1283
|
};
|
|
1129
1284
|
eidTimer = setTimeout(pass, 800);
|
|
1130
1285
|
}
|
|
1286
|
+
if (files.includes(DECK_FILE) && review?.comments.length) commentsChanged({ save: false }); // found again, or flagged
|
|
1131
1287
|
if (files.includes(DECK_FILE)) { // the slide tags, by hand or by Claude: tell both when they stop working
|
|
1132
1288
|
const now = { selector: deckSlides().selector, problem: deckProblemNow() };
|
|
1133
1289
|
if (now.selector !== deckNow.selector || now.problem !== deckNow.problem) {
|
|
@@ -1211,14 +1367,17 @@ function recordHandEdit(before, html, label, note, { manual = true, deletedFiles
|
|
|
1211
1367
|
/* What deleting an element would remove, and which of its files nothing else uses. Files outside the
|
|
1212
1368
|
deck folder, in vendor/, or still referenced elsewhere (the rest of the deck, theme.css, brand.css, or
|
|
1213
1369
|
another deck in the folder or one folder down, which may share figures/) are never offered. */
|
|
1214
|
-
|
|
1370
|
+
/* What deleting one or more elements removes: the elements (a picture with its caption; an element inside another
|
|
1371
|
+
one that goes too is left out) and the files they refer to */
|
|
1372
|
+
function deletePlan(eids) {
|
|
1215
1373
|
const html = readDeck(DECK_FILE);
|
|
1216
|
-
|
|
1217
|
-
if (!
|
|
1218
|
-
|
|
1374
|
+
let targets = [].concat(eids).map((eid) => deleteTarget(html, eid));
|
|
1375
|
+
if (!targets.length || targets.some((t) => !t)) return null;
|
|
1376
|
+
targets = targets.filter((t, i) => !targets.some((o, j) => o.eid === t.eid ? j < i : o.outer.includes(`data-eid="${t.eid}"`)));
|
|
1377
|
+
const rest = targets.reduce((h, t) => removeElement(h, t.eid), html);
|
|
1219
1378
|
const css = (readDeck("theme.css") || "") + (readDeck("brand.css") || "");
|
|
1220
1379
|
const others = otherDeckRefs();
|
|
1221
|
-
const files = fileRefs(
|
|
1380
|
+
const files = [...new Set(targets.flatMap((t) => fileRefs(t.outer)))].flatMap((ref) => {
|
|
1222
1381
|
const abs = path.resolve(DECK_DIR, ref);
|
|
1223
1382
|
const rel = path.relative(DECK_DIR, abs).split(path.sep).join("/");
|
|
1224
1383
|
if (rel.startsWith("..") || rel.startsWith("vendor/") || !fs.existsSync(abs) || !fs.statSync(abs).isFile()) return [];
|
|
@@ -1227,7 +1386,7 @@ function deletePlan(eid) {
|
|
|
1227
1386
|
const usedElsewhere = fileRefs(rest).some((r) => path.resolve(DECK_DIR, r) === abs) || css.includes(ref) || css.includes(rel) || usedBy.length > 0;
|
|
1228
1387
|
return [{ rel, bytes: fs.statSync(abs).size, usedElsewhere, usedBy }];
|
|
1229
1388
|
});
|
|
1230
|
-
return { ...
|
|
1389
|
+
return { ...targets[0], targets: targets.map(({ eid, tag }) => ({ eid, tag })), html: rest, files };
|
|
1231
1390
|
}
|
|
1232
1391
|
/* The other decks of this folder and one folder down, each with a test of whether it refers to a file: in its
|
|
1233
1392
|
HTML (relative to its own folder), or in the stylesheets beside it */
|
|
@@ -1302,7 +1461,7 @@ function undo(res) {
|
|
|
1302
1461
|
/* ---- PowerPoint import -----------------------------------------------------------------------------
|
|
1303
1462
|
Upload: the file is read at once (slide list for the dialog); in the background PowerPoint or Keynote
|
|
1304
1463
|
makes a PDF of it, from which come the thumbnails and, on import, the pictures of diagrams.
|
|
1305
|
-
Only the latest upload is kept, in ~/.pindeck-
|
|
1464
|
+
Only the latest upload is kept, in ~/.pindeck-v4/decks/<deck>/imports/. */
|
|
1306
1465
|
const IMPORT_DIR = path.join(STATE_DIR, "imports");
|
|
1307
1466
|
const IMPORT_MAX = 500 * 1024 * 1024;
|
|
1308
1467
|
let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
|
|
@@ -1468,6 +1627,7 @@ function serveFile(res, file, transform, req, extra = {}) {
|
|
|
1468
1627
|
let ready = false;
|
|
1469
1628
|
const NEEDS_DECK = /^\/api\/(message|slides|layout|shot|pdf|pdf-figure|edit|text|split|remove|notes|delete|delete-info|source|slide-hidden|move-slide|add-slide|import-apply|export)$/;
|
|
1470
1629
|
const CLI_ROUTES = /^\/api\/(slides|layout|shot|pdf|pdf-figure)$/; // answered as text, for cli.mjs
|
|
1630
|
+
const HAND_EDITS = /^\/api\/(edit|text|split|remove|notes|delete|slide-hidden|move-slide|add-slide|import|import-apply)$/; // refused for a Quarto deck
|
|
1471
1631
|
/* Scriptable files (HTML, SVG) from the deck get the sandbox, wherever they are asked for. Under /v/ they are
|
|
1472
1632
|
read by documents of that sandboxed origin, so fonts and module scripts need CORS; the path is the secret.
|
|
1473
1633
|
On localhost (Present) there is no sandbox: no login works there (see presentHost). */
|
|
@@ -1494,7 +1654,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1494
1654
|
const ok = authed(req);
|
|
1495
1655
|
if (req.method === "GET" && (p === "/" || p === "/index.html")) {
|
|
1496
1656
|
if (!ok) { res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); return res.end(LOGIN_PAGE); }
|
|
1497
|
-
return serveFile(res, path.join(EDITOR_DIR, "public/index.html"),
|
|
1657
|
+
return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), (s) => s.replace("{{VERSION}}", VERSION), req);
|
|
1498
1658
|
}
|
|
1499
1659
|
if (req.method === "GET" && p.startsWith("/editor/")) {
|
|
1500
1660
|
const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
|
|
@@ -1571,6 +1731,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
1571
1731
|
return;
|
|
1572
1732
|
}
|
|
1573
1733
|
// the deck file gone (moved, renamed, deleted) while PinDeck runs: say so, instead of failing on it
|
|
1734
|
+
// a Quarto deck is rendered from its source: no hand edits of the output (the next render would undo them)
|
|
1735
|
+
if (SOURCE && req.method === "POST" && HAND_EDITS.test(p))
|
|
1736
|
+
return json(res, 409, { code: "quarto", error: `${DECK_FILE} is rendered by Quarto from ${SOURCE}: ask the assistant, or edit ${SOURCE} yourself.` });
|
|
1737
|
+
// what the assistant checks is the deck rendered from its last edit
|
|
1738
|
+
if (SOURCE && CLI_ROUTES.test(p)) {
|
|
1739
|
+
const failed = await rendered();
|
|
1740
|
+
if (failed) return text(res, 409, failed + `\nFix ${SOURCE}; PinDeck renders again after every change.`);
|
|
1741
|
+
}
|
|
1574
1742
|
if (NEEDS_DECK.test(p) && readDeck(DECK_FILE) == null) {
|
|
1575
1743
|
const why = `${path.join(DECK_DIR, DECK_FILE)} is not there any more (moved, renamed or deleted). Put it back, ` +
|
|
1576
1744
|
`or start PinDeck for the deck where it is now.`;
|
|
@@ -1599,6 +1767,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1599
1767
|
const placed = (x) => x && typeof x.eid === "string" && x.slide && Number.isInteger(x.slide.index) && x.box;
|
|
1600
1768
|
b.pins = (Array.isArray(b.pins) ? b.pins : []).filter((p) => placed(p) && p.point && p.at);
|
|
1601
1769
|
if (!placed(b.selection)) b.selection = null;
|
|
1770
|
+
else b.selection.also = (Array.isArray(b.selection.also) ? b.selection.also : []).filter((x) => x && typeof x.eid === "string" && x.box);
|
|
1602
1771
|
if (!Array.isArray(b.layouts)) b.layouts = [];
|
|
1603
1772
|
if (b.current && !Number.isInteger(b.current.index)) b.current = null;
|
|
1604
1773
|
const preparation = { cancelled: false, controller: new AbortController() };
|
|
@@ -1625,13 +1794,72 @@ const server = http.createServer(async (req, res) => {
|
|
|
1625
1794
|
const entry = addEntry({ kind: "user", text: b.text,
|
|
1626
1795
|
screenshot: screenshot && { file: path.basename(screenshot.path), slide: screenshot.slide },
|
|
1627
1796
|
attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
|
|
1628
|
-
selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
|
|
1797
|
+
selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, "") + (b.selection.also.length ? ` +${b.selection.also.length}` : ""), slide: b.selection.slide.index + 1 } : null,
|
|
1629
1798
|
pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
|
|
1630
1799
|
if (shotFailed) addEntry({ kind: "notice", text: `Screenshot of slide ${shotFailed.slide} failed: ${shotFailed.error}` });
|
|
1631
1800
|
startTurn(buildPrompt({ ...b, screenshot, shotFailed }), entry);
|
|
1632
1801
|
return json(res, 200, { id: entry.id });
|
|
1633
1802
|
} finally { state.preparing = null; if (!state.turn) { state.busy = false; pushStatus(); } }
|
|
1634
1803
|
}
|
|
1804
|
+
if (p === "/api/comments" && req.method === "GET") return json(res, 200, commentsView());
|
|
1805
|
+
// `cli.mjs resolve <id or C3> [what was done]`: the assistant (or a terminal) marks a comment resolved;
|
|
1806
|
+
// `cli.mjs reply <id or C3> <text>`: it answers one it leaves open (why not, a question), in the comment's thread
|
|
1807
|
+
if (p === "/api/resolve" || p === "/api/reply") {
|
|
1808
|
+
syncReview(); // the poll may not have seen another program's edit yet: saving now would undo it
|
|
1809
|
+
if (!review) return text(res, 409, reviewProblem);
|
|
1810
|
+
const want = String(url.searchParams.get("id") || "").trim();
|
|
1811
|
+
const n = /^C(\d+)$/i.exec(want)?.[1];
|
|
1812
|
+
const c = n ? review.comments[+n - 1] : review.comments.find((x) => x.id === want);
|
|
1813
|
+
if (!c) return text(res, 404, `No comment "${want}". Comment ids look like c-1a2b3c4d (or C3, its number in the editor).`);
|
|
1814
|
+
const note = String(url.searchParams.get("note") || "").trim().slice(0, 2000);
|
|
1815
|
+
const by = state.busy ? "assistant" : "command line";
|
|
1816
|
+
const name = `C${review.comments.indexOf(c) + 1} (${c.id})`;
|
|
1817
|
+
if (p === "/api/reply") {
|
|
1818
|
+
if (!note) return text(res, 400, "Write the reply after the comment id.");
|
|
1819
|
+
(c.replies ||= []).push({ by, text: note, at: new Date().toISOString() });
|
|
1820
|
+
} else {
|
|
1821
|
+
c.status = "resolved";
|
|
1822
|
+
c.resolvedBy = by;
|
|
1823
|
+
c.resolution = note.slice(0, 500) || null;
|
|
1824
|
+
}
|
|
1825
|
+
c.updated = new Date().toISOString();
|
|
1826
|
+
commentsChanged();
|
|
1827
|
+
return text(res, 200, p === "/api/reply" ? `Replied to ${name}; it stays open.` : `Resolved ${name}.`);
|
|
1828
|
+
}
|
|
1829
|
+
if (p === "/api/comments-request" && req.method === "GET") {
|
|
1830
|
+
if (!review) return json(res, 409, { error: reviewProblem });
|
|
1831
|
+
return json(res, 200, { text: commentsRequest(review, deckIndex(), { source: SOURCE, srcText: sourceText() }) });
|
|
1832
|
+
}
|
|
1833
|
+
if (/^\/api\/comment(-update|-delete)?$/.test(p) && req.method === "POST") {
|
|
1834
|
+
syncReview();
|
|
1835
|
+
if (!review) return json(res, 409, { error: reviewProblem });
|
|
1836
|
+
const b = await body(req);
|
|
1837
|
+
if (p === "/api/comment") {
|
|
1838
|
+
if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Write the comment first." });
|
|
1839
|
+
let c;
|
|
1840
|
+
try { c = commentFromPin(deckIndex(), { pin: b.pin || null, slide: b.slide }, b.text); }
|
|
1841
|
+
catch (e) { return json(res, 409, { error: e.message }); }
|
|
1842
|
+
review.comments.push(c);
|
|
1843
|
+
return json(res, 200, { id: c.id, ...commentsChanged() });
|
|
1844
|
+
}
|
|
1845
|
+
const c = review.comments.find((x) => x.id === b.id);
|
|
1846
|
+
if (!c) return json(res, 404, { error: "No such comment (removed meanwhile?)." });
|
|
1847
|
+
if (p === "/api/comment-delete") review.comments = review.comments.filter((x) => x !== c);
|
|
1848
|
+
else {
|
|
1849
|
+
if (typeof b.text === "string") { if (!b.text.trim()) return json(res, 400, { error: "A comment cannot be empty." }); c.text = b.text.trim(); }
|
|
1850
|
+
if (typeof b.reply === "string") {
|
|
1851
|
+
if (!b.reply.trim()) return json(res, 400, { error: "Write the reply first." });
|
|
1852
|
+
(c.replies ||= []).push({ by: "you", text: b.reply.trim(), at: new Date().toISOString() });
|
|
1853
|
+
}
|
|
1854
|
+
if (b.status === "open" || b.status === "resolved") {
|
|
1855
|
+
c.status = b.status;
|
|
1856
|
+
c.resolvedBy = b.status === "resolved" ? "you" : undefined;
|
|
1857
|
+
if (b.status === "open") c.resolution = undefined;
|
|
1858
|
+
}
|
|
1859
|
+
c.updated = new Date().toISOString();
|
|
1860
|
+
}
|
|
1861
|
+
return json(res, 200, commentsChanged());
|
|
1862
|
+
}
|
|
1635
1863
|
if (p === "/api/upload" && req.method === "POST") {
|
|
1636
1864
|
const original = decodeURIComponent(String(req.headers["x-filename"] || "file"));
|
|
1637
1865
|
const kind = attachKind(original);
|
|
@@ -1817,27 +2045,27 @@ const server = http.createServer(async (req, res) => {
|
|
|
1817
2045
|
|
|
1818
2046
|
// Delete an element (and, if the user agrees, the files only it uses)
|
|
1819
2047
|
if (p === "/api/delete-info") {
|
|
1820
|
-
const plan = deletePlan(url.searchParams.
|
|
2048
|
+
const plan = deletePlan(url.searchParams.getAll("eid"));
|
|
1821
2049
|
if (!plan) return json(res, 404, { error: "Element not found in the file." });
|
|
1822
|
-
return json(res, 200, { eid: plan.eid, tag: plan.tag, files: plan.files });
|
|
2050
|
+
return json(res, 200, { eid: plan.eid, tag: plan.tag, targets: plan.targets, files: plan.files });
|
|
1823
2051
|
}
|
|
1824
2052
|
if (p === "/api/delete" && req.method === "POST") {
|
|
1825
2053
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1826
2054
|
const b = await body(req);
|
|
1827
2055
|
if (staleEdit(res, b)) return;
|
|
1828
|
-
const plan = deletePlan(b.eid);
|
|
2056
|
+
const plan = deletePlan(Array.isArray(b.eids) ? b.eids : b.eid);
|
|
1829
2057
|
if (!plan) return json(res, 404, { error: "Element not found in the file." });
|
|
1830
2058
|
const before = snapshot(EDITABLE);
|
|
1831
2059
|
const wanted = new Set(b.files || []);
|
|
1832
2060
|
const deletedFiles = plan.files.filter((f) => wanted.has(f.rel) && !f.usedElsewhere)
|
|
1833
2061
|
.map((f) => ({ rel: f.rel, data: fs.readFileSync(path.join(DECK_DIR, f.rel)) }));
|
|
1834
|
-
const short = plan.eid.replace(/^[^/]+\//, "/");
|
|
2062
|
+
const short = plan.targets.map((t) => t.eid.replace(/^[^/]+\//, "/")).join(", ");
|
|
1835
2063
|
const label = `Deleted ${short}` + (deletedFiles.length ? ` and ${deletedFiles.map((f) => f.rel).join(", ")}` : "");
|
|
1836
2064
|
const turnId = recordHandEdit(before, plan.html, label,
|
|
1837
|
-
`The user deleted ${plan.eid} (${
|
|
2065
|
+
`The user deleted ${plan.targets.map((t) => `${t.eid} (${t.tag})`).join(", ")} by hand` + (deletedFiles.length ? `, together with the file(s) ${deletedFiles.map((f) => f.rel).join(", ")}` : "") + ".",
|
|
1838
2066
|
{ manual: false, deletedFiles });
|
|
1839
2067
|
for (const f of deletedFiles) removeDeckFile(f.rel);
|
|
1840
|
-
return json(res, 200, { turnId, deleted: plan.eid, files: deletedFiles.map((f) => f.rel) });
|
|
2068
|
+
return json(res, 200, { turnId, deleted: plan.targets.map((t) => t.eid), files: deletedFiles.map((f) => f.rel) });
|
|
1841
2069
|
}
|
|
1842
2070
|
|
|
1843
2071
|
// Hand edits from the editor: drag/resize (inline styles) and text
|
|
@@ -1948,6 +2176,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1948
2176
|
if (why) return text(res, 409, why);
|
|
1949
2177
|
return text(res, 200, list.map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
|
|
1950
2178
|
}
|
|
2179
|
+
if (p === "/api/element") {
|
|
2180
|
+
const eid = url.searchParams.get("eid") || "";
|
|
2181
|
+
const outer = getOuter(readDeck(DECK_FILE) || "", eid);
|
|
2182
|
+
return outer === null ? text(res, 404, `No element "${eid}" in ${DECK_FILE}. Give a data-eid.`) : text(res, 200, outer);
|
|
2183
|
+
}
|
|
1951
2184
|
if (p === "/api/layout" || p === "/api/shot") {
|
|
1952
2185
|
const slide = resolveSlide(url.searchParams.get("slide"));
|
|
1953
2186
|
if (!slide) return text(res, 404, deckProblemNow() || `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
|
|
@@ -1991,6 +2224,7 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
1991
2224
|
const first = runEidPass();
|
|
1992
2225
|
if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
|
|
1993
2226
|
startWatcher();
|
|
2227
|
+
try { writePacket(); } catch (e) { console.error(e.message); }
|
|
1994
2228
|
} catch (e) {
|
|
1995
2229
|
console.error(e.plain ? e.message : `PinDeck could not open the deck: ${e.message}`);
|
|
1996
2230
|
removeServerInfo();
|
|
@@ -2019,6 +2253,7 @@ async function quit() {
|
|
|
2019
2253
|
if (quitting) process.exit(1);
|
|
2020
2254
|
quitting = true;
|
|
2021
2255
|
try { watcher?.close(); } catch {}
|
|
2256
|
+
fs.unwatchFile(REVIEW.json);
|
|
2022
2257
|
clearTimeout(flush); clearTimeout(eidTimer);
|
|
2023
2258
|
for (const res of clients) res.end();
|
|
2024
2259
|
for (const w of pending.values()) { clearTimeout(w.t); w.reject(new Error("PinDeck is stopping")); }
|