From 2851c7d93668f427663d6273f65e7e706324eafe Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Fri, 23 Oct 2026 07:47:27 +0200 Subject: [PATCH] Release 0.5.8 --- docs/ACCEPTANCE.md | 31 +++ docs/ARCHITECTURE.md | 33 ++- docs/DECISIONS.md | 34 +++ docs/STATUS.md | 23 ++ migrations/0007_editorial_workflows.sql | 58 +++++ migrations/0008_durable_planning_commands.sql | 18 ++ package-lock.json | 4 +- package.json | 2 +- src/app.css | 144 ++++++++++++- src/lib/components/UsageEditorDialog.svelte | 18 +- src/lib/server/content.spec.ts | 42 ++++ src/lib/server/content.ts | 198 +++++++++++------- src/lib/server/database.spec.ts | 2 +- src/lib/server/live.spec.ts | 66 +++++- src/lib/server/live.ts | 190 ++++++++++++----- src/lib/server/shows.spec.ts | 64 +++++- src/lib/server/shows.ts | 86 +++++++- src/routes/fundus/+page.server.ts | 20 +- src/routes/fundus/+page.svelte | 38 +++- src/routes/fundus/[id]/+page.server.ts | 29 ++- src/routes/fundus/[id]/+page.svelte | 30 ++- .../fundus/[id]/praesentation/+page.server.ts | 2 +- src/routes/heinzlive/[token]/+page.server.ts | 13 +- src/routes/heinzlive/[token]/+page.svelte | 24 ++- .../heinzlive/[token]/access/+server.ts | 18 ++ .../heinzlive/[token]/media/[id]/+server.ts | 10 +- .../[token]/preview-image/+server.ts | 9 +- src/routes/heinzlive/[token]/state/+server.ts | 9 +- src/routes/live/+page.server.ts | 34 ++- src/routes/live/+page.svelte | 37 +++- src/routes/sendungen/[id]/+page.server.ts | 18 +- src/routes/sendungen/[id]/+page.svelte | 70 ++++++- tests/ui.e2e.ts | 161 ++++++++------ 33 files changed, 1263 insertions(+), 272 deletions(-) create mode 100644 migrations/0007_editorial_workflows.sql create mode 100644 migrations/0008_durable_planning_commands.sql create mode 100644 src/routes/heinzlive/[token]/access/+server.ts diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index a9138d7..8336e95 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -306,3 +306,34 @@ - Desktop-/Mobilaufnahmen mit und ohne Popup visuell verglichen - vollständige Funktionstests gemäß Auftrag nicht ausgeführt - `git diff --check`: erfolgreich + +## Redaktionelle Arbeitsabläufe 0.5.8 + +| Kriterium | Nachweis | Stand | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------ | --------------------- | +| Persistente unabhängige Piece-Vorlagen | Servicetests prüfen Snapshot, Revision, Neuanlage, Wiederverwendung und Löschen | erfüllt am 23.10.2026 | +| Atomare idempotente Direktanlage | Verhaltenstests prüfen Wiederholung derselben Befehls-ID und vollständigen Rollback | erfüllt am 23.10.2026 | +| Interner und öffentlicher Titel getrennt | Service- und Browserabläufe prüfen getrennte Werte, Snapshots und Fallback | erfüllt am 23.10.2026 | +| Entfernen betrifft nur konkrete Verwendung | Browser prüft Bestätigung, Rückgabe in Fundus und unverändertes Original | erfüllt am 23.10.2026 | +| Beliebige Live-Verwendung auswählbar | Verhaltenstests wählen ersten und erledigten Eintrag über stabile Usage-ID | erfüllt am 23.10.2026 | +| Stabiler widerrufbarer OBS-Kanal | Servicetests prüfen Wiederöffnung/Rotation; anonymer Browser prüft Fragmenttausch und Cookie | erfüllt am 23.10.2026 | +| Private Quellen und Uploads bleiben intern | HTML- und JSON-Prüfung enthält weder implizite Quellen, Uploadnamen noch interne Redaktionstexte | erfüllt am 23.10.2026 | +| Responsive und gleichwertig bedienbar | Chromium prüft Desktop/Mobil, Light/Dark, Drag-and-drop sowie Klick- und Tastaturalternativen | erfüllt am 23.10.2026 | +| Reale OBS- und Anbieterwiedergabe | Keine OBS-Installation und keine echten Anbieteraufrufe in der lokalen Prüfumgebung | offen | + +## Ausgeführte Prüfungen 0.5.8 + +- `npm run lint`: nach Formatierung erfolgreich +- `npm run check`: 0 Fehler und 0 Warnungen +- `npm test`: 33 Tests in 6 Dateien erfolgreich +- `npm run test:e2e`: 15 von 15 Chromium-Browsertests erfolgreich +- Browsermatrix bei 390, 768, 960, 1280 und 1440 Pixeln einschließlich + Light/Dark, horizontalem Überlauf, Tastaturbedienung, Planung, Live-Steuerung + und anonymer Ausgabe geprüft; Screenshots wurden durch die Suite erzeugt +- `npm run build`: erfolgreich mit `@sveltejs/adapter-node` +- `npm run db:migrate`: Migrationen `0007_editorial_workflows.sql` und + `0008_durable_planning_commands.sql` erfolgreich auf der zuvor gesicherten + lokalen Entwicklungsdatenbank angewendet +- `bash -n start.sh`: erfolgreich +- reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt; + Anbieterrequests wurden in den Browsertests lokal abgefangen diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e240b0b..875ca82 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -41,7 +41,7 @@ und spätere Bereiche. | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später | | Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft | -| Inhalt | `pieces` mit Status, Dauer, Revision, getrennten Textbereichen, Quellen und privaten Medien; Vorlagen sind definierte Startwerte | +| Inhalt | `pieces` mit Status, Dauer, Revision, getrennten Textbereichen, Quellen und privaten Medien; revisionierte Vorlagen speichern Snapshots | | Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen sind umgesetzt; Original-ID bleibt Referenz | | Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe | | Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet | @@ -102,6 +102,13 @@ Linkkarte beziehungsweise Video auswählen. Abgerufene Metadaten und Kennzeichen für manuell korrigierte Felder liegen am Piece; ein erneuter Abruf überschreibt diese Felder nicht. +Piece-Vorlagen liegen als validierte JSON-Snapshots in `piece_templates`. Jeder +Speichervorgang kopiert den vollständigen Vorlageninhalt tief und erhöht bei +einer Aktualisierung die Revision; spätere Änderungen am Ausgangs-Piece wirken +nicht auf die Vorlage zurück. Die leere Vorlage bleibt virtuell. Interner Titel +und optionaler Präsentationstitel sind getrennte Felder; ohne öffentlichen Titel +verwendet die Präsentationsprojektion den internen Titel als Fallback. + Jedes Piece besitzt eine ganzzahlige Revision. Speichern, Archivieren und Medienänderungen verwenden ein bedingtes Update auf die erwartete Revision. Eine veraltete Bearbeitung liefert einen Konflikt und verändert weder Piece noch @@ -161,6 +168,11 @@ Sendungsvorlagen und ihre Abschnitte sind eigene Tabellen. Beim Anlegen werden die Abschnitte mit neuen IDs kopiert; spätere Änderungen an der Sendung können die Vorlage deshalb nicht verändern. +Die Direktanlage aus einer Sendung erzeugt Piece und Verwendung innerhalb einer +verschachtelbaren SQLite-Transaktion. `planning_commands` speichert die +Befehls-ID und das Ergebnis, sodass ein wiederholter Request dieselben IDs +zurückgibt und weder ein zweites Piece noch eine zweite Einplanung erzeugt. + Die Editorformulare bleiben progressive SvelteKit-Form-Actions, werden im Browser aber mit `use:enhance` gesendet. Der Verwendungseditor liegt in einem nativen Dialog, den ein beschrifteter Icon-Button öffnet. Dadurch bleibt das @@ -191,12 +203,19 @@ Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von de geplanten Dauer nachvollziehbar. Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf -Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe -`/heinzlive/[token]` liegt dagegen in einem reduzierten Layout ohne Navigation -oder Nutzerdaten. In SQLite liegt nur der SHA-256-Hash des 256-Bit-Tokens; ein -neu erzeugter Ausgabezugang widerruft den vorherigen. Page, Polling-JSON und -Medienroute leiten ihre Daten aus einer eigenen Positivliste ab. Bilder werden -nur ausgeliefert, wenn sie zum aktuell ausdrücklich präsentierten Piece gehören. +Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt +den stabilen Kanal `/heinzlive/default` in einem reduzierten Layout ohne +Navigation oder Nutzerdaten. Der geheime 256-Bit-Schlüssel steht nur im +URL-Fragment und wird per POST gegen ein `HttpOnly`-, `SameSite=Strict`-Cookie +getauscht. SQLite speichert seinen SHA-256-Hash für die Zugangsprüfung sowie den +rohen Schlüssel, weil die angemeldete Steuerung dieselbe stabile OBS-Adresse +erneut ausgeben können muss. Datenbank und Sicherungen sind deshalb Teil der +Geheimnisgrenze. Eine Rotation widerruft den bisherigen Schlüssel, normales +erneutes Öffnen behält ihn bei. +Page, Polling-JSON und Proxyrouten leiten ihre Daten aus einer eigenen +Positivliste ab. Implizite Piece-Quellen und private Uploads werden nicht +veröffentlicht; nur die ausdrücklich gewählte Link- oder Videoquelle und deren +erlaubte Vorschau-URL können die Grenze passieren. Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 0cccec8..9e1206d 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -276,3 +276,37 @@ Quellen: - - - + +## D-020: Stabile Ausgabekanal-Adresse mit Fragment-Schlüssel + +OBS soll eine dauerhaft konfigurierte Adresse verwenden können, ohne dass ein +Zugangsschlüssel in HTTP-Requests, Serverlogs oder Referer-Headern erscheint. +Darum bleibt die Kanaladresse `/heinzlive/default` stabil und trägt den +zufälligen Schlüssel ausschließlich als URL-Fragment. Ein kleiner Browser-POST +tauscht ihn gegen ein `HttpOnly`-, `SameSite=Strict`-Cookie; danach greifen +Seite, Polling und erlaubte Proxys nur mit diesem Cookie zu. Für die Prüfung +liegt ein SHA-256-Hash vor. Der rohe Schlüssel muss zusätzlich in SQLite +gespeichert werden, weil die angemeldete Steuerung dieselbe stabile OBS-Adresse +nach einem Neustart erneut ausgeben können muss. Damit sind Datenbank und +Sicherungen bewusst Teil der Geheimnisgrenze. Wiederholtes Öffnen behält den +Schlüssel, eine ausdrückliche Rotation widerruft ihn sofort. + +Die öffentliche Projektion folgt einer engeren Positivliste als der interne +Piece-Snapshot. Weder private Uploads noch alle Piece-Quellen werden implizit +veröffentlicht. Passieren dürfen nur öffentlicher Text, Präsentationstitel sowie +die ausdrücklich gewählte und erneut validierte Link- oder Videoquelle mit +erlaubten Vorschaufeldern. + +## D-021: Vorlagen-Snapshots und idempotente Direktanlage + +Persistente Piece-Vorlagen speichern validierte, tief kopierte JSON-Snapshots +mit eigener Revision. Damit bleiben sie unabhängig von späteren Änderungen am +Ausgangs-Piece, ohne für jedes optionale Piece-Feld eine zweite Tabellenstruktur +zu benötigen. Die virtuelle leere Vorlage wird nicht persistiert. + +Eine Direktanlage im Sendungsplan ist ein einzelner fachlicher Befehl. Piece, +Verwendung und Sendungsrevision werden in derselben SQLite-Transaktion +geschrieben. `planning_commands` macht die Befehls-ID eindeutig und speichert +das Ergebnis, damit Wiederholungen nach Verbindungsabbrüchen keine Dubletten +erzeugen. Interner und öffentlicher Titel werden getrennt gespeichert und beim +Einplanen getrennt in den Verwendungssnapshot kopiert. diff --git a/docs/STATUS.md b/docs/STATUS.md index 1a5bb9e..a4e0385 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -7,6 +7,29 @@ Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung, Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert. Etappe 6 wurde nicht begonnen. +## Redaktionelle Arbeitsabläufe 0.5.8 + +- persistente Piece-Vorlagen als unabhängige, revisionierte Snapshots; eigene + Vorlagen lassen sich aus Fundus-Pieces speichern, erneut verwenden und löschen +- atomare, idempotente Piece-Neuanlage direkt in einer Sendung einschließlich + Einplanung; fehlgeschlagene Schritte hinterlassen weder Piece noch Verwendung +- getrennte interne und öffentliche Titel an Piece und Verwendung; ein leerer + Präsentationstitel fällt kontrolliert auf den internen Titel zurück +- präzises Entfernen einzelner Verwendungen mit Bestätigung sowie zusätzlicher + Drag-and-drop-Rückgabe in den Fundusbereich +- Auswahl jedes konkreten Ablauf-Pieces in der Live-Steuerung, einschließlich + erstem und bereits erledigtem Eintrag +- stabiler OBS-Ausgabekanal `/heinzlive/default` mit geheimem URL-Fragment, + HttpOnly-Cookie-Austausch und ausdrücklicher Schlüsselrotation +- öffentliche Ausgabe als Positivliste: implizite Piece-Quellen und private + Uploads werden nicht mehr veröffentlicht; nur ausdrücklich gewählte + Link-/Videoquellen und deren erlaubte Vorschau bleiben sichtbar + +Die vollständige lokale Prüfmatrix umfasst 33 Verhaltenstests und 15 +Chromium-Abläufe. Desktop, Mobil, Light/Dark, Tastatur- und Touchalternativen, +Planung, Live-Ausgabe sowie anonyme Ausgabedaten wurden geprüft. Reales OBS und +echte YouTube-/TikTok-Wiedergabe bleiben mangels verfügbarer Umgebung offen. + ## Materialkorrektur 0.5.7 - statische Gold-/Blauviolettlichter so positioniert und verstärkt, dass sie im diff --git a/migrations/0007_editorial_workflows.sql b/migrations/0007_editorial_workflows.sql new file mode 100644 index 0000000..b28d1c4 --- /dev/null +++ b/migrations/0007_editorial_workflows.sql @@ -0,0 +1,58 @@ +ALTER TABLE pieces ADD COLUMN output_title TEXT NOT NULL DEFAULT ''; +ALTER TABLE piece_usages ADD COLUMN output_title_snapshot TEXT NOT NULL DEFAULT ''; + +CREATE TABLE piece_templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + snapshot_json TEXT NOT NULL CHECK (json_valid(snapshot_json)), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at_utc TEXT NOT NULL, + updated_at_utc TEXT NOT NULL +); + +INSERT INTO piece_templates ( + id, name, description, snapshot_json, revision, created_at_utc, updated_at_utc +) VALUES + ( + 'talk', 'Gespräch', + 'Leitfragen, Recherche und eine klare Überleitung.', + '{"title":"Unbenanntes Gespräch","formatId":"format-talk","moderationNotes":"## Leitfragen\n\n- ","categoryIds":["cat-jc"]}', + 1, '2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z' + ), + ( + 'find', 'Reaction / Fundstück', + 'Quelle, Einordnung und präsentierbare Kernaussage.', + '{"title":"Neues Fundstück","formatId":"format-find","presentationText":"## Kernaussage\n\n","categoryIds":["cat-reaction"]}', + 1, '2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z' + ), + ( + 'interaction', 'Interaktion', + 'Quiz, Publikumsfrage oder kleines Spiel.', + '{"title":"Neue Interaktion","formatId":"format-quiz","moderationNotes":"## Ablauf\n\n1. ","categoryIds":["cat-game"]}', + 1, '2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z' + ); + +CREATE TABLE planning_commands ( + show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE, + command_id TEXT NOT NULL, + piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE CASCADE, + usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE, + result_revision INTEGER NOT NULL CHECK (result_revision >= 1), + created_at_utc TEXT NOT NULL, + PRIMARY KEY (show_id, command_id) +); + +CREATE TABLE presentation_channels ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + access_key TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + show_id TEXT REFERENCES shows(id) ON DELETE SET NULL, + revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0), + created_at_utc TEXT NOT NULL, + updated_at_utc TEXT NOT NULL +); + +UPDATE presentation_access_tokens +SET revoked_at_utc = COALESCE(revoked_at_utc, '2026-10-23T00:00:00.000Z'); diff --git a/migrations/0008_durable_planning_commands.sql b/migrations/0008_durable_planning_commands.sql new file mode 100644 index 0000000..36b5ac2 --- /dev/null +++ b/migrations/0008_durable_planning_commands.sql @@ -0,0 +1,18 @@ +CREATE TABLE planning_commands_durable ( + show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE, + command_id TEXT NOT NULL, + piece_id TEXT NOT NULL, + usage_id TEXT NOT NULL, + result_revision INTEGER NOT NULL CHECK (result_revision >= 1), + created_at_utc TEXT NOT NULL, + PRIMARY KEY (show_id, command_id) +); + +INSERT INTO planning_commands_durable ( + show_id, command_id, piece_id, usage_id, result_revision, created_at_utc +) +SELECT show_id, command_id, piece_id, usage_id, result_revision, created_at_utc +FROM planning_commands; + +DROP TABLE planning_commands; +ALTER TABLE planning_commands_durable RENAME TO planning_commands; diff --git a/package-lock.json b/package-lock.json index 185011d..242466e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sendewerk", - "version": "0.5.7", + "version": "0.5.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sendewerk", - "version": "0.5.7", + "version": "0.5.8", "dependencies": { "@node-rs/argon2": "^2.2.2", "better-sqlite3": "^13.0.3", diff --git a/package.json b/package.json index befbea5..44e3700 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sendewerk", "private": true, - "version": "0.5.7", + "version": "0.5.8", "type": "module", "engines": { "node": ">=22.17 <25" diff --git a/src/app.css b/src/app.css index 68e0257..852934c 100644 --- a/src/app.css +++ b/src/app.css @@ -1242,6 +1242,33 @@ textarea:focus { text-align: right; } +.live-output-settings { + position: relative; +} + +.live-output-settings > summary { + cursor: pointer; + color: var(--text-muted); + font-size: 0.7rem; + font-weight: 700; +} + +.live-output-settings > div { + position: absolute; + z-index: 4; + top: calc(100% + 0.45rem); + left: 0; + display: grid; + width: max-content; + max-width: calc(100vw - 2rem); + gap: 0.35rem; + padding: 0.6rem; + border: 1px solid var(--line-strong); + border-radius: var(--radius-small); + background: var(--glass-strong); + box-shadow: var(--shadow); +} + .live-session-summary strong { font-size: 0.78rem; } @@ -2089,14 +2116,11 @@ textarea:focus { gap: 0.55rem; } -.template-grid { +.template-popover { position: absolute; top: calc(100% + 0.5rem); right: 0; - display: grid; width: min(42rem, calc(100vw - var(--sidebar-width) - 4rem)); - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.6rem; padding: 0.7rem; border: 1px solid color-mix(in srgb, var(--gold) 34%, var(--line)); border-radius: var(--radius-medium); @@ -2106,6 +2130,33 @@ textarea:focus { backdrop-filter: blur(22px) saturate(1.18); } +.template-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.6rem; +} + +.template-management { + margin-top: 0.6rem; +} + +.template-picker .template-management > summary { + min-height: auto; + padding: 0.35rem 0; + background: transparent; + box-shadow: none; + color: var(--text-muted); +} + +.template-management form { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.3rem 0; + border-top: 1px solid var(--line); +} + .template-grid button { display: grid; width: 100%; @@ -3839,6 +3890,7 @@ textarea:focus { .piece-meta-grid > label, .piece-meta-grid fieldset, .piece-source-editor label, +.piece-presentation-controls > label, .piece-presentation-controls .presentation-settings > label { display: grid; gap: 0.25rem; @@ -4002,6 +4054,38 @@ textarea:focus { gap: 0.3rem; } +.template-save-menu { + position: relative; +} + +.template-save-menu > summary { + cursor: pointer; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; +} + +.template-save-menu > form { + position: absolute; + z-index: 4; + bottom: calc(100% + 0.7rem); + left: 0; + display: grid; + width: min(28rem, calc(100vw - 2rem)); + padding: 0.75rem; + border: 1px solid var(--line-strong); + border-radius: var(--radius-medium); + background: var(--glass-strong); + box-shadow: var(--shadow); +} + +.template-save-menu label { + display: grid; + gap: 0.2rem; + color: var(--text-muted); + font-size: 0.66rem; +} + .piece-dialog-primary-action span { color: var(--text-muted); font-size: 0.66rem; @@ -4170,6 +4254,53 @@ textarea:focus { margin-bottom: 0.45rem; } +.plan-piece-create { + margin-bottom: 0.55rem; + padding: 0.55rem 0.65rem; + border: 1px solid var(--line); + border-radius: var(--radius-small); + background: var(--panel-soft); +} + +.plan-piece-create > summary { + cursor: pointer; + font-size: 0.72rem; + font-weight: 750; +} + +.plan-piece-create form { + display: grid; + grid-template-columns: minmax(8rem, 1fr) minmax(7rem, 0.8fr); + gap: 0.45rem; + margin-top: 0.6rem; +} + +.plan-piece-create label { + display: grid; + gap: 0.2rem; + color: var(--text-muted); + font-size: 0.64rem; +} + +.plan-piece-create button { + grid-column: 1 / -1; +} + +.drag-remove-target { + display: grid; + gap: 0.15rem; + margin-bottom: 0.55rem; + padding: 0.75rem; + border: 1px dashed var(--danger); + border-radius: var(--radius-small); + background: color-mix(in srgb, var(--danger) 9%, var(--panel-soft)); +} + +.drag-remove-target span { + color: var(--text-muted); + font-size: 0.68rem; +} + .builder-filter-options { display: flex; flex-wrap: wrap; @@ -4920,7 +5051,7 @@ textarea:focus { .template-picker { margin-left: auto; } - .template-grid, + .template-popover, .filter-panel { position: absolute; top: calc(100% + 0.5rem); @@ -4930,6 +5061,9 @@ textarea:focus { max-height: calc(100dvh - var(--mobile-header) - var(--mobile-nav) - 1rem); overflow-y: auto; } + .template-grid { + grid-template-columns: 1fr; + } .fundus-search-row { align-items: stretch; flex-wrap: wrap; diff --git a/src/lib/components/UsageEditorDialog.svelte b/src/lib/components/UsageEditorDialog.svelte index 6a2b589..a832b9e 100644 --- a/src/lib/components/UsageEditorDialog.svelte +++ b/src/lib/components/UsageEditorDialog.svelte @@ -183,6 +183,14 @@
+ {draft.presentationSourceUrl}{/if}
diff --git a/src/lib/server/content.spec.ts b/src/lib/server/content.spec.ts index 5323d58..5e3f034 100644 --- a/src/lib/server/content.spec.ts +++ b/src/lib/server/content.spec.ts @@ -12,9 +12,11 @@ import { getMediaFile, getPiece, getTaxonomy, + listPieceTemplates, listPieces, loadPiecePresentationMetadata, savePiece, + savePieceAsTemplate, setPieceArchived, uploadPieceImage, type PieceInput @@ -42,6 +44,7 @@ afterEach(() => { function completeInput(overrides: Partial = {}): PieceInput { return { title: 'Getestetes Piece', + outputTitle: '', summary: 'Eine persistente Kurzbeschreibung.', formatId: 'format-talk', status: 'ready', @@ -73,6 +76,45 @@ function completeInput(overrides: Partial = {}): PieceInput { } describe('content management', () => { + it('persists independently versioned templates and copies their complete snapshot', () => { + const sourceId = createPiece('talk'); + const source = getPiece(sourceId)!; + savePiece( + sourceId, + source.revision, + completeInput({ + title: 'Interner Vorlagentitel', + outputTitle: 'Titel für die Ausgabe', + presentationText: 'Unveränderlicher Vorlagentext' + }) + ); + const created = savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Redaktioneller Stand'); + const template = listPieceTemplates().find((entry) => entry.id === created.id)!; + expect(template).toMatchObject({ name: 'Eigene Vorlage', revision: 1 }); + + const changed = getPiece(sourceId)!; + savePiece(sourceId, changed.revision, { + ...changed, + status: 'ready', + presentationText: 'Spätere Änderung' + }); + const copyId = createPiece(created.id); + expect(getPiece(copyId)).toMatchObject({ + title: 'Interner Vorlagentitel', + outputTitle: 'Titel für die Ausgabe', + presentationText: 'Unveränderlicher Vorlagentext', + sources: [ + { label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 } + ] + }); + + const updated = savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Neuer Stand', created.id, 1); + expect(updated.revision).toBe(2); + expect(() => + savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Veraltet', created.id, 1) + ).toThrow('CONFLICT'); + }); + it('persists complete pieces and rejects stale revisions atomically', () => { const id = createPiece('talk'); const initial = getPiece(id)!; diff --git a/src/lib/server/content.ts b/src/lib/server/content.ts index 9db42fb..b08fdbb 100644 --- a/src/lib/server/content.ts +++ b/src/lib/server/content.ts @@ -23,6 +23,7 @@ export type PieceSourceInput = { export type PieceInput = { title: string; + outputTitle: string; summary: string; formatId: string | null; status: EditablePieceStatus; @@ -88,30 +89,12 @@ export type Taxonomy = { topics: { id: string; name: string }[]; }; -export const pieceTemplates = [ - { - id: 'blank', - name: 'Leer starten', - description: 'Ein freies Piece ohne Vorgaben.' - }, - { - id: 'talk', - name: 'Gespräch', - description: 'Leitfragen, Recherche und eine klare Überleitung.' - }, - { - id: 'find', - name: 'Reaction / Fundstück', - description: 'Quelle, Einordnung und präsentierbare Kernaussage.' - }, - { - id: 'interaction', - name: 'Interaktion', - description: 'Quiz, Publikumsfrage oder kleines Spiel.' - } -] as const; - -export type PieceTemplateId = (typeof pieceTemplates)[number]['id']; +export type PieceTemplate = { + id: string; + name: string; + description: string; + revision: number; +}; const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; const ALLOWED_IMAGES = new Map([ @@ -202,6 +185,7 @@ function validatePieceInput(input: PieceInput): PieceInput { sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url); return { title, + outputTitle: cleanText(input.outputTitle, 300, 'output_title'), summary: cleanText(input.summary, 2_000, 'summary'), formatId: input.formatId || null, status: input.status, @@ -251,6 +235,7 @@ export function getTaxonomy(): Taxonomy { type PieceListRow = { id: string; title: string; + output_title: string; summary: string; status: PieceStatus; revision: number; @@ -341,6 +326,7 @@ export function listPieces(filters: { type PieceRow = { id: string; title: string; + output_title: string; summary: string; format_id: string | null; status: PieceStatus; @@ -371,7 +357,7 @@ export function getPiece(id: string): PieceEditor | null { const database = getDatabase(); const row = database .prepare( - `SELECT id, title, summary, format_id, status, duration_seconds, intro, research_text, + `SELECT id, title, output_title, summary, format_id, status, duration_seconds, intro, research_text, moderation_notes, transition_text, presentation_text, presentation_mode, presentation_source_position, presentation_title, presentation_description, presentation_image_url, presentation_site_name, presentation_provider, @@ -385,6 +371,7 @@ export function getPiece(id: string): PieceEditor | null { return { id: row.id, title: row.title, + outputTitle: row.output_title, summary: row.summary, formatId: row.format_id, status: row.status, @@ -484,7 +471,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI const save = database.transaction(() => { const result = database .prepare( - `UPDATE pieces SET title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?, + `UPDATE pieces SET title = ?, output_title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?, intro = ?, research_text = ?, moderation_notes = ?, transition_text = ?, presentation_text = ?, presentation_mode = ?, presentation_source_position = ?, presentation_title = ?, presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?, @@ -496,6 +483,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI ) .run( input.title, + input.outputTitle, input.summary, input.formatId, input.status, @@ -531,50 +519,19 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI return { revision: expectedRevision + 1, updatedAtUtc }; } -function templateDefaults(template: PieceTemplateId): Partial & { title: string } { - if (template === 'talk') { - return { - title: 'Unbenanntes Gespräch', - formatId: 'format-talk', - categoryIds: ['cat-jc'], - moderationNotes: '## Leitfragen\n\n- ' - }; - } - if (template === 'find') { - return { - title: 'Neues Fundstück', - formatId: 'format-find', - categoryIds: ['cat-reaction'], - presentationText: '## Kernaussage\n\n' - }; - } - if (template === 'interaction') { - return { - title: 'Neue Interaktion', - formatId: 'format-quiz', - categoryIds: ['cat-game'], - moderationNotes: '## Ablauf\n\n1. ' - }; - } - return { title: 'Neues Piece' }; -} - -export function createPiece(template: PieceTemplateId = 'blank'): string { - if (!pieceTemplates.some((entry) => entry.id === template)) throw new Error('INVALID_TEMPLATE'); - const defaults = templateDefaults(template); - const id = randomUUID(); - const now = new Date().toISOString(); - const input = validatePieceInput({ - title: defaults.title, +function blankPieceInput(): PieceInput { + return { + title: 'Neues Piece', + outputTitle: '', summary: '', - formatId: defaults.formatId ?? null, + formatId: null, status: 'preparing', durationSeconds: 0, intro: '', researchText: '', - moderationNotes: defaults.moderationNotes ?? '', + moderationNotes: '', transitionText: '', - presentationText: defaults.presentationText ?? '', + presentationText: '', presentationMode: 'text', presentationSourcePosition: null, presentationTitle: '', @@ -588,20 +545,63 @@ export function createPiece(template: PieceTemplateId = 'blank'): string { presentationImageManual: false, presentationMetadataFetchedAtUtc: null, topicIds: [], - categoryIds: defaults.categoryIds ?? [], + categoryIds: [], sources: [] - }); + }; +} + +function templateInput(templateId: string): PieceInput { + if (templateId === 'blank') return blankPieceInput(); + const row = getDatabase() + .prepare('SELECT snapshot_json FROM piece_templates WHERE id = ?') + .get(templateId) as { snapshot_json: string } | undefined; + if (!row) throw new Error('INVALID_TEMPLATE'); + try { + const snapshot = JSON.parse(row.snapshot_json) as Partial; + return validatePieceInput({ ...blankPieceInput(), ...snapshot, status: 'preparing' }); + } catch { + throw new Error('INVALID_TEMPLATE'); + } +} + +export function listPieceTemplates(): PieceTemplate[] { + const rows = getDatabase() + .prepare('SELECT id, name, description, revision FROM piece_templates ORDER BY name') + .all() as PieceTemplate[]; + return [ + { + id: 'blank', + name: 'Leer starten', + description: 'Ein freies Piece ohne Vorgaben.', + revision: 0 + }, + ...rows.filter((template) => { + try { + templateInput(template.id); + return true; + } catch { + return false; + } + }) + ]; +} + +export function createPiece(template = 'blank'): string { + const input = templateInput(template); + const id = randomUUID(); + const now = new Date().toISOString(); const database = getDatabase(); database.transaction(() => { database .prepare( - `INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro, + `INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro, research_text, moderation_notes, transition_text, presentation_text, created_at_utc, - updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` + updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` ) .run( id, input.title, + input.outputTitle, input.summary, input.formatId, input.status, @@ -619,13 +619,68 @@ export function createPiece(template: PieceTemplateId = 'blank'): string { return id; } +export function savePieceAsTemplate( + pieceId: string, + nameValue: string, + descriptionValue: string, + templateId?: string, + expectedRevision?: number +) { + const piece = getPiece(pieceId); + if (!piece) throw new Error('NOT_FOUND'); + const name = cleanText(nameValue, 120, 'template_name'); + if (!name) throw new Error('INVALID_TEMPLATE_NAME'); + const description = cleanText(descriptionValue, 500, 'template_description'); + const snapshot: Partial = structuredClone(piece); + delete snapshot.id; + delete snapshot.revision; + delete snapshot.createdAtUtc; + delete snapshot.updatedAtUtc; + delete snapshot.media; + snapshot.status = 'preparing'; + const snapshotJson = JSON.stringify(snapshot); + const database = getDatabase(); + const now = new Date().toISOString(); + if (!templateId) { + const id = randomUUID(); + database + .prepare( + `INSERT INTO piece_templates (id, name, description, snapshot_json, revision, created_at_utc, updated_at_utc) + VALUES (?, ?, ?, ?, 1, ?, ?)` + ) + .run(id, name, description, snapshotJson, now, now); + return { id, revision: 1 }; + } + if (!Number.isInteger(expectedRevision) || Number(expectedRevision) < 1) { + throw new Error('INVALID_REVISION'); + } + const result = database + .prepare( + `UPDATE piece_templates SET name = ?, description = ?, snapshot_json = ?, + revision = revision + 1, updated_at_utc = ? WHERE id = ? AND revision = ?` + ) + .run(name, description, snapshotJson, now, templateId, expectedRevision); + if (result.changes !== 1) { + const exists = database.prepare('SELECT 1 FROM piece_templates WHERE id = ?').get(templateId); + throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND'); + } + return { id: templateId, revision: Number(expectedRevision) + 1 }; +} + +export function deletePieceTemplate(templateId: string, expectedRevision: number) { + const result = getDatabase() + .prepare('DELETE FROM piece_templates WHERE id = ? AND revision = ?') + .run(templateId, expectedRevision); + if (result.changes !== 1) throw new Error('CONFLICT'); +} + export function captureInbox(titleValue: string, urlValue: string): string { let title = cleanText(titleValue, 200, 'title'); const url = cleanText(urlValue, 2_000, 'source'); if (url && !validUrl(url)) throw new Error('INVALID_SOURCES'); if (!title && !url) throw new Error('INVALID_TITLE'); if (!title) title = new URL(url).hostname; - const id = createPiece('blank'); + const id = createPiece(); const piece = getPiece(id)!; savePiece(id, piece.revision, { ...piece, @@ -692,18 +747,19 @@ export async function duplicatePiece(sourceId: string): Promise { database.transaction(() => { database .prepare( - `INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro, + `INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro, research_text, moderation_notes, transition_text, presentation_text, presentation_mode, presentation_source_position, presentation_title, presentation_description, presentation_image_url, presentation_site_name, presentation_provider, presentation_provider_id, presentation_title_manual, presentation_description_manual, presentation_image_manual, presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision) - VALUES (?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` + VALUES (?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` ) .run( id, `Kopie von ${source.title}`.slice(0, 200), + source.outputTitle, source.summary, source.formatId, source.durationSeconds, diff --git a/src/lib/server/database.spec.ts b/src/lib/server/database.spec.ts index 1cd7de7..3e0c663 100644 --- a/src/lib/server/database.spec.ts +++ b/src/lib/server/database.spec.ts @@ -62,7 +62,7 @@ describe('database setup', () => { database.close(); expect(counts).toEqual({ - migrations: 6, + migrations: 8, pieces: 6, shows: 2, usages: 5, diff --git a/src/lib/server/live.spec.ts b/src/lib/server/live.spec.ts index 8143ca3..97d1a08 100644 --- a/src/lib/server/live.spec.ts +++ b/src/lib/server/live.spec.ts @@ -6,13 +6,18 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { closeDatabase, getDatabase } from './database.js'; import { completeCurrent, + clearPresentationChannel, + getPresentationChannelView, getLiveView, getPresentationMedia, getPresentationView, goBack, issuePresentationAccess, moveOpenUsage, + presentationChannelAccess, presentUsage, + rotatePresentationChannelKey, + selectUsage, skipCurrent, startOrResumeLive } from './live.js'; @@ -35,6 +40,57 @@ afterEach(() => { }); describe('live mode', () => { + it('selects any rundown usage by stable id, including the first and completed entries', () => { + let live = getLiveView('show-october'); + expect(() => + selectUsage('show-october', 'usage-effects', 'select-before-start', live.liveRevision) + ).toThrow('NOT_STARTED'); + startOrResumeLive('show-october', 'start-selection', live.liveRevision); + live = getLiveView('show-october'); + selectUsage('show-october', 'usage-effects', 'select-second', live.liveRevision); + live = getLiveView('show-october'); + expect(live.current?.id).toBe('usage-effects'); + + selectUsage('show-october', 'usage-console', 'select-first', live.liveRevision); + live = getLiveView('show-october'); + expect(live.current?.id).toBe('usage-console'); + completeCurrent('show-october', 'complete-first', live.liveRevision); + live = getLiveView('show-october'); + expect( + live.sections + .flatMap((section) => section.usages) + .find((usage) => usage.id === 'usage-console')?.status + ).toBe('done'); + + selectUsage('show-october', 'usage-console', 'return-to-completed', live.liveRevision); + expect(getLiveView('show-october').current).toMatchObject({ + id: 'usage-console', + status: 'current' + }); + }); + + it('keeps one stable output channel until its key is explicitly rotated', () => { + const key = issuePresentationAccess('show-october'); + expect(issuePresentationAccess('show-october')).toBe(key); + expect(presentationChannelAccess('default', key)).toBe(true); + expect(getPresentationChannelView('default')).toEqual({ + revision: 1_000_000_000, + content: null + }); + + const replacement = rotatePresentationChannelKey('show-october'); + expect(replacement).not.toBe(key); + expect(presentationChannelAccess('default', key)).toBe(false); + expect(presentationChannelAccess('default', replacement)).toBe(true); + startOrResumeLive('show-october', 'start-channel', 0); + presentUsage('show-october', 'usage-console', 'present-channel', 1); + expect(getPresentationChannelView('default')?.content).not.toBeNull(); + clearPresentationChannel(); + expect(getPresentationChannelView('default')?.content).toBeNull(); + issuePresentationAccess('show-october'); + expect(getPresentationChannelView('default')?.content).toBeNull(); + }); + it('restores live state, records actual intervals and applies commands only once', () => { const started = new Date('2026-10-22T18:00:00.000Z'); const initial = getLiveView('show-october', started); @@ -115,7 +171,8 @@ describe('live mode', () => { const output = getPresentationView(token)!; expect(output.content).toMatchObject({ title: 'Die kleine Konsole mit dem großen Dreh', - images: [{ id: 'presentation-image', altText: 'Konsole auf dem Tisch' }] + images: [], + sources: [] }); const serialized = JSON.stringify(output); expect(serialized).toContain('Weniger Leistung, mehr Idee.'); @@ -125,11 +182,14 @@ describe('live mode', () => { expect(serialized).not.toContain('presentation.png'); const media = await getPresentationMedia(token, 'presentation-image'); - expect(media?.data.toString()).toBe('test-image'); + expect(media).toBeNull(); expect(await getPresentationMedia(token, 'unknown-image')).toBeNull(); const replacementToken = issuePresentationAccess('show-october'); - expect(getPresentationView(token)).toBeNull(); + expect(replacementToken).toBe(token); + expect(getPresentationView(token)?.content?.title).toBe( + 'Die kleine Konsole mit dem großen Dreh' + ); expect(getPresentationView(replacementToken)?.content?.title).toBe( 'Die kleine Konsole mit dem großen Dreh' ); diff --git a/src/lib/server/live.ts b/src/lib/server/live.ts index be8e46b..86b6d02 100644 --- a/src/lib/server/live.ts +++ b/src/lib/server/live.ts @@ -1,6 +1,5 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto'; import type Database from 'better-sqlite3'; -import { getMediaFile } from './content.js'; import { getDatabase } from './database.js'; import { renderMarkdown } from './markdown.js'; import { buildEmbedUrl, fetchPresentationImage, type PresentationContent } from './presentation.js'; @@ -414,6 +413,34 @@ export function goBack( ); } +export function selectUsage( + showId: string, + usageId: string, + commandId: string, + expectedRevision: number, + nowValue?: Date +) { + return executeCommand( + showId, + commandId, + expectedRevision, + 'select', + (database, state, now) => { + if (!state.started_at_utc) throw new Error('NOT_STARTED'); + usageBelongsToShow(database, showId, usageId); + if (state.current_usage_id === usageId) return; + if (state.current_usage_id) { + closeActiveRun(database, showId, now); + database + .prepare("UPDATE piece_usages SET status = 'open' WHERE id = ?") + .run(state.current_usage_id); + } + activateUsage(database, showId, usageId, now); + }, + nowValue + ); +} + export function presentUsage( showId: string, usageId: string | null, @@ -480,25 +507,90 @@ export function issuePresentationAccess(showId: string) { const database = getDatabase(); const now = nowIso(); ensureLiveState(database, showId, now); - const token = randomBytes(32).toString('base64url'); - database.transaction(() => { - database - .prepare( - 'UPDATE presentation_access_tokens SET revoked_at_utc = ? WHERE show_id = ? AND revoked_at_utc IS NULL' - ) - .run(now, showId); - database - .prepare( - `INSERT INTO presentation_access_tokens (id, show_id, token_hash, created_at_utc) - VALUES (?, ?, ?, ?)` - ) - .run(randomUUID(), showId, hashToken(token), now); + return database.transaction(() => { + let channel = database + .prepare("SELECT * FROM presentation_channels WHERE id = 'default'") + .get() as { access_key: string; show_id: string | null } | undefined; + if (!channel) { + const accessKey = randomBytes(32).toString('base64url'); + clearPresentationState(database, showId); + database + .prepare( + `INSERT INTO presentation_channels + (id, name, access_key, key_hash, show_id, revision, created_at_utc, updated_at_utc) + VALUES ('default', 'Standardausgabe', ?, ?, ?, 1, ?, ?)` + ) + .run(accessKey, hashToken(accessKey), showId, now, now); + channel = { access_key: accessKey, show_id: showId }; + } else if (channel.show_id !== showId) { + clearPresentationState(database, showId); + database + .prepare( + `UPDATE presentation_channels SET show_id = ?, revision = revision + 1, + updated_at_utc = ? WHERE id = 'default'` + ) + .run(showId, now); + } + return channel.access_key; })(); - return token; +} + +function clearPresentationState(database: Database.Database, showId: string) { + database + .prepare( + `UPDATE show_live_states SET presentation_usage_id = NULL, + presentation_revision = presentation_revision + 1, updated_at_utc = ? + WHERE show_id = ? AND presentation_usage_id IS NOT NULL` + ) + .run(nowIso(), showId); +} + +export function clearPresentationChannel() { + const now = nowIso(); + getDatabase().transaction(() => { + const database = getDatabase(); + const channel = database + .prepare("SELECT show_id FROM presentation_channels WHERE id = 'default'") + .get() as { show_id: string | null } | undefined; + if (!channel) throw new Error('NOT_FOUND'); + if (channel.show_id) clearPresentationState(database, channel.show_id); + database + .prepare( + `UPDATE presentation_channels SET show_id = NULL, revision = revision + 1, + updated_at_utc = ? WHERE id = 'default'` + ) + .run(now); + })(); +} + +export function rotatePresentationChannelKey(showId: string) { + issuePresentationAccess(showId); + const accessKey = randomBytes(32).toString('base64url'); + const now = nowIso(); + getDatabase() + .prepare( + `UPDATE presentation_channels SET access_key = ?, key_hash = ?, revision = revision + 1, + updated_at_utc = ? WHERE id = 'default'` + ) + .run(accessKey, hashToken(accessKey), now); + return accessKey; +} + +export function presentationChannelAccess(channelId: string, accessKey: string) { + if (!channelId || !accessKey || accessKey.length > 200) return false; + return Boolean( + getDatabase() + .prepare('SELECT 1 FROM presentation_channels WHERE id = ? AND key_hash = ?') + .get(channelId, hashToken(accessKey)) + ); } function presentationShowId(database: Database.Database, token: string) { if (!token || token.length > 200) return null; + const channel = database + .prepare('SELECT show_id FROM presentation_channels WHERE key_hash = ?') + .get(hashToken(token)) as { show_id: string | null } | undefined; + if (channel) return channel.show_id; const row = database .prepare( `SELECT show_id FROM presentation_access_tokens @@ -508,15 +600,30 @@ function presentationShowId(database: Database.Database, token: string) { return row?.show_id ?? null; } +export function getPresentationChannelView(channelId: string): PresentationView | null { + const channel = getDatabase() + .prepare('SELECT show_id, revision FROM presentation_channels WHERE id = ?') + .get(channelId) as { show_id: string | null; revision: number } | undefined; + if (!channel) return null; + if (!channel.show_id) return { revision: channel.revision * 1_000_000_000, content: null }; + const view = getPresentationViewForShow(channel.show_id); + return { ...view, revision: channel.revision * 1_000_000_000 + view.revision }; +} + export function getPresentationView(token: string): PresentationView | null { const database = getDatabase(); const showId = presentationShowId(database, token); if (!showId) return null; + return getPresentationViewForShow(showId); +} + +function getPresentationViewForShow(showId: string): PresentationView { + const database = getDatabase(); const state = ensureLiveState(database, showId, nowIso()); if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null }; const usage = database .prepare( - `SELECT u.title_snapshot, u.presentation_snapshot, u.original_piece_id, + `SELECT u.title_snapshot, u.output_title_snapshot, u.presentation_snapshot, u.original_piece_id, u.presentation_mode_snapshot, u.presentation_source_label_snapshot, u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot, u.presentation_title_meta_snapshot, u.presentation_description_snapshot, @@ -528,6 +635,7 @@ export function getPresentationView(token: string): PresentationView | null { .get(state.presentation_usage_id, showId) as | { title_snapshot: string; + output_title_snapshot: string; presentation_snapshot: string; original_piece_id: string | null; presentation_mode_snapshot: PresentationContent['mode']; @@ -543,33 +651,11 @@ export function getPresentationView(token: string): PresentationView | null { } | undefined; if (!usage) return { revision: state.presentation_revision, content: null }; - const sources = - usage.presentation_mode_snapshot === 'text' && usage.original_piece_id - ? (database - .prepare( - `SELECT label, url, timecode_seconds AS timecodeSeconds - FROM piece_sources WHERE piece_id = ? ORDER BY position` - ) - .all(usage.original_piece_id) as { - label: string; - url: string; - timecodeSeconds: number | null; - }[]) - : []; - const images = - usage.presentation_mode_snapshot === 'text' && usage.original_piece_id - ? (database - .prepare( - `SELECT id, alt_text AS altText FROM piece_media - WHERE piece_id = ? ORDER BY position` - ) - .all(usage.original_piece_id) as { id: string; altText: string }[]) - : []; return { revision: state.presentation_revision, content: { mode: usage.presentation_mode_snapshot, - title: usage.title_snapshot, + title: usage.output_title_snapshot || usage.title_snapshot, html: renderMarkdown(usage.presentation_snapshot), source: usage.presentation_source_url_snapshot ? { @@ -589,8 +675,8 @@ export function getPresentationView(token: string): PresentationView | null { usage.presentation_provider_id_snapshot, usage.presentation_source_timecode_snapshot ), - sources, - images + sources: [], + images: [] } }; } @@ -605,19 +691,11 @@ export async function getPresentationPreviewImage(token: string) { } } -export async function getPresentationMedia(token: string, mediaId: string) { - const database = getDatabase(); - const showId = presentationShowId(database, token); - if (!showId) return null; - const allowed = database - .prepare( - `SELECT 1 FROM show_live_states l - JOIN piece_usages u ON u.id = l.presentation_usage_id - JOIN show_sections s ON s.id = u.show_section_id - JOIN piece_media m ON m.piece_id = u.original_piece_id - WHERE l.show_id = ? AND s.show_id = ? AND m.id = ?` - ) - .get(showId, showId, mediaId); - if (!allowed) return null; - return getMediaFile(mediaId); +export async function getPresentationMedia( + token: string, + mediaId: string +): Promise<{ data: Buffer; mimeType: string; byteSize: number; altText: string } | null> { + void token; + void mediaId; + return null; } diff --git a/src/lib/server/shows.spec.ts b/src/lib/server/shows.spec.ts index da644ec..8a5815f 100644 --- a/src/lib/server/shows.spec.ts +++ b/src/lib/server/shows.spec.ts @@ -9,6 +9,7 @@ import { addPieceUsage, addShowSection, createShow, + createAndAddPieceUsage, duplicatePieceUsage, getPieceUsageContexts, getShow, @@ -44,6 +45,7 @@ function editedUsage(usage: PieceUsage, overrides: Partial = {}) { const edited = { ...usage, ...overrides }; return { title: edited.title, + outputTitle: edited.outputTitle, intro: edited.intro, research: edited.research, moderation: edited.moderation, @@ -63,6 +65,64 @@ function editedUsage(usage: PieceUsage, overrides: Partial = {}) { } describe('show builder data layer', () => { + it('creates a fundus original and usage atomically and idempotently', () => { + const created = createShow({ title: 'Direkte Planung', startsAtUtc: null, templateId: null }); + const section = addShowSection(created.id, created.revision, { + name: 'Ablauf', + categoryId: null, + explicitDurationSeconds: null + }); + const before = ( + getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number } + ).count; + const first = createAndAddPieceUsage(created.id, section.revision, 'new-piece-command', { + title: 'Direkt geplantes Piece', + templateId: 'talk', + sectionId: section.id + }); + const repeated = createAndAddPieceUsage(created.id, section.revision, 'new-piece-command', { + title: 'Darf nicht doppelt entstehen', + templateId: 'blank', + sectionId: section.id + }); + expect(repeated).toEqual({ ...first, duplicate: true }); + expect(getShow(created.id)?.sections[0].usages).toHaveLength(1); + expect(getPiece(first.pieceId)).toMatchObject({ + title: 'Direkt geplantes Piece', + status: 'ready', + formatId: 'format-talk' + }); + expect( + (getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number }) + .count + ).toBe(before + 1); + + expect(() => + createAndAddPieceUsage(created.id, first.revision, 'failing-command', { + title: 'Rollback', + templateId: 'blank', + sectionId: 'missing' + }) + ).toThrow('NOT_FOUND'); + expect( + (getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number }) + .count + ).toBe(before + 1); + + const removed = removePieceUsage(created.id, first.usageId, first.revision); + const afterRemoval = createAndAddPieceUsage(created.id, removed.revision, 'new-piece-command', { + title: 'Darf auch nach dem Entfernen nicht neu entstehen', + templateId: 'blank', + sectionId: section.id + }); + expect(afterRemoval).toEqual({ ...first, duplicate: true }); + expect(getShow(created.id)?.sections[0].usages).toHaveLength(0); + expect( + (getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number }) + .count + ).toBe(before + 1); + }); + it('creates blank and templated shows with independent sections and reserve banks', () => { const templatesBefore = listShowTemplates(); expect( @@ -360,7 +420,8 @@ describe('show builder data layer', () => { .run('https://youtu.be/dQw4w9WgXcQ'); getDatabase() .prepare( - `UPDATE pieces SET presentation_mode = 'video', presentation_source_position = 0, + `UPDATE pieces SET output_title = 'Öffentlicher Ausgabetitel', + presentation_mode = 'video', presentation_source_position = 0, presentation_title = 'Gespeicherte Vorschau', presentation_description = 'Erster Stand', presentation_site_name = 'YouTube', presentation_provider = 'youtube', presentation_provider_id = 'dQw4w9WgXcQ' WHERE id = 'piece-console'` @@ -378,6 +439,7 @@ describe('show builder data layer', () => { }); let usage = getShow(show.id)!.sections[0].usages[0]; expect(usage).toMatchObject({ + outputTitle: 'Öffentlicher Ausgabetitel', presentationMode: 'video', presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ', presentationSourceTimecodeSeconds: 75, diff --git a/src/lib/server/shows.ts b/src/lib/server/shows.ts index 89d5c46..d0fb778 100644 --- a/src/lib/server/shows.ts +++ b/src/lib/server/shows.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import type Database from 'better-sqlite3'; +import { createPiece } from './content.js'; import { getDatabase } from './database.js'; import { detectVideoProvider, type PresentationMode } from './presentation.js'; @@ -42,6 +43,7 @@ export type PieceUsage = { status: UsageStatus; durationSeconds: number; title: string; + outputTitle: string; intro: string; research: string; moderation: string; @@ -129,6 +131,7 @@ export type PieceUsageInput = { export type PieceUsageUpdate = { title: string; + outputTitle: string; intro: string; research: string; moderation: string; @@ -185,6 +188,7 @@ type UsageRow = { status: UsageStatus; duration_seconds: number; title_snapshot: string; + output_title_snapshot: string; intro_snapshot: string; research_snapshot: string; moderation_snapshot: string; @@ -376,6 +380,7 @@ function usageFromRow(row: UsageRow): PieceUsage { status: row.status, durationSeconds: row.duration_seconds, title: row.title_snapshot, + outputTitle: row.output_title_snapshot, intro: row.intro_snapshot, research: row.research_snapshot, moderation: row.moderation_snapshot, @@ -478,7 +483,7 @@ export function getShow(id: string): Show | null { const usageRows = database .prepare( `SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status, - u.duration_seconds, u.title_snapshot, u.intro_snapshot, u.research_snapshot, + u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot, u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot, u.presentation_mode_snapshot, u.presentation_source_label_snapshot, u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot, @@ -805,7 +810,7 @@ export function addPieceUsage( sectionBelongsToShow(database, showId, sectionId, input.sectionId === null); const piece = database .prepare( - `SELECT p.title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes, + `SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes, p.transition_text, p.presentation_text, p.status, p.presentation_mode, COALESCE(ps.label, '') AS presentation_source_label, COALESCE(ps.url, '') AS presentation_source_url, @@ -818,6 +823,7 @@ export function addPieceUsage( .get(input.pieceId) as | { title: string; + output_title: string; duration_seconds: number; intro: string; research_text: string; @@ -849,14 +855,14 @@ export function addPieceUsage( database .prepare( `INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, - duration_seconds, title_snapshot, intro_snapshot, research_snapshot, + duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot, presentation_mode_snapshot, presentation_source_label_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) - VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)` + VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)` ) .run( id, @@ -865,6 +871,7 @@ export function addPieceUsage( ids.length, piece.duration_seconds, piece.title, + piece.output_title, piece.intro, piece.research_text, piece.moderation_notes, @@ -888,6 +895,60 @@ export function addPieceUsage( })(); } +export function createAndAddPieceUsage( + showId: string, + expectedRevision: number, + commandId: string, + input: Omit & { title: string; templateId: string } +) { + if (!commandId || commandId.length > 200) throw new Error('INVALID_COMMAND'); + const title = cleanText(input.title, 200, 'title', true); + const database = getDatabase(); + return database.transaction(() => { + const duplicate = database + .prepare( + `SELECT piece_id, usage_id, result_revision FROM planning_commands + WHERE show_id = ? AND command_id = ?` + ) + .get(showId, commandId) as + { piece_id: string; usage_id: string; result_revision: number } | undefined; + if (duplicate) { + return { + pieceId: duplicate.piece_id, + usageId: duplicate.usage_id, + revision: duplicate.result_revision, + duplicate: true + }; + } + + const pieceId = createPiece(input.templateId || 'blank'); + database + .prepare( + `UPDATE pieces SET title = ?, status = 'ready', revision = revision + 1, + updated_at_utc = ? WHERE id = ?` + ) + .run(title, new Date().toISOString(), pieceId); + const usage = addPieceUsage(showId, expectedRevision, { + pieceId, + sectionId: input.sectionId, + position: input.position + }); + database + .prepare( + `INSERT INTO planning_commands + (show_id, command_id, piece_id, usage_id, result_revision, created_at_utc) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .run(showId, commandId, pieceId, usage.id, usage.revision, new Date().toISOString()); + return { + pieceId, + usageId: usage.id, + revision: usage.revision, + duplicate: false + }; + })(); +} + export function duplicatePieceUsage( showId: string, usageId: string, @@ -899,7 +960,7 @@ export function duplicatePieceUsage( const sourceSection = usageSection(database, showId, usageId); const source = database .prepare( - `SELECT original_piece_id, status, duration_seconds, title_snapshot, intro_snapshot, + `SELECT original_piece_id, status, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot, presentation_mode_snapshot, presentation_source_label_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot, @@ -922,14 +983,14 @@ export function duplicatePieceUsage( database .prepare( `INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, - duration_seconds, title_snapshot, intro_snapshot, research_snapshot, + duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot, presentation_mode_snapshot, presentation_source_label_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) - VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( id, @@ -938,6 +999,7 @@ export function duplicatePieceUsage( ids.length, source.duration_seconds, source.title_snapshot, + source.output_title_snapshot, source.intro_snapshot, source.research_snapshot, source.moderation_snapshot, @@ -973,7 +1035,7 @@ export function refreshPieceUsageFromOriginal( usageSection(database, showId, usageId); const source = database .prepare( - `SELECT p.title, p.duration_seconds, p.intro, p.research_text, + `SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes, p.transition_text, p.presentation_text, p.presentation_mode, COALESCE(ps.label, '') AS presentation_source_label, COALESCE(ps.url, '') AS presentation_source_url, @@ -988,6 +1050,7 @@ export function refreshPieceUsageFromOriginal( .get(usageId) as | { title: string; + output_title: string; duration_seconds: number; intro: string; research_text: string; @@ -1009,7 +1072,7 @@ export function refreshPieceUsageFromOriginal( if (!source) throw new Error('ORIGINAL_NOT_FOUND'); database .prepare( - `UPDATE piece_usages SET title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?, + `UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?, research_snapshot = ?, moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?, presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?, @@ -1020,6 +1083,7 @@ export function refreshPieceUsageFromOriginal( ) .run( source.title, + source.output_title, source.duration_seconds, source.intro, source.research_text, @@ -1049,6 +1113,7 @@ export function updatePieceUsage( input: PieceUsageUpdate ): MutationResult { const title = cleanText(input.title, 200, 'title', true); + const outputTitle = cleanText(input.outputTitle, 300, 'output_title'); const duration = validDuration(input.durationSeconds)!; const values = { intro: cleanText(input.intro, 50_000, 'intro'), @@ -1074,7 +1139,7 @@ export function updatePieceUsage( usageSection(database, showId, usageId); database .prepare( - `UPDATE piece_usages SET title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?, + `UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?, moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?, presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?, @@ -1085,6 +1150,7 @@ export function updatePieceUsage( ) .run( title, + outputTitle, values.intro, values.research, values.moderation, diff --git a/src/routes/fundus/+page.server.ts b/src/routes/fundus/+page.server.ts index 9d94ccb..05f45da 100644 --- a/src/routes/fundus/+page.server.ts +++ b/src/routes/fundus/+page.server.ts @@ -3,12 +3,12 @@ import type { Actions, PageServerLoad } from './$types'; import { requireUser } from '#lib/server/auth.js'; import { createPiece, + deletePieceTemplate, duplicatePiece, getTaxonomy, listPieces, - pieceTemplates, - setPieceArchived, - type PieceTemplateId + listPieceTemplates, + setPieceArchived } from '#lib/server/content.js'; import { getPieceUsageContexts } from '#lib/server/shows.js'; @@ -39,7 +39,7 @@ export const load: PageServerLoad = ({ locals, url }) => { return { pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })), taxonomy: getTaxonomy(), - templates: pieceTemplates, + templates: listPieceTemplates(), filters }; }; @@ -57,12 +57,22 @@ export const actions = { const data = await request.formData(); let id: string; try { - id = createPiece(String(data.get('template') || 'blank') as PieceTemplateId); + id = createPiece(String(data.get('template') || 'blank')); } catch (cause) { return fail(400, { intent: 'create', error: actionError(cause) }); } redirect(303, `/fundus/${id}`); }, + deleteTemplate: async ({ locals, request }) => { + requireUser(locals.user); + const data = await request.formData(); + try { + deletePieceTemplate(String(data.get('templateId') || ''), Number(data.get('revision'))); + return { intent: 'deleteTemplate', success: 'Vorlage gelöscht.' }; + } catch (cause) { + return fail(409, { intent: 'deleteTemplate', error: actionError(cause) }); + } + }, duplicate: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); diff --git a/src/routes/fundus/+page.svelte b/src/routes/fundus/+page.svelte index f8ab173..f949a6a 100644 --- a/src/routes/fundus/+page.svelte +++ b/src/routes/fundus/+page.svelte @@ -65,15 +65,35 @@
Neues Piece -
- {#each data.templates as template (template.id)} -
- - -
- {/each} +
+
+ {#each data.templates as template (template.id)} +
+ + +
+ {/each} +
+ {#if data.templates.some((template) => template.id !== 'blank')}
+ Vorlagen verwalten + {#each data.templates.filter((template) => template.id !== 'blank') as template (template.id)} +
+ !confirm(`Vorlage „${template.name}“ löschen?`) && event.preventDefault()} + > + + + {template.name} + +
+ {/each} +
{/if}
diff --git a/src/routes/fundus/[id]/+page.server.ts b/src/routes/fundus/[id]/+page.server.ts index a5d4df4..dd0892f 100644 --- a/src/routes/fundus/[id]/+page.server.ts +++ b/src/routes/fundus/[id]/+page.server.ts @@ -6,8 +6,10 @@ import { duplicatePiece, getPiece, getTaxonomy, + listPieceTemplates, loadPiecePresentationMetadata, savePiece, + savePieceAsTemplate, setPieceArchived, uploadPieceImage, type EditablePieceStatus, @@ -57,6 +59,7 @@ function pieceInput(data: FormData): PieceInput { } return { title: text(data, 'title'), + outputTitle: text(data, 'outputTitle'), summary: text(data, 'summary'), formatId: text(data, 'formatId') || null, status: text(data, 'status') as EditablePieceStatus, @@ -120,7 +123,12 @@ export const load: PageServerLoad = ({ locals, params }) => { const piece = getPiece(params.id); if (!piece) error(404, 'Piece nicht gefunden'); if (piece.status === 'archived') redirect(303, '/archiv'); - return { piece, taxonomy: getTaxonomy(), rendered: renderedFields(piece) }; + return { + piece, + taxonomy: getTaxonomy(), + templates: listPieceTemplates().filter((template) => template.id !== 'blank'), + rendered: renderedFields(piece) + }; }; export const actions = { @@ -161,6 +169,25 @@ export const actions = { } redirect(303, `/fundus/${params.id}`); }, + saveTemplate: async ({ locals, params, request }) => { + requireUser(locals.user); + const data = await request.formData(); + const reference = text(data, 'templateRef'); + const [templateId, revision] = reference.split('|'); + try { + savePieceAsTemplate( + params.id, + text(data, 'templateName'), + text(data, 'templateDescription'), + templateId || undefined, + revision ? Number(revision) : undefined + ); + return { intent: 'saveTemplate', success: 'Vorlage gespeichert.' }; + } catch (cause) { + const problem = saveError(cause); + return fail(problem.status, { intent: 'saveTemplate', ...problem }); + } + }, upload: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); diff --git a/src/routes/fundus/[id]/+page.svelte b/src/routes/fundus/[id]/+page.svelte index b315427..4268f78 100644 --- a/src/routes/fundus/[id]/+page.svelte +++ b/src/routes/fundus/[id]/+page.svelte @@ -44,7 +44,7 @@ ); let previewContent = $derived({ mode: draft.presentationMode, - title: draft.title, + title: draft.outputTitle || draft.title, html: rendered.presentation, source: featuredSource ? { @@ -413,6 +413,14 @@
+