diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 8336e95..d88c020 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -337,3 +337,36 @@ - `bash -n start.sh`: erfolgreich - reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt; Anbieterrequests wurden in den Browsertests lokal abgefangen + +## Live-Werkbank 0.5.9 + +| Kriterium | Nachweis | Stand | +| ---------------------------------------------- | -------------------------------------------------------------------------------------------------- | --------------------- | +| Favorit ist globale Originaleigenschaft | Service prüft Revision/Status; Browser prüft Stern, Sortierung und weiterhin wirksamen Planfilter | erfüllt am 23.10.2026 | +| Werkzeugkatalog und Rechte | Barkeeper-Verwaltung serverseitig geschützt; Browser prüft Anlage, Formatbezug und Deaktivierung | erfüllt am 23.10.2026 | +| Deaktivierte Zuordnung bleibt bearbeitbar | Browser öffnet das Piece erneut und findet Werkzeug, Arbeitslink, Notiz und Deaktivierungsstatus | erfüllt am 23.10.2026 | +| Relationen werden unveränderlich gesnapshottet | Service ändert Quelle, Werkzeug und Thema und sieht Änderungen erst nach expliziter Aktualisierung | erfüllt am 23.10.2026 | +| Private Live-Materialien bleiben privat | Live-Steuerung zeigt Snapshot-Quelle; anonymes HTML/JSON enthält den privaten Materiallink nicht | erfüllt am 23.10.2026 | +| Thematische Publikum-Dachzeile | Usage-Themen werden stabil projiziert; Chromium zeigt „Gaming“ oberhalb des Präsentationstitels | erfüllt am 23.10.2026 | +| Persistenter Abschnitts-Countdown | kontrollierte Testzeit prüft eingefrorenes Soll, Reload, Piece-Wechsel und Abschluss ohne Reset | erfüllt am 23.10.2026 | +| Responsive Bedienung | komplette Browsermatrix und gezielter Werkzeugeditor bei 390 × 844 ohne horizontalen Überlauf | 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.9 + +- `npm run lint`: erfolgreich +- `npm run check`: 0 Fehler und 0 Warnungen +- `npm test`: 37 Tests in 7 Dateien erfolgreich +- `npm run test:e2e`: 16 von 16 Chromium-Browsertests erfolgreich +- zusätzlicher gezielter Chromium-Lauf des Werkzeugeditors bei 390 × 844 Pixeln: + 1 von 1 erfolgreich, ohne horizontalen Überlauf +- Browsermatrix bei 390, 768, 960, 1280 und 1440 Pixeln einschließlich + Light/Dark, Planung, Live-Steuerung, anonymer Ausgabe und privater Materialien + erfolgreich; Screenshots wurden durch die Suite erzeugt +- `npm run build`: erfolgreich mit `@sveltejs/adapter-node` +- lokale Entwicklungsdatenbank vorab nach + `.data/backups/0.5.9-pre-migration/sendewerk.sqlite` gesichert; + `npm run db:migrate` hat `0009_live_workbench.sql` erfolgreich angewendet +- `bash -n start.sh` und `git diff --check`: 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 875ca82..cf5fa2a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -37,18 +37,18 @@ Die ersten fünf Etappen materialisieren Identität, Inhaltsverwaltung, Sendungsplanung und Live-Durchführung. Folgende Grenzen gelten für umgesetzte und spätere Bereiche. -| Bereich | Geplante Entitäten und Beziehungen | -| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| 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; 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 | -| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit | -| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen | -| API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel | -| Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie | +| Bereich | Geplante Entitäten und Beziehungen | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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, Favorit, Dauer, Revision, Texten, Quellen, Werkzeugzuordnungen und privaten Medien; Vorlagen speichern Snapshots | +| Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themensnapshots, Reservebank und Vorlagen; Original-ID bleibt Referenz | +| Live | expliziter Live-Stand, aktuelles Element, tatsächliche Piece-Zeiten, persistente Abschnittstimer und getrennte Präsentationsausgabe | +| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet | +| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit | +| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen | +| API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel | +| Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie | ### Historien- und Kopierregel @@ -64,6 +64,11 @@ oder eine explizite Abschnittsdauer. Beides wird nie addiert. Reserveelemente zählen erst nach dem Einplanen. Geplante und tatsächliche Dauer bleiben getrennt. +Ein Live-Abschnittstimer entsteht ausschließlich beim tatsächlichen Eintritt in +einen Abschnitt mit positiver expliziter Dauer. Er speichert dieses Soll als +unveränderlichen Wert und summiert eigene Laufintervalle; Piece-Wechsel im +selben Abschnitt erzeugen deshalb weder Reset noch doppelte Piece-Laufzeit. + ## Anmeldung und Rechte Etappe 2 ergänzt lokale Konten mit den festen Rollen `barkeeper` und @@ -97,6 +102,11 @@ erhalten ausschließlich kompakte Piece-Daten; private Recherche und vollständi Redaktionstexte werden nur an den angemeldeten Editor ausgeliefert. Themen und Abschnittskategorien sind Mehrfachzuordnungen, das Format bleibt eine einzelne unabhängige Zuordnung. Quellen werden gemeinsam mit einem Piece atomar ersetzt. +`is_favorite` ist eine globale Eigenschaft des Originals und keine Nutzung oder +Historienmarkierung. Der Werkzeugkatalog liegt getrennt von Formaten; Formate +sind nur Eignungszuordnungen. `piece_tools` hält pro Piece den privaten +Arbeitslink und eine Notiz, während Deaktivierung das Katalogobjekt und alle +bestehenden Zuordnungen erhält. Ein Piece kann für die Präsentation Text oder genau eine vorhandene Quelle als Linkkarte beziehungsweise Video auswählen. Abgerufene Metadaten und Kennzeichen für manuell korrigierte Felder liegen am Piece; ein erneuter Abruf überschreibt @@ -137,7 +147,9 @@ weitere Bearbeitung geschützt. Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation, Überleitung und der vollständige Präsentationszustand aus dem sendefertigen Piece kopiert. Dazu gehören Modus, ausgewählte Quelladresse samt Zeitmarke, -Metadaten und validierte Anbieter-ID. Eigene Notizen liegen ausschließlich an +Metadaten und validierte Anbieter-ID. Zusätzlich speichern eigene geordnete +Tabellen alle Quellen, Werkzeugnamen samt Start-/Arbeitslink und Notiz sowie die +Themennamen. Eigene Notizen liegen ausschließlich an der Verwendung. Die Referenz zum Original bleibt für Historie und Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt. Eine explizite Aktualisierung aus dem Fundus ersetzt Titel, Dauer, alle fünf Redaktionstexte @@ -213,9 +225,10 @@ 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. +Positivliste ab. Private Quellen-/Werkzeug-Snapshots, Arbeitslinks, Notizen und +Uploads werden nicht veröffentlicht; nur Themennamen sowie 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 9e1206d..c4470e0 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -310,3 +310,27 @@ 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. + +## D-022: Werkzeuge und Live-Abschnittszeit als eigene Snapshot-Grenzen + +Favoriten bleiben ein boolesches Merkmal des Fundus-Originals. Sie ändern weder +Revision, Status noch Verwendungshistorie und werden nicht pro Konto +dupliziert. Der Werkzeugkatalog ist eine eigene Entität: Formatbeziehungen +beschreiben nur die Eignung, während Piece-Zuordnungen den privaten Arbeitslink +und die Notiz tragen. Katalogeinträge werden deaktiviert statt gelöscht, damit +bestehende Piece- und Sendungsbezüge lesbar bleiben. + +Beim Einplanen kopieren eigene Relationstabellen alle Quellen, Werkzeuge und +sortierten Themennamen an die Verwendung. Damit können Katalognamen, URLs, +Themen und Originalzuordnungen später geändert werden, ohne eine vorbereitete +Sendung still umzuschreiben. Nur die bewusste Aktion **Aus Fundus aktualisieren** +ersetzt diese Snapshots. Die öffentliche Positivliste darf daraus ausschließlich +die Themen-Dachzeile übernehmen; private Materiallinks und Werkzeugdaten bleiben +auf der authentifizierten Live-Route. + +Der Abschnitts-Countdown verwendet von den Piece-Laufintervallen getrennte +`live_section_timers` und `live_section_timer_runs`. Beim ersten tatsächlichen +Eintritt wird eine positive explizite Abschnittsdauer eingefroren. Weitere +Pieces desselben Abschnitts nutzen denselben Timer; Abschnittswechsel schließen +ihn. So bleiben Reload und Wiederaufnahme persistent, ohne die vorhandene +Piece-Zeiterfassung doppelt zu zählen. diff --git a/docs/STATUS.md b/docs/STATUS.md index a4e0385..9fc0960 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -7,6 +7,33 @@ Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung, Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert. Etappe 6 wurde nicht begonnen. +## Live-Werkbank 0.5.9 + +- globale Favoriten am Fundus-Original mit stabiler Vorsortierung; verwendete + Favoriten bleiben sichtbar, geplante Favoriten respektieren weiterhin den + Einplanungsfilter +- Barkeeper-Werkzeugkatalog mit Start-URL, Beschreibung, passenden Formaten und + verlustfreier Deaktivierung; Arbeitslink und Notiz bleiben für die gesamte + Thekencrew im gemeinsamen Piece- und Verwendungseditor bearbeitbar +- vollständige, voneinander unabhängige Usage-Snapshots für Quellen, Werkzeuge + und alphabetisch stabilisierte Themen; spätere Original- oder + Katalogänderungen wirken erst nach bewusster Aktualisierung aus dem Fundus +- kompakte private Materialliste in der Live-Steuerung mit Arbeitslink vor + Start-URL und verständlichem Zustand ohne URL +- responsive Themen-Dachzeile in der Publikumsausgabe; ohne Thema bleibt sie + vollständig aus, während Präsentations- und interner Titel getrennt bleiben +- persistenter Abschnitts-Countdown für positive explizite Abschnittsdauern mit + eingefrorenem Soll, sekündlicher Anzeige, Goldwarnung ab zwei Minuten und + roter Überziehung ohne Ton, Blinken oder automatische Weiterschaltung + +Etappe 6 bleibt unverändert unbegonnen. Reales OBS und echte +YouTube-/TikTok-Wiedergabe bleiben mangels verfügbarer Umgebung offen. + +Die lokale Prüfmatrix umfasst 37 Verhaltenstests und 16 Chromium-Abläufe. +Desktop, Smartphone, Light/Dark, Favoritenfilter, Werkzeug-Deaktivierung, +Snapshot-Stabilität, private Live-Materialien, öffentliche Themenprojektion und +persistente Abschnittszeit wurden geprüft. + ## Redaktionelle Arbeitsabläufe 0.5.8 - persistente Piece-Vorlagen als unabhängige, revisionierte Snapshots; eigene diff --git a/migrations/0009_live_workbench.sql b/migrations/0009_live_workbench.sql new file mode 100644 index 0000000..2b2caa5 --- /dev/null +++ b/migrations/0009_live_workbench.sql @@ -0,0 +1,102 @@ +ALTER TABLE pieces ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0 + CHECK (is_favorite IN (0, 1)); + +CREATE TABLE tools ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + start_url TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)), + created_at_utc TEXT NOT NULL, + updated_at_utc TEXT NOT NULL +); + +CREATE TABLE tool_formats ( + tool_id TEXT NOT NULL REFERENCES tools(id) ON DELETE CASCADE, + format_id TEXT NOT NULL REFERENCES formats(id) ON DELETE CASCADE, + PRIMARY KEY (tool_id, format_id) +); + +CREATE TABLE piece_tools ( + id TEXT PRIMARY KEY, + piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE CASCADE, + tool_id TEXT NOT NULL REFERENCES tools(id) ON DELETE RESTRICT, + work_url TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL CHECK (position >= 0), + UNIQUE (piece_id, tool_id), + UNIQUE (piece_id, position) +); + +CREATE TABLE usage_source_snapshots ( + id TEXT PRIMARY KEY, + usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE, + label TEXT NOT NULL, + url TEXT NOT NULL, + timecode_seconds INTEGER CHECK (timecode_seconds IS NULL OR timecode_seconds >= 0), + position INTEGER NOT NULL CHECK (position >= 0), + UNIQUE (usage_id, position) +); + +CREATE TABLE usage_tool_snapshots ( + id TEXT PRIMARY KEY, + usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE, + original_tool_id TEXT REFERENCES tools(id) ON DELETE SET NULL, + name TEXT NOT NULL, + start_url TEXT NOT NULL DEFAULT '', + work_url TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL CHECK (position >= 0), + UNIQUE (usage_id, position) +); + +CREATE TABLE usage_topic_snapshots ( + usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE, + name TEXT NOT NULL, + position INTEGER NOT NULL CHECK (position >= 0), + PRIMARY KEY (usage_id, position) +); + +CREATE TABLE live_section_timers ( + id TEXT PRIMARY KEY, + show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE, + section_id TEXT NOT NULL REFERENCES show_sections(id) ON DELETE CASCADE, + planned_duration_seconds INTEGER NOT NULL CHECK (planned_duration_seconds > 0), + started_at_utc TEXT NOT NULL, + ended_at_utc TEXT, + CHECK (ended_at_utc IS NULL OR ended_at_utc >= started_at_utc) +); + +CREATE TABLE live_section_timer_runs ( + id TEXT PRIMARY KEY, + timer_id TEXT NOT NULL REFERENCES live_section_timers(id) ON DELETE CASCADE, + started_at_utc TEXT NOT NULL, + ended_at_utc TEXT, + CHECK (ended_at_utc IS NULL OR ended_at_utc >= started_at_utc) +); + +CREATE INDEX idx_piece_tools_piece ON piece_tools(piece_id, position); +CREATE INDEX idx_tool_formats_format ON tool_formats(format_id, tool_id); +CREATE INDEX idx_usage_sources_usage ON usage_source_snapshots(usage_id, position); +CREATE INDEX idx_usage_tools_usage ON usage_tool_snapshots(usage_id, position); +CREATE UNIQUE INDEX idx_one_open_section_timer_per_show + ON live_section_timers(show_id) WHERE ended_at_utc IS NULL; +CREATE UNIQUE INDEX idx_one_open_run_per_section_timer + ON live_section_timer_runs(timer_id) WHERE ended_at_utc IS NULL; + +INSERT INTO usage_source_snapshots ( + id, usage_id, label, url, timecode_seconds, position +) +SELECT lower(hex(randomblob(16))), u.id, ps.label, ps.url, ps.timecode_seconds, ps.position +FROM piece_usages u +JOIN piece_sources ps ON ps.piece_id = u.original_piece_id; + +INSERT INTO usage_topic_snapshots (usage_id, name, position) +SELECT usage_id, name, position +FROM ( + SELECT u.id AS usage_id, t.name, + row_number() OVER (PARTITION BY u.id ORDER BY t.name, t.id) - 1 AS position + FROM piece_usages u + JOIN piece_topics pt ON pt.piece_id = u.original_piece_id + JOIN topics t ON t.id = pt.topic_id +); diff --git a/package-lock.json b/package-lock.json index 242466e..5bbbecc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sendewerk", - "version": "0.5.8", + "version": "0.5.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sendewerk", - "version": "0.5.8", + "version": "0.5.9", "dependencies": { "@node-rs/argon2": "^2.2.2", "better-sqlite3": "^13.0.3", diff --git a/package.json b/package.json index 44e3700..1671a5e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sendewerk", "private": true, - "version": "0.5.8", + "version": "0.5.9", "type": "module", "engines": { "node": ">=22.17 <25" diff --git a/scripts/seed.mjs b/scripts/seed.mjs index a5252dc..6adba4f 100644 --- a/scripts/seed.mjs +++ b/scripts/seed.mjs @@ -425,6 +425,28 @@ export function seed(databasePath = getPaths().database) { ] ] ); + database.prepare('DELETE FROM usage_source_snapshots').run(); + database + .prepare( + `INSERT INTO usage_source_snapshots + (id, usage_id, label, url, timecode_seconds, position) + SELECT 'seed-source-' || u.id || '-' || ps.position, u.id, ps.label, ps.url, + ps.timecode_seconds, ps.position + FROM piece_usages u JOIN piece_sources ps ON ps.piece_id = u.original_piece_id` + ) + .run(); + database.prepare('DELETE FROM usage_topic_snapshots').run(); + database + .prepare( + `INSERT INTO usage_topic_snapshots (usage_id, name, position) + SELECT usage_id, name, position FROM ( + SELECT u.id AS usage_id, t.name, + row_number() OVER (PARTITION BY u.id ORDER BY t.name, t.id) - 1 AS position + FROM piece_usages u JOIN piece_topics pt ON pt.piece_id = u.original_piece_id + JOIN topics t ON t.id = pt.topic_id + )` + ) + .run(); }); seedDatabase(); diff --git a/src/app.css b/src/app.css index 852934c..c081473 100644 --- a/src/app.css +++ b/src/app.css @@ -1439,6 +1439,87 @@ textarea:focus { text-transform: uppercase; } +.output-topics { + max-width: 100%; + margin: 0 0 0.55rem; + overflow-wrap: anywhere; + color: #f3ba63; + font-size: clamp(0.72rem, 1.4vw, 1rem); + font-weight: 800; + letter-spacing: 0.09em; + line-height: 1.35; + text-transform: uppercase; +} + +.section-countdown { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; + padding: 0.8rem 1rem; + border: 1px solid var(--line); + border-radius: 0.65rem; + background: var(--surface-raised); +} + +.section-countdown strong { + font-variant-numeric: tabular-nums; +} + +.section-countdown.warning { + border-color: #f3ba63; + color: #f3ba63; +} + +.section-countdown.overrun { + border-color: #ff776d; + color: #ff776d; +} + +.live-materials { + display: grid; + grid-template-columns: minmax(9rem, 0.45fr) repeat(2, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1rem; + padding: 1rem; + border: 1px solid var(--line); + border-radius: 0.7rem; + background: var(--surface-raised); +} + +.live-materials h3, +.live-materials ul { + margin: 0; +} + +.live-materials ul { + display: grid; + gap: 0.45rem; + padding: 0; + list-style: none; +} + +.live-material-group li, +.live-material-group small { + display: block; +} + +.live-material-group small { + color: var(--text-muted); +} + +@media (max-width: 760px) { + .live-materials { + grid-template-columns: 1fr; + } + + .section-countdown { + align-items: flex-start; + flex-direction: column; + } +} + .output-content h1, .output-idle h1 { max-width: 16ch; @@ -2347,6 +2428,41 @@ textarea:focus { grid-template-columns: auto minmax(0, 1.65fr) minmax(8rem, 0.85fr) minmax(9rem, auto) auto; } +.piece-favorite-form { + position: absolute; + top: 0.35rem; + left: 0.35rem; + z-index: 3; +} + +.piece-favorite { + display: grid; + width: 1.8rem; + height: 1.8rem; + padding: 0; + place-items: center; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--glass-strong); + color: var(--text-faint); + font-size: 1rem; + line-height: 1; + cursor: pointer; +} + +.piece-favorite.active, +.piece-favorite:hover, +.piece-favorite:focus-visible { + border-color: #f3ba63; + color: #f3ba63; +} + +.piece-editor-kicker { + display: flex; + align-items: center; + gap: 0.65rem; +} + .piece-card:has(.piece-actions[open]) { z-index: 5; } diff --git a/src/lib/components/PresentationDisplay.svelte b/src/lib/components/PresentationDisplay.svelte index b2ea4a0..1a336c8 100644 --- a/src/lib/components/PresentationDisplay.svelte +++ b/src/lib/components/PresentationDisplay.svelte @@ -41,7 +41,7 @@ {#if content.mode === 'text'}
-

