pindeck 3.0.0-beta.2 → 3.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/README.md +94 -16
- package/front.mjs +318 -0
- package/lib/deck-list.mjs +49 -0
- package/lib/deck-target.mjs +25 -0
- package/lib/eids.mjs +101 -8
- package/lib/frameworks.mjs +26 -8
- package/lib/login.mjs +61 -0
- package/lib/usage.mjs +15 -0
- package/package.json +4 -3
- package/public/bridge.js +248 -69
- package/public/editor.css +24 -3
- package/public/editor.js +78 -21
- package/public/front.css +14 -0
- package/public/index.html +9 -1
- package/server.mjs +116 -101
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# PinDeck
|
|
2
2
|
|
|
3
|
-
> **macOS, and Linux as a beta · Version 3 beta preview** (`3.
|
|
3
|
+
> **macOS, and Linux as a beta · Version 3 beta preview** (`3.1.0-beta.1`). On Linux, PinDeck runs as a
|
|
4
4
|
> headless server that you use from your Mac's browser through an SSH tunnel (see
|
|
5
5
|
> [Running on a Linux server](#running-on-a-linux-server-beta)). Windows is not supported. The beta is on
|
|
6
6
|
> npm’s `beta` channel and the [`v3-beta`](https://github.com/ehabets/PinDeck/tree/v3-beta) branch; stable
|
|
@@ -42,7 +42,7 @@ Install the beta from npm:
|
|
|
42
42
|
```bash
|
|
43
43
|
npx pindeck@beta /path/to/deck
|
|
44
44
|
# Or select this exact beta:
|
|
45
|
-
npx pindeck@3.
|
|
45
|
+
npx pindeck@3.1.0-beta.1 /path/to/deck
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
Or run it from the `v3-beta` branch, which includes the example deck:
|
|
@@ -50,14 +50,14 @@ Or run it from the `v3-beta` branch, which includes the example deck:
|
|
|
50
50
|
```bash
|
|
51
51
|
git clone --branch v3-beta --single-branch https://github.com/ehabets/PinDeck.git PinDeck-v3
|
|
52
52
|
cd PinDeck-v3
|
|
53
|
-
node
|
|
53
|
+
node front.mjs ocean-example
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
Use `npx pindeck@latest /path/to/deck` for the stable release; the beta channel does not replace it.
|
|
57
57
|
Releases, with notes for each beta, are on [GitHub](https://github.com/ehabets/PinDeck/releases).
|
|
58
58
|
|
|
59
59
|
Then open the login link PinDeck prints (`PinDeck: http://127.0.0.1:5180/login?code=...`). It works
|
|
60
|
-
once and logs this browser in; for a new one, run `pindeck login` (or `node
|
|
60
|
+
once and logs this browser in; for a new one, run `pindeck login` (or `node front.mjs login`).
|
|
61
61
|
|
|
62
62
|
The deck is a file (`pindeck path/to/talk.html`) or a folder, which opens its `index.html`; without
|
|
63
63
|
one, the current folder. Several decks can share a folder, each with its own chat and undo history;
|
|
@@ -68,6 +68,17 @@ model in `~/.pindeck-v3/config.json`), `--asset-root <folder>` (see [Files outsi
|
|
|
68
68
|
`--help`. Runtime files (chat history, screenshots, backups) are kept in `~/.pindeck-v3/` (change with
|
|
69
69
|
`PINDECK_HOME`), not in the deck. `ocean-example/` is a complete example deck to start from.
|
|
70
70
|
|
|
71
|
+
### Several decks
|
|
72
|
+
|
|
73
|
+
Click the deck's name above the slide for the other decks: the `.html` files in the deck's folder and in
|
|
74
|
+
the asset roots, and in their folders one level down (`lecture/index.html`). A deck opens in the same
|
|
75
|
+
tab, or with ⌘-click in a new one, on the same port and login, so one SSH tunnel serves them all. Each keeps
|
|
76
|
+
its own chat and undo history while it is open, and the assistant finishes a turn in a deck you leave. A
|
|
77
|
+
file PinDeck cannot open is greyed out with the reason; listing a deck changes nothing, and only opening it
|
|
78
|
+
adds its element ids. Started with a folder that has no `index.html`, PinDeck shows the list of its decks.
|
|
79
|
+
A deck with no editor open for 30 minutes is closed (its undo history goes, as on a restart) and opens
|
|
80
|
+
again when you choose it.
|
|
81
|
+
|
|
71
82
|
## Compatibility and requirements
|
|
72
83
|
|
|
73
84
|
* **Runtime:** Node.js 20 or later, plus a modern browser. The release workflow uses Node.js 24.
|
|
@@ -101,7 +112,8 @@ errors; PinDeck does not fall back to broader permissions. Recheck backend compa
|
|
|
101
112
|
|
|
102
113
|
## Preparing a deck
|
|
103
114
|
|
|
104
|
-
PinDeck works with reveal.js decks whose slides are `<section>` elements inside `.slides
|
|
115
|
+
PinDeck works with reveal.js decks whose slides are `<section>` elements inside `.slides`, and with other
|
|
116
|
+
decks that name their slides (see [Decks made without reveal.js](#decks-made-without-revealjs)). Two
|
|
105
117
|
optional `<meta>` tags in the deck's `<head>` tell it about the design:
|
|
106
118
|
|
|
107
119
|
```html
|
|
@@ -116,6 +128,40 @@ numbers will jump where a slide is hidden.
|
|
|
116
128
|
Without them PinDeck runs no footer check and locks only `.slide-foot` and `.logo`. A file called
|
|
117
129
|
`brand.css` in the deck folder is locked for both backends. See `ocean-example/` for a complete deck.
|
|
118
130
|
|
|
131
|
+
### Decks made without reveal.js
|
|
132
|
+
|
|
133
|
+
A deck with a script of its own, or made with a framework whose slides are elements of their own
|
|
134
|
+
(Shower, deck.js, a hand-written deck), can be edited too once it names its slides in its `<head>`:
|
|
135
|
+
|
|
136
|
+
```html
|
|
137
|
+
<meta name="pindeck:slides" content="main.deck > section.slide"> <!-- a CSS selector for the slides -->
|
|
138
|
+
<meta name="pindeck:size" content="1440x1080"> <!-- their size in px -->
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The selector may use tag names, `#id`, `.class`, `[attr]` and `[attr="value"]`, joined by spaces or `>`,
|
|
142
|
+
and a list of them separated by commas. In the editor, PinDeck shows the slides itself, one at a time and
|
|
143
|
+
scaled to the window, and switches off the deck's own scaling, slide keys and inert or hidden marks while
|
|
144
|
+
you edit; that changes nothing in the deck file. Everything in [Working with it](#working-with-it) works as
|
|
145
|
+
with a reveal.js deck, with these differences:
|
|
146
|
+
|
|
147
|
+
* Build steps (`.fragment`) belong to reveal.js: other decks show every element at once.
|
|
148
|
+
* Hidden slides (`data-visibility="hidden"`) are left out of PinDeck's PDF exports; whether presenting
|
|
149
|
+
skips them is up to the deck's own script.
|
|
150
|
+
* Speaker notes are an `<aside class="notes">` in the slide, as in reveal.js. PinDeck hides them in the
|
|
151
|
+
editor; the deck's CSS should hide them too (`aside.notes { display: none }`).
|
|
152
|
+
* **Present** opens the deck at `#<slide id>`, or `#<number>` (counting from 1) for a slide without an id.
|
|
153
|
+
Present may open on the first slide if the deck does not go to slides by their address.
|
|
154
|
+
* **+ New slide** models a new slide on the deck's own (see [Making your own template](#making-your-own-template)):
|
|
155
|
+
a content slide gets placeholder bullets in a `.slide-body`; without one it has only its title. A new or
|
|
156
|
+
duplicated slide does not take the original's `id`.
|
|
157
|
+
|
|
158
|
+
Both tags are needed: PinDeck opens the slides at the given size for screenshots and PDF pages. A missing
|
|
159
|
+
size, or a selector that finds no slides or that PinDeck cannot read, is reported before anything changes.
|
|
160
|
+
While PinDeck runs, it reads the tags again for every screenshot, PDF and assistant session; the editor
|
|
161
|
+
reads them when the slide reloads. If a tag stops working meanwhile, the editor shows the same message and
|
|
162
|
+
the assistant is told. A session already running keeps the slide selector it started with; **New chat**
|
|
163
|
+
starts one with the new selector.
|
|
164
|
+
|
|
119
165
|
## Making your own template
|
|
120
166
|
|
|
121
167
|
PinDeck has no template generator; a template is simply a deck folder. Start from a copy of the example:
|
|
@@ -207,7 +253,7 @@ The v3 preview defaults to **`~/.pindeck-v3`**, keeping v2 settings and conversa
|
|
|
207
253
|
not copy the v2 directory automatically. To run both versions at once, use another port, for example:
|
|
208
254
|
|
|
209
255
|
```bash
|
|
210
|
-
node
|
|
256
|
+
node front.mjs /path/to/a/deck-copy --port 5183 --provider codex
|
|
211
257
|
```
|
|
212
258
|
|
|
213
259
|
## Backend permissions and attachments
|
|
@@ -249,7 +295,7 @@ the `claude` or `codex` CLI installed and logged in under your account there, if
|
|
|
249
295
|
editing by hand still works. Then, in one SSH session on the server:
|
|
250
296
|
|
|
251
297
|
```bash
|
|
252
|
-
node
|
|
298
|
+
node front.mjs path/to/deck --port 5180
|
|
253
299
|
```
|
|
254
300
|
|
|
255
301
|
and on your Mac, in another terminal (`myserver` is your usual SSH host):
|
|
@@ -262,7 +308,7 @@ Open the login link PinDeck printed on the server in the Mac's browser. The loca
|
|
|
262
308
|
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
|
|
263
309
|
port, as long as the address is `127.0.0.1` or `localhost`. If the tunnel drops, a message you have already sent keeps running on the
|
|
264
310
|
server. Restart the tunnel and the editor reconnects and shows the files as they are then; it never sends a
|
|
265
|
-
message twice. If PinDeck restarts, the editor asks you to log in again: run `node
|
|
311
|
+
message twice. If PinDeck restarts, the editor asks you to log in again: run `node front.mjs login
|
|
266
312
|
path/to/deck` on the server for a new link.
|
|
267
313
|
|
|
268
314
|
Other users of a shared server cannot open the editor or read the deck through PinDeck: the token that
|
|
@@ -376,6 +422,10 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
376
422
|
|
|
377
423
|
## How it works
|
|
378
424
|
|
|
425
|
+
* **One port, a server per deck.** `pindeck` starts the front (`front.mjs`): it holds the port, the login
|
|
426
|
+
and the deck list, and serves each open deck at `/d/<deck>/`. Every deck runs its own deck server
|
|
427
|
+
(`server.mjs`) on a private port of 127.0.0.1 with a token of its own; the front passes each request on
|
|
428
|
+
with that token. A deck's server, its assistant and its undo history are those of that deck alone.
|
|
379
429
|
* **Element ids.** On start, the server gives every slide and every visible element a readable
|
|
380
430
|
`data-eid` attribute (`summary`, `summary/callout`, `summary/li-2`). Only the attribute is
|
|
381
431
|
inserted; the rest of the file stays as it was. A backup of the file before the first change is
|
|
@@ -427,7 +477,9 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
427
477
|
|
|
428
478
|
| File | Purpose |
|
|
429
479
|
|---|---|
|
|
430
|
-
| `
|
|
480
|
+
| `front.mjs` | What `pindeck` starts (127.0.0.1 only, random token per run, one-time login links): the login, the deck list and one deck server per open deck at `/d/<deck>/`; `login`. |
|
|
481
|
+
| `server.mjs` | The server for one deck: UI, deck files, events, backend session, undo, CLI endpoints. Behind the front, or on its own (`node server.mjs <deck>`). |
|
|
482
|
+
| `lib/login.mjs`, `lib/deck-target.mjs`, `lib/deck-list.mjs` | Login links and cookies, which deck a path names (and its state folder), and the decks the menu lists. |
|
|
431
483
|
| `cli.mjs` | `layout`, `shot`, `slides`, `pdf handout` / `pdf presentation` and `pdf-figure` commands for the backends (and for you), and `git-setup`. |
|
|
432
484
|
| `public/` | The editor page, and `bridge.js`, which is injected into the deck in edit mode (never saved to it). |
|
|
433
485
|
| `lib/pptx.mjs`, `lib/zip.mjs`, `lib/xml.mjs` | Reading `.pptx` files (no dependencies): slides, text, shapes and their positions, notes. |
|
|
@@ -448,19 +500,39 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
448
500
|
|
|
449
501
|
## Current Limitations
|
|
450
502
|
|
|
451
|
-
* reveal.js decks
|
|
452
|
-
|
|
503
|
+
* reveal.js decks, and decks that name their slides with `<meta name="pindeck:slides">` (see [Decks made
|
|
504
|
+
without reveal.js](#decks-made-without-revealjs)). Any other deck made with another framework (Marp, Slidev,
|
|
505
|
+
impress.js, remark, Spectacle, Shower, deck.js, bespoke.js) is recognised and refused with its name before
|
|
506
|
+
PinDeck changes anything.
|
|
453
507
|
* Text inside inline SVG diagrams cannot be edited in place; pin it and ask the assistant.
|
|
454
508
|
* Parts of an inline SVG move with the whole SVG; inline text (bold, spans) moves with its block.
|
|
455
509
|
* No indenting or outdenting of bullets with `Tab` yet, and `Backspace` at the start of a bullet does not merge it with the one above; ask the assistant for those.
|
|
456
510
|
* Moves are offsets (`translate`), so the space an element came from stays reserved. Ask the assistant to turn hand edits into proper CSS when a layout should really change.
|
|
457
|
-
* The undo history lives on the server and starts fresh when PinDeck restarts
|
|
511
|
+
* The undo history lives on the server and starts fresh when PinDeck restarts, or when a deck closes after
|
|
512
|
+
30 minutes without an open editor.
|
|
458
513
|
* One writer at a time: PinDeck is not made for editing the same deck from two editors or tools at once.
|
|
459
514
|
* Linux (beta): pictures of PDF pages and PowerPoint slides, and waveforms of non-WAV audio, need
|
|
460
515
|
Poppler, LibreOffice and ffmpeg on the server (see [Running on a Linux server](#running-on-a-linux-server-beta)).
|
|
461
516
|
|
|
462
517
|
## Versions
|
|
463
518
|
|
|
519
|
+
* **3.1.0-beta.1**: several decks on one port, and screenshots you can open from the chat.
|
|
520
|
+
- A deck menu: click the deck's name above the slide to open another deck from its folder or the
|
|
521
|
+
asset roots (and one folder down). Each open deck keeps its own chat, assistant session and Undo;
|
|
522
|
+
listing decks changes no file. `pindeck` now starts a small front that holds the port and the
|
|
523
|
+
login and runs one deck server per open deck. A deck with no editor open for 30 minutes closes
|
|
524
|
+
(its Undo history goes, as on a restart) and reopens when chosen again.
|
|
525
|
+
- A folder without `index.html` opens a page listing its decks instead of failing.
|
|
526
|
+
- `pindeck login talk.html` prints a link that opens that deck.
|
|
527
|
+
- Screenshots the assistant took show as a small picture on the chat line; click it to see it large.
|
|
528
|
+
A screenshot PinDeck no longer keeps says so.
|
|
529
|
+
|
|
530
|
+
* **3.0.0-beta.3**: everything from 2.10.0, for both assistants.
|
|
531
|
+
- Decks made without reveal.js: a deck that names its slides with `<meta name="pindeck:slides">`
|
|
532
|
+
and their size with `<meta name="pindeck:size">` is edited like a reveal.js deck (contributed by
|
|
533
|
+
Zeyu Xu). Claude and Codex are both told what the deck's slides are.
|
|
534
|
+
- Screenshots fill the picture instead of being letterboxed into a shorter page.
|
|
535
|
+
|
|
464
536
|
* **3.0.0-beta.2**: everything from 2.9 and 2.10, with Codex brought up to it.
|
|
465
537
|
- Security: the fix from 2.9. The editor page no longer contains the token, and the deck needs it
|
|
466
538
|
too, so other users of the same machine cannot read either. PinDeck prints a one-time login link at
|
|
@@ -480,8 +552,8 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
480
552
|
switching with optional handover, curated model lists, a separate effort selector with per-model
|
|
481
553
|
preferences, restricted Codex tools, and separate v3 runtime state. On npm as `pindeck@beta`.
|
|
482
554
|
|
|
483
|
-
* **2.10
|
|
484
|
-
on the whole deck.
|
|
555
|
+
* **2.10**: decks with their own file names and shared files, decks made without reveal.js, and Claude
|
|
556
|
+
working on the whole deck.
|
|
485
557
|
- A deck file by name: `pindeck path/to/talk.html` (a folder still opens its `index.html`). Several
|
|
486
558
|
decks can share a folder, each with its own chat and undo history.
|
|
487
559
|
- Claude may edit and create other files of the deck (scripts, style sheets, notes), never
|
|
@@ -492,8 +564,14 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
492
564
|
screenshots and PDF exports. A symlink that leads out of the deck and its asset roots is refused.
|
|
493
565
|
- Shared instructions: the deck's `CLAUDE.md` and `AGENTS.md` may import other files with `@path`.
|
|
494
566
|
`CLAUDE.md` files in parent folders are still not loaded, on purpose (see the README).
|
|
495
|
-
- Decks made
|
|
496
|
-
|
|
567
|
+
- Decks made without reveal.js (a script of their own, Shower, deck.js, a hand-written deck): a deck
|
|
568
|
+
that names its slides with `<meta name="pindeck:slides">` (a CSS selector) and their size with
|
|
569
|
+
`<meta name="pindeck:size">` is edited like a reveal.js deck, with screenshots at the slide size and
|
|
570
|
+
one PDF page per slide (contributed by Zeyu Xu). Decks made with other slide frameworks (Marp,
|
|
571
|
+
Slidev, impress.js, remark and others) without these tags are recognised and refused with their
|
|
572
|
+
name, before PinDeck changes anything; the message says how to add the tags.
|
|
573
|
+
- Screenshots fill the picture: headless Chrome lays the page out shorter than its window, which
|
|
574
|
+
left the slide letterboxed in some Chrome versions (reported by Zeyu Xu).
|
|
497
575
|
- Linux: pictures of PDF pages with Poppler, the PowerPoint import with slide pictures from
|
|
498
576
|
LibreOffice, and waveforms of non-WAV audio with ffmpeg, each when installed.
|
|
499
577
|
|
package/front.mjs
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* PinDeck: the front, which is what `pindeck` starts.
|
|
3
|
+
|
|
4
|
+
pindeck [deck-dir | deck.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <dir>]
|
|
5
|
+
pindeck login [deck-dir | deck.html]
|
|
6
|
+
|
|
7
|
+
One port and one login for several decks. Each deck that is open runs its own deck server (server.mjs, on
|
|
8
|
+
a private port of 127.0.0.1 with a token of its own) and is reached here as /d/<deck key>/: the front logs
|
|
9
|
+
the browser in, then passes each request on with that deck's token. A deck starts when it is first asked
|
|
10
|
+
for (from the editor's deck menu, or a bookmark), so its chat, Claude session and undo stay while it is
|
|
11
|
+
open, and Claude can go on working in one deck while you edit another. A deck with no editor open for
|
|
12
|
+
PINDECK_IDLE_MS (30 minutes) is closed; it opens again when asked for.
|
|
13
|
+
|
|
14
|
+
The menu lists the decks in the start folder and the asset roots, and in their folders one level down
|
|
15
|
+
(lib/deck-list.mjs). Only those can be opened: there is no free path entry. */
|
|
16
|
+
|
|
17
|
+
import http from "node:http";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import crypto from "node:crypto";
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { isSupportedPlatform, detectCapabilities, publicCapabilities, parsePort } from "./lib/capabilities.mjs";
|
|
25
|
+
import { deckTarget, homeDir } from "./lib/deck-target.mjs";
|
|
26
|
+
import { goodHost, LOGIN_PAGE, createLogin } from "./lib/login.mjs";
|
|
27
|
+
import { listDecks } from "./lib/deck-list.mjs";
|
|
28
|
+
import { USAGE } from "./lib/usage.mjs";
|
|
29
|
+
|
|
30
|
+
const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const SERVER = path.join(EDITOR_DIR, "server.mjs");
|
|
32
|
+
const HOME_DIR = homeDir();
|
|
33
|
+
const IDLE_MS = Number(process.env.PINDECK_IDLE_MS) || 30 * 60000;
|
|
34
|
+
|
|
35
|
+
const argv = process.argv.slice(2);
|
|
36
|
+
if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
|
|
37
|
+
const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
|
|
38
|
+
const portArg = opt("--port", process.env.PORT || 5180);
|
|
39
|
+
const PASS = []; // what every deck server gets: the backend and the asset roots
|
|
40
|
+
for (const name of ["--provider", "--model"]) { const v = opt(name); if (v !== undefined) PASS.push(name, v); }
|
|
41
|
+
const assetArgs = [];
|
|
42
|
+
for (let a; (a = opt("--asset-root")) !== undefined;) { assetArgs.push(a); PASS.push("--asset-root", a); }
|
|
43
|
+
let PORT = parsePort(portArg); // the port actually bound once listening (--port 0 picks a free one)
|
|
44
|
+
if (PORT === null) { console.error(`Invalid port "${portArg}": use a number from 1 to 65535.\n\n${USAGE}`); process.exit(2); }
|
|
45
|
+
if (!isSupportedPlatform(process.platform)) {
|
|
46
|
+
const { showPlatformNotice } = await import("./lib/platform-notice.mjs");
|
|
47
|
+
await showPlatformNotice(PORT);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* pindeck login: a new one-time login link from the running front, asked with the token in its server file
|
|
52
|
+
(readable only by this user). With a deck, the link opens that deck. A deck server started on its own
|
|
53
|
+
(node server.mjs) for that deck answers instead, as before the front. */
|
|
54
|
+
const SERVER_FILE = path.join(HOME_DIR, "server.json");
|
|
55
|
+
if (argv[0] === "login") {
|
|
56
|
+
const deck = argv[1] ? deckTarget(argv[1]) : null;
|
|
57
|
+
let info = null;
|
|
58
|
+
for (const f of [...(deck ? [path.join(HOME_DIR, "decks", deck.key, "server.json")] : []), SERVER_FILE]) {
|
|
59
|
+
try { info = JSON.parse(fs.readFileSync(f, "utf8")); break; } catch {}
|
|
60
|
+
}
|
|
61
|
+
if (!info) { console.error("No PinDeck is running."); process.exit(1); }
|
|
62
|
+
const next = deck && info.front ? `/d/${deck.key}/` : "/";
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(`${info.url}/api/login-link?` + new URLSearchParams({ next }), { headers: { "x-editor-token": info.token } });
|
|
65
|
+
if (!res.ok) throw new Error(res.status);
|
|
66
|
+
console.log(`PinDeck (${deck ? path.join(deck.dir, deck.file) : info.deckDir}): ${(await res.json()).url}`);
|
|
67
|
+
process.exit(0);
|
|
68
|
+
} catch {
|
|
69
|
+
console.error(`The PinDeck at ${info.url} does not answer. Is it still running?`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* The start deck, and the folders whose decks the menu offers. A folder without index.html opens the list
|
|
75
|
+
of its decks; if it has none, its deck server says why, as for any deck that cannot be opened. */
|
|
76
|
+
const START = deckTarget(argv[0] || process.cwd());
|
|
77
|
+
const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
|
|
78
|
+
let fileConfig = {};
|
|
79
|
+
try { fileConfig = JSON.parse(fs.readFileSync(path.join(HOME_DIR, "config.json"), "utf8")); } catch {}
|
|
80
|
+
const ROOTS = [START.dir, ...assetArgs, ...(Array.isArray(fileConfig.assetRoots) ? fileConfig.assetRoots : [])]
|
|
81
|
+
.map((r) => path.resolve(expandHome(String(r))))
|
|
82
|
+
.filter((r, i, all) => all.indexOf(r) === i && (() => { try { return fs.statSync(r).isDirectory(); } catch { return false; } })());
|
|
83
|
+
const decksNow = () => listDecks(ROOTS);
|
|
84
|
+
const chooser = !START.named && !fs.existsSync(path.join(START.dir, START.file)) && decksNow().some((d) => !d.problem);
|
|
85
|
+
|
|
86
|
+
const TOKEN = crypto.randomBytes(16).toString("hex");
|
|
87
|
+
let ORIGIN = `http://127.0.0.1:${PORT}`; // set again from the bound port once listening
|
|
88
|
+
const { loginCode, loginUrl, authed, login } = createLogin({ token: TOKEN, port: () => PORT, origin: () => ORIGIN });
|
|
89
|
+
const CAPS = detectCapabilities();
|
|
90
|
+
|
|
91
|
+
/* ---- Deck servers -------------------------------------------------------------------------------- */
|
|
92
|
+
const decks = new Map(); // key -> { key, dir, file, label, proc, url, token, ready, exited, sse, last }
|
|
93
|
+
const labelOf = (d) => (d.file === "index.html" ? path.basename(d.dir) : d.file);
|
|
94
|
+
let quitting = false;
|
|
95
|
+
|
|
96
|
+
/* Its output goes to this terminal, each line marked with the deck. hold(): keep the lines back for now */
|
|
97
|
+
function forward(stream, label, to, keep, hold = () => false) {
|
|
98
|
+
let rest = "";
|
|
99
|
+
const put = (l) => { keep?.(l + "\n"); if (!hold()) to.write(`[${label}] ${l}\n`); };
|
|
100
|
+
stream.on("data", (chunk) => {
|
|
101
|
+
const lines = (rest + chunk).split("\n");
|
|
102
|
+
rest = lines.pop();
|
|
103
|
+
lines.forEach(put);
|
|
104
|
+
});
|
|
105
|
+
stream.on("end", () => { if (rest) put(rest); });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* The deck's server, started once: the promise resolves when it listens, or rejects with what it printed.
|
|
109
|
+
The start deck's complaints are printed by the front as they are, like those of PinDeck before the front. */
|
|
110
|
+
function openDeck(entry, start = false) {
|
|
111
|
+
const known = decks.get(entry.key);
|
|
112
|
+
if (known) return known.ready;
|
|
113
|
+
const d = { key: entry.key, dir: entry.dir, file: entry.file, label: labelOf(entry), sse: 0, last: Date.now() };
|
|
114
|
+
decks.set(d.key, d);
|
|
115
|
+
// its own process group: Ctrl+C reaches the front only, which then ends every deck once
|
|
116
|
+
d.proc = spawn(process.execPath, [SERVER, entry.file === "index.html" ? entry.dir : path.join(entry.dir, entry.file),
|
|
117
|
+
"--port", "0", ...PASS], { env: { ...process.env, PINDECK_FRONT: "1" }, stdio: ["ignore", "pipe", "pipe", "ipc"], detached: true });
|
|
118
|
+
let said = "";
|
|
119
|
+
forward(d.proc.stdout, d.label, process.stdout);
|
|
120
|
+
forward(d.proc.stderr, d.label, process.stderr, (l) => { said += l; }, () => start && !d.url);
|
|
121
|
+
d.exited = new Promise((r) => d.proc.once("exit", r));
|
|
122
|
+
d.ready = new Promise((resolve, reject) => {
|
|
123
|
+
d.proc.on("message", (m) => {
|
|
124
|
+
if (!m?.ready) return;
|
|
125
|
+
Object.assign(d, { url: m.ready.url, token: m.ready.token });
|
|
126
|
+
if (!start) console.log(`[${d.label}] opened: ${path.join(d.dir, d.file)}`);
|
|
127
|
+
resolve(d);
|
|
128
|
+
});
|
|
129
|
+
d.proc.once("exit", (code, signal) => {
|
|
130
|
+
if (decks.get(d.key) === d) decks.delete(d.key);
|
|
131
|
+
if (d.url && !quitting) console.log(`[${d.label}] closed`);
|
|
132
|
+
reject(Object.assign(new Error(said.trim() || `The deck server stopped (${code ?? signal}).`), { exitCode: code || 1 }));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
d.ready.catch(() => {});
|
|
136
|
+
return d.ready;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function stopDeck(d) {
|
|
140
|
+
try { d.proc.kill("SIGTERM"); } catch {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* A deck with no editor open (no event stream) and nothing to do for IDLE_MS is closed */
|
|
144
|
+
setInterval(async () => {
|
|
145
|
+
for (const d of decks.values()) {
|
|
146
|
+
if (!d.url || d.sse > 0 || Date.now() - d.last < IDLE_MS) continue;
|
|
147
|
+
try {
|
|
148
|
+
const res = await fetch(`${d.url}/api/status`, { headers: { "x-editor-token": d.token } });
|
|
149
|
+
const st = await res.json();
|
|
150
|
+
if (st.busy || d.sse > 0 || Date.now() - d.last < IDLE_MS) continue;
|
|
151
|
+
} catch { continue; }
|
|
152
|
+
console.log(`[${d.label}] no editor open for ${Math.round(IDLE_MS / 60000)} min: closing it (it opens again when asked for)`);
|
|
153
|
+
stopDeck(d);
|
|
154
|
+
}
|
|
155
|
+
}, Math.min(60000, IDLE_MS / 2)).unref();
|
|
156
|
+
|
|
157
|
+
/* The request, without the front's cookie and with the deck's token, to the deck's server; the answer
|
|
158
|
+
streamed back (event streams, media ranges and large uploads included) */
|
|
159
|
+
function proxy(req, res, d, rest) {
|
|
160
|
+
d.last = Date.now();
|
|
161
|
+
const headers = { ...req.headers, host: new URL(d.url).host, "x-editor-token": d.token };
|
|
162
|
+
delete headers.cookie;
|
|
163
|
+
delete headers.origin; // checked here against the front's address; the deck server checks its own
|
|
164
|
+
const up = http.request(d.url + rest, { method: req.method, headers }, (r) => {
|
|
165
|
+
res.writeHead(r.statusCode, r.headers);
|
|
166
|
+
r.pipe(res);
|
|
167
|
+
});
|
|
168
|
+
up.on("error", () => { if (!res.headersSent) page(res, 502, "This deck stopped answering", "Reload the page to open it again."); else res.destroy(); });
|
|
169
|
+
if (rest.split("?")[0] === "/api/events") {
|
|
170
|
+
d.sse++;
|
|
171
|
+
res.on("close", () => { d.sse--; d.last = Date.now(); });
|
|
172
|
+
}
|
|
173
|
+
res.on("close", () => { if (!res.writableFinished) up.destroy(); });
|
|
174
|
+
req.pipe(up);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* ---- HTTP ------------------------------------------------------------------------------------- */
|
|
178
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
179
|
+
const text = (res, code, s) => { res.writeHead(code, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); res.end(s); };
|
|
180
|
+
const json = (res, code, o) => { res.writeHead(code, { "content-type": "application/json", "cache-control": "no-store" }); res.end(JSON.stringify(o)); };
|
|
181
|
+
function page(res, code, title, body) {
|
|
182
|
+
res.writeHead(code, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
183
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>${esc(title)} · PinDeck</title>
|
|
184
|
+
<link rel="icon" href="/editor/favicon.svg" type="image/svg+xml"><link rel="stylesheet" href="/editor/front.css">
|
|
185
|
+
<main class="front"><h1>${esc(title)}</h1>${body}</main>`);
|
|
186
|
+
}
|
|
187
|
+
/* The decks for the menu (and the start page without a start deck), with which are open and working */
|
|
188
|
+
const deckList = () => decksNow().map((e) => ({ key: e.key, rel: e.rel, root: e.root, problem: e.problem,
|
|
189
|
+
open: !!decks.get(e.key)?.url }));
|
|
190
|
+
function chooserPage(res) {
|
|
191
|
+
const list = deckList(), groups = new Map(ROOTS.map((r) => [r, list.filter((d) => d.root === r)]).filter(([, l]) => l.length));
|
|
192
|
+
const body = [...groups].map(([root, list]) => `<h2>${esc(root)}</h2><ul>${list.map((d) => d.problem
|
|
193
|
+
? `<li class="off" title="${esc(d.problem)}">${esc(d.rel)}<small>${esc(d.problem)}</small></li>`
|
|
194
|
+
: `<li><a href="/d/${esc(d.key)}/">${esc(d.rel)}</a>${d.open ? " <small>open</small>" : ""}</li>`).join("")}</ul>`).join("");
|
|
195
|
+
page(res, 200, "Decks", body || "<p>No decks here.</p>");
|
|
196
|
+
}
|
|
197
|
+
const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8",
|
|
198
|
+
svg: "image/svg+xml", png: "image/png" };
|
|
199
|
+
function serveEditorFile(res, rel) {
|
|
200
|
+
const base = path.join(EDITOR_DIR, "public");
|
|
201
|
+
const f = path.resolve(base, "." + path.posix.normalize("/" + decodeURIComponent(rel)));
|
|
202
|
+
if (!f.startsWith(base + path.sep)) return text(res, 403, "Forbidden");
|
|
203
|
+
fs.readFile(f, (err, data) => {
|
|
204
|
+
if (err) return text(res, 404, "Not found");
|
|
205
|
+
res.writeHead(200, { "content-type": MIME[path.extname(f).slice(1)] || "application/octet-stream", "cache-control": "no-store" });
|
|
206
|
+
res.end(data);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const server = http.createServer(async (req, res) => {
|
|
211
|
+
const host = String(req.headers.host || "").toLowerCase();
|
|
212
|
+
if (!goodHost(host)) return text(res, 403, "Bad host");
|
|
213
|
+
const url = new URL(req.url, ORIGIN);
|
|
214
|
+
const p = url.pathname;
|
|
215
|
+
try {
|
|
216
|
+
if (req.method === "GET" && p === "/login") return login(res, url);
|
|
217
|
+
// the editor's own scripts and styles hold nothing secret; the deck page loads bridge.js from here
|
|
218
|
+
if (req.method === "GET" && p.startsWith("/editor/")) return serveEditorFile(res, p.slice(8));
|
|
219
|
+
if (!authed(req)) {
|
|
220
|
+
if (req.method === "GET" && /^\/(d\/[^/]+\/(index\.html)?)?$/.test(p)) {
|
|
221
|
+
res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
222
|
+
return res.end(LOGIN_PAGE);
|
|
223
|
+
}
|
|
224
|
+
return text(res, 403, "Not logged in");
|
|
225
|
+
}
|
|
226
|
+
const origin = req.headers.origin;
|
|
227
|
+
if (req.method === "POST" && origin && origin.toLowerCase() !== `http://${host}`) return text(res, 403, "Bad origin");
|
|
228
|
+
|
|
229
|
+
if (p === "/") {
|
|
230
|
+
if (chooser) return chooserPage(res);
|
|
231
|
+
res.writeHead(302, { location: `/d/${START.key}/`, "cache-control": "no-store" });
|
|
232
|
+
return res.end();
|
|
233
|
+
}
|
|
234
|
+
if (p === "/api/ping") return json(res, 200, { ok: true, front: true });
|
|
235
|
+
if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000), url.searchParams.get("next") || "/") });
|
|
236
|
+
if (p === "/api/decks") return json(res, 200, { decks: deckList() });
|
|
237
|
+
|
|
238
|
+
const m = /^\/d\/([\w.-]+)(\/.*)?$/.exec(p);
|
|
239
|
+
if (!m) return text(res, 404, "Not found");
|
|
240
|
+
if (!m[2]) {
|
|
241
|
+
res.writeHead(301, { location: `/d/${m[1]}/${url.search}` });
|
|
242
|
+
return res.end();
|
|
243
|
+
}
|
|
244
|
+
let d = decks.get(m[1]);
|
|
245
|
+
if (!d) {
|
|
246
|
+
// only a deck from the list, and only one PinDeck can open
|
|
247
|
+
const entry = START.key === m[1] && !chooser ? { key: START.key, dir: START.dir, file: START.file }
|
|
248
|
+
: decksNow().find((e) => e.key === m[1]);
|
|
249
|
+
if (!entry) return page(res, 404, "No such deck", `<p>It is not in the deck folder or an asset root (any more).</p><p><a href="/">All decks</a></p>`);
|
|
250
|
+
if (entry.problem) return page(res, 409, "PinDeck cannot open this deck", `<p>${esc(entry.problem)}</p><p><a href="/">All decks</a></p>`);
|
|
251
|
+
d = entry;
|
|
252
|
+
}
|
|
253
|
+
try { d = await openDeck(d); }
|
|
254
|
+
catch (e) { return page(res, 502, "PinDeck could not open this deck", `<pre>${esc(e.message)}</pre><p><a href="/">All decks</a></p>`); }
|
|
255
|
+
return proxy(req, res, d, m[2] + url.search);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
if (!res.headersSent) text(res, 500, String(e.message || e));
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
/* ---- Start ----------------------------------------------------------------------------------------
|
|
262
|
+
The port is bound first, then the start deck opens (its server checks it before writing anything). If it
|
|
263
|
+
cannot, the front says why and stops: as before the front, nothing is written. */
|
|
264
|
+
function writeServerInfo() {
|
|
265
|
+
fs.mkdirSync(HOME_DIR, { recursive: true, mode: 0o700 });
|
|
266
|
+
fs.writeFileSync(SERVER_FILE, JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: START.dir, pid: process.pid, front: true }, null, 2), { mode: 0o600 });
|
|
267
|
+
fs.chmodSync(SERVER_FILE, 0o600);
|
|
268
|
+
}
|
|
269
|
+
function removeServerInfo() {
|
|
270
|
+
try { if (JSON.parse(fs.readFileSync(SERVER_FILE, "utf8")).token === TOKEN) fs.rmSync(SERVER_FILE, { force: true }); } catch {}
|
|
271
|
+
}
|
|
272
|
+
let listening = false;
|
|
273
|
+
server.on("error", (e) => {
|
|
274
|
+
if (listening) return console.error(e);
|
|
275
|
+
console.error(e.code === "EADDRINUSE"
|
|
276
|
+
? `Port ${PORT} is in use (another PinDeck, or an SSH tunnel?). Choose another with --port, the same on both ends of a tunnel.`
|
|
277
|
+
: `Cannot listen on 127.0.0.1:${PORT}: ${e.message}`);
|
|
278
|
+
process.exit(1);
|
|
279
|
+
});
|
|
280
|
+
server.listen(PORT, "127.0.0.1", async () => {
|
|
281
|
+
listening = true;
|
|
282
|
+
PORT = server.address().port;
|
|
283
|
+
ORIGIN = `http://127.0.0.1:${PORT}`;
|
|
284
|
+
if (!chooser) {
|
|
285
|
+
try { await openDeck({ key: START.key, dir: START.dir, file: START.file }, true); }
|
|
286
|
+
catch (e) {
|
|
287
|
+
console.error(e.message.replace(/\n+$/, ""));
|
|
288
|
+
process.exit(e.exitCode); // 2 for a bad argument, as the deck server says
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
writeServerInfo();
|
|
292
|
+
console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
|
|
293
|
+
console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
|
|
294
|
+
console.log(chooser ? `Decks: ${START.dir}` : `Deck: ${START.dir}`);
|
|
295
|
+
console.log(`Server: ${CAPS.label}`);
|
|
296
|
+
for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
|
|
297
|
+
if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
/* Ctrl+C or SIGTERM: every deck ends as it does on its own (its assistant gets end of input; at most 6 s),
|
|
301
|
+
then the front. A second Ctrl+C ends them at once. */
|
|
302
|
+
async function quit() {
|
|
303
|
+
if (quitting) {
|
|
304
|
+
for (const d of decks.values()) try { process.kill(-d.proc.pid, "SIGKILL"); } catch {}
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
quitting = true;
|
|
308
|
+
server.close();
|
|
309
|
+
server.closeAllConnections?.();
|
|
310
|
+
removeServerInfo();
|
|
311
|
+
const all = [...decks.values()];
|
|
312
|
+
all.forEach(stopDeck);
|
|
313
|
+
await Promise.race([Promise.all(all.map((d) => d.exited)), new Promise((r) => setTimeout(r, 7000))]);
|
|
314
|
+
for (const d of all) if (d.proc.exitCode === null && d.proc.signalCode === null) try { process.kill(-d.proc.pid, "SIGKILL"); } catch {}
|
|
315
|
+
process.exit(0);
|
|
316
|
+
}
|
|
317
|
+
process.on("SIGINT", quit);
|
|
318
|
+
process.on("SIGTERM", quit);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* The decks the front offers in the editor's deck menu: .html files in the start folder and the asset roots,
|
|
2
|
+
and in their folders one level down. Reading only: a deck gets its element ids (and PinDeck writes its
|
|
3
|
+
state) only once it is opened. A file PinDeck cannot open is listed with the reason, as at start-up. */
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { deckTarget } from "./deck-target.mjs";
|
|
8
|
+
import { unsupportedMessage } from "./frameworks.mjs";
|
|
9
|
+
|
|
10
|
+
const SKIP = new Set(["node_modules", "export"]); // dependencies, and PinDeck's own PDF exports
|
|
11
|
+
const MAX_FILES = 300;
|
|
12
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
const htmlFiles = (dir) => {
|
|
15
|
+
try {
|
|
16
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
17
|
+
.filter((e) => e.isFile() && /\.html?$/i.test(e.name) && !e.name.startsWith(".")).map((e) => e.name).sort();
|
|
18
|
+
} catch { return []; }
|
|
19
|
+
};
|
|
20
|
+
const subfolders = (dir) => {
|
|
21
|
+
try {
|
|
22
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
23
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP.has(e.name)).map((e) => e.name).sort();
|
|
24
|
+
} catch { return []; }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/* [{ key, dir, file, rel, root, problem }]: rel is the path shown in the menu, relative to its root;
|
|
28
|
+
problem is null for a deck PinDeck can open. A file under several roots is listed once, under the first. */
|
|
29
|
+
export function listDecks(roots) {
|
|
30
|
+
const out = [], seen = new Set();
|
|
31
|
+
for (const root of roots) {
|
|
32
|
+
for (const dir of [root, ...subfolders(root).map((d) => path.join(root, d))]) {
|
|
33
|
+
for (const file of htmlFiles(dir)) {
|
|
34
|
+
const abs = path.join(dir, file);
|
|
35
|
+
if (seen.has(abs) || out.length >= MAX_FILES) continue;
|
|
36
|
+
seen.add(abs);
|
|
37
|
+
let problem = null;
|
|
38
|
+
try {
|
|
39
|
+
const size = fs.statSync(abs).size;
|
|
40
|
+
problem = size > MAX_BYTES ? `${file} is larger than 5 MB, too large for a deck.`
|
|
41
|
+
: unsupportedMessage(fs.readFileSync(abs, "utf8"), file)?.replace(/ Nothing was changed\.$/, "") || null;
|
|
42
|
+
} catch (e) { problem = `${file} cannot be read (${e.code || e.message}).`; }
|
|
43
|
+
const t = deckTarget(file === "index.html" ? dir : abs);
|
|
44
|
+
out.push({ key: t.key, dir, file, rel: path.relative(root, abs).split(path.sep).join("/"), root, problem });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/* Which deck a path names, and where PinDeck keeps that deck's state: shared by the front (pindeck), which
|
|
2
|
+
lists decks and addresses them as /d/<key>/, and the deck server, which keeps its chat and undo there. */
|
|
3
|
+
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
|
|
9
|
+
export const homeDir = (env = process.env) => env.PINDECK_HOME || path.join(os.homedir(), ".pindeck-v3");
|
|
10
|
+
|
|
11
|
+
/* A file (talk.html), or a folder, which means its index.html. A path ending in .html or .htm that is
|
|
12
|
+
not a folder is a file, so a missing file gets a clear message. */
|
|
13
|
+
export function deckTarget(target) {
|
|
14
|
+
const abs = path.resolve(target);
|
|
15
|
+
const isFolder = (() => { try { return fs.statSync(abs).isDirectory(); } catch { return false; } })();
|
|
16
|
+
const dir = isFolder || !/\.html?$/i.test(abs) ? abs : path.dirname(abs);
|
|
17
|
+
const file = dir === abs ? "index.html" : path.basename(abs);
|
|
18
|
+
const named = file !== "index.html"; // a deck file with its own name; several can share a folder
|
|
19
|
+
const name = named ? file.replace(/\.html?$/i, "") : path.basename(dir); // for exports and titles
|
|
20
|
+
/* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
|
|
21
|
+
histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
|
|
22
|
+
const key = (named ? `${path.basename(dir)}-${name}` : path.basename(dir)).replace(/[^\w.-]+/g, "_") + "-" +
|
|
23
|
+
crypto.createHash("sha1").update(named ? path.join(dir, file) : dir).digest("hex").slice(0, 8);
|
|
24
|
+
return { dir, file, named, name, key };
|
|
25
|
+
}
|