pindeck 3.2.0 → 3.4.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 +30 -12
- package/front.mjs +4 -2
- package/lib/capabilities.mjs +13 -0
- package/lib/claude.mjs +1 -1
- package/lib/eids.mjs +1 -1
- package/lib/login.mjs +11 -2
- package/lib/system-prompt.md +2 -2
- package/package.json +1 -1
- package/public/editor.css +7 -4
- package/public/editor.js +58 -18
- package/public/index.html +3 -2
- package/server.mjs +43 -30
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# PinDeck
|
|
2
2
|
|
|
3
|
-
> **macOS and Linux · Version 3** (`3.
|
|
3
|
+
> **macOS and Linux · Version 3** (`3.4.0`). On Linux, PinDeck runs as a headless server that you use
|
|
4
4
|
> from your Mac's browser through an SSH tunnel (see [Running on a Linux server](#running-on-a-linux-server)).
|
|
5
5
|
> Windows is not supported. Coming from PinDeck 2: version 3 keeps its settings and chats in
|
|
6
6
|
> `~/.pindeck-v3`, apart from 2.x (see [Models, providers and configuration](#models-providers-and-configuration)).
|
|
@@ -328,7 +328,7 @@ ssh -N -T -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCo
|
|
|
328
328
|
|
|
329
329
|
Open the login link PinDeck printed on the server in the Mac's browser. The local port may differ from
|
|
330
330
|
the server's: with `-L 127.0.0.1:8080:127.0.0.1:5180`, change 5180 in the link to 8080. PinDeck accepts any
|
|
331
|
-
port, as long as the address is `127.0.0.1
|
|
331
|
+
port, as long as the address is `127.0.0.1`: `localhost` is where decks are presented, and nobody logs in there. If the tunnel drops, a message you have already sent keeps running on the
|
|
332
332
|
server. Restart the tunnel and the editor reconnects and shows the files as they are then; it never sends a
|
|
333
333
|
message twice. If PinDeck restarts, the editor asks you to log in again: run `node front.mjs login
|
|
334
334
|
path/to/deck` on the server for a new link.
|
|
@@ -391,7 +391,7 @@ show and commit as usual. Clones without the filter configured simply see the fi
|
|
|
391
391
|
| Stop | **Stop** interrupts the assistant; the conversation continues. **New chat** starts a new conversation; your slides, Undo and Redo stay as they are. |
|
|
392
392
|
| New slide | **+ New slide** above the slide list: a **content slide** (title and bullets), a **section divider**, or a **duplicate** of the current slide. It is added after the current slide, modelled on the deck's own slides; a new content slide or divider opens its title for typing. |
|
|
393
393
|
| Import from PowerPoint | **+ New slide** → **Import from PowerPoint…**, then choose a `.pptx` file and tick the slides you want (hidden PowerPoint slides start unticked). They are added after the current slide in this deck's own layouts, and the assistant can improve the complex ones; see [PowerPoint import](#powerpoint-import). `⌘Z` removes them again, with their figures. |
|
|
394
|
-
| Speaker notes | The **Notes** field under the slide shows the current slide's notes (the speaker view, `S`, shows them when presenting). Type to change them; they are saved when you click elsewhere or press `⌘Enter`, and `Esc` puts back what was there. A slide without notes gets them; emptying the field removes them. Notes
|
|
394
|
+
| Speaker notes | The **Notes** field under the slide shows the current slide's notes (the speaker view, `S`, shows them when presenting). Type to change them; they are saved when you click elsewhere or press `⌘Enter`, and `Esc` puts back what was there. A slide without notes gets them; emptying the field removes them. Notes show their formatting: `⌘B`, `⌘I` and `⌘U` format the selection, `Enter` starts a new line, and pasted text comes in plain. Notes with markup the field cannot show safely (other tags, styles, scripts) are edited as HTML. Drag the line above the notes to give them more or less room (double-click it for the default size); collapse them with their header. |
|
|
395
395
|
| Move a slide | Drag it up or down in the slide list; an orange line shows where it will go. It moves with its notes and the comment above it, and `⌘Z` puts it back. Slides inside vertical stacks cannot be moved yet. |
|
|
396
396
|
| Hide a slide | The eye on a slide in the list, or `H`. A hidden slide stays in the deck and in PinDeck (greyed out, with a banner) but is skipped when presenting and in PDFs; **Export ▾** can include hidden slides. Show it the same way. |
|
|
397
397
|
| Model and effort | Choose a discovered Claude or OpenAI model in the chat header, then use the separate reasoning-effort selector. Provider changes offer an optional handover; same-provider changes continue the conversation. The choice is saved in `~/.pindeck-v3/config.json`. |
|
|
@@ -457,10 +457,13 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
457
457
|
only six connections to one port; if a deck server stops, the front opens the deck again and the tab
|
|
458
458
|
catches up. Each PinDeck process notes its address in `~/.pindeck-v3/servers/` for `pindeck login` and
|
|
459
459
|
`cli.mjs`, and a deck server holds its deck's `open.json`, so a deck opens in one PinDeck only.
|
|
460
|
-
* **Deck pages are sandboxed.** The deck page
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
460
|
+
* **Deck pages are sandboxed.** The deck page in the editor's frames runs with a sandbox policy in an
|
|
461
|
+
origin of its own, at a per-run address that serves the deck's files and nothing else. A script in a
|
|
462
|
+
deck, a template, or written by the assistant cannot reach the login cookie, the API, the chat or other
|
|
463
|
+
decks. The editor and its bridge in the slide frame talk by `postMessage`. Present opens the same
|
|
464
|
+
address on `localhost` without the sandbox, so reveal.js's speaker view (`S`) works: browsers treat
|
|
465
|
+
`localhost` and `127.0.0.1` as different sites, PinDeck takes no login and no cookie there, and the tab is
|
|
466
|
+
opened without a handle on the editor. Headless Chrome
|
|
464
467
|
(screenshots, layout, PDFs) does not log in at all; the page it asks for is served without the sandbox,
|
|
465
468
|
which its screenshot and print modes cannot handle, only with a key made for that one run.
|
|
466
469
|
* **Element ids.** On start, the server gives every slide and every visible element a readable
|
|
@@ -492,8 +495,8 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
492
495
|
from `lib/system-prompt.md` and the deck's `CLAUDE.md` and `AGENTS.md`. Codex model and effort changes
|
|
493
496
|
reuse the active thread writer and apply to the next turn. Claude changes and provider switches prepare
|
|
494
497
|
a replacement process before retiring the old one. Events from replaced sessions are ignored.
|
|
495
|
-
* **Permissions.** The assistant may read
|
|
496
|
-
editor's CLI. Nothing else: no other commands, and no edits to `brand.css`, `vendor/`, `.git/` or, for a
|
|
498
|
+
* **Permissions.** The assistant may read the deck folder, its screenshots, attached PDFs and asset roots,
|
|
499
|
+
edit and create files in the deck folder, and run the editor's CLI. Nothing else: no other commands, and no edits to `brand.css`, `vendor/`, `.git/` or, for a
|
|
497
500
|
named deck file, the other decks in its folder. Claude Code loads no settings files and no MCP servers,
|
|
498
501
|
so no allow rule in your own, the deck's or a parent folder's settings widens this. Codex has no shell
|
|
499
502
|
and no file access of its own: it reads and edits through PinDeck's own tools, which apply the same
|
|
@@ -551,14 +554,29 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
551
554
|
* The undo history lives on the server and starts fresh when PinDeck restarts, or when a deck closes after
|
|
552
555
|
30 minutes without an open editor.
|
|
553
556
|
* One writer at a time: PinDeck is not made for editing the same deck from two editors or tools at once.
|
|
554
|
-
* A deck's own scripts run sandboxed in the editor
|
|
555
|
-
|
|
556
|
-
|
|
557
|
+
* A deck's own scripts run sandboxed in the editor: they cannot use `localStorage`, cookies or the editor
|
|
558
|
+
page. A deck that needs them for its slides to work should catch the error. In Present they can, on
|
|
559
|
+
`localhost`, where every deck of one PinDeck shares the same `localStorage`.
|
|
557
560
|
* Linux: pictures of PDF pages and PowerPoint slides, and waveforms of non-WAV audio, need
|
|
558
561
|
Poppler, LibreOffice and ffmpeg on the server (see [Running on a Linux server](#running-on-a-linux-server)).
|
|
559
562
|
|
|
560
563
|
## Versions
|
|
561
564
|
|
|
565
|
+
* **3.4.0**: formatted speaker notes, and the speaker view works.
|
|
566
|
+
- The Notes field shows the notes' formatting: `⌘B`, `⌘I` and `⌘U` format the selection, `Enter` starts
|
|
567
|
+
a new line, and pasted text comes in plain. Notes with markup the field cannot show safely are still
|
|
568
|
+
edited as HTML, and nothing in them is lost on save.
|
|
569
|
+
- Present opens the deck on localhost, so reveal.js's speaker view (`S`) shows the notes and the next slide.
|
|
570
|
+
- Claude runs PinDeck's commands by this server's path when the deck's `CLAUDE.md` names another
|
|
571
|
+
(such as `node ../pindeck/cli.mjs`), and a chat that used an old path is told the right one.
|
|
572
|
+
|
|
573
|
+
* **3.3.0**: fixes from a code audit.
|
|
574
|
+
- After a PowerPoint import, refining the slides no longer deletes pictures that the deck still uses
|
|
575
|
+
(when the turn was stopped, failed or only changed `theme.css`); Undo brings removed pictures back.
|
|
576
|
+
- Long texts with non-ASCII characters (large notes, tables, SVG) no longer get broken characters.
|
|
577
|
+
- Claude reads only the deck, its screenshots, attached PDFs and asset roots, as Codex already did, and
|
|
578
|
+
cannot create a `.git` file in the deck folder.
|
|
579
|
+
|
|
562
580
|
* **3.2.0**: PinDeck 3, the first stable release of version 3: everything from the 3.0, 3.1 and 3.2 betas
|
|
563
581
|
below. Compared with 2.11:
|
|
564
582
|
- OpenAI Codex as a second assistant next to Claude Code, with the models and efforts each backend
|
package/front.mjs
CHANGED
|
@@ -150,7 +150,8 @@ setInterval(async () => {
|
|
|
150
150
|
streamed back (event streams, media ranges and large uploads included) */
|
|
151
151
|
function proxy(req, res, d, rest, anonymous = false) {
|
|
152
152
|
d.last = Date.now();
|
|
153
|
-
|
|
153
|
+
// the deck's pages keep the browser's host: on localhost (Present) the deck server leaves out the sandbox
|
|
154
|
+
const headers = { ...req.headers, host: anonymous ? req.headers.host : new URL(d.url).host, ...(anonymous ? {} : { "x-editor-token": d.token }) };
|
|
154
155
|
if (anonymous) delete headers["x-editor-token"];
|
|
155
156
|
delete headers.cookie;
|
|
156
157
|
delete headers.origin; // checked here against the front's address; the deck server checks its own
|
|
@@ -243,6 +244,7 @@ function follow(st, tab, key) {
|
|
|
243
244
|
}
|
|
244
245
|
async function readJson(req) {
|
|
245
246
|
let s = "";
|
|
247
|
+
req.setEncoding("utf8");
|
|
246
248
|
for await (const c of req) { s += c; if (s.length > 10000) throw new Error("body too large"); }
|
|
247
249
|
return s ? JSON.parse(s) : {};
|
|
248
250
|
}
|
|
@@ -286,7 +288,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
286
288
|
try {
|
|
287
289
|
const url = new URL(req.url, ORIGIN);
|
|
288
290
|
const p = url.pathname;
|
|
289
|
-
if (req.method === "GET" && p === "/login") return login(res, url);
|
|
291
|
+
if (req.method === "GET" && p === "/login") return login(res, url, host);
|
|
290
292
|
// the editor's own scripts and styles hold nothing secret; the deck page loads bridge.js from here
|
|
291
293
|
if (req.method === "GET" && p.startsWith("/editor/")) return serveEditorFile(res, p.slice(8));
|
|
292
294
|
/* A deck's pages and files under /d/<deck>/v/<token>/: asked for by the deck itself, which runs sandboxed
|
package/lib/capabilities.mjs
CHANGED
|
@@ -119,3 +119,16 @@ export function cliCommand(rel) {
|
|
|
119
119
|
if (!/["$`\\!]/.test(rel)) return `node "${rel}"`;
|
|
120
120
|
return `node '${rel.replaceAll("'", `'\\''`)}'`;
|
|
121
121
|
}
|
|
122
|
+
|
|
123
|
+
/* A deck's CLAUDE.md names the CLI by where PinDeck sat when it was written (`node ../cli.mjs`,
|
|
124
|
+
`node ../pindeck/cli.mjs`). Claude may run only this server's command, so every one outside the deck (../) becomes it. */
|
|
125
|
+
export function useCliCommand(text, cmd) {
|
|
126
|
+
return text.replace(/node (?:\.\.\/)+(?:[\w.@-]+\/)*cli\.mjs\b/g, () => cmd);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* Why a refused command was refused, when it is PinDeck's CLI under another path (one Claude saw earlier
|
|
130
|
+
in a resumed conversation, from where PinDeck sat then): the same command as it must be run. Else null. */
|
|
131
|
+
export function cliRefusal(command, cmd) {
|
|
132
|
+
const fixed = useCliCommand(String(command ?? ""), cmd);
|
|
133
|
+
return fixed !== String(command ?? "") ? `Refused: PinDeck's command is \`${cmd}\` now. Run \`${fixed}\` instead.` : null;
|
|
134
|
+
}
|
package/lib/claude.mjs
CHANGED
|
@@ -33,7 +33,7 @@ export class ClaudeSession {
|
|
|
33
33
|
onMessage: ev => {
|
|
34
34
|
if (this.transport !== transport) return;
|
|
35
35
|
if (ev.type === 'control_request') { transport.write({ type: 'control_response', response: { subtype: 'success', request_id: ev.request_id,
|
|
36
|
-
response: { behavior: 'deny', message: 'This tool is outside PinDeck permissions.' } } }); return; }
|
|
36
|
+
response: { behavior: 'deny', message: this.denyMessage?.(ev.request) || 'This tool is outside PinDeck permissions.' } } }); return; }
|
|
37
37
|
if (ev.session_id) this.sessionId = ev.session_id;
|
|
38
38
|
this.onEvent?.(ev);
|
|
39
39
|
},
|
package/lib/eids.mjs
CHANGED
|
@@ -122,7 +122,7 @@ export function parse(html) {
|
|
|
122
122
|
const classesOf = (node) => (node.attrs.class?.value || "").split(/\s+/).filter(Boolean);
|
|
123
123
|
|
|
124
124
|
const ENTITIES = { nbsp: " ", amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
125
|
-
function decode(s) { // one pass, so "&lt;" stays "<"
|
|
125
|
+
export function decode(s) { // one pass, so "&lt;" stays "<"
|
|
126
126
|
return s.replace(/&(?:(nbsp|amp|lt|gt|quot|apos)|#(\d+)|#x([0-9a-f]+));/gi, (m, name, d, x) =>
|
|
127
127
|
name ? ENTITIES[name.toLowerCase()] : String.fromCodePoint(d ? +d : parseInt(x, 16)));
|
|
128
128
|
}
|
package/lib/login.mjs
CHANGED
|
@@ -11,6 +11,10 @@ import { execFile } from "node:child_process";
|
|
|
11
11
|
/* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
|
|
12
12
|
Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
|
|
13
13
|
export const goodHost = (h) => /^(127\.0\.0\.1|localhost)(:\d{1,5})?$/.test(h);
|
|
14
|
+
/* localhost is where decks are presented, without the sandbox (reveal.js's speaker view needs a real origin):
|
|
15
|
+
no login there, and no cookie counts, so a deck's script on it gets nothing it could not already read.
|
|
16
|
+
Browsers treat localhost and 127.0.0.1 as different sites, so the login cookie of 127.0.0.1 never goes there. */
|
|
17
|
+
export const presentHost = (h) => /^localhost(:\d{1,5})?$/.test(String(h || "").toLowerCase());
|
|
14
18
|
|
|
15
19
|
export const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
|
|
16
20
|
<body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
|
|
@@ -40,10 +44,15 @@ export function createLogin({ token, port, origin }) {
|
|
|
40
44
|
const cookieName = () => `pindeck-${port()}`;
|
|
41
45
|
const sameSecret = (a) => typeof a === "string" && a.length === token.length &&
|
|
42
46
|
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(token));
|
|
43
|
-
const authed = (req) => sameSecret(req.headers["x-editor-token"]) ||
|
|
47
|
+
const authed = (req) => sameSecret(req.headers["x-editor-token"]) ||
|
|
48
|
+
(!presentHost(req.headers.host) && sameSecret(cookie(req, cookieName())));
|
|
44
49
|
/* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
|
|
45
50
|
start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
|
|
46
|
-
function login(res, url) {
|
|
51
|
+
function login(res, url, host) {
|
|
52
|
+
if (presentHost(host)) { // logins happen on 127.0.0.1 only: the same link there, its code still unused
|
|
53
|
+
res.writeHead(302, { location: `http://127.0.0.1${host.slice(9)}${url.pathname}${url.search}`, "cache-control": "no-store" });
|
|
54
|
+
return res.end();
|
|
55
|
+
}
|
|
47
56
|
const code = url.searchParams.get("code") || "";
|
|
48
57
|
const exp = loginCodes.get(code);
|
|
49
58
|
loginCodes.delete(code);
|
package/lib/system-prompt.md
CHANGED
|
@@ -64,8 +64,8 @@ user asks for: facts, numbers and wording for slides, or a figure.
|
|
|
64
64
|
- Never put the PDF itself on a slide or link it from the deck.
|
|
65
65
|
|
|
66
66
|
## Commands
|
|
67
|
-
The only shell commands you can run here are the PinDeck commands below ({{CLI}} ...),
|
|
68
|
-
its own: no scripts, heredocs, temporary files, loops, variables or `&&` chains, and no python, sips,
|
|
67
|
+
The only shell commands you can run here are the PinDeck commands below ({{CLI}} ...), with exactly
|
|
68
|
+
this path to cli.mjs even if another one appears earlier in the conversation, each on its own: no scripts, heredocs, temporary files, loops, variables or `&&` chains, and no python, sips,
|
|
69
69
|
convert or ls. Such commands are refused (or need an approval nobody can give) and are not run. A
|
|
70
70
|
refused command did nothing: do not retry it as it is, do it another way.
|
|
71
71
|
- To change files, use Edit, even for large changes: several Edit calls in a row are fine.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pindeck",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "PinDeck: an editor for HTML slide decks on macOS, and on Linux through an SSH tunnel. Pin elements, describe the change, and Claude Code or OpenAI Codex edits the slides.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"slides",
|
package/public/editor.css
CHANGED
|
@@ -183,12 +183,15 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
|
|
|
183
183
|
.notes-head:hover { background: var(--bg); border: 0; }
|
|
184
184
|
.notes-head .caret { display: inline-block; transition: transform .15s; }
|
|
185
185
|
.notes-panel.collapsed .caret { transform: rotate(-90deg); }
|
|
186
|
-
.notes-panel.collapsed
|
|
186
|
+
.notes-panel.collapsed .notes-box { display: none; }
|
|
187
187
|
.notes-status { margin-left: auto; font-weight: 400; text-transform: none; letter-spacing: 0; }
|
|
188
|
-
.notes-
|
|
188
|
+
.notes-box { height: 96px; overflow-y: auto; box-sizing: border-box; border: 0; border-top: 1px solid var(--line);
|
|
189
189
|
padding: 8px 14px; font: inherit; font-size: 13.5px; line-height: 1.45; color: var(--ink); background: var(--panel); outline: none; }
|
|
190
|
-
.notes-
|
|
191
|
-
.notes-
|
|
190
|
+
.notes-box:focus { background: #fffcf6; }
|
|
191
|
+
.notes-box:empty::before { content: attr(data-placeholder); color: var(--muted); }
|
|
192
|
+
.notes-box[contenteditable="false"] { color: var(--muted); }
|
|
193
|
+
.notes-box[contenteditable="plaintext-only"] { white-space: pre-wrap; font-family: ui-monospace, Menlo, monospace; font-size: 12.5px; }
|
|
194
|
+
.notes-box p, .notes-box ul, .notes-box ol { margin: 0 0 .5em; }
|
|
192
195
|
|
|
193
196
|
/* ---- Chat ---- */
|
|
194
197
|
.chat-pane { display: flex; flex-direction: column; min-height: 0; border-left: 1px solid var(--line); background: var(--panel); }
|
package/public/editor.js
CHANGED
|
@@ -365,16 +365,44 @@ async function moveSlideTo(from, to) { // to: insertion index in the current l
|
|
|
365
365
|
}
|
|
366
366
|
|
|
367
367
|
/* ---- Speaker notes: the field under the slide -------------------------------------------------------
|
|
368
|
-
Shows the current slide's <aside class="notes">
|
|
369
|
-
or on ⌘Enter; Esc puts back what the file says.
|
|
370
|
-
|
|
368
|
+
Shows the current slide's <aside class="notes"> as formatted text (⌘B ⌘I ⌘U; Enter is a line break) and
|
|
369
|
+
saves its HTML as a hand edit when the field loses focus or on ⌘Enter; Esc puts back what the file says.
|
|
370
|
+
Notes with markup the field cannot show safely (other tags or attributes) are edited as their HTML
|
|
371
|
+
("HTML" in the header). Typing is never overwritten by a reload. */
|
|
372
|
+
const NOTE_TAGS = new Set(["P", "BR", "B", "STRONG", "I", "EM", "U", "S", "SUB", "SUP", "CODE", "SPAN", "SMALL", "MARK",
|
|
373
|
+
"UL", "OL", "LI", "A", "BLOCKQUOTE", "DIV"]);
|
|
374
|
+
const safeNoteAttr = (el, a) => a.name === "class" || (a.name === "href" && el.tagName === "A" && /^(https?:|mailto:|#)/i.test(a.value.trim()));
|
|
375
|
+
/* The notes' HTML when the field can show it unchanged (only harmless tags and attributes), else null. Parsed inert. */
|
|
376
|
+
function richNotes(html) {
|
|
377
|
+
const body = new DOMParser().parseFromString("<body>" + html, "text/html").body; // <body>: a leading <style> or <script> stays in it, to be seen
|
|
378
|
+
for (const el of body.querySelectorAll("*"))
|
|
379
|
+
if (!NOTE_TAGS.has(el.tagName) || [...el.attributes].some((a) => !safeNoteAttr(el, a))) return null;
|
|
380
|
+
return body.innerHTML;
|
|
381
|
+
}
|
|
371
382
|
const notesBox = $("notes");
|
|
372
|
-
let notes = { eid: null, text: "", html: false }; // the
|
|
383
|
+
let notes = { eid: null, text: "", html: false }; // the field's content as loaded; html: edited as raw HTML
|
|
373
384
|
let notesSeq = 0;
|
|
374
|
-
const
|
|
385
|
+
const notesValue = () => notes.html ? notesBox.textContent : notesBox.innerHTML;
|
|
386
|
+
const notesDirty = () => !!notes.eid && notesValue().trim() !== notes.text.trim();
|
|
387
|
+
/* What is saved: the raw HTML as typed, or the field's HTML without the browser's own styling of edits */
|
|
388
|
+
function notesHtml() {
|
|
389
|
+
if (notes.html) return notesBox.textContent;
|
|
390
|
+
if (!notesBox.textContent.trim()) return "";
|
|
391
|
+
const c = notesBox.cloneNode(true);
|
|
392
|
+
c.querySelectorAll("[style]").forEach((el) => el.removeAttribute("style"));
|
|
393
|
+
return c.innerHTML.replace(/(\s*<br>)+\s*$/, "");
|
|
394
|
+
}
|
|
395
|
+
/* Shows the notes `d` ({ text, html } from the server, or null: none) of slide `eid` */
|
|
396
|
+
function showNotes(eid, d) {
|
|
397
|
+
const rich = !d ? "" : d.html ? richNotes(d.text || "") : (d.text || "").split("\n").map(esc).join("<br>");
|
|
398
|
+
notes = { eid, text: "", html: rich == null };
|
|
399
|
+
notesBox.contentEditable = !d ? "false" : notes.html ? "plaintext-only" : "true";
|
|
400
|
+
if (notes.html) notesBox.textContent = d.text; else notesBox.innerHTML = rich;
|
|
401
|
+
notes.text = notesValue();
|
|
402
|
+
}
|
|
375
403
|
function notesStatus(msg) {
|
|
376
404
|
$("notes-status").textContent = msg ?? (notes.html ? "HTML" : "");
|
|
377
|
-
$("notes-status").title = msg ? "" : notes.html ? "These notes contain
|
|
405
|
+
$("notes-status").title = msg ? "" : notes.html ? "These notes contain markup the field cannot show, so you edit them as HTML" : "";
|
|
378
406
|
}
|
|
379
407
|
async function loadNotes() {
|
|
380
408
|
const eid = state.slides[state.current]?.eid;
|
|
@@ -386,19 +414,15 @@ async function loadNotes() {
|
|
|
386
414
|
const d = res?.ok ? await res.json().catch(() => null) : null;
|
|
387
415
|
if (seq !== notesSeq || document.activeElement === notesBox || notesDirty()) return;
|
|
388
416
|
if (!d) { // detach from the previous slide, so its notes are not shown, or saved over, as this one's
|
|
389
|
-
|
|
390
|
-
notesBox.value = "";
|
|
391
|
-
notesBox.disabled = true;
|
|
417
|
+
showNotes(null, null);
|
|
392
418
|
return notesStatus("Could not load");
|
|
393
419
|
}
|
|
394
|
-
|
|
395
|
-
notesBox.value = notes.text;
|
|
396
|
-
notesBox.disabled = false;
|
|
420
|
+
showNotes(eid, d);
|
|
397
421
|
notesStatus();
|
|
398
422
|
}
|
|
399
423
|
function saveNotes() {
|
|
400
424
|
if (!notesDirty()) return Promise.resolve(true);
|
|
401
|
-
const edit = { eid: notes.eid, text:
|
|
425
|
+
const edit = { eid: notes.eid, text: notesHtml(), html: true }, shown = notesValue();
|
|
402
426
|
flushNudge();
|
|
403
427
|
const run = async () => {
|
|
404
428
|
notesStatus("Saving…");
|
|
@@ -412,7 +436,7 @@ function saveNotes() {
|
|
|
412
436
|
toast(d?.error || "Could not save the notes.");
|
|
413
437
|
return false;
|
|
414
438
|
}
|
|
415
|
-
if (notes.eid === edit.eid) notes.text =
|
|
439
|
+
if (notes.eid === edit.eid) notes.text = shown;
|
|
416
440
|
notesStatus("Saved");
|
|
417
441
|
setTimeout(() => { if ($("notes-status").textContent === "Saved") notesStatus(); }, 1500);
|
|
418
442
|
return true;
|
|
@@ -424,12 +448,23 @@ notesBox.addEventListener("blur", async () => {
|
|
|
424
448
|
});
|
|
425
449
|
notesBox.addEventListener("keydown", (e) => {
|
|
426
450
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); notesBox.blur(); }
|
|
427
|
-
else if (e.key === "Escape") {
|
|
451
|
+
else if (e.key === "Escape") {
|
|
452
|
+
e.preventDefault();
|
|
453
|
+
if (notes.html) notesBox.textContent = notes.text; else notesBox.innerHTML = notes.text;
|
|
454
|
+
notesBox.blur();
|
|
455
|
+
}
|
|
456
|
+
// a <br>, as the notes have them, not a new <div>
|
|
457
|
+
else if (e.key === "Enter" && !notes.html) { e.preventDefault(); document.execCommand("insertLineBreak"); }
|
|
458
|
+
});
|
|
459
|
+
notesBox.addEventListener("paste", (e) => { // pasted text comes in plain: no styles or markup from elsewhere
|
|
460
|
+
e.preventDefault();
|
|
461
|
+
document.execCommand("insertText", false, e.clipboardData.getData("text/plain"));
|
|
428
462
|
});
|
|
463
|
+
notesBox.addEventListener("input", () => { if (notesBox.innerHTML === "<br>") notesBox.innerHTML = ""; }); // placeholder again
|
|
429
464
|
// closing the tab while typing: send what is there (sendBeacon cannot set headers, hence the token in the URL)
|
|
430
465
|
addEventListener("pagehide", () => {
|
|
431
466
|
if (notesDirty() && !state.busy)
|
|
432
|
-
navigator.sendBeacon(BASE + "api/notes", new Blob([JSON.stringify({ eid: notes.eid, text:
|
|
467
|
+
navigator.sendBeacon(BASE + "api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesHtml(), html: true })]));
|
|
433
468
|
});
|
|
434
469
|
function setNotesOpen(open) {
|
|
435
470
|
$("notes-panel").classList.toggle("collapsed", !open);
|
|
@@ -580,8 +615,13 @@ $("export-menu").addEventListener("click", async (e) => {
|
|
|
580
615
|
}
|
|
581
616
|
});
|
|
582
617
|
|
|
618
|
+
/* Present on localhost, at the deck's view path: no login there, so no sandbox, and reveal.js's speaker view (S)
|
|
619
|
+
works. noopener: the deck's scripts get no handle on this editor. */
|
|
583
620
|
$("present").onclick = () => {
|
|
584
|
-
|
|
621
|
+
const view = state.status?.view || "";
|
|
622
|
+
const u = new URL(`${BASE}${view}deck/${state.slides[state.current]?.hash || ""}`, location.href);
|
|
623
|
+
if (view) u.hostname = "localhost";
|
|
624
|
+
window.open(u, "_blank", "noopener");
|
|
585
625
|
};
|
|
586
626
|
|
|
587
627
|
/* ---- Pins --------------------------------------------------------------------------------------- */
|
|
@@ -852,7 +892,7 @@ function handleKey(k, mod = {}) {
|
|
|
852
892
|
else if (k === "ArrowRight" || k === "ArrowDown" || k === "PageDown" || k === " ") goto(state.current + 1);
|
|
853
893
|
}
|
|
854
894
|
document.addEventListener("keydown", (e) => {
|
|
855
|
-
if (e.target.closest?.("input, textarea, dialog")) return;
|
|
895
|
+
if (e.target.closest?.("input, textarea, dialog, [contenteditable]")) return;
|
|
856
896
|
// a menu and the button that opens it keep Enter, Space and the arrow keys (a menu opens, an item is chosen):
|
|
857
897
|
// no slide change, no jump to the chat. Other buttons do not: after a click on ‹ › the focus stays there
|
|
858
898
|
// while the slide is clicked (the slide frame keeps it), and Enter must still edit the selected text.
|
package/public/index.html
CHANGED
|
@@ -71,8 +71,9 @@
|
|
|
71
71
|
title="Drag to resize the notes (double-click: default size)" aria-label="Resize the speaker notes"></div>
|
|
72
72
|
<button id="notes-toggle" class="notes-head" aria-expanded="true" aria-controls="notes" title="Show or hide the speaker notes">
|
|
73
73
|
<span class="caret" aria-hidden="true">▾</span> Notes <span id="notes-status" class="notes-status"></span></button>
|
|
74
|
-
<
|
|
75
|
-
|
|
74
|
+
<div id="notes" class="notes-box" contenteditable="false" role="textbox" aria-multiline="true" spellcheck="true"
|
|
75
|
+
aria-label="Speaker notes of this slide"
|
|
76
|
+
data-placeholder="Speaker notes for this slide, shown in the speaker view (S) when presenting. ⌘B bold, ⌘I italic, ⌘U underline. Saved when you click elsewhere or press ⌘Enter; Esc undoes your typing."></div>
|
|
76
77
|
</section>
|
|
77
78
|
</main>
|
|
78
79
|
|
package/server.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import path from "node:path";
|
|
|
23
23
|
import crypto from "node:crypto";
|
|
24
24
|
import { spawn, execFile } 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 } from "./lib/eids.mjs";
|
|
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";
|
|
28
28
|
import { ClaudeSession } from "./lib/claude.mjs";
|
|
29
29
|
import { CodexSession } from "./lib/codex.mjs";
|
|
@@ -34,7 +34,7 @@ import { printLayout, printPdf, chromeReason, ROOT_ARGS, PDF_HEAD_TAG, PDF_PAGE_
|
|
|
34
34
|
import { readPptx } from "./lib/pptx.mjs";
|
|
35
35
|
import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
|
|
36
36
|
import { buildImport, planSlides } from "./lib/pptx-import.mjs";
|
|
37
|
-
import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, findExecutable } from "./lib/capabilities.mjs";
|
|
37
|
+
import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, useCliCommand, cliRefusal, findExecutable } from "./lib/capabilities.mjs";
|
|
38
38
|
import { unsupportedMessage } from "./lib/frameworks.mjs";
|
|
39
39
|
import { readInstructions } from "./lib/instructions.mjs";
|
|
40
40
|
import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
|
|
@@ -42,7 +42,7 @@ import { deckTarget, homeDir, homeWarning } from "./lib/deck-target.mjs";
|
|
|
42
42
|
import { listDecks, linkProblem } from "./lib/deck-list.mjs";
|
|
43
43
|
import { assetRoots } from "./lib/asset-roots.mjs";
|
|
44
44
|
import { registerServer, printLogin, alive } from "./lib/servers.mjs";
|
|
45
|
-
import { goodHost, LOGIN_PAGE, createLogin, openOnEnter } from "./lib/login.mjs";
|
|
45
|
+
import { goodHost, presentHost, LOGIN_PAGE, createLogin, openOnEnter } from "./lib/login.mjs";
|
|
46
46
|
import { USAGE } from "./lib/usage.mjs";
|
|
47
47
|
|
|
48
48
|
/* ---- Config ------------------------------------------------------------------ */
|
|
@@ -243,6 +243,7 @@ function staleEdit(res, b) {
|
|
|
243
243
|
const text = (res, code, s) => { res.writeHead(code, { "content-type": "text/plain; charset=utf-8" }); res.end(s); };
|
|
244
244
|
async function body(req) {
|
|
245
245
|
let s = "";
|
|
246
|
+
req.setEncoding("utf8");
|
|
246
247
|
for await (const c of req) { s += c; if (s.length > 5e6) throw new Error("body too large"); }
|
|
247
248
|
return s ? JSON.parse(s) : {};
|
|
248
249
|
}
|
|
@@ -542,7 +543,7 @@ let session = null, starting = null;
|
|
|
542
543
|
const status = () => ({ busy: state.busy, switching: state.switching, preparing: !!state.preparing, model: state.model, sessionId: state.sessionId, settings: { ...settings },
|
|
543
544
|
running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
|
|
544
545
|
chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
|
|
545
|
-
capabilities: publicCapabilities(CAPS), rev: deckRev(),
|
|
546
|
+
capabilities: publicCapabilities(CAPS), rev: deckRev(), view: VIEW.slice(1) + "/",
|
|
546
547
|
undo: changeLabel(liveTurns().at(-1)), undoId: liveTurns().at(-1)?.id ?? null, redo: changeLabel(state.redo.at(-1)) });
|
|
547
548
|
/* Pushed to the editors on every change. The model lists (both providers' catalogs) go along only when they
|
|
548
549
|
changed since the last push: the editor keeps the last ones it got. */
|
|
@@ -576,7 +577,7 @@ function slidesInstructions() {
|
|
|
576
577
|
win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
|
|
577
578
|
function lockedPaths() {
|
|
578
579
|
const { files, folders } = otherDecks();
|
|
579
|
-
return [...LOCKED, "vendor/**", ".git/**", ...files, ...folders.map((f) => `${f}/**`)];
|
|
580
|
+
return [...LOCKED, "vendor/**", ".git", ".git/**", ...files, ...folders.map((f) => `${f}/**`)];
|
|
580
581
|
}
|
|
581
582
|
/* The other decks in this deck's folder: its other .html files, and the subfolders that hold a deck of their
|
|
582
583
|
own (as the deck menu lists them). Neither the assistant nor this deck's undo touches them. */
|
|
@@ -612,10 +613,9 @@ function othersWorkedSince(start) {
|
|
|
612
613
|
for (const f of files) {
|
|
613
614
|
try {
|
|
614
615
|
const o = JSON.parse(fs.readFileSync(path.join(TURNS_DIR, f), "utf8"));
|
|
615
|
-
|
|
616
|
-
try { process.kill(o.pid, 0); } catch (e) { alive = e.code === "EPERM"; }
|
|
616
|
+
const running = alive(o.pid);
|
|
617
617
|
// a turn still open in a server that is gone ended when that server did, at the latest
|
|
618
|
-
if (o.turns.some((x) => (x.end ?? (
|
|
618
|
+
if (o.turns.some((x) => (x.end ?? (running ? now : x.start)) >= start)) names.push(o.deck);
|
|
619
619
|
} catch {}
|
|
620
620
|
}
|
|
621
621
|
return names;
|
|
@@ -625,12 +625,12 @@ function othersWorkedSince(start) {
|
|
|
625
625
|
MCP servers have no place in a slide editor). That also skips CLAUDE.md files, so the deck's own go
|
|
626
626
|
into the system prompt instead, read afresh at each session start: CLAUDE.md and AGENTS.md (the name
|
|
627
627
|
other coding agents read), with their @path imports (lib/instructions.mjs). Parent folders' files are
|
|
628
|
-
not loaded; a deck imports shared rules explicitly.
|
|
629
|
-
|
|
628
|
+
not loaded; a deck imports shared rules explicitly. The CLI path written there becomes this server's
|
|
629
|
+
command (useCliCommand). */
|
|
630
630
|
function deckInstructions() {
|
|
631
631
|
return ["CLAUDE.md", "AGENTS.md"].map((name) => {
|
|
632
632
|
const s = readInstructions(path.join(DECK_DIR, name));
|
|
633
|
-
return s ? `\n\n## The deck's ${name}\n${s
|
|
633
|
+
return s ? `\n\n## The deck's ${name}\n${useCliCommand(s, CLI_CMD)}\n` : "";
|
|
634
634
|
}).join("");
|
|
635
635
|
}
|
|
636
636
|
// a function: ORIGIN is known only once the port is bound
|
|
@@ -668,10 +668,12 @@ function makeSession(choice) {
|
|
|
668
668
|
explicit), PinDeck's own commands, never the locked paths. Deny rules win over allow rules. */
|
|
669
669
|
const agent = choice.provider === "codex" ? new CodexSession({ ...options, settings: choice,
|
|
670
670
|
instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
|
|
671
|
+
// 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
|
+
denyMessage: (r) => r?.tool_name === "Bash" ? cliRefusal(r.input?.command, CLI_CMD) : null,
|
|
671
673
|
args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
|
|
672
674
|
"--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
|
|
673
|
-
"--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
|
|
674
|
-
"--allowedTools",
|
|
675
|
+
"--permission-mode", "default", "--permission-prompt-tool", "stdio", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
|
|
676
|
+
"--allowedTools", ...readRules(), "Edit(./**)", "Write(./**)",
|
|
675
677
|
...["layout", "shot", "slides", "pdf", "pdf-figure"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
|
|
676
678
|
"--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
|
|
677
679
|
...(choice.model ? ["--model", choice.model] : []), ...(choice.effort ? ["--effort", choice.effort] : [])],
|
|
@@ -679,6 +681,13 @@ function makeSession(choice) {
|
|
|
679
681
|
agent.initialEvent = () => initial;
|
|
680
682
|
return agent;
|
|
681
683
|
}
|
|
684
|
+
/* Claude reads what Codex's tools may read (agentTools' readDirs): the deck, screenshots, attached PDFs and
|
|
685
|
+
asset roots, each by its path and its real path ("//" starts an absolute path in a rule) */
|
|
686
|
+
function readRules() {
|
|
687
|
+
const dirs = [SHOT_DIR, PDF_DIR, ...ASSET_ROOTS].flatMap((d) => { try { return [d, fs.realpathSync(d)]; } catch { return [d]; } });
|
|
688
|
+
const globs = ["./**", ...new Set(dirs.map((d) => `/${d}/**`))];
|
|
689
|
+
return ["Read", "Glob", "Grep"].flatMap((tool) => globs.map((g) => `${tool}(${g})`));
|
|
690
|
+
}
|
|
682
691
|
const providerAvailable = (provider) => detectCapabilities()[provider];
|
|
683
692
|
/* Every backend process that is stopped is waited for on exit: the discovery helpers, the session a switch
|
|
684
693
|
replaced, the current one. */
|
|
@@ -892,7 +901,7 @@ function handleEvent(ev) {
|
|
|
892
901
|
function startTurn(promptText, userEntry) {
|
|
893
902
|
// every deck file as it is now: the assistant may change any of them (not brand.css or vendor/), and undo
|
|
894
903
|
// takes back all it changed
|
|
895
|
-
state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]), gitConfig: readDeck(".git/config"), scan: scanDeck(), touched: new Set(),
|
|
904
|
+
state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]), gitConfig: readDeck(".git/config"), hadGit: fs.existsSync(path.join(DECK_DIR, ".git")), scan: scanDeck(), touched: new Set(),
|
|
896
905
|
time: { start: Date.now(), end: null } };
|
|
897
906
|
logTurnTime(state.turn.time);
|
|
898
907
|
state.busy = true;
|
|
@@ -930,6 +939,12 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
930
939
|
restored.push(".git/config");
|
|
931
940
|
} catch (e) { console.error(e.message); }
|
|
932
941
|
}
|
|
942
|
+
// a .git file ("gitdir: elsewhere") points git at another config, which the check above does not see
|
|
943
|
+
const dotGit = path.join(DECK_DIR, ".git");
|
|
944
|
+
if (!turn.hadGit && fs.lstatSync(dotGit, { throwIfNoEntry: false })?.isDirectory() === false) {
|
|
945
|
+
fs.rmSync(dotGit, { force: true });
|
|
946
|
+
restored.push(".git");
|
|
947
|
+
}
|
|
933
948
|
toolEntries.clear();
|
|
934
949
|
const eid = runEidPass();
|
|
935
950
|
const found = scanChanges(turn.scan, scanDeck());
|
|
@@ -952,13 +967,14 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
952
967
|
const deletedFiles = [];
|
|
953
968
|
for (const rel of turn.importedFiles || []) {
|
|
954
969
|
const abs = path.join(DECK_DIR, rel);
|
|
955
|
-
if (!fs.existsSync(abs) || EDITABLE.some((f) => (
|
|
970
|
+
if (!fs.existsSync(abs) || EDITABLE.some((f) => (readDeck(f) || "").includes(rel))) continue;
|
|
956
971
|
deletedFiles.push({ rel, data: fs.readFileSync(abs) });
|
|
957
972
|
fs.rmSync(abs, { force: true });
|
|
958
973
|
}
|
|
959
974
|
const t = { id: turn.id, kind: "agent", provider: settings.provider, conversation: state.conversation, before, after, files, deletedFiles, undone: false };
|
|
960
|
-
|
|
961
|
-
|
|
975
|
+
const kept = files.length || deletedFiles.length;
|
|
976
|
+
if (kept) { state.turns.push(t); state.redo = []; }
|
|
977
|
+
addEntry({ kind: "turn", turnId: kept ? t.id : null, files, error, cost, duration,
|
|
962
978
|
denials, restored, newIds: eid.assigned.length });
|
|
963
979
|
if (deletedFiles.length) addEntry({ kind: "notice", text: `Removed ${deletedFiles.map((f) => f.rel).join(", ")}: replaced, and no longer used.` });
|
|
964
980
|
if (notOurs.length) addEntry({ kind: "notice", text: `${notOurs.join(", ")} changed during this turn while the assistant of ` +
|
|
@@ -1243,11 +1259,7 @@ function undoConflicts(t, direction) {
|
|
|
1243
1259
|
const go = direction === "undo" ? t.addedFiles || [] : t.deletedFiles || [];
|
|
1244
1260
|
// a file to bring back or remove through a symlink out of the deck: not done (deckPath), so not begun
|
|
1245
1261
|
for (const { rel } of [...comeBack, ...go]) { try { deckPath(rel); } catch { files.push(rel); } }
|
|
1246
|
-
for (const { rel, data } of comeBack) {
|
|
1247
|
-
const abs = path.join(DECK_DIR, rel);
|
|
1248
|
-
if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
|
|
1249
|
-
}
|
|
1250
|
-
for (const { rel, data } of go) {
|
|
1262
|
+
for (const { rel, data } of [...comeBack, ...go]) {
|
|
1251
1263
|
const abs = path.join(DECK_DIR, rel);
|
|
1252
1264
|
if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
|
|
1253
1265
|
}
|
|
@@ -1330,8 +1342,7 @@ async function applyImport(imp, numbers, afterEid, refine = false) {
|
|
|
1330
1342
|
const afterIdx = found < 0 ? slides.length - 1 : found; // no such slide (any more): at the end
|
|
1331
1343
|
const after = slides[afterIdx];
|
|
1332
1344
|
const css = (readDeck("theme.css") || "") + (readDeck("brand.css") || "");
|
|
1333
|
-
const prefix = path.basename(imp.name, path.extname(imp.name)).
|
|
1334
|
-
.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").split("-").slice(0, 3).join("-") || "import";
|
|
1345
|
+
const prefix = slug(path.basename(imp.name, path.extname(imp.name))).split("-").slice(0, 3).join("-");
|
|
1335
1346
|
const r = await buildImport({ model: imp.model, numbers, pdf, workDir: imp.dir, deckDir: DECK_DIR, deckHtml: html0, css, after: afterIdx, prefix });
|
|
1336
1347
|
// building takes a while (pictures are cut from the slides): a change made meanwhile must not be overwritten
|
|
1337
1348
|
if (EDITABLE.some((f) => readDeck(f) !== before[f])) throw new Error("The deck changed during the import. Import again.");
|
|
@@ -1458,9 +1469,10 @@ let ready = false;
|
|
|
1458
1469
|
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)$/;
|
|
1459
1470
|
const CLI_ROUTES = /^\/api\/(slides|layout|shot|pdf|pdf-figure)$/; // answered as text, for cli.mjs
|
|
1460
1471
|
/* Scriptable files (HTML, SVG) from the deck get the sandbox, wherever they are asked for. Under /v/ they are
|
|
1461
|
-
read by documents of that sandboxed origin, so fonts and module scripts need CORS; the path is the secret.
|
|
1472
|
+
read by documents of that sandboxed origin, so fonts and module scripts need CORS; the path is the secret.
|
|
1473
|
+
On localhost (Present) there is no sandbox: no login works there (see presentHost). */
|
|
1462
1474
|
const SCRIPTABLE = /\.(html|svg|xml)$/i;
|
|
1463
|
-
const deckHeaders = (f, viewing) => ({ ...(SCRIPTABLE.test(f) ? { "content-security-policy": SANDBOX } : {}),
|
|
1475
|
+
const deckHeaders = (f, viewing, open) => ({ ...(SCRIPTABLE.test(f) && !open ? { "content-security-policy": SANDBOX } : {}),
|
|
1464
1476
|
...(viewing ? { "access-control-allow-origin": "*" } : {}) });
|
|
1465
1477
|
const server = http.createServer(async (req, res) => {
|
|
1466
1478
|
if (!ready) return text(res, 503, "PinDeck is starting. Reload in a moment.");
|
|
@@ -1469,7 +1481,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1469
1481
|
try {
|
|
1470
1482
|
const url = new URL(req.url, ORIGIN);
|
|
1471
1483
|
let p = url.pathname;
|
|
1472
|
-
if (req.method === "GET" && p === "/login") return login(res, url);
|
|
1484
|
+
if (req.method === "GET" && p === "/login") return login(res, url, host);
|
|
1473
1485
|
// /v/<token>/deck/... and /v/<token>/files/...: the deck's pages and files, read-only, nothing else
|
|
1474
1486
|
let viewing = false;
|
|
1475
1487
|
if (p.startsWith("/v/")) {
|
|
@@ -1517,7 +1529,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1517
1529
|
const f = deckPath;
|
|
1518
1530
|
if (!viewing && /\.html$/i.test(f)) return toView(p);
|
|
1519
1531
|
const mode = url.searchParams.get("editor");
|
|
1520
|
-
if (!mode || !/\.html$/i.test(f)) return serveFile(res, f, null, req, deckHeaders(f, viewing));
|
|
1532
|
+
if (!mode || !/\.html$/i.test(f)) return serveFile(res, f, null, req, deckHeaders(f, viewing, presentHost(host)));
|
|
1521
1533
|
return serveFile(res, f, (s) => {
|
|
1522
1534
|
// headless runs must not stream media, or Chrome never finishes the page
|
|
1523
1535
|
if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
|
|
@@ -1528,7 +1540,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1528
1540
|
}
|
|
1529
1541
|
return s.replace(/<\/body>/i, BRIDGE_TAG);
|
|
1530
1542
|
}, req, ["shot", "dump", "pdf"].includes(mode) && headlessKeys.delete(url.searchParams.get("headless"))
|
|
1531
|
-
? deckHeaders(".css", viewing) : deckHeaders(f, viewing)); // headless Chrome's page, once (see chromeStart)
|
|
1543
|
+
? deckHeaders(".css", viewing) : deckHeaders(f, viewing, presentHost(host))); // headless Chrome's page, once (see chromeStart)
|
|
1532
1544
|
}
|
|
1533
1545
|
if (viewing) return text(res, 404, "Not found");
|
|
1534
1546
|
|
|
@@ -1863,8 +1875,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1863
1875
|
if (!r.changed) return json(res, 200, { unchanged: true });
|
|
1864
1876
|
const n = findSlides(parse(before[DECK_FILE])).findIndex((s) => s.attrs["data-eid"]?.value === b.eid) + 1;
|
|
1865
1877
|
const text = String(b.text || "").trim();
|
|
1878
|
+
const said = b.html ? decode(text.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]*>/g, "")).trim() : text; // Claude reads the words, not the tags
|
|
1866
1879
|
const turnId = recordHandEdit(before, r.html, text ? `Edited the notes of slide ${n}` : `Removed the notes of slide ${n}`,
|
|
1867
|
-
text ? `The user edited the speaker notes of slide ${n} (${b.eid}) by hand. They now read: "${
|
|
1880
|
+
text ? `The user edited the speaker notes of slide ${n} (${b.eid}) by hand. They now read: "${said.slice(0, 300)}".`
|
|
1868
1881
|
: `The user removed the speaker notes of slide ${n} (${b.eid}).`); // manual: the slide itself does not change
|
|
1869
1882
|
return json(res, 200, { turnId });
|
|
1870
1883
|
}
|