Release 0.5.9
This commit is contained in:
1 parent
2851c7d936
commit
9327176deb
32 files changed
+1598
-50
No files matched your search
@@ -337,3 +337,36 @@
|
|||||||
- `bash -n start.sh`: erfolgreich
|
- `bash -n start.sh`: erfolgreich
|
||||||
- reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt;
|
- reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt;
|
||||||
Anbieterrequests wurden in den Browsertests lokal abgefangen
|
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
|
||||||
+29
-16
@@ -37,18 +37,18 @@ Die ersten fünf Etappen materialisieren Identität, Inhaltsverwaltung,
|
|||||||
Sendungsplanung und Live-Durchführung. Folgende Grenzen gelten für umgesetzte
|
Sendungsplanung und Live-Durchführung. Folgende Grenzen gelten für umgesetzte
|
||||||
und spätere Bereiche.
|
und spätere Bereiche.
|
||||||
|
|
||||||
| Bereich | Geplante Entitäten und Beziehungen |
|
| Bereich | Geplante Entitäten und Beziehungen |
|
||||||
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später |
|
| 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 |
|
| 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 |
|
| Inhalt | `pieces` mit Status, Favorit, Dauer, Revision, Texten, Quellen, Werkzeugzuordnungen und privaten Medien; Vorlagen speichern Snapshots |
|
||||||
| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen sind umgesetzt; Original-ID bleibt Referenz |
|
| Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themensnapshots, Reservebank und Vorlagen; Original-ID bleibt Referenz |
|
||||||
| Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe |
|
| 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 |
|
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
|
||||||
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie |
|
||||||
|
|
||||||
### Historien- und Kopierregel
|
### 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
|
zählen erst nach dem Einplanen. Geplante und tatsächliche Dauer bleiben
|
||||||
getrennt.
|
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
|
## Anmeldung und Rechte
|
||||||
|
|
||||||
Etappe 2 ergänzt lokale Konten mit den festen Rollen `barkeeper` und
|
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
|
Redaktionstexte werden nur an den angemeldeten Editor ausgeliefert. Themen und
|
||||||
Abschnittskategorien sind Mehrfachzuordnungen, das Format bleibt eine einzelne
|
Abschnittskategorien sind Mehrfachzuordnungen, das Format bleibt eine einzelne
|
||||||
unabhängige Zuordnung. Quellen werden gemeinsam mit einem Piece atomar ersetzt.
|
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
|
Ein Piece kann für die Präsentation Text oder genau eine vorhandene Quelle als
|
||||||
Linkkarte beziehungsweise Video auswählen. Abgerufene Metadaten und Kennzeichen
|
Linkkarte beziehungsweise Video auswählen. Abgerufene Metadaten und Kennzeichen
|
||||||
für manuell korrigierte Felder liegen am Piece; ein erneuter Abruf überschreibt
|
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,
|
Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation,
|
||||||
Überleitung und der vollständige Präsentationszustand aus dem sendefertigen
|
Überleitung und der vollständige Präsentationszustand aus dem sendefertigen
|
||||||
Piece kopiert. Dazu gehören Modus, ausgewählte Quelladresse samt Zeitmarke,
|
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
|
der Verwendung. Die Referenz zum Original bleibt für Historie und
|
||||||
Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt. Eine explizite
|
Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt. Eine explizite
|
||||||
Aktualisierung aus dem Fundus ersetzt Titel, Dauer, alle fünf Redaktionstexte
|
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
|
Geheimnisgrenze. Eine Rotation widerruft den bisherigen Schlüssel, normales
|
||||||
erneutes Öffnen behält ihn bei.
|
erneutes Öffnen behält ihn bei.
|
||||||
Page, Polling-JSON und Proxyrouten leiten ihre Daten aus einer eigenen
|
Page, Polling-JSON und Proxyrouten leiten ihre Daten aus einer eigenen
|
||||||
Positivliste ab. Implizite Piece-Quellen und private Uploads werden nicht
|
Positivliste ab. Private Quellen-/Werkzeug-Snapshots, Arbeitslinks, Notizen und
|
||||||
veröffentlicht; nur die ausdrücklich gewählte Link- oder Videoquelle und deren
|
Uploads werden nicht veröffentlicht; nur Themennamen sowie die ausdrücklich
|
||||||
erlaubte Vorschau-URL können die Grenze passieren.
|
gewählte Link- oder Videoquelle und deren erlaubte Vorschau-URL können die
|
||||||
|
Grenze passieren.
|
||||||
Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den
|
Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den
|
||||||
Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den
|
Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den
|
||||||
gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen
|
gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen
|
||||||
|
|||||||
@@ -310,3 +310,27 @@ geschrieben. `planning_commands` macht die Befehls-ID eindeutig und speichert
|
|||||||
das Ergebnis, damit Wiederholungen nach Verbindungsabbrüchen keine Dubletten
|
das Ergebnis, damit Wiederholungen nach Verbindungsabbrüchen keine Dubletten
|
||||||
erzeugen. Interner und öffentlicher Titel werden getrennt gespeichert und beim
|
erzeugen. Interner und öffentlicher Titel werden getrennt gespeichert und beim
|
||||||
Einplanen getrennt in den Verwendungssnapshot kopiert.
|
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.
|
||||||
@@ -7,6 +7,33 @@ Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
|
|||||||
Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert.
|
Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert.
|
||||||
Etappe 6 wurde nicht begonnen.
|
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
|
## Redaktionelle Arbeitsabläufe 0.5.8
|
||||||
|
|
||||||
- persistente Piece-Vorlagen als unabhängige, revisionierte Snapshots; eigene
|
- persistente Piece-Vorlagen als unabhängige, revisionierte Snapshots; eigene
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"version": "0.5.8",
|
"version": "0.5.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"version": "0.5.8",
|
"version": "0.5.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.2.2",
|
"@node-rs/argon2": "^2.2.2",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.8",
|
"version": "0.5.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.17 <25"
|
"node": ">=22.17 <25"
|
||||||
|
|||||||
@@ -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();
|
seedDatabase();
|
||||||
|
|||||||
+116
@@ -1439,6 +1439,87 @@ textarea:focus {
|
|||||||
text-transform: uppercase;
|
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-content h1,
|
||||||
.output-idle h1 {
|
.output-idle h1 {
|
||||||
max-width: 16ch;
|
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;
|
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]) {
|
.piece-card:has(.piece-actions[open]) {
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
{#if content.mode === 'text'}
|
{#if content.mode === 'text'}
|
||||||
<article class="output-content output-text">
|
<article class="output-content output-text">
|
||||||
<p class="output-kicker">heinzlive</p>
|
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
|
||||||
<h1>{content.title}</h1>
|
<h1>{content.title}</h1>
|
||||||
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
|
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
<img src={imageSrc} alt="" onerror={() => (imageUnavailable = true)} />
|
<img src={imageSrc} alt="" onerror={() => (imageUnavailable = true)} />
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
<div class="output-link-copy">
|
<div class="output-link-copy">
|
||||||
|
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
|
||||||
<p class="output-kicker">{content.siteName || content.source?.label || 'Webseite'}</p>
|
<p class="output-kicker">{content.siteName || content.source?.label || 'Webseite'}</p>
|
||||||
<h1>{content.previewTitle || content.title}</h1>
|
<h1>{content.previewTitle || content.title}</h1>
|
||||||
{#if content.description}<p>{content.description}</p>{/if}
|
{#if content.description}<p>{content.description}</p>{/if}
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<article class:portrait={content.provider === 'tiktok'} class="output-content output-video">
|
<article class:portrait={content.provider === 'tiktok'} class="output-content output-video">
|
||||||
<header>
|
<header>
|
||||||
|
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
|
||||||
<p class="output-kicker">{content.siteName || content.source?.label || 'Video'}</p>
|
<p class="output-kicker">{content.siteName || content.source?.label || 'Video'}</p>
|
||||||
<h1>{content.previewTitle || content.title}</h1>
|
<h1>{content.previewTitle || content.title}</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -25,7 +25,9 @@
|
|||||||
let formId = $derived(`usage-editor-form-${usage.id}`);
|
let formId = $derived(`usage-editor-form-${usage.id}`);
|
||||||
let editor: { showModal: () => void; close: () => void };
|
let editor: { showModal: () => void; close: () => void };
|
||||||
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
|
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
|
||||||
let draft = $state(untrack(() => ({ ...usage })));
|
let draft = $state(
|
||||||
|
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
|
||||||
|
);
|
||||||
let dirty = $state(false);
|
let dirty = $state(false);
|
||||||
|
|
||||||
function durationInMinutes(seconds: number) {
|
function durationInMinutes(seconds: number) {
|
||||||
@@ -163,13 +165,40 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<section class="piece-source-summary">
|
<section class="piece-source-summary">
|
||||||
<p class="eyebrow">Snapshot-Quelle</p>
|
<p class="eyebrow">Snapshot-Quellen</p>
|
||||||
{#if draft.presentationSourceUrl}
|
{#if draft.sources.length}
|
||||||
<strong>{draft.presentationSourceLabel || draft.presentationSourceUrl}</strong>
|
{#each draft.sources as source (source.id)}<div>
|
||||||
<small>{draft.presentationSourceUrl}</small>
|
<strong>{source.label}</strong><small>{source.url}</small>
|
||||||
{:else}
|
</div>{/each}
|
||||||
<p>Für diese Verwendung wurde keine Präsentationsquelle kopiert.</p>
|
{:else}<p>Für diese Verwendung wurden keine Quellen kopiert.</p>{/if}
|
||||||
{/if}
|
</section>
|
||||||
|
<section class="piece-source-editor">
|
||||||
|
<div class="piece-subsection-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Snapshot</p>
|
||||||
|
<h3>Werkzeuge</h3>
|
||||||
|
</div>
|
||||||
|
<small>Name und Start-URL bleiben Teil der Sendungsfassung.</small>
|
||||||
|
</div>
|
||||||
|
{#if draft.tools.length}<div class="source-list">
|
||||||
|
{#each draft.tools as tool (tool.id)}<div class="source-row tool-assignment-row">
|
||||||
|
<input type="hidden" name="usageToolId" value={tool.id} />
|
||||||
|
<div class="tool-assignment-name">
|
||||||
|
<strong>{tool.name}</strong>{#if tool.startUrl}<small>{tool.startUrl}</small>{/if}
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
>Arbeitslink<input
|
||||||
|
name="toolWorkUrl"
|
||||||
|
type="url"
|
||||||
|
maxlength="2000"
|
||||||
|
placeholder={tool.startUrl || 'https://…'}
|
||||||
|
bind:value={tool.workUrl}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label>Notiz<input name="toolNote" maxlength="2000" bind:value={tool.note} /></label
|
||||||
|
>
|
||||||
|
</div>{/each}
|
||||||
|
</div>{:else}<p class="context-empty">Keine Werkzeuge in dieser Sendungsfassung.</p>{/if}
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
savePiece,
|
savePiece,
|
||||||
savePieceAsTemplate,
|
savePieceAsTemplate,
|
||||||
setPieceArchived,
|
setPieceArchived,
|
||||||
|
setPieceFavorite,
|
||||||
uploadPieceImage,
|
uploadPieceImage,
|
||||||
type PieceInput
|
type PieceInput
|
||||||
} from './content.js';
|
} from './content.js';
|
||||||
@@ -76,6 +77,18 @@ function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('content management', () => {
|
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', () => {
|
it('persists independently versioned templates and copies their complete snapshot', () => {
|
||||||
const sourceId = createPiece('talk');
|
const sourceId = createPiece('talk');
|
||||||
const source = getPiece(sourceId)!;
|
const source = getPiece(sourceId)!;
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ export type PieceSourceInput = {
|
|||||||
timecodeSeconds: number | null;
|
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 = {
|
export type PieceInput = {
|
||||||
title: string;
|
title: string;
|
||||||
outputTitle: string;
|
outputTitle: string;
|
||||||
@@ -48,6 +61,7 @@ export type PieceInput = {
|
|||||||
topicIds: string[];
|
topicIds: string[];
|
||||||
categoryIds: string[];
|
categoryIds: string[];
|
||||||
sources: PieceSourceInput[];
|
sources: PieceSourceInput[];
|
||||||
|
tools?: PieceToolInput[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PieceListItem = {
|
export type PieceListItem = {
|
||||||
@@ -63,15 +77,18 @@ export type PieceListItem = {
|
|||||||
topics: { id: string; name: string }[];
|
topics: { id: string; name: string }[];
|
||||||
sourceCount: number;
|
sourceCount: number;
|
||||||
mediaId: string | null;
|
mediaId: string | null;
|
||||||
|
isFavorite: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PieceEditor = Omit<PieceInput, 'status'> & {
|
export type PieceEditor = Omit<PieceInput, 'status' | 'tools'> & {
|
||||||
id: string;
|
id: string;
|
||||||
status: PieceStatus;
|
status: PieceStatus;
|
||||||
revision: number;
|
revision: number;
|
||||||
createdAtUtc: string;
|
createdAtUtc: string;
|
||||||
updatedAtUtc: string;
|
updatedAtUtc: string;
|
||||||
media: PieceMedia[];
|
media: PieceMedia[];
|
||||||
|
isFavorite: boolean;
|
||||||
|
tools: PieceTool[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PieceMedia = {
|
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[]) {
|
function requireTaxonomyIds(table: 'formats' | 'topics' | 'section_categories', ids: string[]) {
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
const placeholders = ids.map(() => '?').join(', ');
|
const placeholders = ids.map(() => '?').join(', ');
|
||||||
@@ -213,7 +256,8 @@ function validatePieceInput(input: PieceInput): PieceInput {
|
|||||||
presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null,
|
presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null,
|
||||||
topicIds,
|
topicIds,
|
||||||
categoryIds,
|
categoryIds,
|
||||||
sources
|
sources,
|
||||||
|
tools: cleanPieceTools(input.tools)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +288,7 @@ type PieceListRow = {
|
|||||||
format: string;
|
format: string;
|
||||||
source_count: number;
|
source_count: number;
|
||||||
media_id: string | null;
|
media_id: string | null;
|
||||||
|
is_favorite: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function listPieces(filters: {
|
export function listPieces(filters: {
|
||||||
@@ -288,13 +333,14 @@ export function listPieces(filters: {
|
|||||||
const database = getDatabase();
|
const database = getDatabase();
|
||||||
const rows = database
|
const rows = database
|
||||||
.prepare(
|
.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,
|
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 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
|
(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
|
FROM pieces p LEFT JOIN formats f ON f.id = p.format_id
|
||||||
WHERE ${conditions.join(' AND ')}
|
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`
|
p.updated_at_utc DESC, p.title`
|
||||||
)
|
)
|
||||||
.all(...parameters) as PieceListRow[];
|
.all(...parameters) as PieceListRow[];
|
||||||
@@ -319,7 +365,8 @@ export function listPieces(filters: {
|
|||||||
categories: categoryStatement.all(row.id) as PieceListItem['categories'],
|
categories: categoryStatement.all(row.id) as PieceListItem['categories'],
|
||||||
topics: topicStatement.all(row.id) as PieceListItem['topics'],
|
topics: topicStatement.all(row.id) as PieceListItem['topics'],
|
||||||
sourceCount: row.source_count,
|
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;
|
revision: number;
|
||||||
created_at_utc: string;
|
created_at_utc: string;
|
||||||
updated_at_utc: string;
|
updated_at_utc: string;
|
||||||
|
is_favorite: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getPiece(id: string): PieceEditor | null {
|
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_image_url, presentation_site_name, presentation_provider,
|
||||||
presentation_provider_id, presentation_title_manual, presentation_description_manual,
|
presentation_provider_id, presentation_title_manual, presentation_description_manual,
|
||||||
presentation_image_manual, presentation_metadata_fetched_at_utc,
|
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 = ?`
|
FROM pieces WHERE id = ?`
|
||||||
)
|
)
|
||||||
.get(id) as PieceRow | undefined;
|
.get(id) as PieceRow | undefined;
|
||||||
@@ -396,6 +444,7 @@ export function getPiece(id: string): PieceEditor | null {
|
|||||||
revision: row.revision,
|
revision: row.revision,
|
||||||
createdAtUtc: row.created_at_utc,
|
createdAtUtc: row.created_at_utc,
|
||||||
updatedAtUtc: row.updated_at_utc,
|
updatedAtUtc: row.updated_at_utc,
|
||||||
|
isFavorite: row.is_favorite === 1,
|
||||||
topicIds: (
|
topicIds: (
|
||||||
database.prepare('SELECT topic_id FROM piece_topics WHERE piece_id = ?').all(id) as {
|
database.prepare('SELECT topic_id FROM piece_topics WHERE piece_id = ?').all(id) as {
|
||||||
topic_id: string;
|
topic_id: string;
|
||||||
@@ -438,6 +487,31 @@ export function getPiece(id: string): PieceEditor | null {
|
|||||||
byteSize: media.byte_size,
|
byteSize: media.byte_size,
|
||||||
altText: media.alt_text,
|
altText: media.alt_text,
|
||||||
position: media.position
|
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) =>
|
input.sources.forEach((source, position) =>
|
||||||
addSource.run(randomUUID(), pieceId, source.label, source.url, source.timecodeSeconds, 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) {
|
export function savePiece(id: string, expectedRevision: number, rawInput: PieceInput) {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ describe('database setup', () => {
|
|||||||
database.close();
|
database.close();
|
||||||
|
|
||||||
expect(counts).toEqual({
|
expect(counts).toEqual({
|
||||||
migrations: 8,
|
migrations: 9,
|
||||||
pieces: 6,
|
pieces: 6,
|
||||||
shows: 2,
|
shows: 2,
|
||||||
usages: 5,
|
usages: 5,
|
||||||
|
|||||||
@@ -40,6 +40,48 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('live mode', () => {
|
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', () => {
|
it('selects any rundown usage by stable id, including the first and completed entries', () => {
|
||||||
let live = getLiveView('show-october');
|
let live = getLiveView('show-october');
|
||||||
expect(() =>
|
expect(() =>
|
||||||
@@ -161,6 +203,14 @@ describe('live mode', () => {
|
|||||||
0,
|
0,
|
||||||
'2026-10-22T18:00:00.000Z'
|
'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');
|
const token = issuePresentationAccess('show-october');
|
||||||
expect(getPresentationView(token)).toEqual({ revision: 0, content: null });
|
expect(getPresentationView(token)).toEqual({ revision: 0, content: null });
|
||||||
@@ -171,6 +221,7 @@ describe('live mode', () => {
|
|||||||
const output = getPresentationView(token)!;
|
const output = getPresentationView(token)!;
|
||||||
expect(output.content).toMatchObject({
|
expect(output.content).toMatchObject({
|
||||||
title: 'Die kleine Konsole mit dem großen Dreh',
|
title: 'Die kleine Konsole mit dem großen Dreh',
|
||||||
|
topics: ['Gaming'],
|
||||||
images: [],
|
images: [],
|
||||||
sources: []
|
sources: []
|
||||||
});
|
});
|
||||||
@@ -180,6 +231,9 @@ describe('live mode', () => {
|
|||||||
expect(serialized).not.toContain('Moderationsstichpunkte');
|
expect(serialized).not.toContain('Moderationsstichpunkte');
|
||||||
expect(serialized).not.toContain('private-original-name');
|
expect(serialized).not.toContain('private-original-name');
|
||||||
expect(serialized).not.toContain('presentation.png');
|
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');
|
const media = await getPresentationMedia(token, 'presentation-image');
|
||||||
expect(media).toBeNull();
|
expect(media).toBeNull();
|
||||||
|
|||||||
+110
-2
@@ -62,12 +62,20 @@ export type LiveView = {
|
|||||||
next: LiveUsage | null;
|
next: LiveUsage | null;
|
||||||
completedCount: number;
|
completedCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
|
sectionTimer: null | {
|
||||||
|
sectionId: string;
|
||||||
|
sectionName: string;
|
||||||
|
plannedDurationSeconds: number;
|
||||||
|
elapsedSeconds: number;
|
||||||
|
runningSinceUtc: string | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PresentationView = {
|
export type PresentationView = {
|
||||||
revision: number;
|
revision: number;
|
||||||
content: null | {
|
content: null | {
|
||||||
mode: PresentationContent['mode'];
|
mode: PresentationContent['mode'];
|
||||||
|
topics: string[];
|
||||||
title: string;
|
title: string;
|
||||||
html: string;
|
html: string;
|
||||||
source: PresentationContent['source'];
|
source: PresentationContent['source'];
|
||||||
@@ -141,6 +149,59 @@ function closeActiveRun(database: Database.Database, showId: string, now: string
|
|||||||
.run(now, showId);
|
.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) {
|
function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) {
|
||||||
usageBelongsToShow(database, showId, usageId);
|
usageBelongsToShow(database, showId, usageId);
|
||||||
database
|
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 = ?'
|
'UPDATE show_live_states SET current_usage_id = ?, ended_at_utc = NULL WHERE show_id = ?'
|
||||||
)
|
)
|
||||||
.run(usageId, showId);
|
.run(usageId, showId);
|
||||||
|
syncSectionTimer(database, showId, usageId, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
function executeCommand(
|
function executeCommand(
|
||||||
@@ -268,6 +330,44 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
|
|||||||
const next =
|
const next =
|
||||||
usages.slice(currentIndex + 1).find((usage) => usage.status === 'open') ??
|
usages.slice(currentIndex + 1).find((usage) => usage.status === 'open') ??
|
||||||
(current ? null : (usages.find((usage) => usage.status === 'open') ?? null));
|
(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 {
|
return {
|
||||||
show: {
|
show: {
|
||||||
id: show.id,
|
id: show.id,
|
||||||
@@ -287,7 +387,8 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
|
|||||||
next,
|
next,
|
||||||
completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped')
|
completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped')
|
||||||
.length,
|
.length,
|
||||||
totalCount: usages.length
|
totalCount: usages.length,
|
||||||
|
sectionTimer
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,10 +454,12 @@ function advanceCurrent(
|
|||||||
.run(status, state.current_usage_id);
|
.run(status, state.current_usage_id);
|
||||||
const next = ordered.slice(currentIndex + 1).find((usage) => usage.status === 'open');
|
const next = ordered.slice(currentIndex + 1).find((usage) => usage.status === 'open');
|
||||||
if (next) activateUsage(database, showId, next.id, now);
|
if (next) activateUsage(database, showId, next.id, now);
|
||||||
else
|
else {
|
||||||
|
closeSectionTimer(database, showId, now);
|
||||||
database
|
database
|
||||||
.prepare('UPDATE show_live_states SET current_usage_id = NULL WHERE show_id = ?')
|
.prepare('UPDATE show_live_states SET current_usage_id = NULL WHERE show_id = ?')
|
||||||
.run(showId);
|
.run(showId);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
nowValue
|
nowValue
|
||||||
);
|
);
|
||||||
@@ -655,6 +758,11 @@ function getPresentationViewForShow(showId: string): PresentationView {
|
|||||||
revision: state.presentation_revision,
|
revision: state.presentation_revision,
|
||||||
content: {
|
content: {
|
||||||
mode: usage.presentation_mode_snapshot,
|
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,
|
title: usage.output_title_snapshot || usage.title_snapshot,
|
||||||
html: renderMarkdown(usage.presentation_snapshot),
|
html: renderMarkdown(usage.presentation_snapshot),
|
||||||
source: usage.presentation_source_url_snapshot
|
source: usage.presentation_source_url_snapshot
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export type PresentationSource = {
|
|||||||
|
|
||||||
export type PresentationContent = {
|
export type PresentationContent = {
|
||||||
mode: PresentationMode;
|
mode: PresentationMode;
|
||||||
|
topics: string[];
|
||||||
title: string;
|
title: string;
|
||||||
html: string;
|
html: string;
|
||||||
source: PresentationSource | null;
|
source: PresentationSource | null;
|
||||||
|
|||||||
@@ -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', () => {
|
it('persists normalized section and usage reordering, moving and removal', () => {
|
||||||
const created = createShow({ title: 'Sortierung', startsAtUtc: null, templateId: null });
|
const created = createShow({ title: 'Sortierung', startsAtUtc: null, templateId: null });
|
||||||
const firstSection = addShowSection(created.id, created.revision, {
|
const firstSection = addShowSection(created.id, created.revision, {
|
||||||
|
|||||||
+233
-3
@@ -60,6 +60,16 @@ export type PieceUsage = {
|
|||||||
presentationProvider: string;
|
presentationProvider: string;
|
||||||
presentationProviderId: string;
|
presentationProviderId: string;
|
||||||
ownNotes: 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 = {
|
export type SentPieceUsage = {
|
||||||
@@ -147,6 +157,7 @@ export type PieceUsageUpdate = {
|
|||||||
presentationSiteName: string;
|
presentationSiteName: string;
|
||||||
ownNotes: string;
|
ownNotes: string;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
|
tools?: { id: string; workUrl: string; note: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MovePieceUsageInput = {
|
export type MovePieceUsageInput = {
|
||||||
@@ -372,7 +383,7 @@ function normalizePositions(
|
|||||||
ids.forEach((id, position) => update.run(position, id));
|
ids.forEach((id, position) => update.run(position, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function usageFromRow(row: UsageRow): PieceUsage {
|
function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
originalPieceId: row.original_piece_id,
|
originalPieceId: row.original_piece_id,
|
||||||
@@ -396,10 +407,189 @@ function usageFromRow(row: UsageRow): PieceUsage {
|
|||||||
presentationSiteName: row.presentation_site_name_snapshot,
|
presentationSiteName: row.presentation_site_name_snapshot,
|
||||||
presentationProvider: row.presentation_provider_snapshot,
|
presentationProvider: row.presentation_provider_snapshot,
|
||||||
presentationProviderId: row.presentation_provider_id_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[] {
|
export function listShows(): ShowListItem[] {
|
||||||
return (
|
return (
|
||||||
getDatabase()
|
getDatabase()
|
||||||
@@ -497,7 +687,7 @@ export function getShow(id: string): Show | null {
|
|||||||
const usagesBySection = new Map<string, PieceUsage[]>();
|
const usagesBySection = new Map<string, PieceUsage[]>();
|
||||||
for (const row of usageRows) {
|
for (const row of usageRows) {
|
||||||
const usages = usagesBySection.get(row.show_section_id) ?? [];
|
const usages = usagesBySection.get(row.show_section_id) ?? [];
|
||||||
usages.push(usageFromRow(row));
|
usages.push(usageFromRow(database, row));
|
||||||
usagesBySection.set(row.show_section_id, usages);
|
usagesBySection.set(row.show_section_id, usages);
|
||||||
}
|
}
|
||||||
const sections = sectionRows
|
const sections = sectionRows
|
||||||
@@ -889,6 +1079,7 @@ export function addPieceUsage(
|
|||||||
piece.presentation_provider_id,
|
piece.presentation_provider_id,
|
||||||
input.sectionId === null ? 1 : 0
|
input.sectionId === null ? 1 : 0
|
||||||
);
|
);
|
||||||
|
snapshotPieceRelations(database, id, input.pieceId);
|
||||||
ids.splice(position, 0, id);
|
ids.splice(position, 0, id);
|
||||||
normalizePositions(database, 'piece_usages', ids);
|
normalizePositions(database, 'piece_usages', ids);
|
||||||
return { ...result, id };
|
return { ...result, id };
|
||||||
@@ -1018,6 +1209,7 @@ export function duplicatePieceUsage(
|
|||||||
source.own_notes,
|
source.own_notes,
|
||||||
source.is_reserve
|
source.is_reserve
|
||||||
);
|
);
|
||||||
|
copyUsageRelations(database, usageId, id);
|
||||||
ids.splice(sourcePosition + 1, 0, id);
|
ids.splice(sourcePosition + 1, 0, id);
|
||||||
normalizePositions(database, 'piece_usages', ids);
|
normalizePositions(database, 'piece_usages', ids);
|
||||||
return { ...result, id };
|
return { ...result, id };
|
||||||
@@ -1102,6 +1294,15 @@ export function refreshPieceUsageFromOriginal(
|
|||||||
source.presentation_provider_id,
|
source.presentation_provider_id,
|
||||||
usageId
|
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;
|
return result;
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
@@ -1170,6 +1371,35 @@ export function updatePieceUsage(
|
|||||||
duration,
|
duration,
|
||||||
usageId
|
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;
|
return result;
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Tool, 'id'>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
getTaxonomy,
|
getTaxonomy,
|
||||||
listPieces,
|
listPieces,
|
||||||
listPieceTemplates,
|
listPieceTemplates,
|
||||||
setPieceArchived
|
setPieceArchived,
|
||||||
|
setPieceFavorite
|
||||||
} from '#lib/server/content.js';
|
} from '#lib/server/content.js';
|
||||||
import { getPieceUsageContexts } from '#lib/server/shows.js';
|
import { getPieceUsageContexts } from '#lib/server/shows.js';
|
||||||
|
|
||||||
@@ -93,5 +94,15 @@ export const actions = {
|
|||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
return fail(409, { intent: 'archive', error: actionError(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;
|
} satisfies Actions;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
import { goto, replaceState } from '$app/navigation';
|
import { goto, replaceState } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
let visiblePieces = $derived(
|
let visiblePieces = $derived(
|
||||||
data.pieces.filter(
|
data.pieces.filter(
|
||||||
(piece) =>
|
(piece) =>
|
||||||
(showUsedPieces || piece.sentHistory.length === 0) &&
|
(showUsedPieces || piece.sentHistory.length === 0 || piece.isFavorite) &&
|
||||||
(showPlannedPieces || !piece.isPlannedInActiveShow)
|
(showPlannedPieces || !piece.isPlannedInActiveShow)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -193,6 +194,19 @@
|
|||||||
class="piece-card"
|
class="piece-card"
|
||||||
style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`}
|
style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`}
|
||||||
>
|
>
|
||||||
|
<form method="POST" action="?/favorite" class="piece-favorite-form" use:enhance>
|
||||||
|
<input type="hidden" name="pieceId" value={piece.id} />
|
||||||
|
<input type="hidden" name="favorite" value={piece.isFavorite ? '0' : '1'} />
|
||||||
|
<button
|
||||||
|
class="piece-favorite"
|
||||||
|
class:active={piece.isFavorite}
|
||||||
|
type="submit"
|
||||||
|
aria-label={piece.isFavorite
|
||||||
|
? `${piece.title} aus Favoriten entfernen`
|
||||||
|
: `${piece.title} als Favorit markieren`}
|
||||||
|
aria-pressed={piece.isFavorite}>★</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
{#if piece.mediaId}<img
|
{#if piece.mediaId}<img
|
||||||
class="piece-thumbnail"
|
class="piece-thumbnail"
|
||||||
src={`/media/${piece.mediaId}`}
|
src={`/media/${piece.mediaId}`}
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ import {
|
|||||||
savePiece,
|
savePiece,
|
||||||
savePieceAsTemplate,
|
savePieceAsTemplate,
|
||||||
setPieceArchived,
|
setPieceArchived,
|
||||||
|
setPieceFavorite,
|
||||||
uploadPieceImage,
|
uploadPieceImage,
|
||||||
type EditablePieceStatus,
|
type EditablePieceStatus,
|
||||||
type PieceInput,
|
type PieceInput,
|
||||||
type PieceSourceInput
|
type PieceSourceInput
|
||||||
} from '#lib/server/content.js';
|
} from '#lib/server/content.js';
|
||||||
import { renderMarkdown } from '#lib/server/markdown.js';
|
import { renderMarkdown } from '#lib/server/markdown.js';
|
||||||
|
import { listTools } from '#lib/server/tools.js';
|
||||||
|
|
||||||
function renderedFields(
|
function renderedFields(
|
||||||
piece: Pick<
|
piece: Pick<
|
||||||
@@ -57,6 +59,9 @@ function pieceInput(data: FormData): PieceInput {
|
|||||||
timecodeSeconds: timecode ? Number(timecode) : null
|
timecodeSeconds: timecode ? Number(timecode) : null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const toolIds = data.getAll('toolId').map(String);
|
||||||
|
const toolWorkUrls = data.getAll('toolWorkUrl').map(String);
|
||||||
|
const toolNotes = data.getAll('toolNote').map(String);
|
||||||
return {
|
return {
|
||||||
title: text(data, 'title'),
|
title: text(data, 'title'),
|
||||||
outputTitle: text(data, 'outputTitle'),
|
outputTitle: text(data, 'outputTitle'),
|
||||||
@@ -85,7 +90,12 @@ function pieceInput(data: FormData): PieceInput {
|
|||||||
presentationMetadataFetchedAtUtc: text(data, 'presentationMetadataFetchedAtUtc') || null,
|
presentationMetadataFetchedAtUtc: text(data, 'presentationMetadataFetchedAtUtc') || null,
|
||||||
topicIds: data.getAll('topicIds').map(String),
|
topicIds: data.getAll('topicIds').map(String),
|
||||||
categoryIds: data.getAll('categoryIds').map(String),
|
categoryIds: data.getAll('categoryIds').map(String),
|
||||||
sources
|
sources,
|
||||||
|
tools: toolIds.map((toolId, index) => ({
|
||||||
|
toolId,
|
||||||
|
workUrl: toolWorkUrls[index] ?? '',
|
||||||
|
note: toolNotes[index] ?? ''
|
||||||
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +136,7 @@ export const load: PageServerLoad = ({ locals, params }) => {
|
|||||||
return {
|
return {
|
||||||
piece,
|
piece,
|
||||||
taxonomy: getTaxonomy(),
|
taxonomy: getTaxonomy(),
|
||||||
|
tools: listTools(true),
|
||||||
templates: listPieceTemplates().filter((template) => template.id !== 'blank'),
|
templates: listPieceTemplates().filter((template) => template.id !== 'blank'),
|
||||||
rendered: renderedFields(piece)
|
rendered: renderedFields(piece)
|
||||||
};
|
};
|
||||||
@@ -243,5 +254,15 @@ export const actions = {
|
|||||||
return fail(409, { intent: 'archive', error: saveError(cause).error });
|
return fail(409, { intent: 'archive', error: saveError(cause).error });
|
||||||
}
|
}
|
||||||
redirect(303, '/archiv');
|
redirect(303, '/archiv');
|
||||||
|
},
|
||||||
|
favorite: async ({ locals, params, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
try {
|
||||||
|
setPieceFavorite(params.id, data.get('favorite') === '1');
|
||||||
|
return { intent: 'favorite', success: true };
|
||||||
|
} catch (cause) {
|
||||||
|
return fail(404, { intent: 'favorite', error: saveError(cause).error });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2))));
|
let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2))));
|
||||||
let revision = $state(untrack(() => data.piece.revision));
|
let revision = $state(untrack(() => data.piece.revision));
|
||||||
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
|
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
|
||||||
|
let pieceTools = $state(untrack(() => data.piece.tools.map((tool) => ({ ...tool }))));
|
||||||
|
let toolToAdd = $state('');
|
||||||
let rendered = $state(untrack(() => ({ ...data.rendered })));
|
let rendered = $state(untrack(() => ({ ...data.rendered })));
|
||||||
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
|
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
|
||||||
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
|
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
|
||||||
@@ -44,6 +46,9 @@
|
|||||||
);
|
);
|
||||||
let previewContent = $derived<PresentationContent>({
|
let previewContent = $derived<PresentationContent>({
|
||||||
mode: draft.presentationMode,
|
mode: draft.presentationMode,
|
||||||
|
topics: data.taxonomy.topics
|
||||||
|
.filter((topic) => draft.topicIds.includes(topic.id))
|
||||||
|
.map((topic) => topic.name),
|
||||||
title: draft.outputTitle || draft.title,
|
title: draft.outputTitle || draft.title,
|
||||||
html: rendered.presentation,
|
html: rendered.presentation,
|
||||||
source: featuredSource
|
source: featuredSource
|
||||||
@@ -147,6 +152,27 @@
|
|||||||
scheduleSave();
|
scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addTool() {
|
||||||
|
const tool = data.tools.find((item) => item.id === toolToAdd);
|
||||||
|
if (!tool || pieceTools.some((item) => item.toolId === tool.id)) return;
|
||||||
|
pieceTools.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
toolId: tool.id,
|
||||||
|
name: tool.name,
|
||||||
|
startUrl: tool.startUrl,
|
||||||
|
isActive: tool.isActive,
|
||||||
|
workUrl: '',
|
||||||
|
note: ''
|
||||||
|
});
|
||||||
|
toolToAdd = '';
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTool(index: number) {
|
||||||
|
pieceTools.splice(index, 1);
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
function presentationSourceChanged() {
|
function presentationSourceChanged() {
|
||||||
draft.presentationMetadataFetchedAtUtc = null;
|
draft.presentationMetadataFetchedAtUtc = null;
|
||||||
draft.presentationProvider = '';
|
draft.presentationProvider = '';
|
||||||
@@ -209,7 +235,19 @@
|
|||||||
onrequestclose={requestClose}
|
onrequestclose={requestClose}
|
||||||
>
|
>
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
<p class="eyebrow">Fundusfassung</p>
|
<div class="piece-editor-kicker">
|
||||||
|
<p class="eyebrow">Fundusfassung</p>
|
||||||
|
<form method="POST" action="?/favorite" use:enhance>
|
||||||
|
<input type="hidden" name="favorite" value={data.piece.isFavorite ? '0' : '1'} />
|
||||||
|
<button
|
||||||
|
class="piece-favorite"
|
||||||
|
class:active={data.piece.isFavorite}
|
||||||
|
type="submit"
|
||||||
|
aria-label={data.piece.isFavorite ? 'Aus Favoriten entfernen' : 'Als Favorit markieren'}
|
||||||
|
aria-pressed={data.piece.isFavorite}>★</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
<label class="piece-dialog-title">
|
<label class="piece-dialog-title">
|
||||||
<span class="sr-only">Titel</span>
|
<span class="sr-only">Titel</span>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -396,6 +434,62 @@
|
|||||||
</div>{/each}
|
</div>{/each}
|
||||||
</div>{:else}<p class="context-empty">Noch keine Quelle hinterlegt.</p>{/if}
|
</div>{:else}<p class="context-empty">Noch keine Quelle hinterlegt.</p>{/if}
|
||||||
</section>
|
</section>
|
||||||
|
<section class="piece-source-editor">
|
||||||
|
<div class="piece-subsection-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Arbeitsmittel</p>
|
||||||
|
<h3>Werkzeuge</h3>
|
||||||
|
</div>
|
||||||
|
<div class="tool-picker">
|
||||||
|
<label class="sr-only" for="piece-tool-picker">Werkzeug auswählen</label>
|
||||||
|
<select id="piece-tool-picker" bind:value={toolToAdd}>
|
||||||
|
<option value="">Werkzeug auswählen</option>
|
||||||
|
{#each data.tools.filter((tool) => tool.isActive && !pieceTools.some((assigned) => assigned.toolId === tool.id)) as tool (tool.id)}
|
||||||
|
<option value={tool.id}
|
||||||
|
>{tool.name}{tool.formatIds.includes(draft.formatId ?? '')
|
||||||
|
? ' · passt zum Format'
|
||||||
|
: ''}</option
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button class="secondary-button" type="button" onclick={addTool} disabled={!toolToAdd}
|
||||||
|
>Hinzufügen</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if pieceTools.length}<div class="source-list">
|
||||||
|
{#each pieceTools as tool, index (tool.id)}<div
|
||||||
|
class="source-row tool-assignment-row"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="toolId" value={tool.toolId} />
|
||||||
|
<div class="tool-assignment-name">
|
||||||
|
<strong>{tool.name}</strong>{#if !tool.isActive}<small>Deaktiviert</small>{/if}
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
>Arbeitslink<input
|
||||||
|
name="toolWorkUrl"
|
||||||
|
type="url"
|
||||||
|
maxlength="2000"
|
||||||
|
placeholder={tool.startUrl || 'https://…'}
|
||||||
|
bind:value={tool.workUrl}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Notiz<input
|
||||||
|
name="toolNote"
|
||||||
|
maxlength="2000"
|
||||||
|
placeholder="Optional"
|
||||||
|
bind:value={tool.note}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="text-button danger-text"
|
||||||
|
type="button"
|
||||||
|
onclick={() => removeTool(index)}>Entfernen</button
|
||||||
|
>
|
||||||
|
</div>{/each}
|
||||||
|
</div>{:else}<p class="context-empty">Noch kein Werkzeug zugeordnet.</p>{/if}
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
|
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { requireUser } from '#lib/server/auth.js';
|
import { requireUser } from '#lib/server/auth.js';
|
||||||
import { getPiece } from '#lib/server/content.js';
|
import { getPiece, getTaxonomy } from '#lib/server/content.js';
|
||||||
import { renderMarkdown } from '#lib/server/markdown.js';
|
import { renderMarkdown } from '#lib/server/markdown.js';
|
||||||
import { buildEmbedUrl, type PresentationContent } from '#lib/server/presentation.js';
|
import { buildEmbedUrl, type PresentationContent } from '#lib/server/presentation.js';
|
||||||
|
|
||||||
@@ -18,6 +18,9 @@ export const load: PageServerLoad = ({ locals, params }) => {
|
|||||||
images: { id: string; altText: string }[];
|
images: { id: string; altText: string }[];
|
||||||
} = {
|
} = {
|
||||||
mode: piece.presentationMode,
|
mode: piece.presentationMode,
|
||||||
|
topics: getTaxonomy()
|
||||||
|
.topics.filter((topic) => piece.topicIds.includes(topic.id))
|
||||||
|
.map((topic) => topic.name),
|
||||||
title: piece.outputTitle || piece.title,
|
title: piece.outputTitle || piece.title,
|
||||||
html: renderMarkdown(piece.presentationText),
|
html: renderMarkdown(piece.presentationText),
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
let now = $state(Date.now());
|
let now = $state(Date.now());
|
||||||
let loadedAt = $state(Date.now());
|
let loadedAt = $state(Date.now());
|
||||||
let currentId = $state(untrack(() => data.live.current?.id ?? null));
|
let currentId = $state(untrack(() => data.live.current?.id ?? null));
|
||||||
|
let loadedRevision = $state(untrack(() => data.live.liveRevision));
|
||||||
let outputWindow: Window | null = null;
|
let outputWindow: Window | null = null;
|
||||||
let progress = $derived(
|
let progress = $derived(
|
||||||
data.live.totalCount ? Math.round((data.live.completedCount / data.live.totalCount) * 100) : 0
|
data.live.totalCount ? Math.round((data.live.completedCount / data.live.totalCount) * 100) : 0
|
||||||
@@ -21,11 +22,21 @@
|
|||||||
(data.live.current?.actualDurationSeconds ?? 0) +
|
(data.live.current?.actualDurationSeconds ?? 0) +
|
||||||
(data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0)
|
(data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0)
|
||||||
);
|
);
|
||||||
|
let sectionElapsedSeconds = $derived(
|
||||||
|
(data.live.sectionTimer?.elapsedSeconds ?? 0) +
|
||||||
|
(data.live.sectionTimer?.runningSinceUtc
|
||||||
|
? Math.max(0, Math.floor((now - loadedAt) / 1000))
|
||||||
|
: 0)
|
||||||
|
);
|
||||||
|
let sectionRemainingSeconds = $derived(
|
||||||
|
(data.live.sectionTimer?.plannedDurationSeconds ?? 0) - sectionElapsedSeconds
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const nextCurrentId = data.live.current?.id ?? null;
|
const nextCurrentId = data.live.current?.id ?? null;
|
||||||
if (nextCurrentId !== currentId) {
|
if (nextCurrentId !== currentId || data.live.liveRevision !== loadedRevision) {
|
||||||
currentId = nextCurrentId;
|
currentId = nextCurrentId;
|
||||||
|
loadedRevision = data.live.liveRevision;
|
||||||
loadedAt = Date.now();
|
loadedAt = Date.now();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -73,6 +84,15 @@
|
|||||||
persistSettings();
|
persistSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCountdown(totalSeconds: number) {
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = Math.floor(totalSeconds % 60);
|
||||||
|
return hours > 0
|
||||||
|
? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
: `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
function liveAction(name: string) {
|
function liveAction(name: string) {
|
||||||
return `?show=${encodeURIComponent(data.live.show.id)}&/${name}`;
|
return `?show=${encodeURIComponent(data.live.show.id)}&/${name}`;
|
||||||
}
|
}
|
||||||
@@ -187,6 +207,21 @@
|
|||||||
style={`--prompt-size: ${promptSize}rem`}
|
style={`--prompt-size: ${promptSize}rem`}
|
||||||
aria-labelledby="current-title"
|
aria-labelledby="current-title"
|
||||||
>
|
>
|
||||||
|
{#if data.live.sectionTimer?.sectionId === data.live.current.sectionId}
|
||||||
|
<section
|
||||||
|
class:warning={sectionRemainingSeconds <= 120 && sectionRemainingSeconds >= 0}
|
||||||
|
class:overrun={sectionRemainingSeconds < 0}
|
||||||
|
class="section-countdown"
|
||||||
|
aria-live="off"
|
||||||
|
>
|
||||||
|
<span>{data.live.sectionTimer.sectionName} · Abschnitt</span>
|
||||||
|
<strong
|
||||||
|
>{sectionRemainingSeconds < 0
|
||||||
|
? `Überzogen +${formatCountdown(Math.abs(sectionRemainingSeconds))}`
|
||||||
|
: `Noch ${formatCountdown(sectionRemainingSeconds)}`}</strong
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
<div class="live-kicker">
|
<div class="live-kicker">
|
||||||
<span>Aktuell · {data.live.current.sectionName}</span>
|
<span>Aktuell · {data.live.current.sectionName}</span>
|
||||||
{#if showTimes}<strong
|
{#if showTimes}<strong
|
||||||
@@ -209,6 +244,45 @@
|
|||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
<div class="live-markdown">{@html data.live.current.researchHtml}</div>
|
<div class="live-markdown">{@html data.live.current.researchHtml}</div>
|
||||||
</details>
|
</details>
|
||||||
|
{#if data.live.current.sources.length || data.live.current.tools.length}
|
||||||
|
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||||
|
<section class="live-materials" aria-labelledby="live-materials-title">
|
||||||
|
<div>
|
||||||
|
<span>Nur Redaktion</span>
|
||||||
|
<h3 id="live-materials-title">Materialien</h3>
|
||||||
|
</div>
|
||||||
|
{#if data.live.current.sources.length}<div class="live-material-group">
|
||||||
|
<strong>Quellen</strong>
|
||||||
|
<ul>
|
||||||
|
{#each data.live.current.sources as source (source.id)}<li>
|
||||||
|
<!-- The URL is a server-validated private HTTP(S) snapshot. -->
|
||||||
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
|
<a href={source.url} target="_blank" rel="noopener noreferrer"
|
||||||
|
>{source.label}</a
|
||||||
|
>{#if source.timecodeSeconds !== null}<small
|
||||||
|
>ab {formatClock(source.timecodeSeconds)}</small
|
||||||
|
>{/if}
|
||||||
|
</li>{/each}
|
||||||
|
</ul>
|
||||||
|
</div>{/if}
|
||||||
|
{#if data.live.current.tools.length}<div class="live-material-group">
|
||||||
|
<strong>Werkzeuge</strong>
|
||||||
|
<ul>
|
||||||
|
{#each data.live.current.tools as tool (tool.id)}<li>
|
||||||
|
{#if tool.workUrl || tool.startUrl}<!-- The URL is a server-validated private HTTP(S) snapshot. -->
|
||||||
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
|
<a
|
||||||
|
href={tool.workUrl || tool.startUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">{tool.name}</a
|
||||||
|
>{:else}<span>{tool.name}</span><small>Kein Link hinterlegt</small>{/if}
|
||||||
|
{#if tool.note}<small>{tool.note}</small>{/if}
|
||||||
|
</li>{/each}
|
||||||
|
</ul>
|
||||||
|
</div>{/if}
|
||||||
|
</section>
|
||||||
|
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||||
|
{/if}
|
||||||
{#if data.live.current.transitionHtml}<section class="transition-card">
|
{#if data.live.current.transitionHtml}<section class="transition-card">
|
||||||
<span>Überleitung</span>
|
<span>Überleitung</span>
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
|||||||
@@ -215,6 +215,9 @@ export const actions = {
|
|||||||
updateUsage: async ({ locals, params, request }) => {
|
updateUsage: async ({ locals, params, request }) => {
|
||||||
requireUser(locals.user);
|
requireUser(locals.user);
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
|
const toolIds = data.getAll('usageToolId').map(String);
|
||||||
|
const toolWorkUrls = data.getAll('toolWorkUrl').map(String);
|
||||||
|
const toolNotes = data.getAll('toolNote').map(String);
|
||||||
return attempt('updateUsage', () =>
|
return attempt('updateUsage', () =>
|
||||||
updatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')), {
|
updatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')), {
|
||||||
title: text(data, 'title'),
|
title: text(data, 'title'),
|
||||||
@@ -235,7 +238,12 @@ export const actions = {
|
|||||||
presentationTitle: text(data, 'presentationTitle'),
|
presentationTitle: text(data, 'presentationTitle'),
|
||||||
presentationDescription: text(data, 'presentationDescription'),
|
presentationDescription: text(data, 'presentationDescription'),
|
||||||
presentationImageUrl: text(data, 'presentationImageUrl'),
|
presentationImageUrl: text(data, 'presentationImageUrl'),
|
||||||
presentationSiteName: text(data, 'presentationSiteName')
|
presentationSiteName: text(data, 'presentationSiteName'),
|
||||||
|
tools: toolIds.map((id, index) => ({
|
||||||
|
id,
|
||||||
|
workUrl: toolWorkUrls[index] ?? '',
|
||||||
|
note: toolNotes[index] ?? ''
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
let visiblePieces = $derived(
|
let visiblePieces = $derived(
|
||||||
data.pieces.filter(
|
data.pieces.filter(
|
||||||
(piece) =>
|
(piece) =>
|
||||||
(showUsedPieces || piece.sentHistory.length === 0) &&
|
(showUsedPieces || piece.sentHistory.length === 0 || piece.isFavorite) &&
|
||||||
(showPlannedPieces || !piece.isPlannedInCurrentShow)
|
(showPlannedPieces || !piece.isPlannedInCurrentShow)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -411,7 +411,10 @@
|
|||||||
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
|
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
|
||||||
><span>{formatClock(piece.durationSeconds)}</span>
|
><span>{formatClock(piece.durationSeconds)}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3>{piece.title}</h3>
|
<h3>
|
||||||
|
{#if piece.isFavorite}<span class="favorite-mark" aria-label="Favorit">★</span
|
||||||
|
>{/if}{piece.title}
|
||||||
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
{piece.format} · {piece.topics.map((item) => item.name).join(', ') || 'Ohne Thema'}
|
{piece.format} · {piece.topics.map((item) => item.name).join(', ') || 'Ohne Thema'}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type TaxonomyKind
|
type TaxonomyKind
|
||||||
} from '#lib/server/content.js';
|
} from '#lib/server/content.js';
|
||||||
import { getMailConfig } from '#lib/server/config.js';
|
import { getMailConfig } from '#lib/server/config.js';
|
||||||
|
import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js';
|
||||||
import {
|
import {
|
||||||
inviteUser,
|
inviteUser,
|
||||||
isRole,
|
isRole,
|
||||||
@@ -25,6 +26,7 @@ export const load: PageServerLoad = ({ locals }) => {
|
|||||||
taxonomy: getTaxonomy(),
|
taxonomy: getTaxonomy(),
|
||||||
users: listUsers(),
|
users: listUsers(),
|
||||||
invitations: listOpenInvitations(),
|
invitations: listOpenInvitations(),
|
||||||
|
tools: listTools(true),
|
||||||
mailTransport: getMailConfig().transport
|
mailTransport: getMailConfig().transport
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -40,9 +42,25 @@ function actionError(cause: unknown): string {
|
|||||||
return 'Verwendete oder für Vorlagen benötigte Einträge können nicht entfernt werden.';
|
return 'Verwendete oder für Vorlagen benötigte Einträge können nicht entfernt werden.';
|
||||||
if (code === 'INVALID_COLOR') return 'Bitte eine gültige Farbe wählen.';
|
if (code === 'INVALID_COLOR') return 'Bitte eine gültige Farbe wählen.';
|
||||||
if (code === 'INVALID_TAXONOMY') return 'Bitte einen gültigen Namen eingeben.';
|
if (code === 'INVALID_TAXONOMY') return 'Bitte einen gültigen Namen eingeben.';
|
||||||
|
if (code === 'TOOL_EXISTS') return 'Ein Werkzeug mit diesem Namen existiert bereits.';
|
||||||
|
if (code === 'INVALID_TOOL_URL')
|
||||||
|
return 'Die Start-URL muss eine vollständige HTTP- oder HTTPS-Adresse sein.';
|
||||||
|
if (code === 'INVALID_TOOL_FORMAT')
|
||||||
|
return 'Mindestens ein gewähltes Format existiert nicht mehr.';
|
||||||
|
if (code === 'INVALID_TOOL') return 'Bitte die Werkzeugdaten prüfen.';
|
||||||
return 'Die Aktion konnte nicht ausgeführt werden.';
|
return 'Die Aktion konnte nicht ausgeführt werden.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toolInput(data: FormData): ToolInput {
|
||||||
|
return {
|
||||||
|
name: String(data.get('name') || ''),
|
||||||
|
startUrl: String(data.get('startUrl') || ''),
|
||||||
|
description: String(data.get('description') || ''),
|
||||||
|
isActive: data.get('isActive') === '1',
|
||||||
|
formatIds: data.getAll('formatIds').map(String)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
invite: async ({ locals, request }) => {
|
invite: async ({ locals, request }) => {
|
||||||
const actor = requireBarkeeper(locals.user);
|
const actor = requireBarkeeper(locals.user);
|
||||||
@@ -131,5 +149,25 @@ export const actions = {
|
|||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
return fail(400, { intent: 'taxonomy', error: actionError(cause) });
|
return fail(400, { intent: 'taxonomy', error: actionError(cause) });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
createTool: async ({ locals, request }) => {
|
||||||
|
requireBarkeeper(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
try {
|
||||||
|
createTool(toolInput(data));
|
||||||
|
return { intent: 'tool', success: 'Werkzeug angelegt.' };
|
||||||
|
} catch (cause) {
|
||||||
|
return fail(400, { intent: 'tool', error: actionError(cause) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateTool: async ({ locals, request }) => {
|
||||||
|
requireBarkeeper(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
try {
|
||||||
|
updateTool(String(data.get('toolId') || ''), toolInput(data));
|
||||||
|
return { intent: 'tool', success: 'Werkzeug aktualisiert.' };
|
||||||
|
} catch (cause) {
|
||||||
|
return fail(400, { intent: 'tool', error: actionError(cause) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<div class="page-wrap narrow-page">
|
<div class="page-wrap narrow-page">
|
||||||
<header class="page-heading">
|
<header class="page-heading">
|
||||||
<h1>Verwaltung</h1>
|
<h1>Verwaltung</h1>
|
||||||
<p class="page-intro">Konten, Rollen und bestehende Taxonomie verwalten.</p>
|
<p class="page-intro">Konten, Werkzeuge und bestehende Taxonomie verwalten.</p>
|
||||||
</header>
|
</header>
|
||||||
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
|
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
|
||||||
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
|
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
|
||||||
@@ -113,6 +113,89 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-heading">Werkzeuge</h2>
|
||||||
|
<section class="quiet-panel tool-management">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Redaktionelle Hilfsmittel</p>
|
||||||
|
<h2>Werkzeugkatalog</h2>
|
||||||
|
</div>
|
||||||
|
<small>Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Sendungen erhalten.</small>
|
||||||
|
</div>
|
||||||
|
<div class="tool-settings-list">
|
||||||
|
{#each data.tools as tool (tool.id)}
|
||||||
|
<details class:inactive={!tool.isActive}>
|
||||||
|
<summary
|
||||||
|
><strong>{tool.name}</strong><span>{tool.isActive ? 'Aktiv' : 'Deaktiviert'}</span
|
||||||
|
></summary
|
||||||
|
>
|
||||||
|
<form class="compact-form tool-form" method="POST" action="?/updateTool">
|
||||||
|
<input type="hidden" name="toolId" value={tool.id} />
|
||||||
|
<label>Name<input name="name" required maxlength="120" value={tool.name} /></label>
|
||||||
|
<label
|
||||||
|
>Start-URL<input
|
||||||
|
name="startUrl"
|
||||||
|
type="url"
|
||||||
|
maxlength="2000"
|
||||||
|
value={tool.startUrl}
|
||||||
|
placeholder="https://…"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="wide-field"
|
||||||
|
>Beschreibung<textarea name="description" rows="2" maxlength="2000"
|
||||||
|
>{tool.description}</textarea
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<fieldset class="wide-field">
|
||||||
|
<legend>Passende Formate</legend>
|
||||||
|
<div class="choice-grid">
|
||||||
|
{#each data.taxonomy.formats as format (format.id)}<label
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
name="formatIds"
|
||||||
|
value={format.id}
|
||||||
|
checked={tool.formatIds.includes(format.id)}
|
||||||
|
/>{format.name}</label
|
||||||
|
>{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<label class="tool-active"
|
||||||
|
><input type="checkbox" name="isActive" value="1" checked={tool.isActive} /> Aktiv</label
|
||||||
|
>
|
||||||
|
<button class="secondary-button" type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<details class="new-tool">
|
||||||
|
<summary>Werkzeug hinzufügen</summary>
|
||||||
|
<form class="compact-form tool-form" method="POST" action="?/createTool">
|
||||||
|
<label>Name<input name="name" required maxlength="120" /></label>
|
||||||
|
<label
|
||||||
|
>Start-URL<input
|
||||||
|
name="startUrl"
|
||||||
|
type="url"
|
||||||
|
maxlength="2000"
|
||||||
|
placeholder="https://…"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="wide-field"
|
||||||
|
>Beschreibung<textarea name="description" rows="2" maxlength="2000"></textarea></label
|
||||||
|
>
|
||||||
|
<fieldset class="wide-field">
|
||||||
|
<legend>Passende Formate</legend>
|
||||||
|
<div class="choice-grid">
|
||||||
|
{#each data.taxonomy.formats as format (format.id)}<label
|
||||||
|
><input type="checkbox" name="formatIds" value={format.id} />{format.name}</label
|
||||||
|
>{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<input type="hidden" name="isActive" value="1" />
|
||||||
|
<button class="primary-button" type="submit">Werkzeug anlegen</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
<h2 class="section-heading">Taxonomie</h2>
|
<h2 class="section-heading">Taxonomie</h2>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<section class="quiet-panel taxonomy-panel">
|
<section class="quiet-panel taxonomy-panel">
|
||||||
|
|||||||
@@ -190,6 +190,16 @@ function removePresentationFixture() {
|
|||||||
database.close();
|
database.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSectionDuration(sectionId: string, seconds: number | null) {
|
||||||
|
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
|
||||||
|
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
|
||||||
|
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
|
||||||
|
database
|
||||||
|
.prepare('UPDATE show_sections SET explicit_duration_seconds = ? WHERE id = ?')
|
||||||
|
.run(seconds, sectionId);
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
test.beforeAll(async ({ browser }) => {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
@@ -311,6 +321,17 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
|
|||||||
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
|
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
|
||||||
await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
|
await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
|
||||||
await expect(page.locator('.piece-card')).toHaveCount(4);
|
await expect(page.locator('.piece-card')).toHaveCount(4);
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Die kleine Konsole mit dem großen Dreh als Favorit markieren' })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: 'Die kleine Konsole mit dem großen Dreh aus Favoriten entfernen'
|
||||||
|
})
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.locator('.piece-card').first()).toContainText(
|
||||||
|
'Die kleine Konsole mit dem großen Dreh'
|
||||||
|
);
|
||||||
await page
|
await page
|
||||||
.locator('.filter-picker')
|
.locator('.filter-picker')
|
||||||
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
|
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
|
||||||
@@ -856,6 +877,49 @@ test('inbox, archive and administration stay concise', async ({ page }) => {
|
|||||||
expect(boxes[1]?.y).toBe(boxes[2]?.y);
|
expect(boxes[1]?.y).toBe(boxes[2]?.y);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tools stay editable in pieces after catalog deactivation', async ({ page }, testInfo) => {
|
||||||
|
await page.goto('/verwaltung');
|
||||||
|
const create = page.locator('.new-tool');
|
||||||
|
await create.getByText('Werkzeug hinzufügen').click();
|
||||||
|
await create.getByLabel('Name').fill('E2E Tierlist');
|
||||||
|
await create.getByLabel('Start-URL').fill('https://example.com/tierlist');
|
||||||
|
await create.getByLabel('Beschreibung').fill('Ranking für die Sendung');
|
||||||
|
await create.getByLabel('Quiz').check();
|
||||||
|
await create.getByRole('button', { name: 'Werkzeug anlegen' }).click();
|
||||||
|
const tool = page.locator('.tool-settings-list details').filter({ hasText: 'E2E Tierlist' });
|
||||||
|
await expect(tool).toContainText('Aktiv');
|
||||||
|
|
||||||
|
await page.goto('/fundus/piece-quiz');
|
||||||
|
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
|
||||||
|
await page
|
||||||
|
.locator('#piece-tool-picker')
|
||||||
|
.selectOption({ label: 'E2E Tierlist · passt zum Format' });
|
||||||
|
await page.getByRole('button', { name: 'Hinzufügen', exact: true }).click();
|
||||||
|
await page.getByLabel('Arbeitslink').fill('https://example.com/tierlist/e2e-board');
|
||||||
|
await page.getByLabel('Notiz').fill('Vor der Sendung sortieren');
|
||||||
|
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
|
||||||
|
|
||||||
|
await page.goto('/verwaltung');
|
||||||
|
const storedTool = page
|
||||||
|
.locator('.tool-settings-list details')
|
||||||
|
.filter({ hasText: 'E2E Tierlist' });
|
||||||
|
await storedTool.getByText('E2E Tierlist', { exact: true }).click();
|
||||||
|
await storedTool.getByLabel('Aktiv', { exact: true }).uncheck();
|
||||||
|
await storedTool.getByRole('button', { name: 'Speichern' }).click();
|
||||||
|
await expect(storedTool).toContainText('Deaktiviert');
|
||||||
|
|
||||||
|
await page.goto('/fundus/piece-quiz');
|
||||||
|
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
|
||||||
|
await expect(page.locator('.tool-assignment-row')).toContainText('E2E Tierlist');
|
||||||
|
await expect(page.locator('.tool-assignment-row')).toContainText('Deaktiviert');
|
||||||
|
await expect(page.getByLabel('Arbeitslink')).toHaveValue(
|
||||||
|
'https://example.com/tierlist/e2e-board'
|
||||||
|
);
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('piece-tools-mobile.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
test('validated YouTube and TikTok players switch without exposing private fields', async ({
|
test('validated YouTube and TikTok players switch without exposing private fields', async ({
|
||||||
page
|
page
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
@@ -918,6 +982,7 @@ test('live control persists state and exposes only explicit presentation data',
|
|||||||
page,
|
page,
|
||||||
browser
|
browser
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
|
setSectionDuration('section-stories', 300);
|
||||||
await page.setViewportSize({ width: 1280, height: 900 });
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
await page.goto('/fundus?q=kleine+Konsole&includePlanned=1');
|
await page.goto('/fundus?q=kleine+Konsole&includePlanned=1');
|
||||||
await page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }).click();
|
await page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }).click();
|
||||||
@@ -938,6 +1003,8 @@ test('live control persists state and exposes only explicit presentation data',
|
|||||||
await expect(page.getByText('Noch nicht gestartet.')).toBeVisible();
|
await expect(page.getByText('Noch nicht gestartet.')).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Sendung starten' }).click();
|
await page.getByRole('button', { name: 'Sendung starten' }).click();
|
||||||
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
|
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
|
||||||
|
await expect(page.locator('.section-countdown')).toContainText(/Noch 0[45]:\d{2}/);
|
||||||
|
await expect(page.locator('.live-materials')).toContainText('Herstellerseite');
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('Herstellerangaben gegen unabhängige Tests prüfen')
|
page.getByText('Herstellerangaben gegen unabhängige Tests prüfen')
|
||||||
).not.toBeVisible();
|
).not.toBeVisible();
|
||||||
@@ -974,6 +1041,7 @@ test('live control persists state and exposes only explicit presentation data',
|
|||||||
timeout: 10_000
|
timeout: 10_000
|
||||||
});
|
});
|
||||||
await expect(output.getByText('Weniger Leistung, mehr Idee.')).toBeVisible();
|
await expect(output.getByText('Weniger Leistung, mehr Idee.')).toBeVisible();
|
||||||
|
await expect(output.locator('.output-topics')).toHaveText('Gaming');
|
||||||
await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toHaveCount(0);
|
await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toHaveCount(0);
|
||||||
await output.screenshot({ path: testInfo.outputPath('heinzlive-output.png'), fullPage: true });
|
await output.screenshot({ path: testInfo.outputPath('heinzlive-output.png'), fullPage: true });
|
||||||
const outputHtml = await output.content();
|
const outputHtml = await output.content();
|
||||||
@@ -981,6 +1049,7 @@ test('live control persists state and exposes only explicit presentation data',
|
|||||||
expect(outputHtml).not.toContain('Erst Formfaktor zeigen');
|
expect(outputHtml).not.toContain('Erst Formfaktor zeigen');
|
||||||
expect(outputHtml).not.toContain(adminEmail);
|
expect(outputHtml).not.toContain(adminEmail);
|
||||||
expect(outputHtml).not.toContain('live-konsole.png');
|
expect(outputHtml).not.toContain('live-konsole.png');
|
||||||
|
expect(outputHtml).not.toContain('https://example.com/console');
|
||||||
const stateUrl = new URL(outputUrl);
|
const stateUrl = new URL(outputUrl);
|
||||||
stateUrl.hash = '';
|
stateUrl.hash = '';
|
||||||
stateUrl.pathname += '/state';
|
stateUrl.pathname += '/state';
|
||||||
|
|||||||
Reference in new issue
Block a user