diff --git a/CHANGELOG.md b/CHANGELOG.md index 63e3211..0bde704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Save and load projects.** A versioned project file holds the whole job — objects, + placements, pens per object, paper, mode and magnets — plus the pen library it refers to, + so a drawing opened on another machine still comes out in the right colours. Save to your + own device as a file, or to the plotter, which now keeps a library every connected client + can list, open and delete (`PLOTTER_PROJECTS`, default `gateway/projects/`). Project names + are sanitised before they become paths on the Pi, and the resolved path is checked against + the projects directory as well. - **Import from anything.** One import that identifies a file by its *contents* rather than its name — operators rename files, and a phone hands over an `image.jpg` that is really HEIC. **PDF** is now a first-class source: a vector page imports as lines at the size the diff --git a/README.md b/README.md index a018856..7958461 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ unattended **Raspberry Pi** setup needs. Confirm and it carries on exactly where it stopped. The pause is a G-code comment the streamer holds on — not `M0` — so the hold belongs to the daemon: reload the page, or walk up with a phone, and the prompt is still there to answer. +- **Save and open projects.** A project is the whole job — objects, placements, pens, paper, + mode and magnets, plus the pens it refers to. Keep it as a file on your own machine, or + store it on the plotter, where it joins a library every connected device can see and open. - **Magnets.** The bed has no vacuum, so tell the app where the hold-down magnets are: they're drawn as keep-out circles you can drag, **Suggest** offers clear positions at the sheet's edges, and **pen-up travel is routed around them** — the detour is drawn on the @@ -180,6 +183,7 @@ On macOS the daemon automatically runs `caffeinate -dimsu` for its lifetime so i | `PLOTTER_STATE` | `gateway/.plotter-state.json` | Where the remembered position is persisted | | `PLOTTER_SESSION` | `gateway/.session.json` | Where the shared editable session (artwork + page) is persisted | | `PLOTTER_APP_SETTINGS` | `gateway/.app-settings.json` | Where app settings (machine setup, preferences) are persisted — shared by every client | +| `PLOTTER_PROJECTS` | `gateway/projects/` | Where saved projects are stored, one file per project | | `GATEWAY_ALLOWED_ORIGINS` | _(none)_ | Extra browser origins allowed to open the WebSocket, comma-separated. Same-origin always passes; add `http://localhost:5173` when driving a live daemon from the Vite dev server | ## Registration (cut to a printed sticker) diff --git a/gateway/server.ts b/gateway/server.ts index ed020b0..88d5e63 100644 --- a/gateway/server.ts +++ b/gateway/server.ts @@ -1,6 +1,6 @@ import { createServer, type IncomingMessage } from 'node:http'; import { spawn } from 'node:child_process'; -import { readFile, writeFile, rename } from 'node:fs/promises'; +import { readFile, writeFile, rename, readdir, mkdir, unlink, stat } from 'node:fs/promises'; import { readFileSync, writeFileSync, renameSync, openSync, fsyncSync, closeSync } from 'node:fs'; import { dirname, extname, join, normalize } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -9,7 +9,14 @@ import { GrblController } from '../src/grbl/GrblController'; import { NodeSerialTransport } from './NodeSerialTransport'; import { isOriginAllowed, parseAllowedOrigins } from './origin'; import { DEFAULT_GATEWAY_PORT } from '../src/gateway/protocol'; -import type { ClientMessage, Snapshot, StreamDebug, UpdateStatus } from '../src/gateway/protocol'; +import type { + ClientMessage, + ProjectSummary, + Snapshot, + StreamDebug, + UpdateStatus, +} from '../src/gateway/protocol'; +import { projectFileName, sanitizeProjectName } from '../src/plot/project'; import { appSettingsFromLegacySession, normalizeAppSettings, @@ -47,6 +54,10 @@ const SESSION_FILE = const APP_SETTINGS_FILE = process.env.PLOTTER_APP_SETTINGS ?? join(fileURLToPath(new URL('.', import.meta.url)), '.app-settings.json'); +// Saved projects live on the Pi so it holds a library of plots any client can +// open — one file per project, in a directory of their own. +const PROJECTS_DIR = + process.env.PLOTTER_PROJECTS ?? join(fileURLToPath(new URL('.', import.meta.url)), 'projects'); // ---- self-update config ---- // Where the update oneshot records its progress; the daemon reads it back after @@ -166,6 +177,47 @@ function saveAppSettings(raw: unknown) { .catch(() => undefined); } +// ---- stored projects ---- +const PROJECT_SUFFIX = '.plot.json'; + +/** List stored projects, newest first. Never throws: a missing directory is empty. */ +async function listProjects(): Promise { + try { + const names = await readdir(PROJECTS_DIR); + const out: ProjectSummary[] = []; + for (const file of names) { + if (!file.endsWith(PROJECT_SUFFIX)) continue; + const info = await stat(join(PROJECTS_DIR, file)).catch(() => null); + out.push({ + name: file.slice(0, -PROJECT_SUFFIX.length), + savedAt: info ? info.mtime.toISOString() : '', + }); + } + return out.sort((a, b) => b.savedAt.localeCompare(a.savedAt)); + } catch { + return []; + } +} + +/** + * The path a project is stored at, or null if the name is unusable. + * + * The name arrives over the WebSocket, so it is sanitised (shared with the + * client, and unit-tested there) and then checked *again* against the resolved + * path: defence in depth, because this is the one place a remote string + * becomes a filesystem path on the Pi. + */ +function projectPath(name: string): string | null { + const safe = sanitizeProjectName(name); + if (!safe) return null; + const path = join(PROJECTS_DIR, projectFileName(safe)); + return path.startsWith(PROJECTS_DIR + '/') ? path : null; +} + +async function broadcastProjects(): Promise { + broadcast({ type: 'event', event: 'projects', payload: await listProjects() }); +} + /** * Restore the remembered work position after (re)connecting — a port open resets * the controller and (with no homing) it forgets where it is. Telling it the @@ -419,9 +471,15 @@ function snapshot(ws: WebSocket): Snapshot { session, appSettings, penChange: ctrl.penChange, + // Filled in by the caller: listing the directory is async, and a snapshot + // has to be ready the moment a client attaches. + projects: knownProjects, }; } +// Cached listing, refreshed whenever a project is written or removed. +let knownProjects: ProjectSummary[] = []; + function releaseControlOnClose(ws: WebSocket) { clients.delete(ws); if (controller === ws) { @@ -492,6 +550,53 @@ async function handleCommand(ws: WebSocket, msg: ClientMessage) { case 'continueProgram': ctrl.continueProgram(); break; + case 'saveProject': { + const path = projectPath(msg.name); + if (!path) { + send(ws, { type: 'cmdError', id, message: 'That project name cannot be used.' }); + return; + } + await mkdir(PROJECTS_DIR, { recursive: true }); + // Atomic: a project half-written by a power cut would be unopenable, + // and the operator would not know until they came to plot it. + const tmp = `${path}.tmp`; + await writeFile(tmp, JSON.stringify(msg.project)); + await rename(tmp, path); + knownProjects = await listProjects(); + await broadcastProjects(); + break; + } + case 'loadProject': { + const path = projectPath(msg.name); + if (!path) { + send(ws, { type: 'cmdError', id, message: 'No such project.' }); + return; + } + const raw = await readFile(path, 'utf8').catch(() => null); + if (raw === null) { + send(ws, { type: 'cmdError', id, message: `No project named "${msg.name}".` }); + return; + } + // Only to the client that asked: opening a project replaces what is on + // screen, which is not something to do to another operator's session. + send(ws, { + type: 'event', + event: 'projectLoaded', + payload: { name: msg.name, project: JSON.parse(raw) }, + }); + break; + } + case 'deleteProject': { + const path = projectPath(msg.name); + if (!path) { + send(ws, { type: 'cmdError', id, message: 'No such project.' }); + return; + } + await unlink(path).catch(() => undefined); + knownProjects = await listProjects(); + await broadcastProjects(); + break; + } case 'saveAppSettings': saveAppSettings(msg.settings); // Push to the *other* clients so every device shows one setup. Echoing @@ -625,6 +730,10 @@ for (const sig of ['SIGINT', 'SIGTERM'] as const) { }); } +void listProjects().then((list) => { + knownProjects = list; +}); + httpServer.listen(PORT, HOST, () => { log(`PenPlotter271 gateway v${APP_VERSION}`); log( diff --git a/openspec/changes/archive/2026-09-12-projects/.openspec.yaml b/openspec/changes/archive/2026-09-12-projects/.openspec.yaml new file mode 100644 index 0000000..2b596d1 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-projects/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-12 diff --git a/openspec/changes/archive/2026-09-12-projects/design.md b/openspec/changes/archive/2026-09-12-projects/design.md new file mode 100644 index 0000000..40119e2 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-projects/design.md @@ -0,0 +1,39 @@ +## Context + +The session already round-trips through the daemon as an opaque blob, which is most of the +machinery a project needs. What it lacks is identity (a name), plurality (a library), and the pens +the artwork refers to. + +## Decisions + +- **A project carries its pens.** Artwork names pens by id; a project opened on another machine — + or after the library has been edited — would otherwise render in whatever pens happen to exist. + Opening merges the project's pens into the library rather than replacing it, so nothing the + operator has defined disappears. +- **Versioned from the first release.** These files outlive the app that wrote them, and "we will + add a version when we need one" means the first file that needs it cannot be read. A file from a + *newer* version is refused with a message rather than being opened with fields silently dropped. +- **`readProject` returns a reason instead of throwing.** Every caller wants to show it: "that is + not a project file" is the useful answer to opening the wrong JSON. +- **One session builder.** The persist effect and the project saver use the same function. Two + copies of that field list would drift, and the field someone forgets to add to the second one is + a field that quietly vanishes from every saved project. +- **Name sanitisation is a security boundary, and is checked twice.** The name arrives over a + socket with no authentication and becomes a filesystem path on the Pi. It is reduced to a safe + name by a pure, unit-tested function shared with the client, and the daemon then verifies the + *resolved* path is inside the projects directory before writing. Either check alone would do; + having both means a bug in one is not a way out of the directory. +- **A load goes only to the client that asked.** Opening a project replaces what is on screen — + not something to do to another operator's session because someone else clicked a name. +- **Saves are atomic.** A project half-written by a power cut would be unopenable, and the operator + would not find out until they came to plot it. +- **The listing is cached and broadcast.** A snapshot has to be ready the instant a client attaches, + and listing a directory is asynchronous. + +## Risks / Trade-offs + +- A project stores the artwork's *geometry*, not the file it was imported from, so reopening gives + the drawing back but not the ability to retune an import's conversion. That matches the session, + and keeping megabytes of source per project on a Pi's SD card is not obviously better. +- There is no rename, and saving under an existing name overwrites it. Both are easy to add once + it is clear how operators actually organise these. diff --git a/openspec/changes/archive/2026-09-12-projects/proposal.md b/openspec/changes/archive/2026-09-12-projects/proposal.md new file mode 100644 index 0000000..c5f9194 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-projects/proposal.md @@ -0,0 +1,35 @@ +## Why + +Drawings were transient. The session persists — on the Pi, shared with every client — but there is +exactly one of it: importing the next job overwrites the last one, and there is no way to put a +plot aside and come back to it. A headless machine in a workshop should hold a library of plots, +not the most recent one. + +## What Changes + +- **A versioned project file** (`src/plot/project.ts`): the whole job — objects, placements, pens + per object, paper, mode, magnets — plus the pen library it refers to, because artwork names pens + by id and a project opened elsewhere would otherwise come out in the wrong colours. +- **Save to this device**: a file the operator keeps, and can open again. +- **Save to the plotter**: the daemon stores projects in a directory of their own, lists them to + every client, serves one to the client that asks, and deletes on request. +- **Names are sanitised, twice.** The name arrives over the WebSocket and becomes a path on the Pi; + it is reduced to something safe (shared pure function, unit-tested) and the resolved path is then + checked to be inside the projects directory anyway. + +## Capabilities + +### Added Capabilities +- `projects`: a job can be saved and reopened, on the operator's machine or on the plotter. + +### Modified Capabilities +- `gateway-protocol`: commands to save, load and delete stored projects, with the listing in the + snapshot and an event when it changes. + +## Impact + +- **Code:** new `src/plot/project.ts` (+ tests); `src/gateway/protocol.ts`, `gateway/server.ts`, + `src/transport/GatewayClient.ts`, `src/ui/App.tsx`. +- **Packaging:** `PLOTTER_PROJECTS` (default `gateway/projects/`). +- **Behaviour:** the live session is unchanged — projects are a separate thing you *keep*, not a + replacement for the session that is always there. diff --git a/openspec/changes/archive/2026-09-12-projects/specs/gateway-protocol/spec.md b/openspec/changes/archive/2026-09-12-projects/specs/gateway-protocol/spec.md new file mode 100644 index 0000000..337cf06 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-projects/specs/gateway-protocol/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Stored-project channel + +The daemon SHALL accept commands to save, load and delete a stored project. The attach snapshot +SHALL carry the list of stored projects, and the daemon SHALL broadcast the list whenever it +changes. A loaded project SHALL be sent only to the client that asked for it. + +#### Scenario: Listing on attach + +- **WHEN** a client attaches +- **THEN** the snapshot carries the names of the stored projects and when each was saved + +#### Scenario: The list stays current + +- **WHEN** any client saves or deletes a project +- **THEN** every attached client is sent the new list + +#### Scenario: A load is addressed + +- **WHEN** a client asks for a project +- **THEN** only that client receives it diff --git a/openspec/changes/archive/2026-09-12-projects/specs/projects/spec.md b/openspec/changes/archive/2026-09-12-projects/specs/projects/spec.md new file mode 100644 index 0000000..df7f5b7 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-projects/specs/projects/spec.md @@ -0,0 +1,83 @@ +# projects Specification + +## Purpose + +A job can be put aside and picked up again: saved as a file on the operator's machine, or stored on +the plotter so the machine holds a library of plots that any client can open. + +## Requirements + +### Requirement: A project is the whole job + +A saved project SHALL contain everything needed to reproduce the plot — the objects and their +placements, which pen draws each of them, the paper, the job's mode, and the magnets — together +with the pens it refers to. + +#### Scenario: Reopening a project + +- **WHEN** the operator opens a project they saved +- **THEN** the page is restored: the same objects, in the same places, with the same pens, paper + and mode + +#### Scenario: A project opened where its pens are not defined + +- **WHEN** a project is opened on a machine whose pen library does not contain its pens +- **THEN** those pens are added to the library, so the drawing renders as it was saved, and the + pens already defined there are kept + +### Requirement: Projects can be kept on either machine + +The operator SHALL be able to save a project as a file on their own device and open it again, and +to store a project on the plotter, list what is stored, open one, and delete one. + +#### Scenario: Saving to the operator's machine + +- **WHEN** the operator saves the job to their device +- **THEN** a project file is downloaded, and opening that file restores the job + +#### Scenario: Saving to the plotter + +- **WHEN** the operator saves the job to the plotter +- **THEN** it is stored there, appears in the list, and is still there after the daemon restarts + +#### Scenario: Every client sees the library + +- **WHEN** one client saves or deletes a project +- **THEN** the other clients' lists reflect it + +#### Scenario: Opening does not disturb other operators + +- **WHEN** one client opens a stored project +- **THEN** only that client's page changes + +### Requirement: A project file is versioned and validated + +The file SHALL identify itself and carry a format version. Opening something that is not a project, +or a project from a newer version, SHALL be refused with a message that says which. + +#### Scenario: The wrong file + +- **WHEN** the operator opens a JSON file that is not a project +- **THEN** they are told it is not a project file, and the page is unchanged + +#### Scenario: A file from a newer version + +- **WHEN** the file's format version is newer than this app understands +- **THEN** it is refused, rather than opened with whatever that version added silently dropped + +### Requirement: Project names cannot escape the projects directory + +A project name arriving from a client SHALL NOT be usable to read or write outside the directory +projects are stored in. A name with nothing usable left in it SHALL be refused. + +#### Scenario: A name that tries to traverse + +- **WHEN** a project is saved with a name containing path separators or parent references +- **THEN** the file is written inside the projects directory under a sanitised name, and nothing + outside it is touched + +#### Scenario: An empty name + +- **WHEN** a project is saved with a blank name, or one made entirely of characters that are + stripped +- **THEN** the save is refused and the operator is told diff --git a/openspec/changes/archive/2026-09-12-projects/tasks.md b/openspec/changes/archive/2026-09-12-projects/tasks.md new file mode 100644 index 0000000..b0b967c --- /dev/null +++ b/openspec/changes/archive/2026-09-12-projects/tasks.md @@ -0,0 +1,31 @@ +## 1. Format + +- [x] 1.1 `src/plot/project.ts`: `makeProject`, `readProject`, `sanitizeProjectName`, file names +- [x] 1.2 Unit tests: round-trip, pens carried and copied, refusal of non-projects, of newer + versions and of empty sessions, tolerance of missing optional fields, and sanitisation of + traversal, separators, control characters, filesystem-hostile characters and over-long names + +## 2. Daemon + +- [x] 2.1 Projects directory (`PLOTTER_PROJECTS`), atomic save, list, load, delete +- [x] 2.2 Listing in the snapshot and broadcast on change; load sent only to the requester +- [x] 2.3 Resolved-path check inside the projects directory + +## 3. Client + UI + +- [x] 3.1 `saveProject` / `loadProject` / `deleteProject`; `projects` and `projectLoaded` events +- [x] 3.2 Projects panel: name, save to plotter, save to device, open a file, list with delete +- [x] 3.3 Opening merges the project's pens into the library; one shared session builder + +## 4. Docs, gate, verification + +- [x] 4.1 `PLOTTER_PROJECTS` in packaging and the README env table; README and CHANGELOG +- [x] 4.2 `mise run ci` green +- [x] 4.3 Verified against the real daemon: save writes into the projects directory and broadcasts + the listing, another client sees it on attach, load returns the stored project to the asking + client only, `../escaped` lands *inside* the directory under a sanitised name, a nameless + project is refused, a missing one reports it, delete removes and re-broadcasts, and the + library survives a daemon restart +- [x] 4.4 Verified in the browser: save to the plotter, clear the page, reopen from the list and + get the drawing back; opening a project file merges its pens; a stray JSON and a + future-version file are both refused with the right message diff --git a/openspec/specs/gateway-protocol/spec.md b/openspec/specs/gateway-protocol/spec.md index 2994685..e9d0847 100644 --- a/openspec/specs/gateway-protocol/spec.md +++ b/openspec/specs/gateway-protocol/spec.md @@ -151,3 +151,24 @@ count as a running plot for the purposes of refusing an in-app update. - **WHEN** a client requests an in-app update while a program is held at a pen change - **THEN** the daemon refuses it, as it does during a running plot + +### Requirement: Stored-project channel + +The daemon SHALL accept commands to save, load and delete a stored project. The attach snapshot +SHALL carry the list of stored projects, and the daemon SHALL broadcast the list whenever it +changes. A loaded project SHALL be sent only to the client that asked for it. + +#### Scenario: Listing on attach + +- **WHEN** a client attaches +- **THEN** the snapshot carries the names of the stored projects and when each was saved + +#### Scenario: The list stays current + +- **WHEN** any client saves or deletes a project +- **THEN** every attached client is sent the new list + +#### Scenario: A load is addressed + +- **WHEN** a client asks for a project +- **THEN** only that client receives it diff --git a/openspec/specs/projects/spec.md b/openspec/specs/projects/spec.md new file mode 100644 index 0000000..df7f5b7 --- /dev/null +++ b/openspec/specs/projects/spec.md @@ -0,0 +1,83 @@ +# projects Specification + +## Purpose + +A job can be put aside and picked up again: saved as a file on the operator's machine, or stored on +the plotter so the machine holds a library of plots that any client can open. + +## Requirements + +### Requirement: A project is the whole job + +A saved project SHALL contain everything needed to reproduce the plot — the objects and their +placements, which pen draws each of them, the paper, the job's mode, and the magnets — together +with the pens it refers to. + +#### Scenario: Reopening a project + +- **WHEN** the operator opens a project they saved +- **THEN** the page is restored: the same objects, in the same places, with the same pens, paper + and mode + +#### Scenario: A project opened where its pens are not defined + +- **WHEN** a project is opened on a machine whose pen library does not contain its pens +- **THEN** those pens are added to the library, so the drawing renders as it was saved, and the + pens already defined there are kept + +### Requirement: Projects can be kept on either machine + +The operator SHALL be able to save a project as a file on their own device and open it again, and +to store a project on the plotter, list what is stored, open one, and delete one. + +#### Scenario: Saving to the operator's machine + +- **WHEN** the operator saves the job to their device +- **THEN** a project file is downloaded, and opening that file restores the job + +#### Scenario: Saving to the plotter + +- **WHEN** the operator saves the job to the plotter +- **THEN** it is stored there, appears in the list, and is still there after the daemon restarts + +#### Scenario: Every client sees the library + +- **WHEN** one client saves or deletes a project +- **THEN** the other clients' lists reflect it + +#### Scenario: Opening does not disturb other operators + +- **WHEN** one client opens a stored project +- **THEN** only that client's page changes + +### Requirement: A project file is versioned and validated + +The file SHALL identify itself and carry a format version. Opening something that is not a project, +or a project from a newer version, SHALL be refused with a message that says which. + +#### Scenario: The wrong file + +- **WHEN** the operator opens a JSON file that is not a project +- **THEN** they are told it is not a project file, and the page is unchanged + +#### Scenario: A file from a newer version + +- **WHEN** the file's format version is newer than this app understands +- **THEN** it is refused, rather than opened with whatever that version added silently dropped + +### Requirement: Project names cannot escape the projects directory + +A project name arriving from a client SHALL NOT be usable to read or write outside the directory +projects are stored in. A name with nothing usable left in it SHALL be refused. + +#### Scenario: A name that tries to traverse + +- **WHEN** a project is saved with a name containing path separators or parent references +- **THEN** the file is written inside the projects directory under a sanitised name, and nothing + outside it is touched + +#### Scenario: An empty name + +- **WHEN** a project is saved with a blank name, or one made entirely of characters that are + stripped +- **THEN** the save is refused and the operator is told diff --git a/packaging/penplotter271.env b/packaging/penplotter271.env index ea85172..54c4f95 100644 --- a/packaging/penplotter271.env +++ b/packaging/penplotter271.env @@ -29,6 +29,7 @@ GATEWAY_DIST=/opt/penplotter271/dist PLOTTER_STATE=/var/lib/penplotter271/.plotter-state.json PLOTTER_SESSION=/var/lib/penplotter271/.session.json PLOTTER_APP_SETTINGS=/var/lib/penplotter271/.app-settings.json +PLOTTER_PROJECTS=/var/lib/penplotter271/projects # In-app updater: GitHub repo (owner/name) whose latest Release supplies the # update .deb, and where the update oneshot records its progress. diff --git a/src/gateway/protocol.ts b/src/gateway/protocol.ts index 2317429..8c4cbbf 100644 --- a/src/gateway/protocol.ts +++ b/src/gateway/protocol.ts @@ -55,6 +55,11 @@ export type ClientCommand = // Carry on after a pen change: the operator has loaded the pen the prompt // named. Ignored unless the program is actually held at one. | { cmd: 'continueProgram' } + // Projects stored on the daemon, so the Pi holds a library of plots that any + // client can open. The payload is opaque here, like the session. + | { cmd: 'saveProject'; name: string; project: unknown } + | { cmd: 'loadProject'; name: string } + | { cmd: 'deleteProject'; name: string } // Trigger a self-update to the latest release. Refused while a plot runs. | { cmd: 'update' }; @@ -93,6 +98,15 @@ export interface Snapshot { * it — the job lives on the daemon, not in the tab that started it. */ penChange: { index: number; label: string } | null; + /** Projects stored on the daemon, newest first. */ + projects: ProjectSummary[]; +} + +/** What the client needs to list a stored project without loading it. */ +export interface ProjectSummary { + name: string; + /** ISO timestamp of the last save. */ + savedAt: string; } /** @@ -117,6 +131,10 @@ export interface ForwardedEvents { updateStatus: UpdateStatus; /** The program is held at a pen change; it continues on `continueProgram`. */ penChange: { index: number; label: string }; + /** The stored-project list changed (a save or a delete). */ + projects: ProjectSummary[]; + /** A project the client asked for. Sent only to that client. */ + projectLoaded: { name: string; project: unknown }; /** * Daemon-originated: app settings changed (by another client). Sent to every * client except the one that saved them, so all clients show one setup. diff --git a/src/plot/__tests__/project.test.ts b/src/plot/__tests__/project.test.ts new file mode 100644 index 0000000..4c80795 Binary files /dev/null and b/src/plot/__tests__/project.test.ts differ diff --git a/src/plot/project.ts b/src/plot/project.ts new file mode 100644 index 0000000..33bb106 --- /dev/null +++ b/src/plot/project.ts @@ -0,0 +1,126 @@ +/** + * Project files: a drawing saved so it can be opened again. + * + * A project is the *whole* job — the objects, where they sit, which pen draws + * each of them, the paper, the mode, the magnets — plus the pen library it + * refers to, because a drawing whose pens have been renamed on another machine + * would otherwise open in the wrong colours. + * + * Versioned from the start: these files outlive the app that wrote them, and + * "we will add a version when we need one" means the first file that needs it + * cannot be read. + */ +import type { Pen } from './pen'; + +/** Marks a file as ours. Checked on load, so a stray JSON is refused clearly. */ +export const PROJECT_FORMAT = 'penplotter271.project'; +export const PROJECT_VERSION = 1; + +export interface ProjectFile { + format: typeof PROJECT_FORMAT; + version: number; + /** ISO timestamp, for the listing. */ + savedAt: string; + name: string; + /** The editable session: artwork, placements, paper, mode, magnets. */ + session: unknown; + /** The pens the session's artwork refers to. */ + pens: Pen[]; +} + +export function makeProject(name: string, session: unknown, pens: readonly Pen[]): ProjectFile { + return { + format: PROJECT_FORMAT, + version: PROJECT_VERSION, + savedAt: new Date().toISOString(), + name: name.trim() || 'Untitled', + session, + pens: pens.map((p) => ({ ...p })), + }; +} + +export type ReadResult = { ok: true; project: ProjectFile } | { ok: false; error: string }; + +/** + * Validate anything claiming to be a project file. Returns a message rather + * than throwing, because every caller here wants to *show* the reason: "that is + * not a project file" is the useful answer to opening the wrong JSON. + */ +export function readProject(raw: unknown): ReadResult { + if (typeof raw !== 'object' || raw === null) { + return { ok: false, error: 'That file is not a PenPlotter271 project.' }; + } + const r = raw as Record; + if (r.format !== PROJECT_FORMAT) { + return { ok: false, error: 'That file is not a PenPlotter271 project.' }; + } + if (typeof r.version !== 'number' || r.version > PROJECT_VERSION) { + return { + ok: false, + error: `That project was saved by a newer version of the app (format ${String(r.version)}).`, + }; + } + if (typeof r.session !== 'object' || r.session === null) { + return { ok: false, error: 'That project file has no drawing in it.' }; + } + return { + ok: true, + project: { + format: PROJECT_FORMAT, + version: r.version, + savedAt: typeof r.savedAt === 'string' ? r.savedAt : '', + name: typeof r.name === 'string' && r.name.trim() ? r.name : 'Untitled', + session: r.session, + pens: Array.isArray(r.pens) ? (r.pens as Pen[]) : [], + }, + }; +} + +/** Longest project name accepted, so a name cannot become an unwieldy filename. */ +const MAX_NAME = 64; + +/** + * Reduce a project name to something safe to use as a filename on the daemon. + * + * This is a security boundary, not a tidiness rule: the name arrives over the + * WebSocket and becomes a path on the Pi, so anything that could climb out of + * the projects directory — separators, `..`, NUL, leading dots — has to be gone + * before it gets near the filesystem. Returns an empty string when nothing + * usable is left, and callers refuse the save. + */ +export function sanitizeProjectName(name: string): string { + return ( + name + .normalize('NFC') + // Control characters are not names, and a NUL truncates a path in some + // syscalls — so they go first, before anything looks at the shape. + .replace(/[\u0000-\u001f\u007f]/g, '') + .replace(/[/\\]/g, ' ') + // Characters that filesystems (or Windows, over a share) refuse outright. + .replace(/[:*?"<>|]/g, '') + .replace(/\s+/g, ' ') + .trim() + // Drop any word that is only dots: with the separators already gone, a + // leftover ".." is harmless, but it is also not part of anyone's name. + .split(' ') + .filter((word) => !/^\.+$/.test(word)) + .join(' ') + // A leading dot hides the file and invites surprises; a trailing one is + // meaningless on some filesystems. + .replace(/^\.+/, '') + .replace(/\.+$/, '') + .slice(0, MAX_NAME) + .trim() + ); +} + +/** The filename a project is stored under. */ +export function projectFileName(name: string): string { + return `${sanitizeProjectName(name)}.plot.json`; +} + +/** The filename offered when downloading to the operator's own machine. */ +export function downloadFileName(name: string): string { + const safe = sanitizeProjectName(name) || 'project'; + return `${safe}.plot.json`; +} diff --git a/src/transport/GatewayClient.ts b/src/transport/GatewayClient.ts index 2fcd4ec..6cd1313 100644 --- a/src/transport/GatewayClient.ts +++ b/src/transport/GatewayClient.ts @@ -1,7 +1,13 @@ import { Emitter } from '../grbl/emitter'; import type { Calibration } from '../grbl/settings'; import type { GrblSettings, StatusReport } from '../grbl/types'; -import type { ClientCommand, ServerMessage, StreamDebug, UpdateStatus } from '../gateway/protocol'; +import type { + ClientCommand, + ProjectSummary, + ServerMessage, + StreamDebug, + UpdateStatus, +} from '../gateway/protocol'; import { normalizeAppSettings, type AppSettings } from '../gateway/appSettings'; type ClientEvents = { @@ -34,6 +40,10 @@ type ClientEvents = { * snapshot too, so a client that attaches mid-job can answer the prompt. */ penChange: { index: number; label: string } | null; + /** Projects stored on the daemon (sent on attach and whenever they change). */ + projects: ProjectSummary[]; + /** A project this client asked for. */ + projectLoaded: { name: string; project: unknown }; }; /** @@ -201,6 +211,7 @@ export class GatewayClient { // Normalised here too: a pre-1.3 daemon sends no field at all. this.events.emit('appSettings', s.appSettings ? normalizeAppSettings(s.appSettings) : null); this.events.emit('penChange', s.penChange ?? null); + this.events.emit('projects', s.projects ?? []); // Continue a plot that was paused by a previous Disconnect-as-pause. if (s.paused) this.resume(); break; @@ -320,6 +331,17 @@ export class GatewayClient { async setSetting(num: number, value: number): Promise { await this.cmd({ cmd: 'setSetting', num, value }); } + /** Store a project on the daemon, so the Pi holds the library. */ + async saveProject(name: string, project: unknown): Promise { + await this.cmd({ cmd: 'saveProject', name, project }); + } + /** Ask for a stored project; it arrives as a `projectLoaded` event. */ + async loadProject(name: string): Promise { + await this.cmd({ cmd: 'loadProject', name }); + } + async deleteProject(name: string): Promise { + await this.cmd({ cmd: 'deleteProject', name }); + } /** Carry on after a pen change, once the operator has loaded the pen. */ async continueProgram(): Promise { await this.cmd({ cmd: 'continueProgram' }); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 4ab28f9..ad9c195 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -12,6 +12,8 @@ import { imageToField, traceField, type FieldSource } from '../plot/raster'; import { ImportWizard, type ImportSpec } from './ImportWizard'; import { loadPdf, type LoadedPdf } from '../plot/pdf'; import { sniffFile } from '../plot/sniff'; +import { downloadFileName, makeProject, readProject } from '../plot/project'; +import type { ProjectSummary } from '../gateway/protocol'; import { applyDetail } from '../plot/detail'; import { estimatePlotTime, @@ -254,6 +256,9 @@ export function App() { const [useCustomPaper, setUseCustomPaper] = useState(restored?.useCustomPaper ?? false); // A multi-page PDF waiting for the operator to say which page to import. const [pdfPick, setPdfPick] = useState<{ name: string; doc: LoadedPdf } | null>(null); + // Projects stored on the daemon, and the name this job was last saved under. + const [projects, setProjects] = useState([]); + const [projectName, setProjectName] = useState(''); // Import wizard: the image being converted. `artId` is set when reopening an // existing import, so confirming replaces that object instead of adding one. const [importing, setImporting] = useState<{ @@ -364,6 +369,10 @@ export function App() { ackChangedAtRef.current = Date.now(); } }), + ctrl.on('projects', (list) => setProjects(list)), + ctrl.on('projectLoaded', (e) => { + openProject(e.project, e.name); + }), ctrl.on('penChange', (e) => { penChangeRef.current = !!e; setPenChange(e); @@ -449,22 +458,10 @@ export function App() { // Persist the editable session: always to localStorage (instant, offline), and // to the daemon (lives on the Pi, any device) once we've synced its session. useEffect(() => { - const blob: Session = { - items, - // `selectedId` stays in the blob so an older build (or an older daemon's - // stored session) still restores a sensible selection. - selectedId, - selectedIds, - nextId: idRef.current, - paperIdx, - orientation, - useCustomPaper, - customPaper, - paperStyleId, - selectedPenId, - mode, - magnets, - }; + // One builder for the session, shared with project saving: two copies of + // this list would drift, and the field someone forgets to add to the second + // one is a field that quietly vanishes from every saved project. + const blob = currentSession(); saveSession(blob); if (sessionLoadedRef.current) ctrlRef.current?.saveSession(blob); }, [ @@ -1171,6 +1168,108 @@ export function App() { setMagnets((list) => list.map((m) => (m.id === id ? { ...m, x, y } : m))); } + /** Everything that makes up the job, as a project file would store it. */ + function currentSession(): Session { + return { + items, + // `selectedId` stays in the blob so an older build (or an older daemon's + // stored session) still restores a sensible selection. + selectedId, + selectedIds, + nextId: idRef.current, + paperIdx, + orientation, + useCustomPaper, + customPaper, + paperStyleId, + selectedPenId, + mode, + magnets, + }; + } + + /** + * Replace the page with a stored project. The pens it was drawn with come + * with it and are merged into the library: artwork refers to pens by id, so a + * project opened on another machine would otherwise come out in whatever + * pens happen to be defined there. + */ + function openProject(raw: unknown, fallbackName = '') { + const result = readProject(raw); + if (!result.ok) { + setAlert(result.error); + return; + } + const { project } = result; + const s = project.session as Session | null; + if (!s || !Array.isArray(s.items)) { + setAlert('That project file has no drawing in it.'); + return; + } + if (project.pens.length > 0) { + setSettings((prev) => { + const byId = new Map(prev.pens.map((pen) => [pen.id, pen])); + for (const pen of project.pens) if (!byId.has(pen.id)) byId.set(pen.id, pen); + return { ...prev, pens: [...byId.values()] }; + }); + } + // The retained import sources belong to the drawing being replaced. + sourcesRef.current.clear(); + setItems(s.items.map(normalizeArt)); + setSelectedIds(s.selectedIds ?? (s.selectedId ? [s.selectedId] : [])); + if (typeof s.nextId === 'number') idRef.current = Math.max(idRef.current, s.nextId); + if (typeof s.paperIdx === 'number') setPaperIdx(s.paperIdx); + if (s.orientation) setOrientation(s.orientation); + if (typeof s.useCustomPaper === 'boolean') setUseCustomPaper(s.useCustomPaper); + if (s.customPaper) setCustomPaper(s.customPaper); + if (s.paperStyleId) setPaperStyleId(s.paperStyleId); + if (s.selectedPenId) setSelectedPenId(s.selectedPenId); + if (s.mode) setMode(s.mode); + setMagnets(s.magnets ?? []); + setProjectName(project.name || fallbackName); + setAlert(`Opened ${project.name || fallbackName}.`); + } + + /** Save the job to the operator's own machine, as a file they can keep. */ + function downloadProject() { + const name = projectName.trim() || 'Untitled'; + const blob = new Blob([JSON.stringify(makeProject(name, currentSession(), pens), null, 2)], { + type: 'application/json', + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = downloadFileName(name); + a.click(); + // Revoking immediately can cancel the download in some browsers; a tick is + // enough for the click to have taken the URL. + setTimeout(() => URL.revokeObjectURL(url), 1000); + setAlert(`Saved ${a.download}.`); + } + + async function openProjectFile(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + try { + openProject(JSON.parse(await file.text()), file.name.replace(/\.plot\.json$/i, '')); + } catch { + setAlert(`Could not read ${file.name} — it is not a project file.`); + } + } + + function saveProjectToPi() { + const name = projectName.trim(); + if (!name) { + setAlert('Give the project a name first.'); + return; + } + void ctrl() + ?.saveProject(name, makeProject(name, currentSession(), pens)) + .then(() => setAlert(`Saved "${name}" on the plotter.`)) + .catch((err) => setAlert(String((err as Error).message ?? err))); + } + function restack(delta: number) { if (plotting || selectedIds.length === 0) return; setItems((list) => reorderMany(list, selectedIds, delta)); @@ -1840,6 +1939,75 @@ export function App() { +
+

+ A project is the whole job — artwork, placement, pens, paper, mode and magnets. + Saved on the plotter it is reachable from any device; saved to this machine it is a + file you keep. +

+ setProjectName(e.target.value)} + /> +
+ + + +
+ {projects.length > 0 && ( +
    + {projects.map((p) => ( +
  • + + +
  • + ))} +
+ )} + {connected && projects.length === 0 && ( +

No projects on the plotter yet.

+ )} +
+

The bed has no vacuum. A magnet the carriage hits at travel speed drags the sheet diff --git a/vite.config.ts b/vite.config.ts index d4f648a..73d7fa7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,10 +26,10 @@ export default defineConfig({ // ratchets against regressions rather than blocking today's work. // Raise these as coverage improves; never lower them to make CI pass. thresholds: { - statements: 50, - branches: 48, - functions: 40, - lines: 50, + statements: 72, + branches: 68, + functions: 62, + lines: 72, }, }, },