heinzlive

+ {#if content.topics.length}

{content.topics.join(' · ')}

{/if}

{content.title}

@@ -66,6 +66,7 @@ (imageUnavailable = true)} /> {/if}
-

Snapshot-Quelle

- {#if draft.presentationSourceUrl} - {draft.presentationSourceLabel || draft.presentationSourceUrl} - {draft.presentationSourceUrl} - {:else} -

Für diese Verwendung wurde keine Präsentationsquelle kopiert.

- {/if} +

Snapshot-Quellen

+ {#if draft.sources.length} + {#each draft.sources as source (source.id)}
+ {source.label}{source.url} +
{/each} + {:else}

Für diese Verwendung wurden keine Quellen kopiert.

{/if} +
+
+
+
+

Snapshot

+

Werkzeuge

+
+ Name und Start-URL bleiben Teil der Sendungsfassung. +
+ {#if draft.tools.length}
+ {#each draft.tools as tool (tool.id)}
+ +
+ {tool.name}{#if tool.startUrl}{tool.startUrl}{/if} +
+ + +
{/each} +
{:else}

Keine Werkzeuge in dieser Sendungsfassung.

{/if}
diff --git a/src/lib/server/content.spec.ts b/src/lib/server/content.spec.ts index 5e3f034..a3e10f5 100644 --- a/src/lib/server/content.spec.ts +++ b/src/lib/server/content.spec.ts @@ -18,6 +18,7 @@ import { savePiece, savePieceAsTemplate, setPieceArchived, + setPieceFavorite, uploadPieceImage, type PieceInput } from './content.js'; @@ -76,6 +77,18 @@ function completeInput(overrides: Partial = {}): PieceInput { } describe('content management', () => { + it('sorts global favorites first without changing their revision', () => { + const before = getPiece('piece-console')!; + setPieceFavorite('piece-console', true); + const pieces = listPieces({ statuses: ['ready'] }); + expect(pieces[0]).toMatchObject({ id: 'piece-console', isFavorite: true }); + expect(getPiece('piece-console')).toMatchObject({ + isFavorite: true, + revision: before.revision, + status: before.status + }); + }); + it('persists independently versioned templates and copies their complete snapshot', () => { const sourceId = createPiece('talk'); const source = getPiece(sourceId)!; diff --git a/src/lib/server/content.ts b/src/lib/server/content.ts index b08fdbb..6eec00c 100644 --- a/src/lib/server/content.ts +++ b/src/lib/server/content.ts @@ -21,6 +21,19 @@ export type PieceSourceInput = { timecodeSeconds: number | null; }; +export type PieceToolInput = { + toolId: string; + workUrl: string; + note: string; +}; + +export type PieceTool = PieceToolInput & { + id: string; + name: string; + startUrl: string; + isActive: boolean; +}; + export type PieceInput = { title: string; outputTitle: string; @@ -48,6 +61,7 @@ export type PieceInput = { topicIds: string[]; categoryIds: string[]; sources: PieceSourceInput[]; + tools?: PieceToolInput[]; }; export type PieceListItem = { @@ -63,15 +77,18 @@ export type PieceListItem = { topics: { id: string; name: string }[]; sourceCount: number; mediaId: string | null; + isFavorite: boolean; }; -export type PieceEditor = Omit & { +export type PieceEditor = Omit & { id: string; status: PieceStatus; revision: number; createdAtUtc: string; updatedAtUtc: string; media: PieceMedia[]; + isFavorite: boolean; + tools: PieceTool[]; }; export type PieceMedia = { @@ -123,6 +140,32 @@ function validUrl(value: string): boolean { } } +function cleanPieceTools(values: PieceToolInput[] = []): PieceToolInput[] { + if (values.length > 20) throw new Error('INVALID_TOOLS'); + const ids = uniqueIds(values.map((value) => value.toolId)); + if (ids.length !== values.length) throw new Error('INVALID_TOOLS'); + if (ids.length) { + const placeholders = ids.map(() => '?').join(', '); + const row = getDatabase() + .prepare(`SELECT count(*) AS count FROM tools WHERE id IN (${placeholders})`) + .get(...ids) as { count: number }; + if (row.count !== ids.length) throw new Error('INVALID_TOOLS'); + } + return values.map((value) => { + const workUrl = cleanText(value.workUrl, 2_000, 'tools'); + if (workUrl) { + if (!validUrl(workUrl)) throw new Error('INVALID_TOOLS'); + const parsed = new URL(workUrl); + if (parsed.username || parsed.password) throw new Error('INVALID_TOOLS'); + } + return { + toolId: value.toolId, + workUrl, + note: cleanText(value.note, 2_000, 'tools') + }; + }); +} + function requireTaxonomyIds(table: 'formats' | 'topics' | 'section_categories', ids: string[]) { if (!ids.length) return; const placeholders = ids.map(() => '?').join(', '); @@ -213,7 +256,8 @@ function validatePieceInput(input: PieceInput): PieceInput { presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null, topicIds, categoryIds, - sources + sources, + tools: cleanPieceTools(input.tools) }; } @@ -244,6 +288,7 @@ type PieceListRow = { format: string; source_count: number; media_id: string | null; + is_favorite: number; }; export function listPieces(filters: { @@ -288,13 +333,14 @@ export function listPieces(filters: { const database = getDatabase(); const rows = database .prepare( - `SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds, + `SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds, p.is_favorite, p.format_id, COALESCE(f.name, 'Ohne Format') AS format, (SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count, (SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id ORDER BY pm.position LIMIT 1) AS media_id FROM pieces p LEFT JOIN formats f ON f.id = p.format_id WHERE ${conditions.join(' AND ')} - ORDER BY CASE p.status WHEN 'ready' THEN 0 WHEN 'preparing' THEN 1 WHEN 'inbox' THEN 2 ELSE 3 END, + ORDER BY p.is_favorite DESC, + CASE p.status WHEN 'ready' THEN 0 WHEN 'preparing' THEN 1 WHEN 'inbox' THEN 2 ELSE 3 END, p.updated_at_utc DESC, p.title` ) .all(...parameters) as PieceListRow[]; @@ -319,7 +365,8 @@ export function listPieces(filters: { categories: categoryStatement.all(row.id) as PieceListItem['categories'], topics: topicStatement.all(row.id) as PieceListItem['topics'], sourceCount: row.source_count, - mediaId: row.media_id + mediaId: row.media_id, + isFavorite: row.is_favorite === 1 })); } @@ -351,6 +398,7 @@ type PieceRow = { revision: number; created_at_utc: string; updated_at_utc: string; + is_favorite: number; }; export function getPiece(id: string): PieceEditor | null { @@ -363,7 +411,7 @@ export function getPiece(id: string): PieceEditor | null { 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, - revision, created_at_utc, updated_at_utc + revision, created_at_utc, updated_at_utc, is_favorite FROM pieces WHERE id = ?` ) .get(id) as PieceRow | undefined; @@ -396,6 +444,7 @@ export function getPiece(id: string): PieceEditor | null { revision: row.revision, createdAtUtc: row.created_at_utc, updatedAtUtc: row.updated_at_utc, + isFavorite: row.is_favorite === 1, topicIds: ( database.prepare('SELECT topic_id FROM piece_topics WHERE piece_id = ?').all(id) as { topic_id: string; @@ -438,6 +487,31 @@ export function getPiece(id: string): PieceEditor | null { byteSize: media.byte_size, altText: media.alt_text, position: media.position + })), + tools: ( + database + .prepare( + `SELECT pt.id, pt.tool_id, pt.work_url, pt.note, t.name, t.start_url, t.is_active + FROM piece_tools pt JOIN tools t ON t.id = pt.tool_id + WHERE pt.piece_id = ? ORDER BY pt.position` + ) + .all(id) as { + id: string; + tool_id: string; + work_url: string; + note: string; + name: string; + start_url: string; + is_active: number; + }[] + ).map((tool) => ({ + id: tool.id, + toolId: tool.tool_id, + workUrl: tool.work_url, + note: tool.note, + name: tool.name, + startUrl: tool.start_url, + isActive: tool.is_active === 1 })) }; } @@ -460,6 +534,21 @@ function replaceRelations(pieceId: string, input: PieceInput) { input.sources.forEach((source, position) => addSource.run(randomUUID(), pieceId, source.label, source.url, source.timecodeSeconds, position) ); + database.prepare('DELETE FROM piece_tools WHERE piece_id = ?').run(pieceId); + const addTool = database.prepare( + `INSERT INTO piece_tools (id, piece_id, tool_id, work_url, note, position) + VALUES (?, ?, ?, ?, ?, ?)` + ); + (input.tools ?? []).forEach((tool, position) => + addTool.run(randomUUID(), pieceId, tool.toolId, tool.workUrl, tool.note, position) + ); +} + +export function setPieceFavorite(id: string, favorite: boolean) { + const result = getDatabase() + .prepare('UPDATE pieces SET is_favorite = ? WHERE id = ?') + .run(favorite ? 1 : 0, id); + if (result.changes !== 1) throw new Error('NOT_FOUND'); } export function savePiece(id: string, expectedRevision: number, rawInput: PieceInput) { diff --git a/src/lib/server/database.spec.ts b/src/lib/server/database.spec.ts index 3e0c663..3ad0d96 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: 8, + migrations: 9, pieces: 6, shows: 2, usages: 5, diff --git a/src/lib/server/live.spec.ts b/src/lib/server/live.spec.ts index 97d1a08..16ed76d 100644 --- a/src/lib/server/live.spec.ts +++ b/src/lib/server/live.spec.ts @@ -40,6 +40,48 @@ afterEach(() => { }); describe('live mode', () => { + it('persists a frozen section countdown across usage changes and reloads', () => { + getDatabase() + .prepare( + "UPDATE show_sections SET explicit_duration_seconds = 300 WHERE id = 'section-stories'" + ) + .run(); + const start = new Date('2026-10-23T18:00:00.000Z'); + startOrResumeLive('show-october', 'timer-start', 0, start); + let live = getLiveView('show-october', new Date('2026-10-23T18:01:40.000Z')); + expect(live.sectionTimer).toMatchObject({ + sectionId: 'section-stories', + plannedDurationSeconds: 300, + elapsedSeconds: 100 + }); + getDatabase() + .prepare( + "UPDATE show_sections SET explicit_duration_seconds = 600 WHERE id = 'section-stories'" + ) + .run(); + completeCurrent( + 'show-october', + 'timer-next-piece', + live.liveRevision, + new Date('2026-10-23T18:02:00.000Z') + ); + live = getLiveView('show-october', new Date('2026-10-23T18:04:00.000Z')); + expect(live.current?.id).toBe('usage-effects'); + expect(live.sectionTimer).toMatchObject({ + plannedDurationSeconds: 300, + elapsedSeconds: 240 + }); + completeCurrent( + 'show-october', + 'timer-finish', + live.liveRevision, + new Date('2026-10-23T18:06:00.000Z') + ); + expect( + getLiveView('show-october', new Date('2026-10-23T18:07:00.000Z')).sectionTimer + ).toBeNull(); + }); + it('selects any rundown usage by stable id, including the first and completed entries', () => { let live = getLiveView('show-october'); expect(() => @@ -161,6 +203,14 @@ describe('live mode', () => { 0, '2026-10-22T18:00:00.000Z' ); + getDatabase() + .prepare( + `INSERT INTO usage_tool_snapshots + (id, usage_id, original_tool_id, name, start_url, work_url, note, position) + VALUES ('private-live-tool', 'usage-console', NULL, 'Privates Werkzeug', '', + 'https://private.example.test/board', 'PRIVATE_TOOL_NOTE', 0)` + ) + .run(); const token = issuePresentationAccess('show-october'); expect(getPresentationView(token)).toEqual({ revision: 0, content: null }); @@ -171,6 +221,7 @@ describe('live mode', () => { const output = getPresentationView(token)!; expect(output.content).toMatchObject({ title: 'Die kleine Konsole mit dem großen Dreh', + topics: ['Gaming'], images: [], sources: [] }); @@ -180,6 +231,9 @@ describe('live mode', () => { expect(serialized).not.toContain('Moderationsstichpunkte'); expect(serialized).not.toContain('private-original-name'); expect(serialized).not.toContain('presentation.png'); + expect(serialized).not.toContain('https://example.com/console'); + expect(serialized).not.toContain('https://private.example.test/board'); + expect(serialized).not.toContain('PRIVATE_TOOL_NOTE'); const media = await getPresentationMedia(token, 'presentation-image'); expect(media).toBeNull(); diff --git a/src/lib/server/live.ts b/src/lib/server/live.ts index 86b6d02..06941eb 100644 --- a/src/lib/server/live.ts +++ b/src/lib/server/live.ts @@ -62,12 +62,20 @@ export type LiveView = { next: LiveUsage | null; completedCount: number; totalCount: number; + sectionTimer: null | { + sectionId: string; + sectionName: string; + plannedDurationSeconds: number; + elapsedSeconds: number; + runningSinceUtc: string | null; + }; }; export type PresentationView = { revision: number; content: null | { mode: PresentationContent['mode']; + topics: string[]; title: string; html: string; source: PresentationContent['source']; @@ -141,6 +149,59 @@ function closeActiveRun(database: Database.Database, showId: string, now: string .run(now, showId); } +function closeSectionTimer(database: Database.Database, showId: string, now: string) { + const timer = database + .prepare('SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL') + .get(showId) as { id: string } | undefined; + if (!timer) return; + database + .prepare( + 'UPDATE live_section_timer_runs SET ended_at_utc = ? WHERE timer_id = ? AND ended_at_utc IS NULL' + ) + .run(now, timer.id); + database + .prepare('UPDATE live_section_timers SET ended_at_utc = ? WHERE id = ?') + .run(now, timer.id); +} + +function syncSectionTimer( + database: Database.Database, + showId: string, + usageId: string, + now: string +) { + const section = database + .prepare( + `SELECT s.id, s.explicit_duration_seconds FROM piece_usages u + JOIN show_sections s ON s.id = u.show_section_id + WHERE u.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0` + ) + .get(usageId, showId) as { id: string; explicit_duration_seconds: number | null } | undefined; + if (!section) throw new Error('NOT_FOUND'); + const active = database + .prepare( + 'SELECT id, section_id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL' + ) + .get(showId) as { id: string; section_id: string } | undefined; + if (active?.section_id === section.id) return; + if (active) closeSectionTimer(database, showId, now); + if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) return; + const timerId = randomUUID(); + database + .prepare( + `INSERT INTO live_section_timers + (id, show_id, section_id, planned_duration_seconds, started_at_utc) + VALUES (?, ?, ?, ?, ?)` + ) + .run(timerId, showId, section.id, section.explicit_duration_seconds, now); + database + .prepare( + `INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc) + VALUES (?, ?, ?)` + ) + .run(randomUUID(), timerId, now); +} + function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) { usageBelongsToShow(database, showId, usageId); database @@ -164,6 +225,7 @@ function activateUsage(database: Database.Database, showId: string, usageId: str 'UPDATE show_live_states SET current_usage_id = ?, ended_at_utc = NULL WHERE show_id = ?' ) .run(usageId, showId); + syncSectionTimer(database, showId, usageId, now); } function executeCommand( @@ -268,6 +330,44 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView { const next = usages.slice(currentIndex + 1).find((usage) => usage.status === 'open') ?? (current ? null : (usages.find((usage) => usage.status === 'open') ?? null)); + const timer = database + .prepare( + `SELECT t.id, t.section_id, s.name AS section_name, t.planned_duration_seconds + FROM live_section_timers t JOIN show_sections s ON s.id = t.section_id + WHERE t.show_id = ? AND t.ended_at_utc IS NULL` + ) + .get(showId) as + | { id: string; section_id: string; section_name: string; planned_duration_seconds: number } + | undefined; + let sectionTimer: LiveView['sectionTimer'] = null; + if (timer) { + const runs = database + .prepare( + `SELECT started_at_utc, ended_at_utc FROM live_section_timer_runs + WHERE timer_id = ? ORDER BY started_at_utc` + ) + .all(timer.id) as { started_at_utc: string; ended_at_utc: string | null }[]; + const running = runs.find((run) => run.ended_at_utc === null) ?? null; + const elapsedSeconds = runs.reduce( + (total, run) => + total + + Math.max( + 0, + Math.floor( + (new Date(run.ended_at_utc ?? now).getTime() - new Date(run.started_at_utc).getTime()) / + 1_000 + ) + ), + 0 + ); + sectionTimer = { + sectionId: timer.section_id, + sectionName: timer.section_name, + plannedDurationSeconds: timer.planned_duration_seconds, + elapsedSeconds, + runningSinceUtc: running?.started_at_utc ?? null + }; + } return { show: { id: show.id, @@ -287,7 +387,8 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView { next, completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped') .length, - totalCount: usages.length + totalCount: usages.length, + sectionTimer }; } @@ -353,10 +454,12 @@ function advanceCurrent( .run(status, state.current_usage_id); const next = ordered.slice(currentIndex + 1).find((usage) => usage.status === 'open'); if (next) activateUsage(database, showId, next.id, now); - else + else { + closeSectionTimer(database, showId, now); database .prepare('UPDATE show_live_states SET current_usage_id = NULL WHERE show_id = ?') .run(showId); + } }, nowValue ); @@ -655,6 +758,11 @@ function getPresentationViewForShow(showId: string): PresentationView { revision: state.presentation_revision, content: { mode: usage.presentation_mode_snapshot, + topics: ( + database + .prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position') + .all(state.presentation_usage_id) as { name: string }[] + ).map((topic) => topic.name), title: usage.output_title_snapshot || usage.title_snapshot, html: renderMarkdown(usage.presentation_snapshot), source: usage.presentation_source_url_snapshot diff --git a/src/lib/server/presentation.ts b/src/lib/server/presentation.ts index a586ad3..87e9f80 100644 --- a/src/lib/server/presentation.ts +++ b/src/lib/server/presentation.ts @@ -28,6 +28,7 @@ export type PresentationSource = { export type PresentationContent = { mode: PresentationMode; + topics: string[]; title: string; html: string; source: PresentationSource | null; diff --git a/src/lib/server/shows.spec.ts b/src/lib/server/shows.spec.ts index 8a5815f..88207d5 100644 --- a/src/lib/server/shows.spec.ts +++ b/src/lib/server/shows.spec.ts @@ -464,6 +464,72 @@ describe('show builder data layer', () => { }); }); + it('keeps private source, tool and topic snapshots stable until an explicit refresh', () => { + const database = getDatabase(); + database + .prepare( + `INSERT INTO tools (id, name, start_url, description, is_active, created_at_utc, updated_at_utc) + VALUES ('tool-tierlist', 'Tierlist', 'https://example.com/start', '', 1, ?, ?)` + ) + .run('2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z'); + database + .prepare( + `INSERT INTO piece_tools (id, piece_id, tool_id, work_url, note, position) + VALUES ('piece-tool-console', 'piece-console', 'tool-tierlist', 'https://example.com/board', 'Erster Stand', 0)` + ) + .run(); + const show = createShow({ title: 'Relationen', startsAtUtc: null, templateId: null }); + const section = addShowSection(show.id, show.revision, { + name: 'Block', + categoryId: null, + explicitDurationSeconds: null + }); + const added = addPieceUsage(show.id, section.revision, { + pieceId: 'piece-console', + sectionId: section.id + }); + let usage = getShow(show.id)!.sections[0].usages[0]; + expect(usage).toMatchObject({ + sources: [{ label: 'Herstellerseite', url: 'https://example.com/console' }], + tools: [{ name: 'Tierlist', workUrl: 'https://example.com/board', note: 'Erster Stand' }], + topics: ['Gaming'] + }); + + database + .prepare("UPDATE piece_sources SET label = 'Neue Quelle' WHERE piece_id = 'piece-console'") + .run(); + database + .prepare("UPDATE tools SET name = 'Neuer Katalogname' WHERE id = 'tool-tierlist'") + .run(); + database + .prepare( + "UPDATE piece_tools SET work_url = 'https://example.com/new' WHERE id = 'piece-tool-console'" + ) + .run(); + database.prepare("DELETE FROM piece_topics WHERE piece_id = 'piece-console'").run(); + database + .prepare( + "INSERT INTO piece_topics (piece_id, topic_id) VALUES ('piece-console', 'topic-internet')" + ) + .run(); + usage = getShow(show.id)!.sections[0].usages[0]; + expect(usage.sources[0].label).toBe('Herstellerseite'); + expect(usage.tools[0]).toMatchObject({ + name: 'Tierlist', + workUrl: 'https://example.com/board' + }); + expect(usage.topics).toEqual(['Gaming']); + + refreshPieceUsageFromOriginal(show.id, usage.id, added.revision); + usage = getShow(show.id)!.sections[0].usages[0]; + expect(usage.sources[0].label).toBe('Neue Quelle'); + expect(usage.tools[0]).toMatchObject({ + name: 'Neuer Katalogname', + workUrl: 'https://example.com/new' + }); + expect(usage.topics).toEqual(['Netzkultur']); + }); + it('persists normalized section and usage reordering, moving and removal', () => { const created = createShow({ title: 'Sortierung', startsAtUtc: null, templateId: null }); const firstSection = addShowSection(created.id, created.revision, { diff --git a/src/lib/server/shows.ts b/src/lib/server/shows.ts index d0fb778..8ed66f7 100644 --- a/src/lib/server/shows.ts +++ b/src/lib/server/shows.ts @@ -60,6 +60,16 @@ export type PieceUsage = { presentationProvider: string; presentationProviderId: string; ownNotes: string; + sources: { id: string; label: string; url: string; timecodeSeconds: number | null }[]; + tools: { + id: string; + originalToolId: string | null; + name: string; + startUrl: string; + workUrl: string; + note: string; + }[]; + topics: string[]; }; export type SentPieceUsage = { @@ -147,6 +157,7 @@ export type PieceUsageUpdate = { presentationSiteName: string; ownNotes: string; durationSeconds: number; + tools?: { id: string; workUrl: string; note: string }[]; }; export type MovePieceUsageInput = { @@ -372,7 +383,7 @@ function normalizePositions( ids.forEach((id, position) => update.run(position, id)); } -function usageFromRow(row: UsageRow): PieceUsage { +function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage { return { id: row.id, originalPieceId: row.original_piece_id, @@ -396,10 +407,189 @@ function usageFromRow(row: UsageRow): PieceUsage { presentationSiteName: row.presentation_site_name_snapshot, presentationProvider: row.presentation_provider_snapshot, presentationProviderId: row.presentation_provider_id_snapshot, - ownNotes: row.own_notes + ownNotes: row.own_notes, + sources: ( + database + .prepare( + `SELECT id, label, url, timecode_seconds FROM usage_source_snapshots + WHERE usage_id = ? ORDER BY position` + ) + .all(row.id) as { + id: string; + label: string; + url: string; + timecode_seconds: number | null; + }[] + ).map((source) => ({ + id: source.id, + label: source.label, + url: source.url, + timecodeSeconds: source.timecode_seconds + })), + tools: ( + database + .prepare( + `SELECT id, original_tool_id, name, start_url, work_url, note + FROM usage_tool_snapshots WHERE usage_id = ? ORDER BY position` + ) + .all(row.id) as { + id: string; + original_tool_id: string | null; + name: string; + start_url: string; + work_url: string; + note: string; + }[] + ).map((tool) => ({ + id: tool.id, + originalToolId: tool.original_tool_id, + name: tool.name, + startUrl: tool.start_url, + workUrl: tool.work_url, + note: tool.note + })), + topics: ( + database + .prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position') + .all(row.id) as { name: string }[] + ).map((topic) => topic.name) }; } +function snapshotPieceRelations(database: Database.Database, usageId: string, pieceId: string) { + database.prepare('DELETE FROM usage_source_snapshots WHERE usage_id = ?').run(usageId); + database.prepare('DELETE FROM usage_tool_snapshots WHERE usage_id = ?').run(usageId); + database.prepare('DELETE FROM usage_topic_snapshots WHERE usage_id = ?').run(usageId); + const sources = database + .prepare( + `SELECT label, url, timecode_seconds, position FROM piece_sources + WHERE piece_id = ? ORDER BY position` + ) + .all(pieceId) as { + label: string; + url: string; + timecode_seconds: number | null; + position: number; + }[]; + const addSource = database.prepare( + `INSERT INTO usage_source_snapshots (id, usage_id, label, url, timecode_seconds, position) + VALUES (?, ?, ?, ?, ?, ?)` + ); + for (const source of sources) { + addSource.run( + randomUUID(), + usageId, + source.label, + source.url, + source.timecode_seconds, + source.position + ); + } + const tools = database + .prepare( + `SELECT pt.tool_id, t.name, t.start_url, pt.work_url, pt.note, pt.position + FROM piece_tools pt JOIN tools t ON t.id = pt.tool_id + WHERE pt.piece_id = ? ORDER BY pt.position` + ) + .all(pieceId) as { + tool_id: string; + name: string; + start_url: string; + work_url: string; + note: string; + position: number; + }[]; + const addTool = database.prepare( + `INSERT INTO usage_tool_snapshots + (id, usage_id, original_tool_id, name, start_url, work_url, note, position) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ); + for (const tool of tools) { + addTool.run( + randomUUID(), + usageId, + tool.tool_id, + tool.name, + tool.start_url, + tool.work_url, + tool.note, + tool.position + ); + } + const topics = database + .prepare( + `SELECT t.name FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id + WHERE pt.piece_id = ? ORDER BY t.name, t.id` + ) + .all(pieceId) as { name: string }[]; + const addTopic = database.prepare( + 'INSERT INTO usage_topic_snapshots (usage_id, name, position) VALUES (?, ?, ?)' + ); + topics.forEach((topic, position) => addTopic.run(usageId, topic.name, position)); +} + +function copyUsageRelations(database: Database.Database, sourceUsageId: string, usageId: string) { + const sources = database + .prepare( + `SELECT label, url, timecode_seconds, position FROM usage_source_snapshots + WHERE usage_id = ? ORDER BY position` + ) + .all(sourceUsageId) as { + label: string; + url: string; + timecode_seconds: number | null; + position: number; + }[]; + const addSource = database.prepare( + `INSERT INTO usage_source_snapshots (id, usage_id, label, url, timecode_seconds, position) + VALUES (?, ?, ?, ?, ?, ?)` + ); + for (const source of sources) + addSource.run( + randomUUID(), + usageId, + source.label, + source.url, + source.timecode_seconds, + source.position + ); + const tools = database + .prepare( + `SELECT original_tool_id, name, start_url, work_url, note, position + FROM usage_tool_snapshots WHERE usage_id = ? ORDER BY position` + ) + .all(sourceUsageId) as { + original_tool_id: string | null; + name: string; + start_url: string; + work_url: string; + note: string; + position: number; + }[]; + const addTool = database.prepare( + `INSERT INTO usage_tool_snapshots + (id, usage_id, original_tool_id, name, start_url, work_url, note, position) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ); + for (const tool of tools) + addTool.run( + randomUUID(), + usageId, + tool.original_tool_id, + tool.name, + tool.start_url, + tool.work_url, + tool.note, + tool.position + ); + database + .prepare( + `INSERT INTO usage_topic_snapshots (usage_id, name, position) + SELECT ?, name, position FROM usage_topic_snapshots WHERE usage_id = ?` + ) + .run(usageId, sourceUsageId); +} + export function listShows(): ShowListItem[] { return ( getDatabase() @@ -497,7 +687,7 @@ export function getShow(id: string): Show | null { const usagesBySection = new Map(); for (const row of usageRows) { const usages = usagesBySection.get(row.show_section_id) ?? []; - usages.push(usageFromRow(row)); + usages.push(usageFromRow(database, row)); usagesBySection.set(row.show_section_id, usages); } const sections = sectionRows @@ -889,6 +1079,7 @@ export function addPieceUsage( piece.presentation_provider_id, input.sectionId === null ? 1 : 0 ); + snapshotPieceRelations(database, id, input.pieceId); ids.splice(position, 0, id); normalizePositions(database, 'piece_usages', ids); return { ...result, id }; @@ -1018,6 +1209,7 @@ export function duplicatePieceUsage( source.own_notes, source.is_reserve ); + copyUsageRelations(database, usageId, id); ids.splice(sourcePosition + 1, 0, id); normalizePositions(database, 'piece_usages', ids); return { ...result, id }; @@ -1102,6 +1294,15 @@ export function refreshPieceUsageFromOriginal( source.presentation_provider_id, usageId ); + snapshotPieceRelations( + database, + usageId, + ( + database + .prepare('SELECT original_piece_id FROM piece_usages WHERE id = ?') + .get(usageId) as { original_piece_id: string } + ).original_piece_id + ); return result; })(); } @@ -1170,6 +1371,35 @@ export function updatePieceUsage( duration, usageId ); + if (input.tools) { + const existing = database + .prepare('SELECT id FROM usage_tool_snapshots WHERE usage_id = ?') + .all(usageId) as { id: string }[]; + const existingIds = new Set(existing.map((tool) => tool.id)); + if ( + input.tools.length !== existing.length || + input.tools.some((tool) => !existingIds.has(tool.id)) + ) { + throw new Error('INVALID_TOOLS'); + } + const updateTool = database.prepare( + 'UPDATE usage_tool_snapshots SET work_url = ?, note = ? WHERE id = ? AND usage_id = ?' + ); + for (const tool of input.tools) { + const workUrl = cleanText(tool.workUrl, 2_000, 'tools'); + if (workUrl) { + try { + const url = new URL(workUrl); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) { + throw new Error(); + } + } catch { + throw new Error('INVALID_TOOLS'); + } + } + updateTool.run(workUrl, cleanText(tool.note, 2_000, 'tools'), tool.id, usageId); + } + } return result; })(); } diff --git a/src/lib/server/tools.spec.ts b/src/lib/server/tools.spec.ts new file mode 100644 index 0000000..b7071b7 --- /dev/null +++ b/src/lib/server/tools.spec.ts @@ -0,0 +1,64 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { closeDatabase } from './database.js'; +import { createTool, listTools, updateTool } from './tools.js'; + +let directory: string; + +beforeEach(() => { + directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-tools-test-')); + process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite'); + process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads'); + execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env }); + execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env }); +}); + +afterEach(() => { + closeDatabase(); + delete process.env.SENDEWERK_DB_PATH; + delete process.env.SENDEWERK_UPLOAD_DIR; + rmSync(directory, { recursive: true, force: true }); +}); + +describe('tool catalog', () => { + it('validates, classifies and deactivates tools without deleting them', () => { + const id = createTool({ + name: 'Tierlist', + startUrl: 'https://example.com/tierlist', + description: 'Gemeinsames Ranking', + isActive: true, + formatIds: ['format-quiz'] + }); + expect(listTools()).toEqual([ + { + id, + name: 'Tierlist', + startUrl: 'https://example.com/tierlist', + description: 'Gemeinsames Ranking', + isActive: true, + formatIds: ['format-quiz'] + } + ]); + updateTool(id, { + name: 'Tierlist', + startUrl: '', + description: '', + isActive: false, + formatIds: [] + }); + expect(listTools()).toEqual([]); + expect(listTools(true)[0]).toMatchObject({ id, isActive: false }); + expect(() => + createTool({ + name: 'Unsicher', + startUrl: 'javascript:alert(1)', + description: '', + isActive: true, + formatIds: [] + }) + ).toThrow('INVALID_TOOL_URL'); + }); +}); diff --git a/src/lib/server/tools.ts b/src/lib/server/tools.ts new file mode 100644 index 0000000..3834b1c --- /dev/null +++ b/src/lib/server/tools.ts @@ -0,0 +1,137 @@ +import { randomUUID } from 'node:crypto'; +import { getDatabase } from './database.js'; + +export type Tool = { + id: string; + name: string; + startUrl: string; + description: string; + isActive: boolean; + formatIds: string[]; +}; + +export type ToolInput = Omit; + +function clean(value: unknown, maximum: number, required = false) { + const text = String(value ?? '').trim(); + if (text.length > maximum || (required && !text)) throw new Error('INVALID_TOOL'); + return text; +} + +function validUrl(value: string) { + if (!value) return ''; + try { + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) + throw new Error(); + return url.href; + } catch { + throw new Error('INVALID_TOOL_URL'); + } +} + +function validated(input: ToolInput): ToolInput { + const formatIds = [...new Set(input.formatIds.filter(Boolean))]; + if (formatIds.length > 30) throw new Error('INVALID_TOOL'); + if (formatIds.length) { + const placeholders = formatIds.map(() => '?').join(', '); + const row = getDatabase() + .prepare(`SELECT count(*) AS count FROM formats WHERE id IN (${placeholders})`) + .get(...formatIds) as { count: number }; + if (row.count !== formatIds.length) throw new Error('INVALID_TOOL_FORMAT'); + } + return { + name: clean(input.name, 120, true), + startUrl: validUrl(clean(input.startUrl, 2_000)), + description: clean(input.description, 2_000), + isActive: Boolean(input.isActive), + formatIds + }; +} + +export function listTools(includeInactive = false): Tool[] { + const database = getDatabase(); + const rows = database + .prepare( + `SELECT id, name, start_url, description, is_active FROM tools + ${includeInactive ? '' : 'WHERE is_active = 1'} ORDER BY is_active DESC, name` + ) + .all() as { + id: string; + name: string; + start_url: string; + description: string; + is_active: number; + }[]; + const formats = database.prepare( + 'SELECT format_id FROM tool_formats WHERE tool_id = ? ORDER BY format_id' + ); + return rows.map((row) => ({ + id: row.id, + name: row.name, + startUrl: row.start_url, + description: row.description, + isActive: row.is_active === 1, + formatIds: (formats.all(row.id) as { format_id: string }[]).map((item) => item.format_id) + })); +} + +function replaceFormats(toolId: string, formatIds: string[]) { + const database = getDatabase(); + database.prepare('DELETE FROM tool_formats WHERE tool_id = ?').run(toolId); + const insert = database.prepare('INSERT INTO tool_formats (tool_id, format_id) VALUES (?, ?)'); + for (const formatId of formatIds) insert.run(toolId, formatId); +} + +export function createTool(rawInput: ToolInput) { + const input = validated(rawInput); + const database = getDatabase(); + const id = randomUUID(); + const now = new Date().toISOString(); + try { + database.transaction(() => { + database + .prepare( + `INSERT INTO tools (id, name, start_url, description, is_active, created_at_utc, updated_at_utc) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run(id, input.name, input.startUrl, input.description, input.isActive ? 1 : 0, now, now); + replaceFormats(id, input.formatIds); + })(); + } catch (cause) { + if (cause instanceof Error && cause.message.includes('UNIQUE')) { + throw new Error('TOOL_EXISTS', { cause }); + } + throw cause; + } + return id; +} + +export function updateTool(id: string, rawInput: ToolInput) { + const input = validated(rawInput); + const database = getDatabase(); + try { + database.transaction(() => { + const result = database + .prepare( + `UPDATE tools SET name = ?, start_url = ?, description = ?, is_active = ?, updated_at_utc = ? + WHERE id = ?` + ) + .run( + input.name, + input.startUrl, + input.description, + input.isActive ? 1 : 0, + new Date().toISOString(), + id + ); + if (result.changes !== 1) throw new Error('NOT_FOUND'); + replaceFormats(id, input.formatIds); + })(); + } catch (cause) { + if (cause instanceof Error && cause.message.includes('UNIQUE')) { + throw new Error('TOOL_EXISTS', { cause }); + } + throw cause; + } +} diff --git a/src/routes/fundus/+page.server.ts b/src/routes/fundus/+page.server.ts index 05f45da..3037620 100644 --- a/src/routes/fundus/+page.server.ts +++ b/src/routes/fundus/+page.server.ts @@ -8,7 +8,8 @@ import { getTaxonomy, listPieces, listPieceTemplates, - setPieceArchived + setPieceArchived, + setPieceFavorite } from '#lib/server/content.js'; import { getPieceUsageContexts } from '#lib/server/shows.js'; @@ -93,5 +94,15 @@ export const actions = { } catch (cause) { return fail(409, { intent: 'archive', error: actionError(cause) }); } + }, + favorite: async ({ locals, request }) => { + requireUser(locals.user); + const data = await request.formData(); + try { + setPieceFavorite(String(data.get('pieceId') || ''), data.get('favorite') === '1'); + return { intent: 'favorite', success: 'Favorit aktualisiert.' }; + } catch (cause) { + return fail(404, { intent: 'favorite', error: actionError(cause) }); + } } } satisfies Actions; diff --git a/src/routes/fundus/+page.svelte b/src/routes/fundus/+page.svelte index f949a6a..a7011b1 100644 --- a/src/routes/fundus/+page.svelte +++ b/src/routes/fundus/+page.svelte @@ -1,4 +1,5 @@