pindeck 3.4.0 → 4.0.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 +121 -0
- package/README.md +33 -736
- package/cli.mjs +8 -1
- 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 +7 -3
- package/lib/review.mjs +200 -0
- package/lib/servers.mjs +1 -1
- package/lib/system-prompt.md +9 -0
- package/lib/usage.mjs +4 -3
- package/package.json +4 -1
- package/public/bridge.js +19 -9
- package/public/editor.css +34 -3
- package/public/editor.js +205 -34
- package/public/index.html +21 -2
- package/server.mjs +240 -17
package/server.mjs
CHANGED
|
@@ -10,18 +10,18 @@
|
|
|
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
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";
|
|
27
27
|
import { unifiedDiff } from "./lib/diff.mjs";
|
|
@@ -44,6 +44,7 @@ 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
|
|
@@ -70,9 +71,21 @@ const MODEL = opt("--model", "");
|
|
|
70
71
|
if (process.env.CLAUDE_MODEL && !LOGIN && !FRONT) // PinDeck 2 read it; behind the front, the front says so
|
|
71
72
|
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
73
|
/* 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
|
-
|
|
74
|
+
const { dir: DECK_DIR, file: DECK_FILE, named: NAMED, name: DECK_NAME, key: deckKey, source: SOURCE } = deckTarget(argv[0] || process.cwd());
|
|
75
|
+
/* A Quarto deck (SOURCE: talk.qmd): DECK_FILE is what Quarto renders from it, build output. The assistant edits
|
|
76
|
+
the sources; PinDeck renders after every change (see Quarto below) and adds its element ids to the output. */
|
|
77
|
+
const OUTPUTS = SOURCE ? [DECK_FILE, DECK_FILE.replace(/\.html$/i, "_files"), ".quarto"] : [];
|
|
78
|
+
const isOutput = (rel) => OUTPUTS.some((o) => rel === o || rel.startsWith(o + "/"));
|
|
79
|
+
const EDITABLE = SOURCE ? [SOURCE, DECK_FILE] : [DECK_FILE, "theme.css"]; // both backends may edit these (a Quarto deck: its source)
|
|
80
|
+
/* A Quarto deck's format extensions (_extensions/[<org>/]<name>/, e.g. Ocean) carry their own brand.css and vendor/ */
|
|
81
|
+
function extensionBrands() {
|
|
82
|
+
try {
|
|
83
|
+
return fs.readdirSync(path.join(DECK_DIR, "_extensions"), { recursive: true }).map((f) => String(f).split(path.sep).join("/"))
|
|
84
|
+
.filter((f) => /(^|\/)brand\.css$/.test(f) && !f.includes("vendor/")).map((f) => `_extensions/${f}`);
|
|
85
|
+
} catch { return []; }
|
|
86
|
+
}
|
|
87
|
+
const BRANDS = SOURCE ? extensionBrands() : [];
|
|
88
|
+
const LOCKED = ["brand.css", ...BRANDS]; // never, enforced twice
|
|
76
89
|
const HOME_DIR = homeDir();
|
|
77
90
|
const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
|
|
78
91
|
const SHOT_DIR = path.join(STATE_DIR, "shots");
|
|
@@ -92,6 +105,34 @@ const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(
|
|
|
92
105
|
const CLI_CMD = cliCommand(CLI_REL);
|
|
93
106
|
const CAPS = detectCapabilities();
|
|
94
107
|
|
|
108
|
+
/* ---- Quarto ----------------------------------------------------------------------------------------
|
|
109
|
+
`quarto render <source>` makes DECK_FILE. One render at a time; a change to a source while one runs renders
|
|
110
|
+
again when it ends. What the assistant checks (shot, layout, slides, pdf) waits for the render, so it always
|
|
111
|
+
sees its last edit. Quarto's own errors go to the chat, and to the assistant. */
|
|
112
|
+
const QUARTO = SOURCE ? findExecutable(process.env.QUARTO_BIN || "quarto") : null;
|
|
113
|
+
const RENDER_TIMEOUT = 180000;
|
|
114
|
+
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
|
|
115
|
+
/* Quarto's message, without colours and its JavaScript stack: what says what is wrong in the source */
|
|
116
|
+
const quartoError = (stderr) => `Quarto could not render ${SOURCE}:\n` + String(stderr).replace(/\x1b\[[0-9;]*m/g, "")
|
|
117
|
+
.split(/\nStack trace:|\n\s+at /)[0].trim().split("\n").slice(-25).join("\n");
|
|
118
|
+
function renderSync() {
|
|
119
|
+
console.log(`Rendering ${SOURCE} with Quarto…`);
|
|
120
|
+
try { execFileSync(QUARTO.path, ["render", SOURCE], { cwd: DECK_DIR, stdio: ["ignore", "ignore", "pipe"], timeout: RENDER_TIMEOUT }); }
|
|
121
|
+
catch (e) { return quartoError(e.stderr || e.message); }
|
|
122
|
+
return fs.existsSync(path.join(DECK_DIR, DECK_FILE)) ? null
|
|
123
|
+
: `Quarto rendered ${SOURCE}, but not to ${DECK_FILE} beside it (an output-dir in _quarto.yml?). PinDeck needs the HTML next to its source.`;
|
|
124
|
+
}
|
|
125
|
+
let startRenderError = null; // a failed render at start with an older output: PinDeck opens that, the assistant fixes the source
|
|
126
|
+
if (SOURCE && !LOGIN) {
|
|
127
|
+
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); }
|
|
128
|
+
const mtime = (f) => { try { return fs.statSync(path.join(DECK_DIR, f)).mtimeMs; } catch { return 0; } };
|
|
129
|
+
if (mtime(DECK_FILE) < mtime(SOURCE)) {
|
|
130
|
+
const failed = renderSync();
|
|
131
|
+
if (failed && !fs.existsSync(path.join(DECK_DIR, DECK_FILE))) { console.error(failed); process.exit(1); }
|
|
132
|
+
if (failed) { console.error(failed + `\nOpening the last render of ${SOURCE}.`); startRenderError = failed; }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
95
136
|
/* Checks before anything is written: a deck that cannot be opened or saved is left exactly as it is. */
|
|
96
137
|
function checkDeck() {
|
|
97
138
|
const file = path.join(DECK_DIR, DECK_FILE);
|
|
@@ -304,13 +345,14 @@ function refreshGit() {
|
|
|
304
345
|
}
|
|
305
346
|
function slideList() {
|
|
306
347
|
const html = readDeck(DECK_FILE);
|
|
307
|
-
return findSlides(parse(html)).map((s, index) => ({ index, eid: s.attrs["data-eid"]?.value || null }));
|
|
348
|
+
return findSlides(parse(html)).map((s, index) => ({ index, eid: s.attrs["data-eid"]?.value || null, id: s.attrs.id?.value || null }));
|
|
308
349
|
}
|
|
309
350
|
function resolveSlide(spec) {
|
|
310
351
|
const slides = slideList();
|
|
311
352
|
if (!spec || spec === "current") return slides[state.current?.index ?? 0] || slides[0];
|
|
312
353
|
if (/^\d+$/.test(spec)) return slides[+spec - 1];
|
|
313
|
-
|
|
354
|
+
// or the slide's own id attribute (Quarto's heading anchor, which stays when the title changes)
|
|
355
|
+
return slides.find((s) => s.eid === spec) || slides.find((s) => s.id === spec) || slides.find((s) => s.eid && s.eid.startsWith(spec));
|
|
314
356
|
}
|
|
315
357
|
|
|
316
358
|
/* ---- Headless Chrome (screenshots, and layout when no editor tab is open) --------------- */
|
|
@@ -544,7 +586,8 @@ const status = () => ({ busy: state.busy, switching: state.switching, preparing:
|
|
|
544
586
|
running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
|
|
545
587
|
chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
|
|
546
588
|
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))
|
|
589
|
+
undo: changeLabel(liveTurns().at(-1)), undoId: liveTurns().at(-1)?.id ?? null, redo: changeLabel(state.redo.at(-1)),
|
|
590
|
+
source: SOURCE, rendering: !!rendering, renderError });
|
|
548
591
|
/* Pushed to the editors on every change. The model lists (both providers' catalogs) go along only when they
|
|
549
592
|
changed since the last push: the editor keeps the last ones it got. */
|
|
550
593
|
let catalogSent = "";
|
|
@@ -563,6 +606,19 @@ const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md
|
|
|
563
606
|
(ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
|
|
564
607
|
`them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
|
|
565
608
|
`other decks may use them too.\n` : "");
|
|
609
|
+
/* A Quarto deck: what to edit, and how its output relates to the source */
|
|
610
|
+
const quartoInstructions = () => SOURCE ? `\n\n## This deck is made with Quarto
|
|
611
|
+
${DECK_FILE} is rendered by Quarto from ${SOURCE}: it is build output, locked, and overwritten by every render. Make every ` +
|
|
612
|
+
`change in ${SOURCE} (Quarto Markdown: \`##\` headings start slides and become the slides' id attributes, \`::: {.notes}\` holds ` +
|
|
613
|
+
`speaker notes, \`::: {.incremental}\` and \`. . .\` make build steps, \`{ojs}\` cells are Observable JS; when you change a ` +
|
|
614
|
+
`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 ` +
|
|
615
|
+
`(its .scss theme, figures). Where these instructions say to edit ${DECK_FILE} or theme.css, edit ${SOURCE} or its stylesheet ` +
|
|
616
|
+
`instead. Pins and element ids (data-eid) name elements of the rendered HTML: read ${DECK_FILE} to see what they are, then ` +
|
|
617
|
+
`find the matching text in ${SOURCE}. PinDeck renders after every change you save; ${CLI_CMD} shot, layout and slides wait ` +
|
|
618
|
+
`for that render, and report Quarto's error if it fails. Interactive OJS output is drawn in the browser, so screenshots show it ` +
|
|
619
|
+
`only once its libraries have loaded.\n` + (BRANDS.length ? `The deck's look comes from a Quarto format extension (` +
|
|
620
|
+
BRANDS.map((b) => b.replace(/\/brand\.css$/, "/")).join(", ") + `): read its README.md for the slide layouts and classes. ` +
|
|
621
|
+
`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
622
|
/* For a deck made without reveal.js: what its slides are, from its selector as it is at each session start */
|
|
567
623
|
function slidesInstructions() {
|
|
568
624
|
const { selector } = deckSlides();
|
|
@@ -577,7 +633,7 @@ function slidesInstructions() {
|
|
|
577
633
|
win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
|
|
578
634
|
function lockedPaths() {
|
|
579
635
|
const { files, folders } = otherDecks();
|
|
580
|
-
return [...LOCKED, "vendor/**", ".git", ".git/**", ...files, ...folders.map((f) => `${f}/**`)];
|
|
636
|
+
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
637
|
}
|
|
582
638
|
/* The other decks in this deck's folder: its other .html files, and the subfolders that hold a deck of their
|
|
583
639
|
own (as the deck menu lists them). Neither the assistant nor this deck's undo touches them. */
|
|
@@ -588,7 +644,7 @@ function otherDecks() {
|
|
|
588
644
|
.map((d) => path.relative(DECK_DIR, d.dir).split(path.sep).join("/")))];
|
|
589
645
|
return { files, folders };
|
|
590
646
|
}
|
|
591
|
-
const scanDeck = () => { const { files, folders } = otherDecks(); return scanFiles(DECK_DIR, { skip: [...files, ...folders] }); };
|
|
647
|
+
const scanDeck = () => { const { files, folders } = otherDecks(); return scanFiles(DECK_DIR, { skip: [...files, ...folders, ...OUTPUTS] }); };
|
|
592
648
|
|
|
593
649
|
/* Decks that share a folder share its files (theme.css, figures/): while the assistant of one works, that of
|
|
594
650
|
another may change them too. Each deck server keeps the times of its turns in a file of its own under
|
|
@@ -642,13 +698,13 @@ const codexInstructions = () => systemPrompt.replace(/## Attached files[\s\S]*?#
|
|
|
642
698
|
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
699
|
Use pindeck_read for text files, images and PDF pages (supply page: 1, then further pages as needed).
|
|
644
700
|
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, pdf
|
|
701
|
+
Use pindeck_command with separate command and args for layout, shot, slides, pdf, pdf-figure, resolve or reply.
|
|
646
702
|
Do not use shell commands, native file edits, or other tools to bypass these restrictions.
|
|
647
703
|
When context mentions Read, use pindeck_read. Read screenshots with pindeck_read.
|
|
648
704
|
Read README.md and the deck instructions below for conventions. Use layout after every visual change.
|
|
649
705
|
PDF tables should be reconstructed as HTML; pdf-figure renders figures. Audio is embedded by path.
|
|
650
706
|
|
|
651
|
-
## Hand edits`) + slidesInstructions() + deckInstructions();
|
|
707
|
+
## Hand edits`) + slidesInstructions() + quartoInstructions() + deckInstructions();
|
|
652
708
|
function makeSession(choice) {
|
|
653
709
|
let initial = null;
|
|
654
710
|
const options = {
|
|
@@ -670,11 +726,11 @@ function makeSession(choice) {
|
|
|
670
726
|
instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
|
|
671
727
|
// 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
728
|
denyMessage: (r) => r?.tool_name === "Bash" ? cliRefusal(r.input?.command, CLI_CMD) : null,
|
|
673
|
-
args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
|
|
729
|
+
args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + quartoInstructions() + deckInstructions(),
|
|
674
730
|
"--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
|
|
675
731
|
"--permission-mode", "default", "--permission-prompt-tool", "stdio", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
|
|
676
732
|
"--allowedTools", ...readRules(), "Edit(./**)", "Write(./**)",
|
|
677
|
-
...["layout", "shot", "slides", "pdf", "pdf-figure"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
|
|
733
|
+
...["layout", "shot", "slides", "pdf", "pdf-figure", "resolve", "reply"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
|
|
678
734
|
"--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
|
|
679
735
|
...(choice.model ? ["--model", choice.model] : []), ...(choice.effort ? ["--effort", choice.effort] : [])],
|
|
680
736
|
});
|
|
@@ -848,7 +904,7 @@ function toolSummary(name, input = {}) {
|
|
|
848
904
|
// must show as the script, or a refused script looks like a refused layout check
|
|
849
905
|
const cmd = String(input.command || "").trim();
|
|
850
906
|
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" };
|
|
907
|
+
const names = { shot: "Screenshot", layout: "Check layout", "pdf-figure": "PDF figure", resolve: "Resolve comment", reply: "Reply to comment" };
|
|
852
908
|
return m ? { name: names[m[1]] || m[1], detail: m[1] === "pdf-figure" ? m[2].trim().replace(/^\S*\//, "") : m[2].trim() }
|
|
853
909
|
: { name: "Run", detail: cmd.split("\n")[0].slice(0, 80) };
|
|
854
910
|
}
|
|
@@ -1082,6 +1138,98 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
|
|
|
1082
1138
|
return { text: `${out} (${size})` };
|
|
1083
1139
|
}
|
|
1084
1140
|
|
|
1141
|
+
/* ---- Review comments (lib/review.mjs) ---------------------------------------------------------------
|
|
1142
|
+
Kept in <deck folder>/.pindeck/: never a deck file, never in an undo, locked for the assistant. The packet
|
|
1143
|
+
next to them is written once there are comments, and again whenever they or the deck change. An agent outside
|
|
1144
|
+
PinDeck may set a comment's status in the file; the editor follows. A file that cannot be read is left as it
|
|
1145
|
+
is: comments are then off, with the reason. */
|
|
1146
|
+
const REVIEW = reviewFiles(DECK_DIR, DECK_FILE);
|
|
1147
|
+
let review = null, reviewProblem = null, reviewSaved = null;
|
|
1148
|
+
function readReview() {
|
|
1149
|
+
try { review = loadReview(REVIEW.json); reviewProblem = null; }
|
|
1150
|
+
catch (e) { review = null; reviewProblem = `The comments file ${REVIEW.json} cannot be read (${e.message}): fix or remove it.`; }
|
|
1151
|
+
try { reviewSaved = fs.readFileSync(REVIEW.json, "utf8"); } catch { reviewSaved = null; }
|
|
1152
|
+
}
|
|
1153
|
+
readReview();
|
|
1154
|
+
const deckIndex = () => indexDeck(readDeck(DECK_FILE) || "");
|
|
1155
|
+
// the Quarto source's text, for the source lines of comments (null for an HTML deck)
|
|
1156
|
+
const sourceText = () => { try { return SOURCE ? fs.readFileSync(path.join(DECK_DIR, SOURCE), "utf8") : null; } catch { return null; } };
|
|
1157
|
+
function commentsView(deck = deckIndex()) {
|
|
1158
|
+
if (!review) return { comments: [], problem: reviewProblem };
|
|
1159
|
+
return { file: path.relative(DECK_DIR, REVIEW.json), packet: path.relative(DECK_DIR, REVIEW.packet), problem: null,
|
|
1160
|
+
comments: review.comments.map((c, i) => {
|
|
1161
|
+
const loc = locate(deck, c);
|
|
1162
|
+
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 } };
|
|
1163
|
+
}) };
|
|
1164
|
+
}
|
|
1165
|
+
function writePacket(deck = deckIndex()) {
|
|
1166
|
+
if (!review || (!review.comments.length && !fs.existsSync(REVIEW.packet))) return;
|
|
1167
|
+
fs.mkdirSync(path.dirname(REVIEW.packet), { recursive: true });
|
|
1168
|
+
writeAtomic(REVIEW.packet, packet(review, deck, { deckFile: DECK_FILE, source: SOURCE, srcText: sourceText(), commentsFile: path.relative(DECK_DIR, REVIEW.json) }));
|
|
1169
|
+
}
|
|
1170
|
+
function commentsChanged({ save = true } = {}) {
|
|
1171
|
+
const deck = deckIndex();
|
|
1172
|
+
if (save) { saveReview(REVIEW.json, review); reviewSaved = fs.readFileSync(REVIEW.json, "utf8"); }
|
|
1173
|
+
try { writePacket(deck); } catch (e) { console.error(e.message); }
|
|
1174
|
+
const view = commentsView(deck);
|
|
1175
|
+
broadcast("comments", view);
|
|
1176
|
+
return view;
|
|
1177
|
+
}
|
|
1178
|
+
// edits of the file by another program (an agent marks a comment resolved): polled, which survives replaced files
|
|
1179
|
+
function syncReview() {
|
|
1180
|
+
let now = null;
|
|
1181
|
+
try { now = fs.readFileSync(REVIEW.json, "utf8"); } catch {}
|
|
1182
|
+
if (now === reviewSaved) return false;
|
|
1183
|
+
readReview();
|
|
1184
|
+
return true;
|
|
1185
|
+
}
|
|
1186
|
+
fs.watchFile(REVIEW.json, { interval: 1000 }, () => { if (syncReview()) commentsChanged({ save: false }); });
|
|
1187
|
+
|
|
1188
|
+
/* Rendering while PinDeck runs (see Quarto above): started by a change to a source file */
|
|
1189
|
+
let rendering = null, renderAgain = false, renderError = startRenderError, renderTimer = null, renderStarted = Date.now();
|
|
1190
|
+
if (renderError) {
|
|
1191
|
+
addEntry({ kind: "notice", text: renderError + `\nThe deck shown is the last render that worked.` });
|
|
1192
|
+
state.notes.push(renderError + "\nFix the source; PinDeck renders again after every change.");
|
|
1193
|
+
}
|
|
1194
|
+
function render() {
|
|
1195
|
+
clearTimeout(renderTimer);
|
|
1196
|
+
renderTimer = null;
|
|
1197
|
+
if (rendering) { renderAgain = true; return rendering; }
|
|
1198
|
+
renderStarted = Date.now();
|
|
1199
|
+
rendering = new Promise((done) => {
|
|
1200
|
+
execFile(QUARTO.path, ["render", SOURCE], { cwd: DECK_DIR, timeout: RENDER_TIMEOUT, maxBuffer: 1 << 24 }, (err, _out, stderr) => {
|
|
1201
|
+
const was = renderError;
|
|
1202
|
+
renderError = err ? quartoError(stderr || err.message) : null;
|
|
1203
|
+
if (renderError && renderError !== was) {
|
|
1204
|
+
addEntry({ kind: "notice", text: renderError });
|
|
1205
|
+
state.notes.push(renderError + "\nFix the source; PinDeck renders again after every change.");
|
|
1206
|
+
} else if (!renderError && was) addEntry({ kind: "notice", text: `${SOURCE} renders again.` });
|
|
1207
|
+
if (!err) runEidPass(); // the fresh output gets its element ids (and the editor reloads it)
|
|
1208
|
+
done();
|
|
1209
|
+
});
|
|
1210
|
+
}).finally(() => {
|
|
1211
|
+
rendering = null;
|
|
1212
|
+
if (renderAgain) { renderAgain = false; render(); }
|
|
1213
|
+
pushStatus();
|
|
1214
|
+
});
|
|
1215
|
+
pushStatus();
|
|
1216
|
+
return rendering;
|
|
1217
|
+
}
|
|
1218
|
+
function sourceChanged() {
|
|
1219
|
+
clearTimeout(renderTimer);
|
|
1220
|
+
renderTimer = setTimeout(render, 150); // an editor's save is often several events
|
|
1221
|
+
}
|
|
1222
|
+
/* Before the assistant looks at the deck: a pending or running render ends first, and a source saved since the
|
|
1223
|
+
last render began (its file event may not have come yet) is rendered now */
|
|
1224
|
+
async function rendered() {
|
|
1225
|
+
if (!SOURCE) return null;
|
|
1226
|
+
let saved = 0;
|
|
1227
|
+
try { saved = fs.statSync(path.join(DECK_DIR, SOURCE)).mtimeMs; } catch {}
|
|
1228
|
+
if (renderTimer || saved > renderStarted) render();
|
|
1229
|
+
while (rendering) await rendering;
|
|
1230
|
+
return renderError;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1085
1233
|
/* ---- File watching --------------------------------------------------------------------------
|
|
1086
1234
|
Changes are collected for 120 ms and sent to the editor with the deck's revision, so a tab that missed
|
|
1087
1235
|
events (reconnect) can still tell that it shows an old state. Dot files (PinDeck's temporary files,
|
|
@@ -1092,6 +1240,10 @@ function onFileEvent(_ev, file) {
|
|
|
1092
1240
|
if (!file) { changed.add(DECK_FILE); return scheduleFlush(); } // some systems omit the name: assume the deck
|
|
1093
1241
|
file = String(file).split(path.sep).join("/");
|
|
1094
1242
|
if (/(^|\/)\.|\.bak$|~$|^export\//.test(file)) return;
|
|
1243
|
+
if (SOURCE) {
|
|
1244
|
+
if (isOutput(file)) { if (rendering || file !== DECK_FILE) return; } // a render's own writes: its id pass reloads
|
|
1245
|
+
else if (SOURCE_FILE.test(file)) sourceChanged();
|
|
1246
|
+
}
|
|
1095
1247
|
changed.add(file);
|
|
1096
1248
|
scheduleFlush();
|
|
1097
1249
|
}
|
|
@@ -1128,6 +1280,7 @@ function scheduleFlush() {
|
|
|
1128
1280
|
};
|
|
1129
1281
|
eidTimer = setTimeout(pass, 800);
|
|
1130
1282
|
}
|
|
1283
|
+
if (files.includes(DECK_FILE) && review?.comments.length) commentsChanged({ save: false }); // found again, or flagged
|
|
1131
1284
|
if (files.includes(DECK_FILE)) { // the slide tags, by hand or by Claude: tell both when they stop working
|
|
1132
1285
|
const now = { selector: deckSlides().selector, problem: deckProblemNow() };
|
|
1133
1286
|
if (now.selector !== deckNow.selector || now.problem !== deckNow.problem) {
|
|
@@ -1302,7 +1455,7 @@ function undo(res) {
|
|
|
1302
1455
|
/* ---- PowerPoint import -----------------------------------------------------------------------------
|
|
1303
1456
|
Upload: the file is read at once (slide list for the dialog); in the background PowerPoint or Keynote
|
|
1304
1457
|
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-
|
|
1458
|
+
Only the latest upload is kept, in ~/.pindeck-v4/decks/<deck>/imports/. */
|
|
1306
1459
|
const IMPORT_DIR = path.join(STATE_DIR, "imports");
|
|
1307
1460
|
const IMPORT_MAX = 500 * 1024 * 1024;
|
|
1308
1461
|
let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
|
|
@@ -1468,6 +1621,7 @@ function serveFile(res, file, transform, req, extra = {}) {
|
|
|
1468
1621
|
let ready = false;
|
|
1469
1622
|
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
1623
|
const CLI_ROUTES = /^\/api\/(slides|layout|shot|pdf|pdf-figure)$/; // answered as text, for cli.mjs
|
|
1624
|
+
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
1625
|
/* Scriptable files (HTML, SVG) from the deck get the sandbox, wherever they are asked for. Under /v/ they are
|
|
1472
1626
|
read by documents of that sandboxed origin, so fonts and module scripts need CORS; the path is the secret.
|
|
1473
1627
|
On localhost (Present) there is no sandbox: no login works there (see presentHost). */
|
|
@@ -1571,6 +1725,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
1571
1725
|
return;
|
|
1572
1726
|
}
|
|
1573
1727
|
// the deck file gone (moved, renamed, deleted) while PinDeck runs: say so, instead of failing on it
|
|
1728
|
+
// a Quarto deck is rendered from its source: no hand edits of the output (the next render would undo them)
|
|
1729
|
+
if (SOURCE && req.method === "POST" && HAND_EDITS.test(p))
|
|
1730
|
+
return json(res, 409, { code: "quarto", error: `${DECK_FILE} is rendered by Quarto from ${SOURCE}: ask the assistant, or edit ${SOURCE} yourself.` });
|
|
1731
|
+
// what the assistant checks is the deck rendered from its last edit
|
|
1732
|
+
if (SOURCE && CLI_ROUTES.test(p)) {
|
|
1733
|
+
const failed = await rendered();
|
|
1734
|
+
if (failed) return text(res, 409, failed + `\nFix ${SOURCE}; PinDeck renders again after every change.`);
|
|
1735
|
+
}
|
|
1574
1736
|
if (NEEDS_DECK.test(p) && readDeck(DECK_FILE) == null) {
|
|
1575
1737
|
const why = `${path.join(DECK_DIR, DECK_FILE)} is not there any more (moved, renamed or deleted). Put it back, ` +
|
|
1576
1738
|
`or start PinDeck for the deck where it is now.`;
|
|
@@ -1632,6 +1794,65 @@ const server = http.createServer(async (req, res) => {
|
|
|
1632
1794
|
return json(res, 200, { id: entry.id });
|
|
1633
1795
|
} finally { state.preparing = null; if (!state.turn) { state.busy = false; pushStatus(); } }
|
|
1634
1796
|
}
|
|
1797
|
+
if (p === "/api/comments" && req.method === "GET") return json(res, 200, commentsView());
|
|
1798
|
+
// `cli.mjs resolve <id or C3> [what was done]`: the assistant (or a terminal) marks a comment resolved;
|
|
1799
|
+
// `cli.mjs reply <id or C3> <text>`: it answers one it leaves open (why not, a question), in the comment's thread
|
|
1800
|
+
if (p === "/api/resolve" || p === "/api/reply") {
|
|
1801
|
+
syncReview(); // the poll may not have seen another program's edit yet: saving now would undo it
|
|
1802
|
+
if (!review) return text(res, 409, reviewProblem);
|
|
1803
|
+
const want = String(url.searchParams.get("id") || "").trim();
|
|
1804
|
+
const n = /^C(\d+)$/i.exec(want)?.[1];
|
|
1805
|
+
const c = n ? review.comments[+n - 1] : review.comments.find((x) => x.id === want);
|
|
1806
|
+
if (!c) return text(res, 404, `No comment "${want}". Comment ids look like c-1a2b3c4d (or C3, its number in the editor).`);
|
|
1807
|
+
const note = String(url.searchParams.get("note") || "").trim().slice(0, 2000);
|
|
1808
|
+
const by = state.busy ? "assistant" : "command line";
|
|
1809
|
+
const name = `C${review.comments.indexOf(c) + 1} (${c.id})`;
|
|
1810
|
+
if (p === "/api/reply") {
|
|
1811
|
+
if (!note) return text(res, 400, "Write the reply after the comment id.");
|
|
1812
|
+
(c.replies ||= []).push({ by, text: note, at: new Date().toISOString() });
|
|
1813
|
+
} else {
|
|
1814
|
+
c.status = "resolved";
|
|
1815
|
+
c.resolvedBy = by;
|
|
1816
|
+
c.resolution = note.slice(0, 500) || null;
|
|
1817
|
+
}
|
|
1818
|
+
c.updated = new Date().toISOString();
|
|
1819
|
+
commentsChanged();
|
|
1820
|
+
return text(res, 200, p === "/api/reply" ? `Replied to ${name}; it stays open.` : `Resolved ${name}.`);
|
|
1821
|
+
}
|
|
1822
|
+
if (p === "/api/comments-request" && req.method === "GET") {
|
|
1823
|
+
if (!review) return json(res, 409, { error: reviewProblem });
|
|
1824
|
+
return json(res, 200, { text: commentsRequest(review, deckIndex(), { source: SOURCE, srcText: sourceText() }) });
|
|
1825
|
+
}
|
|
1826
|
+
if (/^\/api\/comment(-update|-delete)?$/.test(p) && req.method === "POST") {
|
|
1827
|
+
syncReview();
|
|
1828
|
+
if (!review) return json(res, 409, { error: reviewProblem });
|
|
1829
|
+
const b = await body(req);
|
|
1830
|
+
if (p === "/api/comment") {
|
|
1831
|
+
if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Write the comment first." });
|
|
1832
|
+
let c;
|
|
1833
|
+
try { c = commentFromPin(deckIndex(), { pin: b.pin || null, slide: b.slide }, b.text); }
|
|
1834
|
+
catch (e) { return json(res, 409, { error: e.message }); }
|
|
1835
|
+
review.comments.push(c);
|
|
1836
|
+
return json(res, 200, { id: c.id, ...commentsChanged() });
|
|
1837
|
+
}
|
|
1838
|
+
const c = review.comments.find((x) => x.id === b.id);
|
|
1839
|
+
if (!c) return json(res, 404, { error: "No such comment (removed meanwhile?)." });
|
|
1840
|
+
if (p === "/api/comment-delete") review.comments = review.comments.filter((x) => x !== c);
|
|
1841
|
+
else {
|
|
1842
|
+
if (typeof b.text === "string") { if (!b.text.trim()) return json(res, 400, { error: "A comment cannot be empty." }); c.text = b.text.trim(); }
|
|
1843
|
+
if (typeof b.reply === "string") {
|
|
1844
|
+
if (!b.reply.trim()) return json(res, 400, { error: "Write the reply first." });
|
|
1845
|
+
(c.replies ||= []).push({ by: "you", text: b.reply.trim(), at: new Date().toISOString() });
|
|
1846
|
+
}
|
|
1847
|
+
if (b.status === "open" || b.status === "resolved") {
|
|
1848
|
+
c.status = b.status;
|
|
1849
|
+
c.resolvedBy = b.status === "resolved" ? "you" : undefined;
|
|
1850
|
+
if (b.status === "open") c.resolution = undefined;
|
|
1851
|
+
}
|
|
1852
|
+
c.updated = new Date().toISOString();
|
|
1853
|
+
}
|
|
1854
|
+
return json(res, 200, commentsChanged());
|
|
1855
|
+
}
|
|
1635
1856
|
if (p === "/api/upload" && req.method === "POST") {
|
|
1636
1857
|
const original = decodeURIComponent(String(req.headers["x-filename"] || "file"));
|
|
1637
1858
|
const kind = attachKind(original);
|
|
@@ -1991,6 +2212,7 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
1991
2212
|
const first = runEidPass();
|
|
1992
2213
|
if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
|
|
1993
2214
|
startWatcher();
|
|
2215
|
+
try { writePacket(); } catch (e) { console.error(e.message); }
|
|
1994
2216
|
} catch (e) {
|
|
1995
2217
|
console.error(e.plain ? e.message : `PinDeck could not open the deck: ${e.message}`);
|
|
1996
2218
|
removeServerInfo();
|
|
@@ -2019,6 +2241,7 @@ async function quit() {
|
|
|
2019
2241
|
if (quitting) process.exit(1);
|
|
2020
2242
|
quitting = true;
|
|
2021
2243
|
try { watcher?.close(); } catch {}
|
|
2244
|
+
fs.unwatchFile(REVIEW.json);
|
|
2022
2245
|
clearTimeout(flush); clearTimeout(eidTimer);
|
|
2023
2246
|
for (const res of clients) res.end();
|
|
2024
2247
|
for (const w of pending.values()) { clearTimeout(w.t); w.reject(new Error("PinDeck is stopping")); }
|