5 Commits
Author SHA1 Message Date
hnzio 64f55a8b1f Complete 0.7.1 fixes 2026-10-26 09:35:19 +01:00
hnzio a08b02c34b Release 0.7.1 2026-10-25 23:29:00 +01:00
hnzio 6d96900d81 Release 0.7.0 2026-10-25 18:40:01 +01:00
hnzio d586fbd8ce Release 0.6.1 2026-10-25 18:18:23 +01:00
hnzio b80dded55d Release 0.6.0 2026-10-25 09:45:31 +01:00
70 changed files with 7063 additions and 353 deletions

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk", "title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung", "tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.5.12", "version": "0.7.1",
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 3000, "httpPort": 3000,
"addons": { "addons": {
+110
View File
@@ -1,5 +1,81 @@
# Abnahme # Abnahme
## Wartungsrelease 0.7.1
| Bereich | Nachweis | Stand |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Abschnittsdauer und Rückkehr zu früheren Pieces (#1) | Live-Verhaltenstests für geänderte Plandauer, Wiederaufnahme, Abschnittslaufzeit und Gesamtzeit; Chromium-Ablauf | erfüllt am 26.10.2026 |
| Mobiler Live-Ablauf und Metadaten (#3, #13) | mobile Live-Regie ohne horizontalen Überlauf; viewportgebundenes Sheet, Safe-Area-/Standalone-Basis und Fokus in Chromium | Chromium erfüllt; Safari und installierte iOS-Webapp offen |
| Mobile Sendungskarten (#5) | responsive Karten im vollständigen Chromium-Lauf bei 390 Pixeln geöffnet | erfüllt am 26.10.2026 |
| Vorbereitung nach Einplanung (#6, #15) | Migration und Servertest für konkrete Usage-Snapshots; Chromium öffnet die Usage und entfernt sie nach „sendefertig“ | erfüllt am 26.10.2026 |
| Kontextabhängige Präsentation (#8) | vier serverseitig getrennte Modi; Fundus-/Usage-Vorschauen sowie Link-, Video-, Text-/Bildausgabe in Chromium | erfüllt am 26.10.2026 |
| Gastansicht und Freigaben (#11, #12) | gestalteter Einzeldialog, Verwaltungsreihenfolge und Kartenpadding per Chromium; Tokenersatz mit Invalidierungstest | erfüllt am 26.10.2026 |
| Iconsuche (#14) | Suchfokus innerhalb der öffnenden Bedienaktion; Chromium bei mobilen und breiten Ansichten | erfüllt am 26.10.2026 |
- `npm test`: 60 von 60 Verhaltenstests erfolgreich.
- `npm run lint`: erfolgreich.
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
- `npm run test:e2e`: 19 von 19 Chromium-Abläufen erfolgreich.
- Safari auf echtem iOS-Gerät: offen.
- Installierte Webapp auf echtem iOS-Gerät: separat offen.
## Etappe 7
| Kriterium | Nachweis | Stand |
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| Feedverwaltung und geplanter/manueller Abruf | Barkeeper-Oberfläche, Prozess-Scheduler und gemeinsame Abruffunktion | erfüllt am 25.10.2026 |
| Getrennter Vorschau-Eingang | RSS-Beiträge erscheinen separat und werden nur nach Mehrfachauswahl zu Pieces | erfüllt am 25.10.2026 |
| Kategoriezuordnung und Herkunft | Auswahlkategorien oder Feed-Standard sowie Original-URL als Piece-Quelle im Verhaltenstest | erfüllt am 25.10.2026 |
| Dubletten und dauerhaftes Verwerfen | wiederholter Testabruf erzeugt keinen zweiten Eintrag und stellt Verworfenes nicht wieder her | erfüllt am 25.10.2026 |
| HTML-Bereinigung und Abrufgrenzen | Testfeed entfernt fremdes HTML; Loopback wird vor Request blockiert; Größe, Zeit, Redirects und Eintragszahl sind begrenzt | erfüllt am 25.10.2026 |
| Fehlerisolation | defekter lokaler Testfeed wird protokolliert, weiterer fälliger Feed trotzdem importiert | erfüllt am 25.10.2026 |
| Versionierte REST-API und OpenAPI | `/api/v1` für Pieces, Sendungen und Einplanungen; `/api/v1/openapi.json` | erfüllt am 25.10.2026 |
| Begrenzte und widerrufbare Zugänge | gehashte Tokens; Verhaltenstest für Lesen, verweigertes Schreiben und sofortigen Widerruf | erfüllt am 25.10.2026 |
| Änderungsnachweis und Idempotenz | Audit-Ereignisse und persistente Idempotenz; identische Wiederholung liefert dasselbe Ergebnis | erfüllt am 25.10.2026 |
| API-Rundlauf | echter HTTP-Test: lesen, anlegen, ändern, erneut lesen, einplanen und sortieren | erfüllt am 25.10.2026 |
| Nutzung ohne KI | RSS und API sind optional; bestehende Redaktion funktioniert unverändert ohne externen Client | erfüllt am 25.10.2026 |
| Konkrete ChatGPT-/Codex-Anbindung | keine Clientumgebung oder Zugangsdaten vorhanden | offen |
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm test`: 58 von 58 Verhaltenstests erfolgreich.
- Gezielte Etappe-7-Tests: 6 von 6 erfolgreich.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
- `npm audit --omit=dev`: 0 gemeldete Schwachstellen.
- Gezielte Chromium-Prüfung: Desktop-Verwaltung, mobiler Eingang und kompletter
HTTP-API-Rundlauf erfolgreich, keine Konsolenfehler.
- `npm run test:e2e`: auf dem Standardport wegen eines bereits laufenden fremden
Prozesses nicht startbar; identische temporäre Konfiguration auf Port 4188 mit
16 von 18 erfolgreichen Abläufen. Bekannte Fehler bleiben bei Live-Zeitanzeige
und Abschluss/Freigabe bestehen.
- `npm run lint`: nicht erfolgreich wegen acht bereits vorhandener
Prettier-Abweichungen außerhalb der Etappe-7-Dateien; geänderte Dateien wurden
separat formatiert.
- Dokumentationsstatus: **API/MCP getestet, Client-Anbindung offen**. Konkret ist
die REST-API getestet; ein zusätzlicher MCP-Adapter wurde nicht benötigt und
keine echte ChatGPT-/Codex-Verbindung hergestellt.
## Wartungsrelease 0.6.1
| Bereich | Stand |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Live-Timer, Kopfzeile, Drawer und Positionsanzeige | behoben |
| Mobile Formulare und Iconsuche | behoben |
| Vorbereitungsaufwand und Übersicht | behoben |
| Bild-Präsentationsmodus | behoben |
| Twitch-Tags je Abschnitt | behoben |
| Sendungskarten und Gastabstände | behoben |
| Gemeinsamer Freigabedialog einschließlich Live-Regie | behoben |
| Editorzustand und Drag-and-drop | bestehende zustandserhaltende bzw. alternative Bedienwege unverändert; Browserprüfung offen |
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm test`: 52 von 52 Verhaltenstests erfolgreich.
- `npm run build`: erfolgreich.
- `npm run test:e2e`: zweiter Lauf mit 14 erfolgreichen Abläufen; Live-Regie und
Abschluss/Freigaben schlugen fehl, zwei nachfolgende Abläufe wurden wegen des
120-Sekunden-Zeitlimits nicht mehr ausgeführt. Die Browsermatrix ist offen.
- Safari auf echtem iOS-Gerät: nicht verfügbar.
## Etappe 1 ## Etappe 1
| Kriterium | Nachweis | Stand | | Kriterium | Nachweis | Stand |
@@ -506,3 +582,37 @@ vollständige erneute Matrix.
- Zusätzliche visuelle Chromium-Prüfung bei 656 × 400 und 390 × 700 Pixeln: - Zusätzliche visuelle Chromium-Prüfung bei 656 × 400 und 390 × 700 Pixeln:
Sendungskarte unter 130 Pixel hoch, Menü-/Ausgabe-/Timericons sichtbar und Sendungskarte unter 130 Pixel hoch, Menü-/Ausgabe-/Timericons sichtbar und
Pausebutton wechselt bedienbar zu Fortsetzen. Pausebutton wechselt bedienbar zu Fortsetzen.
## Etappe 6
| Kriterium | Nachweis | Stand |
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------- |
| Spätere Originaländerung verändert Historie nicht | Verhaltenstest ändert Piece und Abschnittskategorie nach Abschluss; historische Snapshots bleiben gleich | erfüllt am 25.10.2026 |
| Abschluss nicht doppelt; übersprungen ist nicht gesendet | idempotente Befehlswiederholung, fremde Befehls-ID und materialisierte Usage-Historie im Verhaltenstest | erfüllt am 25.10.2026 |
| Bewusste Auswahl archiviert nur gewählte Originale | Abschlusskandidaten und explizite Archivauswahl im Service; Dialog trennt gesendet, übersprungen und offen | erfüllt am 25.10.2026 |
| Archivierte Sendung stabil und duplizierbar | Service- und Browsertest prüfen Nur-Lese-Ansicht sowie neuen Entwurf ohne Live-, Historien- oder Freigabedaten | erfüllt am 25.10.2026 |
| Gast sieht genau eine Sendung und nur erlaubte Felder | anonymer Chromium-Kontext und Scope-Verhaltenstests für Ablauf, Moderation und Recherche | erfüllt am 25.10.2026 |
| Gast-Schreibzugriff und fremde IDs abgelehnt | anonymer direkter POST erhält 403; fremde Medien-ID erhält 404 | erfüllt am 25.10.2026 |
| Passwort, Ablauf und Widerruf schützen direkte Aufrufe | Service- und Browsertests prüfen Seite, Gast-Sitzung und direkten Medienrequest vor und nach Widerruf | erfüllt am 25.10.2026 |
| Barkeeper verwaltet Freigaben | Anlage, Bearbeitung und Löschung direkt in der Sendung sowie gruppiert in der Verwaltung im Browser geprüft | erfüllt am 25.10.2026 |
| Offene Sendungen sind sicher freigebbar | Service und anonymer Browser prüfen aktuellen Plan vor Abschluss und stabilen Stand danach | erfüllt am 25.10.2026 |
| Bestehende Abschlüsse werden einmalig übernommen | Upgrade-Test wendet `0015` auf einen Stand bis `0014` zweimal an und findet genau eine Historie | erfüllt am 25.10.2026 |
| Bestehende Freigaben bleiben bei Erweiterung erhalten | Migration `0016` ersetzt nur die Fremdschlüsselgrenze und kopiert Freigaben sowie Gast-Sitzungen | erfüllt am 25.10.2026 |
## Ausgeführte Prüfungen Etappe 6
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 52 Tests in 11 Dateien erfolgreich
- gezielter Chromium-Ablauf für Abschluss, Archiv, historische Ansicht,
offene und abgeschlossene Freigabe, Bearbeitung und Löschung in beiden
Barkeeper-Oberflächen, Passwort, privaten Scope, Reserve-Medienschutz, fremde
ID und Gast-POST: 1 von 1 erfolgreich
- `npm run test:e2e`: nach Korrektur eines bestehenden, auf den 24. Oktober
festgelegten „Heute“-Fixtures 18 von 18 Chromium-Browsertests erfolgreich
- Browserprüfung von Sendungsfreigabe und Gastansicht bei 390 × 844 Pixeln sowie
der Verwaltung bei 1280 × 900 Pixeln ohne horizontalen Überlauf; Screenshots
visuell kontrolliert
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run lint`: Prettier und ESLint erfolgreich
- Versionsgleichheit `0.6.0` in Paket, Lockdatei und Cloudron-Manifest geprüft
- `bash -n start.sh` und `git diff --check`: erfolgreich
+120 -14
View File
@@ -3,7 +3,7 @@
## Systemgrenze ## Systemgrenze
Sendewerk ist ein einzelner SvelteKit-Prozess. Browserseiten, serverseitige Sendewerk ist ein einzelner SvelteKit-Prozess. Browserseiten, serverseitige
Actions, spätere REST-Endpunkte und Hintergrundaufgaben verwenden dieselbe Actions, REST-Endpunkte und Hintergrundaufgaben verwenden dieselbe
Geschäftslogik. SQLite und ein lokales Upload-Verzeichnis sind die einzigen Geschäftslogik. SQLite und ein lokales Upload-Verzeichnis sind die einzigen
persistenten Laufzeitabhängigkeiten. Externe Mail-, RSS- oder KI-Dienste sind persistenten Laufzeitabhängigkeiten. Externe Mail-, RSS- oder KI-Dienste sind
nicht Voraussetzung für Kernfunktionen. nicht Voraussetzung für Kernfunktionen.
@@ -11,7 +11,7 @@ nicht Voraussetzung für Kernfunktionen.
```text ```text
Browser -> SvelteKit-Routen -> Anwendungsdienste -> SQLite Browser -> SvelteKit-Routen -> Anwendungsdienste -> SQLite
| -> Upload-Verzeichnis | -> Upload-Verzeichnis
+-> später: Mail/RSS über Adapter +-> Mail/RSS über begrenzte Adapter
``` ```
Servercode lebt unter `src/lib/server` und darf nicht in Browser-Bundles Servercode lebt unter `src/lib/server` und darf nicht in Browser-Bundles
@@ -33,13 +33,13 @@ spätere API dieselben Regeln verwenden.
## Gesamtdatenmodell ## Gesamtdatenmodell
Die ersten fünf Etappen materialisieren Identität, Inhaltsverwaltung, Die ersten sieben Etappen materialisieren Identität, Inhaltsverwaltung,
Sendungsplanung und Live-Durchführung. Folgende Grenzen gelten für umgesetzte Sendungsplanung, Live-Durchführung, Historie, Lesefreigaben, RSS-Import und eine
und spätere Bereiche. technische API. Folgende Grenzen gelten für umgesetzte 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 sowie getrennte technische Zugänge |
| Taxonomie | getrennte `section_categories`, `formats`, `topics` samt optionalem Themenicon; Pieces und Vorschläge mehrfach verknüpft | | Taxonomie | getrennte `section_categories`, `formats`, `topics` samt optionalem Themenicon; Pieces und Vorschläge mehrfach verknüpft |
| Inhalt | `pieces` mit Status, Soft Delete, Favorit, Revision, Texten, Quellen, Werkzeugen und privaten Medien; Vorlagen speichern Snapshots | | Inhalt | `pieces` mit Status, Soft Delete, Favorit, Revision, Texten, Quellen, Werkzeugen und privaten Medien; Vorlagen speichern Snapshots |
| Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themen-/Mediensnapshots, Reservebank und Vorlagen | | Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themen-/Mediensnapshots, Reservebank und Vorlagen |
@@ -99,6 +99,43 @@ konfigurierten Outbox-Pfad, `smtp` nutzt Nodemailer nur nach expliziter Auswahl.
Die feste Anwendungs-Origin erzeugt Links und bestimmt den `Secure`-Status der Die feste Anwendungs-Origin erzeugt Links und bestimmt den `Secure`-Status der
Cookies; sie wird nicht aus ungeprüften Request-Headern übernommen. Cookies; sie wird nicht aus ungeprüften Request-Headern übernommen.
Technische Zugänge sind keine Browsersitzungen. Sie besitzen zufällige
Bearer-Tokens, von denen nur SHA-256-Hashes gespeichert werden, und genau einen
der Umfänge `read` oder `read_write`. Widerruf wirkt beim nächsten Request.
`/api/v1` umgeht lediglich den Browser-Loginredirect; jeder Endpunkt prüft Token
und erforderlichen Umfang selbst. Schreibzugriffe hinterlassen ein
`audit_event`; Neuanlage und Planungsänderungen verlangen zusätzlich einen je
Zugang eindeutigen, persistent gespeicherten Idempotenzschlüssel.
## RSS-Import
`src/lib/server/feeds.ts` bildet Abruf, XML-Verarbeitung und Übernahme gemeinsam
für manuelle Aktionen und den Prozess-Scheduler ab. Der Scheduler startet beim
ersten Request und prüft minütlich höchstens zehn fällige aktive Feeds. Jeder
Feed wird getrennt behandelt, sodass ein Fehler weitere Abrufe nicht stoppt.
HTTP-Abrufe erlauben nur HTTP(S) auf Standardports, prüfen DNS sowie jedes
Redirectziel gegen interne Adressen und begrenzen Laufzeit, Redirects und
Antwortgröße. RSS/Atom wird mit deaktivierter Entity-Verarbeitung gelesen;
höchstens 200 Einträge je Antwort werden berücksichtigt und fremdes HTML wird zu
reinem Text reduziert.
`feed_entries` ist ein eigener Eingang. `(feed_id, external_id)` verhindert
Dubletten; Aktualisierungen ändern den redaktionellen Status nicht. `discarded`
bleibt deshalb auch nach späteren Abrufen erhalten. Erst die bewusste
Mehrfachauswahl erzeugt innerhalb einer Transaktion normale Inbox-Pieces mit
Kategorien und Original-URL als Quelle und setzt den Eintrag auf `imported`.
## Technische REST-API
Die Routen unter `/api/v1` bilden keine zweite Fachlogik, sondern rufen
`content.ts` und `shows.ts` auf. Piece- und Sendungsrevisionen schützen weiterhin
vor verlorenen Änderungen. `api_idempotency` speichert Request-Hash, Status und
JSON-Antwort, sodass wiederholte Schreibbefehle dasselbe Ergebnis liefern und
ein gleicher Schlüssel mit verändertem Inhalt abgewiesen wird. Die OpenAPI-3.1-
Beschreibung ist unter `/api/v1/openapi.json` erreichbar. Ein MCP-Adapter oder
produktspezifischer KI-Client gehört nicht zur Laufzeit und ist nicht
Voraussetzung für die Bedienung.
## Inhalte, Markdown und Medien ## Inhalte, Markdown und Medien
Etappe 3 führt die Inhaltslogik in `src/lib/server/content.ts` zusammen. Listen Etappe 3 führt die Inhaltslogik in `src/lib/server/content.ts` zusammen. Listen
@@ -238,13 +275,12 @@ Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von de
geplanten Dauer nachvollziehbar. geplanten Dauer nachvollziehbar.
`live_show_timer_runs` hält die unabhängige Gesamtzeit. Pause und Fortsetzen `live_show_timer_runs` hält die unabhängige Gesamtzeit. Pause und Fortsetzen
behandeln Gesamt-, Abschnitts- und Piece-Intervalle gemeinsam. Beenden setzt den behandeln Gesamt-, Abschnitts- und Piece-Intervalle gemeinsam. Beenden schließt
Sendungsstatus auf `completed`, schließt laufende Intervalle, markiert offene laufende Intervalle und setzt `ended_at_utc`, finalisiert die Sendung aber noch
Pieces aber nicht als erledigt. Eine ausdrückliche Wiederaufnahme bewahrt Zeiten nicht. Eine ausdrückliche Wiederaufnahme bewahrt Zeiten und Fortschritt; nur
und Fortschritt; nur „Neuer Durchlauf“ verwirft Laufintervalle und setzt die „Neuer Durchlauf“ verwirft Laufintervalle und setzt die Piece-Status zurück. Die
Piece-Status zurück. Die Restzeitschätzung verwendet je Abschnitt entweder die Restzeitschätzung verwendet je Abschnitt entweder die positive Abschnittsdauer
positive Abschnittsdauer oder die verbleibenden Piece-Dauern und zählt beide nie oder die verbleibenden Piece-Dauern und zählt beide nie zusammen.
zusammen.
Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf
Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt
@@ -295,6 +331,52 @@ sichtbar; die Oberfläche kennzeichnet den Abbruch und versucht die Verbindung
mit verlangsamtem Intervall erneut. Private Live-Daten werden nicht Teil dieses mit verlangsamtem Intervall erneut. Private Live-Daten werden nicht Teil dieses
Zustandsobjekts. Zustandsobjekts.
## Abschluss, Historie und Lesefreigaben
`src/lib/server/completion.ts` ist die Transaktionsgrenze des endgültigen
Sendungsabschlusses. Er verlangt einen beendeten Live-Lauf sowie passende Show-
und Live-Revisionen. Eine Befehls-ID in `show_finalizations` macht Wiederholungen
idempotent. In derselben Transaktion werden offene Intervalle geschlossen,
Abschnittskategorien eingefroren, ausschließlich erledigte Verwendungen außerhalb
der Reservebank in `piece_usage_history` materialisiert, bewusst gewählte
Fundus-Originale archiviert und die Sendung auf `completed` gesetzt. Jede normale
Planungs- und Live-Mutation sperrt diesen Zustand.
Die eigentlichen Sendungstexte, Quellen, Werkzeuge, Themen und Medien liegen
weiterhin in den beim Einplanen erzeugten Usage-Snapshots. Finalisierung friert
die zuvor noch katalogabhängige Abschnittskategorie ein. Abgeschlossene
Sendungen werden ausschließlich gelesen oder vollständig in einen neuen Entwurf
dupliziert. Das Duplikat erhält neue Show-, Abschnitts-, Usage- und Relations-IDs,
offene Usage-Status und weder Live-, Historien- noch Freigabedaten.
`src/lib/server/shares.ts` erzeugt für Barkeeper zufällige 256-Bit-Lesetokens;
SQLite speichert nur SHA-256-Hashes. Eine Freigabe verweist direkt auf eine
Sendung und darf deshalb bereits im Entwurf, im bereiten oder im laufenden
Zustand entstehen. Der Umfang ist eine Positivliste aus `rundown`, `moderation`
oder `research`. Optionale Passwörter liegen als Argon2id-Hash vor. Nach
erfolgreicher Prüfung entsteht eine höchstens zwölf Stunden gültige, ebenfalls
nur gehashte Gast-Sitzung; Fehlversuche werden pro Link und Clientadresse
begrenzt. Änderungen an Umfang, Passwort oder Ablauf invalidieren bestehende
Gast-Sitzungen. Ablauf und Löschung werden bei jedem HTML-, Seitendaten- und
Medienrequest erneut geprüft.
Die öffentliche Route `/freigabe/[token]` erhält keine interne Sendungs-ID und
keinen ungefilterten `Show`-Datensatz. Sie projiziert genau die am Token gebundene
Sendung und nur die Felder ihres Umfangs. Vor dem Abschluss werden aktuelle
Sendungs- und Usage-Snapshots bei jedem Aufruf neu gelesen; danach liefert
dieselbe Projektion die durch die Finalisierung stabilisierten Snapshots. Die
separate Medienroute akzeptiert ausschließlich das ausdrücklich gewählte
Präsentationsmedium einer eingeplanten Nicht-Reserve-Verwendung derselben
Sendung. Private `/media`-Dateien bleiben weiterhin an eine Redaktionssitzung
gebunden.
Die wiederverwendbare Barkeeper-Komponente `ShareManagement.svelte` liegt direkt
in der Sendungsbearbeitung und in der Verwaltung. Beide Routen verwenden
dieselben Servicefunktionen für Anlage, Bearbeitung und Löschung; die
Sendungsroute bindet Mutationen zusätzlich an ihre eigene Sendungs-ID. Die
Verwaltung gruppiert bestehende Freigaben nach Sendung und zeigt deren aktuellen
Status und Termin.
## Oberflächenstruktur ## Oberflächenstruktur
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung. Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
@@ -359,3 +441,27 @@ betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor
Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Netzwerkabrufe für Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Netzwerkabrufe für
Präsentationsmetadaten und Vorschaubilder schützen interne Ziele auch nach Präsentationsmetadaten und Vorschaubilder schützen interne Ziele auch nach
DNS-Auflösung und Redirects und besitzen Größen-, Zeit- und Redirectlimits. DNS-Auflösung und Redirects und besitzen Größen-, Zeit- und Redirectlimits.
## Wartungsfelder 0.6.1
Migration `0017` ergänzt den optionalen Vorbereitungsaufwand am Original und
kommagetrennt validierte Twitch-Tags am Abschnitt. Der Bildmodus wird wegen der
bestehenden SQLite-Check-Constraints als zusätzliches Kennzeichen an Original
und Usage-Snapshot gespeichert; nach außen bleibt er der eindeutige Modus
`image`. Öffentlich passiert weiterhin nur das ausdrücklich gewählte
Präsentationsmedium.
## Wartungskorrekturen 0.7.1
Migration `0019` ergänzt `preparation_status_snapshot` und
`preparation_effort_snapshot` an `piece_usages` und übernimmt für vorhandene
Einplanungen einmalig die damaligen Originalwerte. Danach ist ausschließlich die
konkrete Sendungsfassung maßgeblich. Einplanen, Duplizieren und bewusstes
Aktualisieren aus dem Fundus kopieren beide Werte; der Usage-Editor ändert sie
unabhängig. Die Übersicht berücksichtigt vorbereitende Nicht-Reserve-Usages
aktiver Sendungen, priorisiert zuerst deren Termin und führt direkt zum Editor.
Ein Freigabe-Ersatzlink rotiert ausschließlich den gehashten Token derselben
Freigabe. Umfang, Passwort-Hash und Ablauf bleiben erhalten, der alte Token und
alle bisherigen Gastsitzungen werden in derselben Transaktion ungültig. Der neue
Klartexttoken wird weiterhin nur in der Action-Antwort ausgegeben.
+94 -1
View File
@@ -419,9 +419,102 @@ verbleibenden Piece-Dauern. Bereits abgeschlossene Abschnitte werden ausgelassen
ungeplante Abschnitte werden sichtbar gezählt und eine Überziehung reduziert ungeplante Abschnitte werden sichtbar gezählt und eine Überziehung reduziert
nicht die geplante Zeit kommender Inhalte. nicht die geplante Zeit kommender Inhalte.
`completed` beendet einen Durchlauf, nicht automatisch dessen offene Pieces. `ended_at_utc` beendet einen Durchlauf, nicht automatisch dessen offene Pieces.
Wiederaufnahme und neuer Durchlauf sind deshalb getrennte Befehle: Erstere Wiederaufnahme und neuer Durchlauf sind deshalb getrennte Befehle: Erstere
bewahrt Intervalle und Fortschritt, letzterer setzt beides ausdrücklich zurück. bewahrt Intervalle und Fortschritt, letzterer setzt beides ausdrücklich zurück.
Das private Markdownfeld „Interaktion“ folgt derselben Original-/Snapshotgrenze Das private Markdownfeld „Interaktion“ folgt derselben Original-/Snapshotgrenze
wie Moderation und Überleitung, ist aber nicht Teil der öffentlichen wie Moderation und Überleitung, ist aber nicht Teil der öffentlichen
Präsentations-Positivliste. Präsentations-Positivliste.
## D-027: Abschlusszustand und Freigaben als eigene Sicherheitsgrenzen
Ein beendeter Live-Lauf ist wiederaufnehmbar und deshalb nicht identisch mit
einer historischen Sendung. Erst eine eigene transaktionale Finalisierung setzt
`shows.status` auf `completed`. Eine dauerhaft gespeicherte Befehls-ID verhindert
Doppelverbuchung; Usage-Historie enthält nur `done`, nie `skipped` oder Reserve.
Bestehende Usage-Snapshots bleiben Inhaltsquelle, während Abschnittskategorien
beim Abschluss zusätzlich eingefroren werden. Historische Sendungen werden nicht
erneut bearbeitet, sondern mit neuen IDs und zurückgesetztem Laufzustand kopiert.
Lesefreigaben sind keine abgeschwächten Redaktionssitzungen. Sie verwenden eine
eigene Positivprojektion mit drei kumulativen Umfängen. Roh-Tokens und
Gast-Sitzungstokens stehen nie in SQLite, optionale Passwörter verwenden Argon2id.
Passwort, Ablauf und Widerruf werden zentral vor Seite, Seitendaten und Medium
geprüft. Präsentationsmedien sind an Freigabe, Sendung und Usage-Snapshot
gebunden; beliebige Fundus-Dateien bleiben ausgeschlossen.
Freigaben verweisen auf `shows` statt ausschließlich auf Finalisierungen. Damit
kann ein Barkeeper bereits einen Entwurf oder eine laufende Planung teilen. Die
Gastprojektion liest bei jedem Aufruf die aktuellen Sendungssnapshots; der
spätere Abschluss stabilisiert genau diese bestehende Datenquelle, ohne Token
oder URL zu wechseln. Bearbeitungen behalten den Link, invalidieren aber
vorhandene Passwortsitzungen. Vollständiges Löschen ist der unmittelbare
Widerruf. Die Sendungsbearbeitung und die zentrale Verwaltung verwenden dieselbe
Geschäftslogik und bleiben Barkeepern vorbehalten.
## D-028: Additive Wartungsfelder und gemeinsamer Freigabedialog
Vorbereitungsaufwand und Twitch-Tags sind optionale additive Felder. Der neue
Bildmodus verwendet ein separates boolesches Kennzeichen, damit bestehende
Tabellen und Daten trotz älterer Modus-Check-Constraints ohne riskanten
Tabellenumbau erhalten bleiben. Freigabeverwaltung bleibt eine gemeinsame
Komponente und öffnet in allen drei internen Einstiegen als nativer Dialog.
## D-029: RSS als persistenter Eingang mit begrenztem Abruf
Externe Feeds schreiben nie direkt in den Fundus. Quelle und stabiler
Eintragsschlüssel werden in einem eigenen Eingang gespeichert; dessen Zustand
`discarded` oder `imported` wird bei einem erneuten Abruf nicht zurückgesetzt.
Erst eine bewusste Auswahl erzeugt über `content.ts` ein normales Piece samt
Originalquelle und gewählten Kategorien. Dadurch bleiben redaktionelle
Entscheidung, Dublettenschutz und Herkunft nachvollziehbar.
Manueller und geplanter Abruf verwenden denselben Adapter. Wie bei
Linkmetadaten werden private Ziele, Nichtstandardports und ungeprüfte Redirects
gesperrt; zusätzlich gelten enge Zeit-, Größen- und Mengenlimits. XML-Entities
bleiben deaktiviert und fremdes HTML wird nicht gespeichert. Ein Fehler wird am
betroffenen Feed persistiert und unterbricht keine weiteren fälligen Feeds.
## D-030: Technische Tokens, Scopes und persistente Idempotenz
Externe Assistenz erhält keine simulierte Redaktionssitzung und keine
Sonderrechte. Eigene zufällige Bearer-Tokens liegen nur gehasht vor, sind sofort
widerrufbar und erlauben entweder Lesen oder Lesen und Schreiben. REST-Routen
unter `/api/v1` verwenden die bestehenden Inhalts- und Sendungsdienste, damit
Validierung, Revisionen und Transaktionen identisch bleiben. Jede erfolgreiche
Mutation wird mit technischem Akteur, Aktion und Ziel protokolliert.
Neuanlage, Einplanung und Sortierung verlangen einen `Idempotency-Key`.
Zugang, Schlüssel, Hash des Requestinhalts und vollständige Antwort werden in
derselben SQLite-Transaktion gespeichert. Eine identische Wiederholung erhält
dieselbe Antwort; ein veränderter Request mit demselben Schlüssel wird
abgewiesen. OpenAPI 3.1 beschreibt diese Grenze maschinenlesbar. Ein MCP-Adapter
oder ein bestimmter KI-Client wird nicht vorgetäuscht: Die Anwendung bleibt ohne
KI vollständig nutzbar und eine konkrete Client-Verbindung muss separat geprüft
werden.
## D-031: Usage-basierte Vorbereitung und rotierende Freigabelinks
Die Sendungsübersicht erzeugt keinen zusätzlichen Vorbereitungszustand. Sie
projiziert konkrete Usage-Snapshots aktiver Sendungen, deren Fundus-Original noch
`preparing` ist. So bleiben bestehende Daten und Snapshots unverändert, während
ungeplante Fundus-Arbeit aus der Sendungsübersicht verschwindet.
Weil Freigabetokens nur gehasht gespeichert werden, kann ein bestehender Link
nicht erneut angezeigt werden. Ein Ersatzlink rotiert deshalb den Token derselben
Freigabe transaktional und löscht alle Gastsitzungen; Sichtumfang, Passwort und
Ablauf werden nicht stillschweigend verändert.
## D-032: Vorbereitung als Eigenschaft der konkreten Einplanung
Die erste 0.7.1-Umsetzung aus D-031 leitete den Vorbereitungszustand noch vom
veränderlichen Fundus-Original ab. Das widerspricht der bestehenden Kopierregel
für Sendungsfassungen und verhindert unterschiedliche Arbeitsstände bei
Mehrfachverwendung. D-032 ersetzt deshalb diesen Teil von D-031: Status und
Aufwand werden mit Migration `0019` additiv an `piece_usages` gespeichert.
Vorbereitende und sendefertige Originale dürfen eingeplant werden. Einplanung,
Duplikation und ausdrückliches Aktualisieren kopieren den Zustand, während der
Usage-Editor ihn danach unabhängig ändert. `piece_usages.status` bleibt davon
getrennt und beschreibt weiterhin ausschließlich den Livezustand `open`,
`current`, `done` oder `skipped`.
+115
View File
@@ -1,5 +1,120 @@
# Release Notes # Release Notes
## 0.7.1 - Bedienung und Live-Korrekturen
Veröffentlicht am 25. Oktober 2026, Korrekturstand 26. Oktober 2026
- Live-Abschnittszeiten übernehmen geänderte Plandauern. Frühere erledigte oder
übersprungene Pieces können offiziell wiederaufgenommen werden, ohne ihre
Abschnittslaufzeit oder die Gesamtzeit zurückzusetzen (#1).
- Mobiler Live-Ablauf, lange Metadaten und Sendungskarten halten ihre verfügbare
Breite ein. Der Ablauf bleibt als fokussierbares Sheet bedienbar (#3, #5, #13).
- „Noch vorzubereiten“ speichert Status und Aufwand an der konkreten Einplanung,
priorisiert aktive Sendungen nach Termin und verlinkt mit den Themenicons direkt
in den Usage-Editor. Vorbereitende Originale können regulär eingeplant werden;
Mehrfachverwendungen bleiben unabhängig (#6, #15).
- Fundus- und Usage-Editor unterscheiden Text, Nur Bild, Linkvorschau und Video
durch kontextsensitive Eingaben und passende Vorschauen (#8).
- Gast- und historische Abschnitte besitzen klar gepolsterte äußere Glaskarten.
Die Freigabeverwaltung liegt direkt vor dem Werkzeugkatalog; der gemeinsame
Dialog und seine Formfelder folgen ohne nativen Doppelrahmen dem Sendewerk-
Design. Einmalige Links lassen sich kopieren und sicher ersetzen (#11, #12).
- Die Iconsuche ist nach dem Öffnen sofort sichtbar und fokussiert (#14).
- RSS-Feeds und technische Zugänge verwenden in der Verwaltung kompakte
Hinzufügen-Einstiege und dieselbe visuelle Listenlogik wie der Werkzeugkatalog.
- Mobiles Live-Sheet und Backdrop sind am Viewport verankert. Viewport-Fit,
Standalone-Metadaten und Web-App-Manifest aktivieren iOS-Safe-Areas; ein echter
Safari-/Home-Bildschirm-Test bleibt mangels Gerät offen (#3, #13).
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.1`. Migration
`0019_usage_preparation.sql` ergänzt additive Usage-Snapshots. 60
Verhaltenstests und 19 Chromium-Abläufe sind
erfolgreich; echtes Safari und die installierte Webapp auf iOS bleiben offen.
## 0.7.0 - RSS-Import und externe Assistenz
Veröffentlicht am 25. Oktober 2026
- Barkeeper verwalten RSS-/Atom-Feeds mit Intervall und Standardkategorie;
sichere manuelle und geplante Abrufe laufen fehlerisoliert.
- Neue Beiträge landen in einem getrennten Auswahlbereich im Eingang. Erst eine
bewusste Übernahme erzeugt Pieces, während dauerhaftes Verwerfen, Herkunft,
Kategorien und Dublettenschutz erhalten bleiben.
- Abrufe sperren interne Ziele und begrenzen Ports, Redirects, Laufzeit,
Antwortgröße und Eintragszahl. Fremdes Feed-HTML wird zu Text bereinigt.
- Die versionierte REST-API `/api/v1` liest und ändert Pieces, Sendungen und
Einplanungen über getrennte technische Lese- oder Schreibzugänge.
- Technische Tokens werden nur einmal gezeigt und gehasht gespeichert. Widerruf,
Audit-Ereignisse, Revisionen und persistente Idempotenz schützen Änderungen.
- `/api/v1/openapi.json` beschreibt die Schnittstelle für externe Clients.
Sendewerk bleibt ohne KI nutzbar; eine konkrete ChatGPT-/Codex-Anbindung ist
weiterhin offen.
- Migration `0018_rss_and_api.sql` ergänzt Feed-, Zugangs-, Audit- und
Idempotenzdaten. Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.0`.
## 0.6.1 - Redaktionelle Wartung
Veröffentlicht am 25. Oktober 2026
- Live-Countdowns übernehmen geänderte Abschnittsplanzeiten, ohne Laufzeit zu verlieren; die mobile Regie gruppiert ihre Kopfaktionen robuster und zeigt den Ablauf als oberstes Sheet.
- Mobile Formularfelder und die Iconsuche vermeiden iOS-Fokuszoom; Sendungskarten und Gastansicht erhalten klarere Abstände.
- Pieces führen optionalen Vorbereitungsaufwand, die Sendungsübersicht bündelt offene Vorbereitung und Abschnitte speichern kopierbare Twitch-Tags.
- Präsentationen können ein ausdrücklich gewähltes Bild ohne Begleittext ausgeben; Originale und Sendungsfassungen bleiben getrennte Snapshots.
- Lesefreigaben öffnen in Sendung, Verwaltung und Live-Regie über denselben Dialog.
- Migration `0017_maintenance_fields.sql` ergänzt ausschließlich optionale, rückwärtskompatible Felder.
## 0.6.0 - Abschluss, Archiv und Freigaben
Veröffentlicht am 25. Oktober 2026
Sendewerk 0.6.0 trennt das wiederaufnehmbare Ende eines Live-Laufs vom
unveränderlichen redaktionellen Abschluss. Abgeschlossene Sendungen erhalten
eine stabile Historie, liegen im Archiv und können als neuer Entwurf dupliziert
werden. Barkeeper geben genau eine geplante, laufende oder historische Sendung
über widerrufbare, optional passwortgeschützte Leselinks frei.
### Abschluss und Historie
- Ein Abschlussdialog zeigt gesendete, übersprungene und offene Pieces getrennt
und bietet ausschließlich gesendete Originale zur bewussten Archivierung an.
- Finalisierung ist transaktional und idempotent. Übersprungene Pieces und die
Reservebank werden nicht als gesendet verbucht.
- Usage-Texte, Relationen, Kategorien und tatsächliche Zeiten bleiben nach dem
Abschluss stabil, auch wenn Fundus-Originale oder Kataloge später wechseln.
- Historische Sendungen sind intern nur lesbar. Die Duplikation erzeugt einen
unabhängigen Entwurf mit offenen Pieces und ohne früheren Lauf- oder
Freigabezustand.
### Archiv und Freigaben
- Das Archiv zeigt abgeschlossene Sendungen neben archivierten und gelöschten
Pieces und bietet die Duplikation direkt an.
- Barkeeper erstellen Leselinks direkt in der Sendungsbearbeitung oder zentral
in der Verwaltung für Ablauf, Moderation oder vollständige Recherche, optional
mit Passwort und Ablaufzeit.
- Noch nicht abgeschlossene Sendungen zeigen Gästen beim nächsten Aufruf den
aktuellen Plan. Nach dem Abschluss bleibt derselbe Link auf dem stabilen
historischen Stand.
- Freigaben lassen sich in nach Sendung gruppierten Verwaltungskarten bearbeiten
und endgültig löschen; Passwortänderungen beenden vorhandene Gast-Sitzungen.
- Tokens und Gast-Sitzungen liegen nur gehasht vor; Passwörter verwenden
Argon2id und Fehlversuche sind begrenzt.
- Gastseiten projizieren genau eine Sendung. Private Originaldaten, Nutzer,
interne IDs und Felder außerhalb des gewählten Umfangs werden nicht geliefert.
- Direkte Medienaufrufe prüfen dieselbe Freigabe, Passwortsitzung, Ablaufzeit und
den Widerruf erneut und erlauben nur das freigegebene Präsentationsmedium.
### Technik und Prüfung
- Migration `0015_completion_archive_and_shares.sql` übernimmt bestehende
abgeschlossene Sendungen und ergänzt Finalisierung, Historie,
Duplikationsbefehle, Freigaben, Gast-Sitzungen und
Passwortversuchsbegrenzung.
- Migration `0016_shares_for_active_shows.sql` erhält vorhandene Freigaben und
erweitert ihre Referenz auf alle Sendungszustände.
- Paket, Lockdatei und Cloudron-Manifest tragen gemeinsam Version `0.6.0`.
- 52 Verhaltenstests und 18 Chromium-Abläufe decken den bisherigen Umfang sowie
Abschluss, Archiv und anonyme Lesefreigaben ab.
## 0.5.12 - Live-Regie und Sendungskalender ## 0.5.12 - Live-Regie und Sendungskalender
Veröffentlicht am 24. Oktober 2026 Veröffentlicht am 24. Oktober 2026
+113 -3
View File
@@ -2,10 +2,120 @@
## Aktueller Umfang ## Aktueller Umfang
Etappe 1 bis Etappe 5 sind abgeschlossen. Architektur, Gesamtdatenmodell, Etappe 1 bis Etappe 7 sind abgeschlossen. Architektur, Gesamtdatenmodell,
Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung, Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert. Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie,
Etappe 6 wurde nicht begonnen. Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind
dokumentiert.
## Fehlerkorrekturen 0.7.1
- Explizite Abschnittsdauern werden während der Live-Regie nach Änderungen
synchronisiert. Erledigte oder übersprungene Pieces lassen sich bewusst
wiederaufnehmen; frühere Abschnitts- und Gesamtzeiten bleiben dabei erhalten.
- Der mobile Ablauf liegt innerhalb eines isolierten Live-Containers, lange
Metadaten verbreitern die Regie nicht mehr und das Sheet ist am mobilen
Viewport verankert. `viewport-fit=cover`, Standalone-Metadaten und Manifest
aktivieren Safe Areas auch beim Start vom Home-Bildschirm. Sendungskarten
stapeln ihre Inhalte auf kleinen Bildschirmen eindeutig.
- Die Vorbereitung liegt als eigener Status- und Aufwandsnapshot an der konkreten
Einplanung. Vorbereitende Fundus-Pieces lassen sich planen; Originaländerungen,
Duplikation und bewusste Aktualisierung folgen der bestehenden Snapshotregel.
Usage-Titel, Sendung, Abschnitt, Termin und Themenicons führen direkt in den
Usage-Editor; „sendefertig“ entfernt die Fassung aus der Übersicht.
- Text, Nur Bild, Linkvorschau und Video besitzen in Fundus- und Usage-Editor
kontextsensitive Felder und Vorschauen. Ausgeblendete Werte bleiben beim
Moduswechsel erhalten.
- Die Iconsuche fokussiert innerhalb der öffnenden Klick-/Tastaturaktion das
Suchfeld. RSS-Feeds und technische Zugänge verwenden gestaltete Formfelder.
- Lesefreigaben öffnen nach Serverantworten wieder mit dem einmalig sichtbaren
Link, bieten Kopieren und sichere Ersatzlinks. Ersatzlinks invalidieren den
alten Token und dessen Gastsitzungen. Die Verwaltung zeigt Freigaben direkt
vor dem Werkzeugkatalog; der Dialog besitzt nur eine gestaltete Glasfläche.
Gast- und historische Abschnitte besitzen äußere Karten mit Innenabstand.
Geprüft wurden 60 von 60 Verhaltenstests, Lint, Typprüfung, Produktionsbuild und
19 von 19 Chromium-Abläufe. Die Browsermatrix öffnete beide Piece-Editoren,
Sendungsübersicht, Verwaltung und Freigabedialoge, Gastansicht sowie Live-Regie
bei Desktop- und Mobilbreiten. Safari und die installierte Webapp auf einem
echten iOS-Gerät bleiben mangels Gerät getrennt offen.
## RSS-Import und externe Assistenz 0.7.0
- Barkeeper verwalten RSS-/Atom-Feeds mit Standardkategorie, Aktivierung und
Abrufintervall; manueller Abruf und ein begrenzter Prozess-Scheduler verwenden
dieselbe Fehler- und Dublettenlogik.
- Feedabrufe prüfen Protokoll, Port, DNS und Redirects, begrenzen Laufzeit,
Antwortgröße und Eintragszahl und lassen Fehler anderer Feeds unberührt.
- Fremdes HTML wird entfernt. Beiträge bleiben mit stabiler Herkunft im
getrennten Importeingang, bis die Redaktion sie auswählt, kategorisiert und
als normale Pieces übernimmt oder dauerhaft verwirft.
- `/api/v1` bietet Pieces und Sendungen über gehashte, widerrufbare technische
Bearer-Tokens mit reinen Lese- oder Lese-/Schreibrechten an. Mutationen nutzen
bestehende Dienste, Audit-Ereignisse, Revisionen und persistente
Idempotenzschlüssel.
- Die maschinenlesbare Beschreibung liegt unter `/api/v1/openapi.json`; eine
ChatGPT-/Codex- oder andere konkrete Client-Anbindung wurde nicht eingerichtet.
- Migration `0018_rss_and_api.sql` ergänzt ausschließlich additive Tabellen und
Indizes. Sendewerk bleibt ohne KI und ohne konfigurierte Feeds vollständig
nutzbar.
Geprüft wurden 58 Verhaltenstests, Typprüfung, Produktionsbuild, Dependency-
Audit, vier RSS- und zwei Zugangssicherheitstests sowie ein echter HTTP-Rundlauf
lesen → anlegen → idempotent wiederholen → ändern → erneut lesen → einplanen →
sortieren. Desktop-Verwaltung und mobiler Eingang wurden in Chromium ohne
Konsolenfehler geprüft. In der bestehenden Gesamtmatrix waren 16 von 18 Abläufen
erfolgreich; die bereits bekannte Live-Zeitanzeige und der Ablauf
Abschluss/Freigabe schlugen weiterhin fehl. Der globale Lintlauf bleibt an acht
vorhandenen Formatierungsabweichungen außerhalb dieser Etappe offen.
## Wartungsrelease 0.6.1
- Abschnitts-Countdown, mobile Live-Kopfzeile und Ablauf-Sheet wurden korrigiert; die doppelte Piece-Positionsanzeige entfällt.
- Optionaler Vorbereitungsaufwand, kompakte Vorbereitungsliste und Abschnitts-Twitch-Tags sind additiv persistiert.
- Der Präsentationsmodus `image` gibt ausschließlich das bewusst gewählte Snapshot-Medium aus.
- Mobile Eingaben, Iconsuche, Sendungskarten und Gastabstände wurden angepasst.
- Lesefreigaben verwenden einen gemeinsamen Dialog und sind auch aus der Live-Regie erreichbar.
Geprüft wurden 52 Verhaltenstests, Typprüfung und Produktionsbuild. Im zweiten
Chromium-Lauf waren 14 Abläufe erfolgreich; zwei Abläufe schlugen vor dem
Zeitlimit fehl, zwei weitere wurden nicht mehr ausgeführt. Die vollständige
Browsermatrix bleibt damit offen.
## Abschluss, Archiv und Freigaben 0.6.0
- das Ende eines Live-Laufs bleibt wiederaufnehmbar; erst der getrennte,
idempotente Abschluss friert Sendung, Kategorien, tatsächliche Verwendungen
und Zeiten unveränderlich ein
- der Abschlussdialog zählt gesendete, übersprungene und offene Pieces getrennt
und archiviert Fundus-Originale ausschließlich nach bewusster Auswahl
- die Verwendungshistorie wird beim Abschluss materialisiert; übersprungene
Verwendungen und die Reservebank zählen nicht als gesendet
- abgeschlossene Sendungen liegen getrennt im Archiv, sind intern nur lesbar und
lassen sich mit neuen IDs, offenen Verwendungen und ohne Live- oder
Freigabezustand in einen neuen Entwurf duplizieren
- Barkeeper verwalten gehashte Leselinks direkt in der Sendungsbearbeitung und
gesammelt in der Verwaltung; Umfang, Passwort und Ablauf lassen sich ändern,
Freigaben vollständig löschen
- Entwürfe, bereite und laufende Sendungen lassen sich bereits freigeben;
Planänderungen werden beim nächsten Gastaufruf sichtbar, während der Abschluss
denselben Link auf den unveränderlichen historischen Stand festlegt
- passwortgeschützte Freigaben verwenden kurzlebige, gehashte Gast-Sitzungen und
begrenzen Fehlversuche pro Link und Clientadresse
- Gastseiten liefern genau eine positiv projizierte Sendung ohne interne IDs,
Originaldaten oder nicht freigegebene Felder; geschützte Präsentationsmedien
prüfen Umfang, Passwortsitzung, Ablauf und Widerruf bei jedem Direktaufruf
- bestehende abgeschlossene Sendungen werden durch Migration `0015` mit
eingefrorener Historie übernommen; Migration `0016` erweitert bestehende
Freigaben verlustfrei auf noch nicht abgeschlossene Sendungen
Die lokale Prüfmatrix umfasst 52 Verhaltenstests und 18 Chromium-Abläufe.
Abschluss, Archiv, historische Nur-Lese-Ansicht, Passwortfreigabe,
Scope-Begrenzung, direkter Medienzugriff, fremde IDs, Gast-Schreibversuch und
Widerruf wurden zusätzlich in einem anonymen Browserkontext geprüft. Der
erweiterte Ablauf prüft außerdem offene Sendungen, aktuelle Planänderungen,
Bearbeiten und Löschen in beiden Barkeeper-Oberflächen sowie Desktop- und
Smartphone-Layout.
## Live-Regie und Sendungskalender 0.5.12 ## Live-Regie und Sendungskalender 0.5.12
@@ -0,0 +1,115 @@
ALTER TABLE shows ADD COLUMN completed_at_utc TEXT;
ALTER TABLE show_sections ADD COLUMN category_name_snapshot TEXT;
ALTER TABLE show_sections ADD COLUMN category_color_snapshot TEXT;
CREATE TABLE show_finalizations (
show_id TEXT PRIMARY KEY REFERENCES shows(id) ON DELETE CASCADE,
command_id TEXT NOT NULL,
source_revision INTEGER NOT NULL CHECK (source_revision >= 1),
finalized_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
finalized_at_utc TEXT NOT NULL,
UNIQUE (show_id, command_id)
);
CREATE TABLE show_finalization_archived_pieces (
show_id TEXT NOT NULL REFERENCES show_finalizations(show_id) ON DELETE CASCADE,
piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE RESTRICT,
PRIMARY KEY (show_id, piece_id)
);
CREATE TABLE piece_usage_history (
usage_id TEXT PRIMARY KEY REFERENCES piece_usages(id) ON DELETE RESTRICT,
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE RESTRICT,
original_piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE RESTRICT,
show_title_snapshot TEXT NOT NULL,
usage_title_snapshot TEXT NOT NULL,
used_at_utc TEXT NOT NULL,
actual_duration_seconds INTEGER NOT NULL CHECK (actual_duration_seconds >= 0)
);
CREATE INDEX idx_piece_usage_history_original
ON piece_usage_history(original_piece_id, used_at_utc DESC);
CREATE TABLE show_duplication_commands (
source_show_id TEXT NOT NULL REFERENCES show_finalizations(show_id) ON DELETE CASCADE,
command_id TEXT NOT NULL,
created_show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
created_at_utc TEXT NOT NULL,
PRIMARY KEY (source_show_id, command_id)
);
CREATE TABLE show_shares (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES show_finalizations(show_id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL CHECK (scope IN ('rundown', 'moderation', 'research')),
password_hash TEXT,
expires_at_utc TEXT,
revoked_at_utc TEXT,
created_by_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at_utc TEXT NOT NULL
);
CREATE INDEX idx_show_shares_show ON show_shares(show_id, created_at_utc DESC);
CREATE TABLE show_share_sessions (
token_hash TEXT PRIMARY KEY,
share_id TEXT NOT NULL REFERENCES show_shares(id) ON DELETE CASCADE,
created_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL
);
CREATE INDEX idx_show_share_sessions_share ON show_share_sessions(share_id, expires_at_utc);
CREATE TABLE share_password_attempts (
id TEXT PRIMARY KEY,
share_key TEXT NOT NULL,
address_key TEXT NOT NULL,
attempted_at_utc TEXT NOT NULL
);
CREATE INDEX idx_share_password_attempts_share
ON share_password_attempts(share_key, attempted_at_utc);
CREATE INDEX idx_share_password_attempts_address
ON share_password_attempts(address_key, attempted_at_utc);
UPDATE show_sections
SET category_name_snapshot = (
SELECT name FROM section_categories WHERE id = show_sections.category_id
),
category_color_snapshot = (
SELECT color FROM section_categories WHERE id = show_sections.category_id
)
WHERE show_id IN (SELECT id FROM shows WHERE status = 'completed');
UPDATE shows
SET completed_at_utc = COALESCE(
(SELECT ended_at_utc FROM show_live_states WHERE show_id = shows.id),
updated_at_utc
)
WHERE status = 'completed';
INSERT INTO show_finalizations (
show_id, command_id, source_revision, finalized_by_user_id, finalized_at_utc
)
SELECT id, 'migration-' || id, revision, NULL, completed_at_utc
FROM shows
WHERE status = 'completed';
INSERT INTO piece_usage_history (
usage_id, show_id, original_piece_id, show_title_snapshot, usage_title_snapshot,
used_at_utc, actual_duration_seconds
)
SELECT u.id, sh.id, u.original_piece_id, sh.title, u.title_snapshot,
COALESCE(sh.starts_at_utc, sh.completed_at_utc),
COALESCE((
SELECT sum(max(0, CAST((julianday(COALESCE(r.ended_at_utc, sh.completed_at_utc)) -
julianday(r.started_at_utc)) * 86400 AS INTEGER)))
FROM live_usage_runs r WHERE r.usage_id = u.id
), 0)
FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id
WHERE sh.status = 'completed' AND s.is_reserve_bank = 0
AND u.status = 'done' AND u.original_piece_id IS NOT NULL;
@@ -0,0 +1,43 @@
DROP INDEX idx_show_share_sessions_share;
DROP INDEX idx_show_shares_show;
ALTER TABLE show_share_sessions RENAME TO show_share_sessions_v15;
ALTER TABLE show_shares RENAME TO show_shares_v15;
CREATE TABLE show_shares (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL CHECK (scope IN ('rundown', 'moderation', 'research')),
password_hash TEXT,
expires_at_utc TEXT,
revoked_at_utc TEXT,
created_by_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at_utc TEXT NOT NULL
);
CREATE INDEX idx_show_shares_show ON show_shares(show_id, created_at_utc DESC);
CREATE TABLE show_share_sessions (
token_hash TEXT PRIMARY KEY,
share_id TEXT NOT NULL REFERENCES show_shares(id) ON DELETE CASCADE,
created_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL
);
CREATE INDEX idx_show_share_sessions_share ON show_share_sessions(share_id, expires_at_utc);
INSERT INTO show_shares (
id, show_id, token_hash, scope, password_hash, expires_at_utc, revoked_at_utc,
created_by_user_id, created_at_utc
)
SELECT id, show_id, token_hash, scope, password_hash, expires_at_utc, revoked_at_utc,
created_by_user_id, created_at_utc
FROM show_shares_v15;
INSERT INTO show_share_sessions (token_hash, share_id, created_at_utc, expires_at_utc)
SELECT token_hash, share_id, created_at_utc, expires_at_utc
FROM show_share_sessions_v15;
DROP TABLE show_share_sessions_v15;
DROP TABLE show_shares_v15;
+10
View File
@@ -0,0 +1,10 @@
ALTER TABLE pieces ADD COLUMN preparation_effort TEXT CHECK (
preparation_effort IS NULL OR preparation_effort IN ('low', 'medium', 'high')
);
ALTER TABLE pieces ADD COLUMN presentation_image_only INTEGER NOT NULL DEFAULT 0
CHECK (presentation_image_only IN (0, 1));
ALTER TABLE piece_usages ADD COLUMN presentation_image_only_snapshot INTEGER NOT NULL DEFAULT 0
CHECK (presentation_image_only_snapshot IN (0, 1));
ALTER TABLE show_sections ADD COLUMN twitch_tags TEXT NOT NULL DEFAULT '';
ALTER TABLE show_template_sections ADD COLUMN twitch_tags TEXT NOT NULL DEFAULT '';
+66
View File
@@ -0,0 +1,66 @@
CREATE TABLE feed_sources (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
url TEXT NOT NULL UNIQUE,
is_enabled INTEGER NOT NULL DEFAULT 1 CHECK (is_enabled IN (0, 1)),
refresh_interval_minutes INTEGER NOT NULL DEFAULT 60 CHECK (refresh_interval_minutes BETWEEN 15 AND 10080),
default_category_id TEXT REFERENCES section_categories(id) ON DELETE SET NULL,
etag TEXT NOT NULL DEFAULT '',
last_modified TEXT NOT NULL DEFAULT '',
last_fetched_at_utc TEXT,
next_fetch_at_utc TEXT,
last_error TEXT NOT NULL DEFAULT '',
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE TABLE feed_entries (
id TEXT PRIMARY KEY,
feed_id TEXT NOT NULL REFERENCES feed_sources(id) ON DELETE CASCADE,
external_id TEXT NOT NULL,
title TEXT NOT NULL,
url TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
published_at_utc TEXT,
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'imported', 'discarded')),
piece_id TEXT REFERENCES pieces(id) ON DELETE SET NULL,
first_seen_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL,
UNIQUE (feed_id, external_id)
);
CREATE INDEX feed_entries_status_idx ON feed_entries(status, first_seen_at_utc DESC);
CREATE INDEX feed_sources_due_idx ON feed_sources(is_enabled, next_fetch_at_utc);
CREATE TABLE technical_accesses (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
token_hash TEXT NOT NULL UNIQUE,
permissions TEXT NOT NULL CHECK (permissions IN ('read', 'read_write')),
created_by_user_id TEXT NOT NULL REFERENCES users(id),
created_at_utc TEXT NOT NULL,
last_used_at_utc TEXT,
revoked_at_utc TEXT
);
CREATE TABLE api_idempotency (
access_id TEXT NOT NULL REFERENCES technical_accesses(id) ON DELETE CASCADE,
request_key TEXT NOT NULL,
request_hash TEXT NOT NULL,
status_code INTEGER NOT NULL,
response_json TEXT NOT NULL CHECK (json_valid(response_json)),
created_at_utc TEXT NOT NULL,
PRIMARY KEY (access_id, request_key)
);
CREATE TABLE audit_events (
id TEXT PRIMARY KEY,
actor_type TEXT NOT NULL CHECK (actor_type IN ('user', 'technical')),
actor_id TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
created_at_utc TEXT NOT NULL
);
CREATE INDEX audit_events_created_idx ON audit_events(created_at_utc DESC);
+17
View File
@@ -0,0 +1,17 @@
ALTER TABLE piece_usages ADD COLUMN preparation_status_snapshot TEXT NOT NULL DEFAULT 'ready'
CHECK (preparation_status_snapshot IN ('preparing', 'ready'));
ALTER TABLE piece_usages ADD COLUMN preparation_effort_snapshot TEXT
CHECK (preparation_effort_snapshot IS NULL OR preparation_effort_snapshot IN ('low', 'medium', 'high'));
UPDATE piece_usages
SET preparation_status_snapshot = CASE
WHEN EXISTS (
SELECT 1 FROM pieces p
WHERE p.id = piece_usages.original_piece_id AND p.status = 'preparing'
) THEN 'preparing'
ELSE 'ready'
END,
preparation_effort_snapshot = (
SELECT p.preparation_effort FROM pieces p WHERE p.id = piece_usages.original_piece_id
);
+123 -2
View File
@@ -1,15 +1,16 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.12", "version": "0.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.12", "version": "0.7.1",
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"fast-xml-parser": "^5.11.2",
"file-type": "^22.2.0", "file-type": "^22.2.0",
"marked": "^18.1.1", "marked": "^18.1.1",
"metascraper": "^5.58.5", "metascraper": "^5.58.5",
@@ -599,6 +600,18 @@
"node": ">= 22.13" "node": ">= 22.13"
} }
}, },
"node_modules/@nodable/entities": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz",
"integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@node-rs/argon2": { "node_modules/@node-rs/argon2": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.2.tgz", "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.2.tgz",
@@ -1630,6 +1643,18 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
@@ -2689,6 +2714,45 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-xml-builder": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.6.2",
"xml-naming": "^0.3.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.11.2",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz",
"integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^3.0.1",
"fast-xml-builder": "^1.2.0",
"is-unsafe": "^2.0.0",
"path-expression-matcher": "^1.6.2",
"strnum": "^2.4.2",
"xml-naming": "^0.3.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -3055,6 +3119,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-unsafe": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
"integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/is-uri": { "node_modules/is-uri": {
"version": "1.2.14", "version": "1.2.14",
"resolved": "https://registry.npmjs.org/is-uri/-/is-uri-1.2.14.tgz", "resolved": "https://registry.npmjs.org/is-uri/-/is-uri-1.2.14.tgz",
@@ -4052,6 +4128,21 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/path-expression-matcher": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -4545,6 +4636,21 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/strnum": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz",
"integrity": "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"anynum": "^1.0.1"
}
},
"node_modules/strtok3": { "node_modules/strtok3": {
"version": "10.6.2", "version": "10.6.2",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.6.2.tgz", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.6.2.tgz",
@@ -5284,6 +5390,21 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/xmlchars": { "node_modules/xmlchars": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"private": true, "private": true,
"version": "0.5.12", "version": "0.7.1",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
@@ -26,6 +26,7 @@
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"fast-xml-parser": "^5.11.2",
"file-type": "^22.2.0", "file-type": "^22.2.0",
"marked": "^18.1.1", "marked": "^18.1.1",
"metascraper": "^5.58.5", "metascraper": "^5.58.5",
+510 -2
View File
@@ -47,6 +47,419 @@
--radius-small: 0.65rem; --radius-small: 0.65rem;
} }
.completion-dialog {
width: min(38rem, calc(100% - 2rem));
max-height: calc(100dvh - 2rem);
padding: 0;
border: 1px solid color-mix(in srgb, var(--gold) 55%, var(--line));
border-radius: var(--radius-large);
background: var(--panel-strong);
color: var(--text);
}
.completion-dialog::backdrop {
background: rgb(3 8 16 / 68%);
}
.completion-dialog form {
display: grid;
gap: 1rem;
padding: 1.25rem;
}
.completion-dialog h2,
.completion-dialog p {
margin: 0;
}
.completion-dialog fieldset {
display: grid;
gap: 0.5rem;
max-height: 15rem;
overflow-y: auto;
padding: 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
}
.completion-dialog footer,
.completion-counts,
.check-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.completion-dialog footer {
justify-content: flex-end;
}
.completion-counts {
flex-wrap: wrap;
}
.completion-counts span {
padding: 0.45rem 0.65rem;
border-radius: 999px;
background: var(--panel-soft);
}
.check-row input {
width: auto;
}
.check-row span {
display: grid;
}
.share-management {
max-height: min(82dvh, 54rem);
padding: clamp(1rem, 2.5vw, 1.4rem);
overflow-y: auto;
}
.share-management-dialog {
width: min(64rem, calc(100% - 2rem));
max-width: none;
max-height: none;
margin: auto;
padding: 0;
border: 1px solid var(--line-strong);
border-radius: var(--radius-large);
background: color-mix(in srgb, var(--bg-elevated) 94%, transparent);
box-shadow: var(--shadow);
color: var(--text);
overflow: hidden;
backdrop-filter: blur(24px) saturate(1.12);
}
.share-management-dialog::backdrop {
background: rgb(3 8 16 / 58%);
backdrop-filter: blur(5px);
}
.share-catalog-panel {
display: grid;
gap: 0.75rem;
margin-bottom: 0.75rem;
padding: 1rem;
}
.share-catalog-panel .panel-heading {
margin: 0;
}
.share-catalog-panel h2 {
margin: 0.15rem 0 0;
font-size: 1rem;
}
.share-catalog-panel .catalog-note {
margin: 0;
}
.share-create-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
gap: 0.8rem;
margin-top: 1rem;
}
.share-create-form label,
.share-edit-form label {
display: grid;
gap: 0.35rem;
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 700;
}
.share-create-form :is(input, select),
.share-edit-form :is(input, select),
.integration-form :is(input, select) {
width: 100%;
min-width: 0;
min-height: 2.65rem;
padding: 0.55rem 0.7rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-small);
background: var(--field);
color: var(--text);
font: inherit;
}
.share-create-form :is(input, select):focus-visible,
.share-edit-form :is(input, select):focus-visible,
.integration-form :is(input, select):focus-visible {
border-color: var(--accent);
outline: 2px solid color-mix(in srgb, var(--accent) 30%, transparent);
outline-offset: 1px;
}
.share-create-form button {
align-self: end;
}
.share-intro {
max-width: 58rem;
margin: 0;
color: var(--text-muted);
font-size: 0.76rem;
}
.share-show-list {
display: grid;
gap: 0.75rem;
margin-top: 1rem;
}
.share-show-card {
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel-soft);
}
.share-show-card > header,
.share-row summary {
display: grid;
align-items: center;
gap: 0.75rem;
}
.share-show-card > header {
grid-template-columns: minmax(0, 1fr) auto;
}
.share-show-card > header > div,
.share-row summary > span:nth-child(2) {
display: grid;
min-width: 0;
}
.share-show-card > header a {
color: var(--text);
font-weight: 760;
}
.share-show-card small,
.share-row small {
color: var(--text-muted);
font-size: 0.68rem;
}
.share-list {
display: grid;
gap: 0.4rem;
margin-top: 0.75rem;
}
.share-row {
border-top: 1px solid var(--line);
}
.share-row summary {
grid-template-columns: auto minmax(0, 1fr) auto auto;
min-height: 3.25rem;
cursor: pointer;
list-style: none;
}
.share-row summary::-webkit-details-marker {
display: none;
}
.share-row-icon {
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent-text);
}
.share-row summary > span:nth-child(3) {
padding: 0.2rem 0.45rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent-text);
font-size: 0.65rem;
font-weight: 700;
}
.share-row summary > span.locked {
background: color-mix(in srgb, var(--danger) 12%, var(--panel));
color: var(--danger);
}
.share-edit-area {
display: grid;
gap: 0.75rem;
padding: 0.75rem 0 0.25rem 2.75rem;
}
.share-edit-area > p {
margin: 0;
color: var(--text-muted);
font-size: 0.74rem;
}
.share-edit-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.share-edit-form input,
.share-edit-form select {
width: 100%;
}
.share-result {
display: grid;
gap: 0.25rem;
}
.share-result a {
overflow-wrap: anywhere;
}
.share-result > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.guest-shell {
min-height: 100dvh;
}
.share-page {
width: min(58rem, calc(100% - 2rem));
margin: 0 auto;
padding: clamp(2rem, 6vw, 5rem) 0;
}
.share-gate {
width: min(30rem, 100%);
margin: 8vh auto 0;
}
.share-heading {
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--line);
}
.share-heading h1 {
margin: 0.25rem 0;
font-size: clamp(2rem, 6vw, 4rem);
}
.shared-rundown,
.historical-rundown {
display: grid;
gap: 1rem;
margin-top: 1.25rem;
}
.shared-section {
padding: clamp(1rem, 2.5vw, 1.35rem);
}
.historical-section {
padding: clamp(1rem, 2.5vw, 1.35rem);
}
.shared-section > header,
.historical-section > header,
.shared-usage-heading,
.historical-usage > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.shared-section h2,
.shared-usage h3,
.historical-section h2,
.historical-usage h3 {
margin: 0;
}
.shared-usage,
.historical-usage {
margin-top: 0.75rem;
padding: 1rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel-soft);
}
.shared-usage img {
max-width: min(100%, 32rem);
max-height: 22rem;
margin-top: 0.8rem;
border-radius: var(--radius-medium);
object-fit: contain;
}
.output-image-only {
display: grid;
height: 100%;
place-items: center;
}
.output-image-only img {
width: 100%;
height: 100%;
object-fit: contain;
}
.shared-copy,
.historical-usage section,
.historical-usage details {
margin-top: 0.8rem;
}
.historical-show-page {
width: min(72rem, 100%);
}
.historical-usage p {
white-space: pre-wrap;
}
@media (max-width: 42rem) {
.share-create-form,
.share-edit-form {
grid-template-columns: 1fr;
}
.share-row summary {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.share-row summary > .app-icon:last-child {
display: none;
}
.share-edit-area {
padding-left: 0;
}
.completion-dialog footer,
.shared-section > header,
.historical-section > header,
.shared-usage-heading,
.historical-usage > header {
align-items: stretch;
flex-direction: column;
}
}
:root[data-theme='dark'] { :root[data-theme='dark'] {
color-scheme: dark; color-scheme: dark;
--bg: #0b1220; --bg: #0b1220;
@@ -981,6 +1394,7 @@ textarea:focus {
} }
.live-controller-shell { .live-controller-shell {
height: 100vh;
height: 100dvh; height: 100dvh;
min-height: 0; min-height: 0;
margin-left: 0 !important; margin-left: 0 !important;
@@ -990,7 +1404,10 @@ textarea:focus {
.live-page { .live-page {
display: grid; display: grid;
position: relative;
isolation: isolate;
width: 100%; width: 100%;
height: 100vh;
height: 100dvh; height: 100dvh;
grid-template-rows: auto auto minmax(0, 1fr) auto; grid-template-rows: auto auto minmax(0, 1fr) auto;
gap: 0.55rem; gap: 0.55rem;
@@ -1461,7 +1878,7 @@ textarea:focus {
.live-piece-heading { .live-piece-heading {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
align-items: baseline; align-items: baseline;
gap: 0.35rem 0.65rem; gap: 0.35rem 0.65rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@@ -1484,6 +1901,7 @@ textarea:focus {
} }
.live-current h1 { .live-current h1 {
min-width: 0;
max-width: none; max-width: none;
margin: 0; margin: 0;
font-size: clamp(1.2rem, 2.2vw, 1.9rem); font-size: clamp(1.2rem, 2.2vw, 1.9rem);
@@ -2059,7 +2477,7 @@ textarea:focus {
z-index: 80; z-index: 80;
inset: max(0.4rem, env(safe-area-inset-top)) max(0.4rem, env(safe-area-inset-right)) inset: max(0.4rem, env(safe-area-inset-top)) max(0.4rem, env(safe-area-inset-right))
max(0.4rem, env(safe-area-inset-bottom)) auto; max(0.4rem, env(safe-area-inset-bottom)) auto;
width: min(22rem, calc(100vw - 0.8rem - env(safe-area-inset-left))); width: min(22rem, calc(100% - 0.8rem - env(safe-area-inset-left)));
transform: translateX(calc(100% + 1rem)); transform: translateX(calc(100% + 1rem));
visibility: hidden; visibility: hidden;
transition: transition:
@@ -2096,9 +2514,16 @@ textarea:focus {
} }
.live-piece-heading { .live-piece-heading {
min-width: 0;
gap: 0.3rem 0.45rem; gap: 0.3rem 0.45rem;
} }
.live-piece-time {
max-width: 8rem;
overflow: hidden;
text-overflow: ellipsis;
}
.live-support-actions { .live-support-actions {
display: grid; display: grid;
} }
@@ -4408,6 +4833,58 @@ textarea:focus {
gap: 0.5rem; gap: 0.5rem;
} }
.preparation-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: 0.5rem;
}
.preparation-list a {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel-soft);
}
.preparation-copy,
.preparation-copy > span:last-child {
display: grid;
min-width: 0;
}
.preparation-copy {
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 0.6rem;
}
.preparation-topics {
display: flex;
gap: 0.2rem;
color: var(--accent-text);
}
.preparation-list small {
overflow: hidden;
color: var(--text-muted);
font-size: 0.68rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.effort {
padding: 0.2rem 0.45rem;
border-radius: 2rem;
background: var(--panel-strong);
color: var(--text-muted);
font-size: 0.62rem;
}
.show-card { .show-card {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -6104,6 +6581,21 @@ textarea:focus {
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
} }
input:not([type='checkbox']):not([type='radio']):not([type='hidden']),
select,
textarea,
.icon-picker-popover input[type='search'] {
font-size: 1rem;
}
.icon-picker-popover {
display: grid;
max-height: calc(100dvh - var(--mobile-header) - var(--mobile-nav) - 1rem);
grid-template-rows: auto minmax(0, 1fr) auto;
}
.icon-picker-grid {
min-height: 0;
overflow-y: auto;
}
.usage-editor-dialog { .usage-editor-dialog {
width: calc(100vw - 1rem); width: calc(100vw - 1rem);
max-height: calc(100dvh - 1rem); max-height: calc(100dvh - 1rem);
@@ -6353,6 +6845,10 @@ textarea:focus {
.show-card-facts { .show-card-facts {
align-items: flex-start; align-items: flex-start;
} }
.show-card {
flex-direction: column;
align-items: stretch;
}
.show-card-facts { .show-card-facts {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@@ -7038,6 +7534,18 @@ body:has(.heinzlive-output.empty),
calc(0.5rem + env(safe-area-inset-left)); calc(0.5rem + env(safe-area-inset-left));
width: auto; width: auto;
max-height: none; max-height: none;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
}
.icon-picker-popover input[type='search'] {
font-size: 1rem;
}
.icon-picker-grid {
min-height: 0;
overflow-y: auto;
} }
.piece-subsection-heading .choice-picker-popover { .piece-subsection-heading .choice-picker-popover {
+5 -1
View File
@@ -2,8 +2,12 @@
<html lang="de" data-theme="light" data-theme-preference="system"> <html lang="de" data-theme="light" data-theme-preference="system">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#f4f1eb" /> <meta name="theme-color" content="#f4f1eb" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Sendewerk" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<script> <script>
(function () { (function () {
try { try {
+33 -2
View File
@@ -1,6 +1,8 @@
import { randomBytes } from 'node:crypto';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { Handle } from '@sveltejs/kit/hooks'; import type { Handle } from '@sveltejs/kit/hooks';
import { deleteSession, getSessionUser, hasUsers, SESSION_COOKIE } from '#lib/server/auth.js'; import { deleteSession, getSessionUser, hasUsers, SESSION_COOKIE } from '#lib/server/auth.js';
import { startFeedScheduler } from '#lib/server/feeds.js';
const publicPaths = new Set([ const publicPaths = new Set([
'/', '/',
@@ -13,17 +15,36 @@ const publicPaths = new Set([
]); ]);
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
startFeedScheduler();
const presentationPath = event.url.pathname.startsWith('/heinzlive/'); const presentationPath = event.url.pathname.startsWith('/heinzlive/');
const sharePath = event.url.pathname.startsWith('/freigabe/');
const shareMediaPath = /^\/freigabe\/[^/]+\/media\/[^/]+$/.test(event.url.pathname);
const apiPath = event.url.pathname.startsWith('/api/v1/');
const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname); const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname);
const token = event.cookies.get(SESSION_COOKIE); const token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token); event.locals.user = getSessionUser(token);
if (token && !event.locals.user) deleteSession(event.cookies); if (token && !event.locals.user) deleteSession(event.cookies);
if (!presentationPath && !publicPaths.has(event.url.pathname) && !event.locals.user) { if (
!presentationPath &&
!sharePath &&
!publicPaths.has(event.url.pathname) &&
!apiPath &&
!event.locals.user
) {
redirect(303, hasUsers() ? '/login' : '/setup'); redirect(303, hasUsers() ? '/login' : '/setup');
} }
const response = await resolve(event); const sharePageNonce = sharePath && !shareMediaPath ? randomBytes(16).toString('base64') : null;
const response = await resolve(
event,
sharePageNonce
? {
transformPageChunk: ({ html }) =>
html.replaceAll('<script', `<script nonce="${sharePageNonce}"`)
}
: undefined
);
response.headers.set('Referrer-Policy', 'same-origin'); response.headers.set('Referrer-Policy', 'same-origin');
response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-Frame-Options', 'DENY');
@@ -35,6 +56,16 @@ export const handle: Handle = async ({ event, resolve }) => {
"frame-src https://www.youtube-nocookie.com https://www.youtube.com https://www.tiktok.com; frame-ancestors 'none'" "frame-src https://www.youtube-nocookie.com https://www.youtube.com https://www.tiktok.com; frame-ancestors 'none'"
); );
} }
if (sharePath) {
response.headers.set('Cache-Control', 'private, no-store');
response.headers.set('Referrer-Policy', 'same-origin');
if (!shareMediaPath) {
response.headers.set(
'Content-Security-Policy',
`default-src 'self'; script-src 'self' 'nonce-${sharePageNonce}'; style-src 'self' 'unsafe-inline'; img-src 'self'; frame-ancestors 'none'`
);
}
}
if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') { if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') {
response.headers.set('Cache-Control', 'no-store'); response.headers.set('Cache-Control', 'no-store');
} }
+16 -2
View File
@@ -6,6 +6,7 @@
$props(); $props();
let query = $state(''); let query = $state('');
let menu: HTMLDetailsElement; let menu: HTMLDetailsElement;
let searchInput: HTMLInputElement;
let filtered = $derived( let filtered = $derived(
classicRegularIcons.filter((icon) => classicRegularIcons.filter((icon) =>
`${icon.name} ${icon.label} ${icon.terms.join(' ')}` `${icon.name} ${icon.label} ${icon.terms.join(' ')}`
@@ -14,18 +15,31 @@
) )
); );
let visible = $derived(filtered.slice(0, 120)); let visible = $derived(filtered.slice(0, 120));
function openAndFocus(event: MouseEvent) {
if (menu.open) return;
event.preventDefault();
menu.open = true;
searchInput.focus({ preventScroll: true });
}
</script> </script>
<div class="icon-picker"> <div class="icon-picker">
<input type="hidden" name={inputName} value={name} /> <input type="hidden" name={inputName} value={name} />
<details bind:this={menu}> <details
<summary><AppIcon name={name || 'circle'} /> Icon auswählen</summary> bind:this={menu}
ontoggle={() => {
if (menu.open) queueMicrotask(() => searchInput.focus());
}}
>
<summary onclick={openAndFocus}><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
<div class="icon-picker-popover"> <div class="icon-picker-popover">
<label <label
><span class="sr-only">Icons durchsuchen</span><input ><span class="sr-only">Icons durchsuchen</span><input
type="search" type="search"
placeholder="Icon suchen" placeholder="Icon suchen"
bind:value={query} bind:value={query}
bind:this={searchInput}
/></label /></label
> >
<div class="icon-picker-grid"> <div class="icon-picker-grid">
@@ -60,6 +60,10 @@
{/each} {/each}
</footer>{/if} </footer>{/if}
</article> </article>
{:else if content.mode === 'image'}
<article class="output-content output-image-only">
{#if imageSrc}<img src={imageSrc} alt={content.images?.[0]?.altText ?? content.title} />{/if}
</article>
{:else if content.mode === 'link'} {:else if content.mode === 'link'}
<article class="output-content output-link-card"> <article class="output-content output-link-card">
{#if imageSrc && !imageUnavailable}<div class="output-link-image"> {#if imageSrc && !imageUnavailable}<div class="output-link-image">
+330
View File
@@ -0,0 +1,330 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import AppIcon from './AppIcon.svelte';
type ShareShow = {
id: string;
title: string;
startsAtUtc: string | null;
status: string;
};
type ShareItem = {
id: string;
showId: string;
showTitle: string;
showStatus: string;
showStartsAtUtc: string | null;
scope: 'rundown' | 'moderation' | 'research';
isPasswordProtected: boolean;
expiresAtUtc: string | null;
revokedAtUtc: string | null;
createdAtUtc: string;
};
let {
shows,
shares,
fixedShowId = '',
catalogPanel = false,
form = null
}: {
shows: ShareShow[];
shares: ShareItem[];
fixedShowId?: string;
catalogPanel?: boolean;
form?: {
intent?: string;
error?: string;
success?: string | boolean;
shareUrl?: string;
} | null;
} = $props();
let visibleShows = $derived(
fixedShowId
? shows.filter((show) => show.id === fixedShowId)
: shows.filter((show) => shares.some((share) => share.showId === show.id))
);
let dialog: HTMLDialogElement;
let copyMessage = $state('');
onMount(() => {
if (form?.intent?.toLowerCase().includes('share')) dialog.showModal();
});
async function copyShareUrl() {
if (!form?.shareUrl) return;
await navigator.clipboard.writeText(new URL(form.shareUrl, window.location.origin).href);
copyMessage = 'Link kopiert.';
}
function germanDate(value: string | null, fallback = 'Termin offen') {
return value
? new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value))
: fallback;
}
function berlinInput(value: string | null) {
if (!value) return '';
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(new Date(value));
const read = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? '';
return `${read('year')}-${read('month')}-${read('day')}T${read('hour')}:${read('minute')}`;
}
function statusLabel(status: string) {
if (status === 'draft') return 'Entwurf';
if (status === 'ready') return 'Bereit';
if (status === 'live') return 'Live';
return 'Abgeschlossen';
}
function scopeLabel(scope: ShareItem['scope']) {
if (scope === 'rundown') return 'Nur Ablauf';
if (scope === 'moderation') return 'Ablauf und Moderation';
return 'Vollständig mit Recherche';
}
function shareState(share: ShareItem) {
if (share.revokedAtUtc) return 'Widerrufen';
if (share.expiresAtUtc && new Date(share.expiresAtUtc) <= new Date()) return 'Abgelaufen';
return 'Aktiv';
}
</script>
{#if catalogPanel}
<section class="quiet-panel share-catalog-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Gastzugänge</p>
<h2>Lesefreigaben</h2>
</div>
<button
class="compact-add-button share-dialog-trigger"
type="button"
aria-label="Lesefreigaben"
onclick={() => dialog.showModal()}
>
<AppIcon name="link" /> Freigaben verwalten
</button>
</div>
<p class="catalog-note">
Sendungen gezielt als widerrufbaren Leselink mit wählbarem Informationsumfang teilen.
</p>
</section>
{:else}
<button
class="secondary-button share-dialog-trigger"
type="button"
onclick={() => dialog.showModal()}
>
<AppIcon name="link" /> Lesefreigaben
</button>
{/if}
<dialog bind:this={dialog} class="share-management-dialog">
<section class="share-management" aria-labelledby={`share-title-${fixedShowId || 'all'}`}>
<div class="panel-heading">
<div>
<p class="eyebrow">Gastzugänge</p>
<h2 id={`share-title-${fixedShowId || 'all'}`}>Lesefreigaben</h2>
</div>
<span class="mail-mode">Aktualisiert sich bis zum Abschluss</span>
<button
class="dialog-close"
type="button"
aria-label="Lesefreigaben schließen"
onclick={() => dialog.close()}
>
<AppIcon name="xmark" />
</button>
</div>
<p class="share-intro">
Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link
wird aus Sicherheitsgründen nur direkt nach dem Anlegen angezeigt.
</p>
{#if form?.intent?.toLowerCase().includes('share') && form.error}<div
class="form-message error"
role="alert"
>
{form.error}
</div>{/if}
{#if form?.intent?.toLowerCase().includes('share') && typeof form.success === 'string'}<div
class="form-message success"
role="status"
>
{form.success}
</div>{/if}
{#if form?.shareUrl}<div class="form-message success share-result" role="status">
<strong>Neuer Leselink</strong>
<!-- The server returns only a same-origin share path created by this action. -->
<div>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={form.shareUrl}>{form.shareUrl}</a><button
class="secondary-button"
type="button"
onclick={copyShareUrl}><AppIcon name="copy" /> Kopieren</button
>
</div>
{#if copyMessage}<small>{copyMessage}</small>{/if}
</div>{/if}
<form class="share-create-form" method="POST" action="?/createShare">
{#if fixedShowId}
<input type="hidden" name="showId" value={fixedShowId} />
{:else}
<label
>Sendung<select name="showId" required>
<option value="">Sendung wählen</option>
{#each shows as show (show.id)}<option value={show.id}
>{show.title} · {statusLabel(show.status)}</option
>{/each}
</select></label
>
{/if}
<label
>Informationsumfang<select name="scope">
<option value="rundown">Nur Ablauf</option>
<option value="moderation">Ablauf und Moderation</option>
<option value="research">Vollständig mit Recherche</option>
</select></label
>
<label
>Optionales Passwort<input
name="password"
type="password"
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<label>Optionaler Ablauf<input name="expiresAt" type="datetime-local" /></label>
<button class="primary-button" type="submit" disabled={!shows.length}>Leselink anlegen</button
>
</form>
{#if visibleShows.length}
<div class="share-show-list">
{#each visibleShows as show (show.id)}
<article class="share-show-card">
<header>
<div>
<a href={resolve('/sendungen/[id]', { id: show.id })}>{show.title}</a>
<small>{germanDate(show.startsAtUtc)} Uhr</small>
</div>
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
</header>
<div class="share-list">
{#each shares.filter((share) => share.showId === show.id) as share (share.id)}
<details class="share-row">
<summary>
<span class="share-row-icon"><AppIcon name="link" /></span>
<span>
<strong>{scopeLabel(share.scope)}</strong>
<small
>{share.isPasswordProtected ? 'Mit Passwort' : 'Ohne Passwort'} · {germanDate(
share.expiresAtUtc,
'ohne Ablauf'
)}</small
>
</span>
<span class:locked={shareState(share) !== 'Aktiv'}>{shareState(share)}</span>
<AppIcon name="pen" />
</summary>
<div class="share-edit-area">
{#if share.revokedAtUtc}
<p>Diese Freigabe ist widerrufen und kann nur noch gelöscht werden.</p>
{:else}
<form class="share-edit-form" method="POST" action="?/updateShare">
<input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="showId" value={show.id} />
<label
>Informationsumfang<select name="scope" value={share.scope}>
<option value="rundown">Nur Ablauf</option>
<option value="moderation">Ablauf und Moderation</option>
<option value="research">Vollständig mit Recherche</option>
</select></label
>
<label
>Passwort<select name="passwordMode">
<option value="keep">Unverändert</option>
<option value="remove">Kein Passwort</option>
<option value="replace">Neues Passwort setzen</option>
</select></label
>
<label
>Neues Passwort<input
name="password"
type="password"
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<label
>Ablauf<input
name="expiresAt"
type="datetime-local"
value={berlinInput(share.expiresAtUtc)}
/><small>Leer lassen, um den Ablauf zu entfernen.</small></label
>
<button class="secondary-button" type="submit">Freigabe speichern</button>
</form>
{/if}
{#if !share.revokedAtUtc}<form
method="POST"
action="?/replaceShare"
onsubmit={(event) => {
if (
!confirm('Ersatzlink anlegen? Der bisherige Link wird sofort ungültig.')
)
event.preventDefault();
}}
>
<input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="showId" value={show.id} />
<button class="text-button" type="submit">Ersatzlink anlegen</button>
</form>{/if}
<form
method="POST"
action="?/deleteShare"
onsubmit={(event) => {
if (
!confirm('Freigabe endgültig löschen? Der Leselink wird sofort ungültig.')
)
event.preventDefault();
}}
>
<input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="showId" value={show.id} />
<button class="text-button danger-text" type="submit">Freigabe löschen</button
>
</form>
</div>
</details>
{/each}
{#if !shares.some((share) => share.showId === show.id)}
<p class="quiet-copy">Für diese Sendung gibt es noch keine Lesefreigabe.</p>
{/if}
</div>
</article>
{/each}
</div>
{:else}
<p class="quiet-copy">Noch keine Lesefreigaben angelegt.</p>
{/if}
</section>
</dialog>
+133 -56
View File
@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms'; import { enhance, type SubmitFunction } from '$app/forms';
import { untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import MarkdownEditorField from './MarkdownEditorField.svelte'; import MarkdownEditorField from './MarkdownEditorField.svelte';
import InlineTitleEditor from './InlineTitleEditor.svelte'; import InlineTitleEditor from './InlineTitleEditor.svelte';
import NavIcon from './NavIcon.svelte'; import NavIcon from './NavIcon.svelte';
import PieceEditorDialog from './PieceEditorDialog.svelte'; import PieceEditorDialog from './PieceEditorDialog.svelte';
import PresentationDisplay from './PresentationDisplay.svelte';
import type { PieceUsage } from '#lib/server/shows.js'; import type { PieceUsage } from '#lib/server/shows.js';
let { let {
@@ -13,7 +14,8 @@
sectionId, sectionId,
sections, sections,
error, error,
enhanceCommand enhanceCommand,
open = false
}: { }: {
usage: PieceUsage; usage: PieceUsage;
revision: number; revision: number;
@@ -21,6 +23,7 @@
sections: { id: string; name: string }[]; sections: { id: string; name: string }[];
error?: string; error?: string;
enhanceCommand: SubmitFunction; enhanceCommand: SubmitFunction;
open?: boolean;
} = $props(); } = $props();
let formId = $derived(`usage-editor-form-${usage.id}`); let formId = $derived(`usage-editor-form-${usage.id}`);
@@ -30,6 +33,14 @@
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) })) untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
); );
let dirty = $state(false); let dirty = $state(false);
let selectedSourceId = $state(
untrack(
() => usage.sources.find((source) => source.url === usage.presentationSourceUrl)?.id ?? ''
)
);
onMount(() => {
if (open) editor.showModal();
});
let selectedMedia = $derived( let selectedMedia = $derived(
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
); );
@@ -38,12 +49,63 @@
return Number((seconds / 60).toFixed(2)); return Number((seconds / 60).toFixed(2));
} }
function previewHtml(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;')
.replaceAll('\n', '<br>');
}
function requestClose() { function requestClose() {
if (dirty && !confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return; if (dirty && !confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
dirty = false; dirty = false;
editor.close(); editor.close();
} }
function presentationSourceChanged(event: Event) {
selectedSourceId = (event.currentTarget as HTMLSelectElement).value;
const source = draft.sources.find((item) => item.id === selectedSourceId);
if (!source) return;
draft.presentationSourceLabel = source.label;
draft.presentationSourceUrl = source.url;
draft.presentationSourceTimecodeSeconds = source.timecodeSeconds;
}
let previewContent = $derived({
mode: draft.presentationMode,
topics: draft.topics.map((topic) => topic.name),
title: draft.outputTitle || draft.title,
html: previewHtml(draft.presentation),
source: draft.presentationSourceUrl
? {
label: draft.presentationSourceLabel,
url: draft.presentationSourceUrl,
timecodeSeconds: draft.presentationSourceTimecodeSeconds
}
: null,
previewTitle: draft.presentationTitle,
description: draft.presentationDescription,
imageUrl: draft.presentationMode === 'link' ? draft.presentationImageUrl : '',
siteName: draft.presentationSiteName,
provider: draft.presentationProvider,
providerId: draft.presentationProviderId,
embedUrl: '',
sources: [],
images: selectedMedia
? [{ id: selectedMedia.originalMediaId, altText: selectedMedia.altText }]
: []
});
let previewImageSrc = $derived(
selectedMedia && draft.presentationMode === 'text'
? '/media'
: selectedMedia && draft.presentationMode === 'image'
? `/media/${selectedMedia.originalMediaId}`
: ''
);
const enhanceEditor: SubmitFunction = async (input) => { const enhanceEditor: SubmitFunction = async (input) => {
const finish = await enhanceCommand(input); const finish = await enhanceCommand(input);
return async (result) => { return async (result) => {
@@ -125,6 +187,26 @@
<details class="piece-meta-details"> <details class="piece-meta-details">
<summary>Dauer und Einplanung</summary> <summary>Dauer und Einplanung</summary>
<div class="piece-meta-grid"> <div class="piece-meta-grid">
<label
>Vorbereitungsstatus<select
name="preparationStatus"
bind:value={draft.preparationStatus}
>
<option value="preparing">In Vorbereitung</option>
<option value="ready">Sendefertig</option>
</select></label
>
<label
>Vorbereitungsaufwand<select
name="preparationEffort"
bind:value={draft.preparationEffort}
>
<option value={null}>Nicht angegeben</option>
<option value="low">Gering</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
</select></label
>
<label <label
>Dauer in Minuten<input >Dauer in Minuten<input
name="durationMinutes" name="durationMinutes"
@@ -212,22 +294,9 @@
</div> </div>
<div class="piece-presentation-layout"> <div class="piece-presentation-layout">
<div class="piece-presentation-controls"> <div class="piece-presentation-controls">
<label <p class="field-label">Präsentationsart</p>
>Präsentationstitel<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
name="presentation"
label="Öffentlicher Text"
placeholder="Text für die Publikumsausgabe"
bind:value={draft.presentation}
/>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung"> <div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])} {#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
<label <label
><input ><input
type="radio" type="radio"
@@ -238,6 +307,25 @@
> >
{/each} {/each}
</div> </div>
{#if draft.presentationMode === 'text'}
<label
>Öffentliche Überschrift<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
name="presentation"
label="Öffentlicher Vorspann"
placeholder="Text für die Publikumsausgabe"
bind:value={draft.presentation}
/>
{:else}
<input type="hidden" name="outputTitle" value={draft.outputTitle} />
<input type="hidden" name="presentation" value={draft.presentation} />
{/if}
<input <input
type="hidden" type="hidden"
name="presentationSourceLabel" name="presentationSourceLabel"
@@ -251,34 +339,44 @@
/> />
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} /> <input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} /> <input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
{#if draft.presentationMode !== 'text'} {#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
<div class="presentation-settings"> <div class="presentation-settings">
<div class="presentation-source-summary wide-field"> <label class="wide-field"
<span>Ausgewählte Quelle</span> >{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select
<strong bind:value={selectedSourceId}
>{draft.presentationSourceLabel || onchange={presentationSourceChanged}
draft.presentationSourceUrl || >
'Keine Quelle'}</strong <option value="">Quelle auswählen</option>
{#each draft.sources as source (source.id)}
<option value={source.id}>{source.label || source.url}</option>
{/each}
</select></label
> >
{#if draft.presentationSourceUrl}<small>{draft.presentationSourceUrl}</small>{/if}
</div>
<label <label
>Kartentitel<input >{draft.presentationMode === 'video' ? 'Videotitel' : 'Kartentitel'}<input
name="presentationTitle" name="presentationTitle"
maxlength="300" maxlength="300"
placeholder="Optionaler Anzeigetitel" placeholder="Optionaler Anzeigetitel"
bind:value={draft.presentationTitle} bind:value={draft.presentationTitle}
/></label /></label
> >
{#if draft.presentationMode === 'link'}
<label <label
>Bild-URL<input >Vorschaubild-URL<input
name="presentationImageUrl" name="presentationImageUrl"
type="url" type="url"
maxlength="2000" maxlength="2000"
placeholder="https://…" placeholder="Aus der Linkvorschau"
bind:value={draft.presentationImageUrl} bind:value={draft.presentationImageUrl}
/></label /></label
> >
{:else}
<input
type="hidden"
name="presentationImageUrl"
value={draft.presentationImageUrl}
/>
{/if}
<label class="wide-field" <label class="wide-field"
>Anreißer<textarea >Anreißer<textarea
name="presentationDescription" name="presentationDescription"
@@ -298,9 +396,11 @@
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} /> <input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
{/if} {/if}
</div> </div>
{#if draft.media.length}<fieldset class="usage-media-picker"> {#if draft.media.length && ['text', 'image'].includes(draft.presentationMode)}<fieldset
class="usage-media-picker"
>
<legend>Hochgeladenes Präsentationsbild</legend> <legend>Hochgeladenes Präsentationsbild</legend>
<label {#if draft.presentationMode === 'text'}<label
><input ><input
type="radio" type="radio"
checked={!draft.presentationMediaId} checked={!draft.presentationMediaId}
@@ -309,7 +409,7 @@
dirty = true; dirty = true;
}} }}
/> Kein Bild</label /> Kein Bild</label
> >{/if}
<div class="media-grid"> <div class="media-grid">
{#each draft.media as media (media.id)}<label class="media-presentation-choice" {#each draft.media as media (media.id)}<label class="media-presentation-choice"
><input ><input
@@ -326,30 +426,7 @@
</div> </div>
</fieldset>{/if} </fieldset>{/if}
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau"> <aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<p class="output-kicker">Vorschau · {draft.presentationMode}</p> <PresentationDisplay content={previewContent} imageSrc={previewImageSrc} />
<h4>{draft.outputTitle || draft.title}</h4>
{#if draft.presentationMode === 'text'}
<p class="preview-markdown-source">
{draft.presentation || 'Noch kein öffentlicher Text.'}
</p>
{#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{/if}
{:else}
{#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{:else if draft.presentationImageUrl}<img
src={draft.presentationImageUrl}
alt=""
/>{/if}
<p>
{draft.presentationDescription ||
draft.presentationSourceUrl ||
'Noch keine Vorschauangaben.'}
</p>
{/if}
</aside> </aside>
</div> </div>
</section> </section>
+47
View File
@@ -0,0 +1,47 @@
import { json } from '@sveltejs/kit';
export async function apiBody(request: Request) {
const type = request.headers.get('content-type') ?? '';
if (!type.includes('application/json')) throw new Error('API_JSON_REQUIRED');
try {
return (await request.json()) as Record<string, unknown>;
} catch {
throw new Error('API_INVALID_JSON');
}
}
export function apiFailure(cause: unknown) {
const code = cause instanceof Error ? cause.message : 'API_ERROR';
const status =
code === 'API_UNAUTHORIZED'
? 401
: code === 'API_FORBIDDEN'
? 403
: code === 'NOT_FOUND'
? 404
: code === 'CONFLICT' || code === 'API_IDEMPOTENCY_CONFLICT'
? 409
: 400;
return json(
{
error: {
code,
message:
status === 401
? 'Authentifizierung erforderlich.'
: status === 403
? 'Diese Berechtigung fehlt.'
: status === 404
? 'Nicht gefunden.'
: 'Anfrage konnte nicht verarbeitet werden.'
}
},
{ status }
);
}
export function pageInput(url: URL) {
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') ?? 50) || 50));
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0) || 0);
return { limit, offset };
}
+2 -2
View File
@@ -110,11 +110,11 @@ export async function verifyPassword(passwordHash: string, password: string): Pr
const dummyPasswordHash = hashPassword(randomBytes(32).toString('hex')); const dummyPasswordHash = hashPassword(randomBytes(32).toString('hex'));
function createToken(): string { export function createToken(): string {
return randomBytes(32).toString('base64url'); return randomBytes(32).toString('base64url');
} }
function hashToken(token: string): string { export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex'); return createHash('sha256').update(token).digest('hex');
} }
+204
View File
@@ -0,0 +1,204 @@
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 type { AuthUser } from './auth.js';
import { finalizeShow, getCompletionSummary } from './completion.js';
import { closeDatabase, getDatabase } from './database.js';
import {
addPieceUsage,
addShowSection,
createShow,
duplicateCompletedShow,
getPieceUsageContexts,
getShow
} from './shows.js';
let directory: string;
const actor: AuthUser = {
id: 'completion-actor',
email: 'completion@example.test',
displayName: 'Abschluss Test',
role: 'barkeeper',
lockedAtUtc: null
};
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-completion-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 });
getDatabase()
.prepare(
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, ?, 'unused', 'barkeeper', ?, ?, ?)`
)
.run(
actor.id,
actor.email,
actor.email,
actor.displayName,
...Array(3).fill(new Date().toISOString())
);
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
function endedShow() {
const created = createShow({ title: 'Finale Sendung', startsAtUtc: null, templateId: null });
const section = addShowSection(created.id, created.revision, {
name: 'Gesendeter Block',
categoryId: 'cat-jc',
explicitDurationSeconds: null
});
const done = addPieceUsage(created.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const skipped = addPieceUsage(created.id, done.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const database = getDatabase();
database.prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(done.id);
database.prepare("UPDATE piece_usages SET status = 'skipped' WHERE id = ?").run(skipped.id);
database
.prepare(
`INSERT INTO show_live_states
(show_id, revision, started_at_utc, ended_at_utc, presentation_revision, updated_at_utc)
VALUES (?, 0, ?, ?, 0, ?)`
)
.run(
created.id,
'2026-10-25T18:00:00.000Z',
'2026-10-25T19:00:00.000Z',
'2026-10-25T19:00:00.000Z'
);
return { showId: created.id, doneUsageId: done.id, revision: skipped.revision };
}
describe('show completion', () => {
it('finalizes once, records only sent usages and archives only the explicit selection', () => {
const prepared = endedShow();
expect(getCompletionSummary(prepared.showId)).toMatchObject({
doneCount: 1,
skippedCount: 1,
candidates: [{ pieceId: 'piece-console', usageCount: 1 }]
});
const first = finalizeShow({
showId: prepared.showId,
commandId: 'complete-once',
expectedShowRevision: prepared.revision,
expectedLiveRevision: 0,
archivePieceIds: ['piece-console'],
actor,
now: new Date('2026-10-25T19:05:00.000Z')
});
expect(first.duplicate).toBe(false);
expect(
finalizeShow({
showId: prepared.showId,
commandId: 'complete-once',
expectedShowRevision: prepared.revision,
expectedLiveRevision: 0,
archivePieceIds: ['piece-console'],
actor
}).duplicate
).toBe(true);
expect(() =>
finalizeShow({
showId: prepared.showId,
commandId: 'different-command',
expectedShowRevision: prepared.revision,
expectedLiveRevision: 0,
archivePieceIds: [],
actor
})
).toThrow('SHOW_COMPLETED');
const database = getDatabase();
expect(
(
database
.prepare('SELECT count(*) AS count FROM piece_usage_history WHERE show_id = ?')
.get(prepared.showId) as { count: number }
).count
).toBe(1);
expect(database.prepare('SELECT status FROM pieces WHERE id = ?').get('piece-console')).toEqual(
{ status: 'archived' }
);
expect(getPieceUsageContexts(['piece-console'])['piece-console'].sentHistory).toHaveLength(1);
});
it('keeps historical snapshots stable and duplicates them into an editable clean run', () => {
const prepared = endedShow();
finalizeShow({
showId: prepared.showId,
commandId: 'freeze',
expectedShowRevision: prepared.revision,
expectedLiveRevision: 0,
archivePieceIds: [],
actor
});
const historical = getShow(prepared.showId)!;
getDatabase()
.prepare("UPDATE section_categories SET name = 'Später geändert' WHERE id = 'cat-jc'")
.run();
getDatabase()
.prepare("UPDATE pieces SET title = 'Später geändert' WHERE id = 'piece-console'")
.run();
const unchanged = getShow(prepared.showId)!;
expect(unchanged.sections[0].category).toBe(historical.sections[0].category);
expect(unchanged.sections[0].usages[0].title).toBe(historical.sections[0].usages[0].title);
const copy = duplicateCompletedShow(prepared.showId, 'copy-once');
expect(duplicateCompletedShow(prepared.showId, 'copy-once').id).toBe(copy.id);
expect(getShow(copy.id)).toMatchObject({
status: 'draft',
startsAtUtc: null,
sections: [
{
category: historical.sections[0].category,
usages: [{ status: 'open' }, { status: 'open' }]
}
]
});
expect(
getDatabase().prepare('SELECT 1 FROM show_finalizations WHERE show_id = ?').get(copy.id)
).toBeUndefined();
getDatabase()
.prepare(
`UPDATE piece_usages SET status = 'done'
WHERE show_section_id IN (SELECT id FROM show_sections WHERE show_id = ?)`
)
.run(copy.id);
getDatabase()
.prepare(
`INSERT INTO show_live_states
(show_id, revision, started_at_utc, ended_at_utc, presentation_revision, updated_at_utc)
VALUES (?, 0, ?, ?, 0, ?)`
)
.run(
copy.id,
'2026-10-26T18:00:00.000Z',
'2026-10-26T19:00:00.000Z',
'2026-10-26T19:00:00.000Z'
);
finalizeShow({
showId: copy.id,
commandId: 'finalize-copy',
expectedShowRevision: 1,
expectedLiveRevision: 0,
archivePieceIds: [],
actor
});
expect(getShow(copy.id)?.sections[0].category).toBe(historical.sections[0].category);
});
});
+210
View File
@@ -0,0 +1,210 @@
import type { AuthUser } from './auth.js';
import { getDatabase } from './database.js';
export type CompletionCandidate = {
pieceId: string;
title: string;
usageCount: number;
};
export type CompletionSummary = {
doneCount: number;
skippedCount: number;
openCount: number;
candidates: CompletionCandidate[];
};
export function isShowFinalized(showId: string): boolean {
return Boolean(
getDatabase().prepare('SELECT 1 FROM show_finalizations WHERE show_id = ?').get(showId)
);
}
export function getCompletionSummary(showId: string): CompletionSummary {
const database = getDatabase();
if (!database.prepare('SELECT 1 FROM shows WHERE id = ?').get(showId))
throw new Error('NOT_FOUND');
const counts = database
.prepare(
`SELECT
sum(CASE WHEN u.status = 'done' THEN 1 ELSE 0 END) AS done_count,
sum(CASE WHEN u.status = 'skipped' THEN 1 ELSE 0 END) AS skipped_count,
sum(CASE WHEN u.status IN ('open', 'current') THEN 1 ELSE 0 END) AS open_count
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ? AND s.is_reserve_bank = 0`
)
.get(showId) as {
done_count: number | null;
skipped_count: number | null;
open_count: number | null;
};
const candidates = database
.prepare(
`SELECT u.original_piece_id AS piece_id, p.title, count(*) AS usage_count
FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN pieces p ON p.id = u.original_piece_id
WHERE s.show_id = ? AND s.is_reserve_bank = 0 AND u.status = 'done'
AND p.status != 'archived' AND p.deleted_at_utc IS NULL
GROUP BY u.original_piece_id, p.title ORDER BY p.title COLLATE NOCASE`
)
.all(showId) as { piece_id: string; title: string; usage_count: number }[];
return {
doneCount: counts.done_count ?? 0,
skippedCount: counts.skipped_count ?? 0,
openCount: counts.open_count ?? 0,
candidates: candidates.map((candidate) => ({
pieceId: candidate.piece_id,
title: candidate.title,
usageCount: candidate.usage_count
}))
};
}
export function getHistoricalActualDurations(showId: string): Record<string, number> {
return Object.fromEntries(
(
getDatabase()
.prepare(
`SELECT usage_id, actual_duration_seconds FROM piece_usage_history WHERE show_id = ?`
)
.all(showId) as { usage_id: string; actual_duration_seconds: number }[]
).map((row) => [row.usage_id, row.actual_duration_seconds])
);
}
export function finalizeShow(input: {
showId: string;
commandId: string;
expectedShowRevision: number;
expectedLiveRevision: number;
archivePieceIds: string[];
actor: AuthUser;
now?: Date;
}) {
if (!input.commandId || input.commandId.length > 200) throw new Error('INVALID_COMMAND');
if (!Number.isInteger(input.expectedShowRevision) || input.expectedShowRevision < 1) {
throw new Error('INVALID_REVISION');
}
if (!Number.isInteger(input.expectedLiveRevision) || input.expectedLiveRevision < 0) {
throw new Error('INVALID_REVISION');
}
const archivePieceIds = [...new Set(input.archivePieceIds.filter(Boolean))];
const database = getDatabase();
return database.transaction(() => {
const existing = database
.prepare('SELECT command_id FROM show_finalizations WHERE show_id = ?')
.get(input.showId) as { command_id: string } | undefined;
if (existing) {
if (existing.command_id !== input.commandId) throw new Error('SHOW_COMPLETED');
return { duplicate: true };
}
const state = database
.prepare(
`SELECT sh.revision AS show_revision, ls.revision AS live_revision, ls.ended_at_utc
FROM shows sh JOIN show_live_states ls ON ls.show_id = sh.id WHERE sh.id = ?`
)
.get(input.showId) as
{ show_revision: number; live_revision: number; ended_at_utc: string | null } | undefined;
if (!state) throw new Error('NOT_FOUND');
if (!state.ended_at_utc) throw new Error('NOT_ENDED');
if (
state.show_revision !== input.expectedShowRevision ||
state.live_revision !== input.expectedLiveRevision
) {
throw new Error('CONFLICT');
}
const candidates = getCompletionSummary(input.showId).candidates.map(
(candidate) => candidate.pieceId
);
if (archivePieceIds.some((pieceId) => !candidates.includes(pieceId))) {
throw new Error('INVALID_ARCHIVE_SELECTION');
}
const now = (input.now ?? new Date()).toISOString();
database
.prepare(
`UPDATE live_usage_runs SET ended_at_utc = ? WHERE show_id = ? AND ended_at_utc IS NULL`
)
.run(now, input.showId);
database
.prepare(
`UPDATE live_show_timer_runs SET ended_at_utc = ? WHERE show_id = ? AND ended_at_utc IS NULL`
)
.run(now, input.showId);
database
.prepare(
`UPDATE live_section_timer_runs SET ended_at_utc = ?
WHERE ended_at_utc IS NULL AND timer_id IN (
SELECT id FROM live_section_timers WHERE show_id = ?
)`
)
.run(now, input.showId);
database
.prepare(
`UPDATE live_section_timers SET ended_at_utc = ? WHERE show_id = ? AND ended_at_utc IS NULL`
)
.run(now, input.showId);
database
.prepare(
`UPDATE show_sections SET
category_name_snapshot = COALESCE(category_name_snapshot,
(SELECT name FROM section_categories WHERE id = show_sections.category_id)),
category_color_snapshot = COALESCE(category_color_snapshot,
(SELECT color FROM section_categories WHERE id = show_sections.category_id))
WHERE show_id = ?`
)
.run(input.showId);
database
.prepare(
`INSERT INTO piece_usage_history (
usage_id, show_id, original_piece_id, show_title_snapshot, usage_title_snapshot,
used_at_utc, actual_duration_seconds
)
SELECT u.id, sh.id, u.original_piece_id, sh.title, u.title_snapshot,
COALESCE(sh.starts_at_utc, ?),
COALESCE((
SELECT sum(max(0, CAST((julianday(COALESCE(r.ended_at_utc, ?)) -
julianday(r.started_at_utc)) * 86400 AS INTEGER)))
FROM live_usage_runs r WHERE r.usage_id = u.id
), 0)
FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id
WHERE sh.id = ? AND s.is_reserve_bank = 0 AND u.status = 'done'
AND u.original_piece_id IS NOT NULL`
)
.run(now, now, input.showId);
const archivePiece = database.prepare(
`UPDATE pieces SET archived_from_status = status, status = 'archived', revision = revision + 1,
updated_at_utc = ? WHERE id = ? AND status != 'archived' AND deleted_at_utc IS NULL`
);
for (const pieceId of archivePieceIds) {
archivePiece.run(now, pieceId);
}
const updated = database
.prepare(
`UPDATE shows SET status = 'completed', completed_at_utc = ?, revision = revision + 1,
updated_at_utc = ? WHERE id = ? AND revision = ? AND status != 'completed'`
)
.run(now, now, input.showId, input.expectedShowRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
database
.prepare(
`INSERT INTO show_finalizations
(show_id, command_id, source_revision, finalized_by_user_id, finalized_at_utc)
VALUES (?, ?, ?, ?, ?)`
)
.run(input.showId, input.commandId, input.expectedShowRevision, input.actor.id, now);
const recordArchivedPiece = database.prepare(
'INSERT INTO show_finalization_archived_pieces (show_id, piece_id) VALUES (?, ?)'
);
for (const pieceId of archivePieceIds) {
recordArchivedPiece.run(input.showId, pieceId);
}
return { duplicate: false };
})();
}
+8 -1
View File
@@ -74,7 +74,8 @@ function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
sources: [ sources: [
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 } { label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
], ],
...overrides ...overrides,
preparationEffort: overrides.preparationEffort ?? null
}; };
} }
@@ -332,11 +333,17 @@ describe('content management', () => {
presentationMediaId: selected.id presentationMediaId: selected.id
}); });
savePiece(id, source.revision, {
...source,
status: 'preparing',
presentationMode: 'image'
});
const duplicateId = await duplicatePiece(id); const duplicateId = await duplicatePiece(id);
const duplicate = getPiece(duplicateId)!; const duplicate = getPiece(duplicateId)!;
expect(duplicate.media).toHaveLength(1); expect(duplicate.media).toHaveLength(1);
expect(duplicate.presentationMediaId).toBe(duplicate.media[0].id); expect(duplicate.presentationMediaId).toBe(duplicate.media[0].id);
expect(duplicate.presentationMediaId).not.toBe(selected.id); expect(duplicate.presentationMediaId).not.toBe(selected.id);
expect(duplicate.presentationMode).toBe('image');
const template = savePieceAsTemplate(id, 'Ohne Medienreferenz', 'Nur Inhaltsdaten'); const template = savePieceAsTemplate(id, 'Ohne Medienreferenz', 'Nur Inhaltsdaten');
const snapshot = getDatabase() const snapshot = getDatabase()
+35 -13
View File
@@ -14,6 +14,7 @@ import {
export const PIECE_STATUSES = ['inbox', 'preparing', 'ready', 'archived'] as const; export const PIECE_STATUSES = ['inbox', 'preparing', 'ready', 'archived'] as const;
export type PieceStatus = (typeof PIECE_STATUSES)[number]; export type PieceStatus = (typeof PIECE_STATUSES)[number];
export type EditablePieceStatus = Exclude<PieceStatus, 'archived'>; export type EditablePieceStatus = Exclude<PieceStatus, 'archived'>;
export type PreparationEffort = 'low' | 'medium' | 'high' | null;
export type TaxonomyKind = 'category' | 'format' | 'topic'; export type TaxonomyKind = 'category' | 'format' | 'topic';
export type PieceSourceInput = { export type PieceSourceInput = {
@@ -42,6 +43,7 @@ export type PieceInput = {
summary: string; summary: string;
formatId: string | null; formatId: string | null;
status: EditablePieceStatus; status: EditablePieceStatus;
preparationEffort: PreparationEffort;
durationSeconds: number; durationSeconds: number;
intro: string; intro: string;
researchText: string; researchText: string;
@@ -82,6 +84,7 @@ export type PieceListItem = {
sourceCount: number; sourceCount: number;
mediaId: string | null; mediaId: string | null;
isFavorite: boolean; isFavorite: boolean;
preparationEffort: PreparationEffort;
}; };
export type PieceEditor = Omit< export type PieceEditor = Omit<
@@ -198,6 +201,9 @@ function validatePieceInput(input: PieceInput): ValidatedPieceInput {
const title = cleanText(input.title, 200, 'title'); const title = cleanText(input.title, 200, 'title');
if (!title) throw new Error('INVALID_TITLE'); if (!title) throw new Error('INVALID_TITLE');
if (!['inbox', 'preparing', 'ready'].includes(input.status)) throw new Error('INVALID_STATUS'); if (!['inbox', 'preparing', 'ready'].includes(input.status)) throw new Error('INVALID_STATUS');
if (![null, 'low', 'medium', 'high'].includes(input.preparationEffort)) {
throw new Error('INVALID_PREPARATION_EFFORT');
}
if ( if (
!Number.isInteger(input.durationSeconds) || !Number.isInteger(input.durationSeconds) ||
input.durationSeconds < 0 || input.durationSeconds < 0 ||
@@ -226,7 +232,7 @@ function validatePieceInput(input: PieceInput): ValidatedPieceInput {
} }
return { label, url, timecodeSeconds: source.timecodeSeconds }; return { label, url, timecodeSeconds: source.timecodeSeconds };
}); });
if (!['text', 'link', 'video'].includes(input.presentationMode)) { if (!['text', 'image', 'link', 'video'].includes(input.presentationMode)) {
throw new Error('INVALID_PRESENTATION'); throw new Error('INVALID_PRESENTATION');
} }
const sourcePosition = input.presentationSourcePosition; const sourcePosition = input.presentationSourcePosition;
@@ -236,7 +242,7 @@ function validatePieceInput(input: PieceInput): ValidatedPieceInput {
) { ) {
throw new Error('INVALID_PRESENTATION'); throw new Error('INVALID_PRESENTATION');
} }
if (input.presentationMode !== 'text' && sourcePosition === null) { if (['link', 'video'].includes(input.presentationMode) && sourcePosition === null) {
throw new Error('INVALID_PRESENTATION'); throw new Error('INVALID_PRESENTATION');
} }
const presentationImageUrl = cleanText(input.presentationImageUrl, 2_000, 'presentation_image'); const presentationImageUrl = cleanText(input.presentationImageUrl, 2_000, 'presentation_image');
@@ -249,12 +255,16 @@ function validatePieceInput(input: PieceInput): ValidatedPieceInput {
input.presentationMediaId === undefined input.presentationMediaId === undefined
? undefined ? undefined
: cleanText(input.presentationMediaId, 100, 'presentation_media') || null; : cleanText(input.presentationMediaId, 100, 'presentation_media') || null;
if (input.presentationMode === 'image' && !presentationMediaId) {
throw new Error('INVALID_PRESENTATION');
}
return { return {
title, title,
outputTitle: cleanText(input.outputTitle, 300, 'output_title'), outputTitle: cleanText(input.outputTitle, 300, 'output_title'),
summary: cleanText(input.summary, 2_000, 'summary'), summary: cleanText(input.summary, 2_000, 'summary'),
formatId: input.formatId || null, formatId: input.formatId || null,
status: input.status, status: input.status,
preparationEffort: input.preparationEffort,
durationSeconds: input.durationSeconds, durationSeconds: input.durationSeconds,
intro: cleanText(input.intro, 50_000, 'intro'), intro: cleanText(input.intro, 50_000, 'intro'),
researchText: cleanText(input.researchText, 50_000, 'research'), researchText: cleanText(input.researchText, 50_000, 'research'),
@@ -317,6 +327,7 @@ type PieceListRow = {
source_count: number; source_count: number;
media_id: string | null; media_id: string | null;
is_favorite: number; is_favorite: number;
preparation_effort: PreparationEffort;
}; };
export function listPieces(filters: { export function listPieces(filters: {
@@ -366,6 +377,7 @@ export function listPieces(filters: {
const rows = database const rows = database
.prepare( .prepare(
`SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds, p.is_favorite, `SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds, p.is_favorite,
p.preparation_effort,
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 AND pm.removed_at_utc IS NULL ORDER BY pm.position LIMIT 1) AS media_id (SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id AND pm.removed_at_utc IS NULL ORDER BY pm.position LIMIT 1) AS media_id
@@ -398,7 +410,8 @@ export function listPieces(filters: {
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 isFavorite: row.is_favorite === 1,
preparationEffort: row.preparation_effort
})); }));
} }
@@ -433,14 +446,16 @@ type PieceRow = {
updated_at_utc: string; updated_at_utc: string;
is_favorite: number; is_favorite: number;
presentation_media_id: string | null; presentation_media_id: string | null;
preparation_effort: PreparationEffort;
}; };
export function getPiece(id: string): PieceEditor | null { export function getPiece(id: string): PieceEditor | null {
const database = getDatabase(); const database = getDatabase();
const row = database const row = database
.prepare( .prepare(
`SELECT id, title, output_title, summary, format_id, status, duration_seconds, intro, research_text, `SELECT id, title, output_title, summary, format_id, status, preparation_effort, duration_seconds, intro, research_text,
moderation_notes, interaction_text, transition_text, presentation_text, presentation_mode, moderation_notes, interaction_text, transition_text, presentation_text,
CASE WHEN presentation_image_only = 1 THEN 'image' ELSE presentation_mode END AS presentation_mode,
presentation_source_position, presentation_title, presentation_description, presentation_source_position, presentation_title, presentation_description,
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,
@@ -460,6 +475,7 @@ export function getPiece(id: string): PieceEditor | null {
summary: row.summary, summary: row.summary,
formatId: row.format_id, formatId: row.format_id,
status: row.status, status: row.status,
preparationEffort: row.preparation_effort,
durationSeconds: row.duration_seconds, durationSeconds: row.duration_seconds,
intro: row.intro, intro: row.intro,
researchText: row.research_text, researchText: row.research_text,
@@ -612,9 +628,9 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
const save = database.transaction(() => { const save = database.transaction(() => {
const result = database const result = database
.prepare( .prepare(
`UPDATE pieces SET title = ?, output_title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?, `UPDATE pieces SET title = ?, output_title = ?, summary = ?, format_id = ?, status = ?, preparation_effort = ?, duration_seconds = ?,
intro = ?, research_text = ?, moderation_notes = ?, interaction_text = COALESCE(?, interaction_text), transition_text = ?, presentation_text = ?, intro = ?, research_text = ?, moderation_notes = ?, interaction_text = COALESCE(?, interaction_text), transition_text = ?, presentation_text = ?,
presentation_mode = ?, presentation_source_position = ?, presentation_title = ?, presentation_mode = ?, presentation_image_only = ?, presentation_source_position = ?, presentation_title = ?,
presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?, presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?,
presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?, presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?,
presentation_description_manual = ?, presentation_image_manual = ?, presentation_description_manual = ?, presentation_image_manual = ?,
@@ -628,6 +644,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
input.summary, input.summary,
input.formatId, input.formatId,
input.status, input.status,
input.preparationEffort,
input.durationSeconds, input.durationSeconds,
input.intro, input.intro,
input.researchText, input.researchText,
@@ -635,7 +652,8 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
input.interactionText ?? null, input.interactionText ?? null,
input.transitionText, input.transitionText,
input.presentationText, input.presentationText,
input.presentationMode, input.presentationMode === 'image' ? 'text' : input.presentationMode,
input.presentationMode === 'image' ? 1 : 0,
input.presentationSourcePosition, input.presentationSourcePosition,
input.presentationTitle, input.presentationTitle,
input.presentationDescription, input.presentationDescription,
@@ -671,6 +689,7 @@ function blankPieceInput(): ValidatedPieceInput {
summary: '', summary: '',
formatId: null, formatId: null,
status: 'preparing', status: 'preparing',
preparationEffort: null,
durationSeconds: 0, durationSeconds: 0,
intro: '', intro: '',
researchText: '', researchText: '',
@@ -798,6 +817,7 @@ export function savePieceAsTemplate(
delete snapshot.updatedAtUtc; delete snapshot.updatedAtUtc;
delete snapshot.media; delete snapshot.media;
delete snapshot.presentationMediaId; delete snapshot.presentationMediaId;
if (snapshot.presentationMode === 'image') snapshot.presentationMode = 'text';
snapshot.status = 'preparing'; snapshot.status = 'preparing';
const snapshotJson = JSON.stringify(snapshot); const snapshotJson = JSON.stringify(snapshot);
const database = getDatabase(); const database = getDatabase();
@@ -917,16 +937,16 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
database.transaction(() => { database.transaction(() => {
database database
.prepare( .prepare(
`INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro, `INSERT INTO pieces (id, title, output_title, summary, format_id, status, preparation_effort, duration_seconds, intro,
research_text, moderation_notes, interaction_text, transition_text, presentation_text, presentation_mode, research_text, moderation_notes, interaction_text, transition_text, presentation_text, presentation_mode, presentation_image_only,
presentation_source_position, presentation_title, presentation_description, presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider, presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual, presentation_provider_id, presentation_title_manual,
presentation_description_manual, presentation_image_manual, presentation_description_manual, presentation_image_manual,
presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision) presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision)
VALUES ( VALUES (
?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1
)` )`
) )
.run( .run(
@@ -935,6 +955,7 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
source.outputTitle, source.outputTitle,
source.summary, source.summary,
source.formatId, source.formatId,
source.preparationEffort,
source.durationSeconds, source.durationSeconds,
source.intro, source.intro,
source.researchText, source.researchText,
@@ -942,7 +963,8 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
source.interactionText, source.interactionText,
source.transitionText, source.transitionText,
source.presentationText, source.presentationText,
source.presentationMode, source.presentationMode === 'image' ? 'text' : source.presentationMode,
source.presentationMode === 'image' ? 1 : 0,
source.presentationSourcePosition, source.presentationSourcePosition,
source.presentationTitle, source.presentationTitle,
source.presentationDescription, source.presentationDescription,
+95 -2
View File
@@ -1,6 +1,6 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs'; import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close(); database.close();
expect(counts).toEqual({ expect(counts).toEqual({
migrations: 12, migrations: 19,
pieces: 6, pieces: 6,
shows: 2, shows: 2,
usages: 5, usages: 5,
@@ -90,4 +90,97 @@ describe('database setup', () => {
expect(show.totalDurationSeconds).toBe(5100); expect(show.totalDurationSeconds).toBe(5100);
expect(show.sections[1].usages.find((usage) => usage.isReserve)?.durationSeconds).toBe(540); expect(show.sections[1].usages.find((usage) => usage.isReserve)?.durationSeconds).toBe(540);
}); });
it('upgrades existing completed shows into stable finalizations exactly once', () => {
const directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-upgrade-test-'));
temporaryDirectories.push(directory);
const databasePath = path.join(directory, 'test.sqlite');
const environment = {
...process.env,
SENDEWERK_DB_PATH: databasePath,
SENDEWERK_UPLOAD_DIR: path.join(directory, 'uploads')
};
const database = new Database(databasePath);
database.pragma('foreign_keys = ON');
database.exec(
'CREATE TABLE schema_migrations (name TEXT PRIMARY KEY, applied_at_utc TEXT NOT NULL)'
);
const migrationDirectory = path.resolve('migrations');
const oldMigrations = readdirSync(migrationDirectory)
.filter(
(name) =>
/^\d+.+\.sql$/.test(name) && !name.startsWith('0015_') && !name.startsWith('0016_')
)
.sort();
for (const name of oldMigrations) {
database.exec(readFileSync(path.join(migrationDirectory, name), 'utf8'));
database
.prepare('INSERT INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)')
.run(name, new Date().toISOString());
}
database.close();
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: environment });
const legacy = new Database(databasePath);
legacy.pragma('foreign_keys = ON');
const usage = legacy
.prepare(
`SELECT u.id FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = 'show-october' AND s.is_reserve_bank = 0 LIMIT 1`
)
.get() as { id: string };
legacy.prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(usage.id);
legacy.prepare("UPDATE shows SET status = 'completed' WHERE id = 'show-october'").run();
const completionMigration = '0015_completion_archive_and_shares.sql';
legacy.exec(readFileSync(path.join(migrationDirectory, completionMigration), 'utf8'));
legacy
.prepare('INSERT INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)')
.run(completionMigration, new Date().toISOString());
const now = new Date().toISOString();
legacy
.prepare(
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc)
VALUES ('upgrade-barkeeper', 'upgrade@example.test', 'upgrade@example.test',
'Upgrade', 'unused', 'barkeeper', ?, ?, ?)`
)
.run(now, now, now);
legacy
.prepare(
`INSERT INTO show_shares
(id, show_id, token_hash, scope, password_hash, expires_at_utc, revoked_at_utc,
created_by_user_id, created_at_utc)
VALUES ('upgrade-share', 'show-october', 'upgrade-token-hash', 'rundown', NULL,
NULL, NULL, 'upgrade-barkeeper', ?)`
)
.run(now);
legacy
.prepare(
`INSERT INTO show_share_sessions (token_hash, share_id, created_at_utc, expires_at_utc)
VALUES ('upgrade-session-hash', 'upgrade-share', ?, ?)`
)
.run(now, new Date(Date.now() + 60_000).toISOString());
legacy.close();
for (let run = 0; run < 2; run += 1) {
execFileSync(process.execPath, ['scripts/migrate.mjs'], {
cwd: process.cwd(),
env: environment
});
}
const upgraded = new Database(databasePath, { readonly: true });
expect(
upgraded
.prepare(
`SELECT
(SELECT count(*) FROM show_finalizations WHERE show_id = 'show-october') AS finalizations,
(SELECT count(*) FROM piece_usage_history WHERE show_id = 'show-october') AS history,
(SELECT count(*) FROM show_shares WHERE id = 'upgrade-share') AS shares,
(SELECT count(*) FROM show_share_sessions WHERE token_hash = 'upgrade-session-hash') AS sessions`
)
.get()
).toEqual({ finalizations: 1, history: 1, shares: 1, sessions: 1 });
expect(upgraded.pragma('foreign_key_check')).toEqual([]);
upgraded.close();
});
}); });
+134
View File
@@ -0,0 +1,134 @@
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, vi } from 'vitest';
import { closeDatabase, getDatabase } from './database.js';
import {
discardFeedEntries,
fetchDueFeeds,
fetchFeed,
importFeedEntries,
listFeedEntries,
saveFeedSource
} from './feeds.js';
import { getPiece } from './content.js';
vi.mock('node:dns/promises', () => ({
lookup: vi.fn(async () => [{ address: '93.184.216.34', family: 4 }])
}));
const validFeed = `<?xml version="1.0"?><rss version="2.0"><channel><title>Test</title><item>
<guid>entry-1</guid><title>Ein Beitrag</title><link>https://example.org/post</link>
<description><![CDATA[<p>Kurz <strong>und</strong> gut.</p><script>evil()</script>]]></description>
<pubDate>Sat, 24 Oct 2026 12:00:00 GMT</pubDate></item></channel></rss>`;
let directory: string;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-feed-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(() => {
vi.unstubAllGlobals();
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
describe('RSS import inbox', () => {
it('keeps one entry across fetches and preserves a discard decision', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () => new Response(validFeed, { headers: { 'content-type': 'application/rss+xml' } })
)
);
const feedId = saveFeedSource(null, {
name: 'Feed',
url: 'https://example.org/feed.xml',
isEnabled: true,
refreshIntervalMinutes: 60,
defaultCategoryId: null
});
await fetchFeed(feedId);
const [entry] = listFeedEntries();
expect(entry.title).toBe('Ein Beitrag');
expect(discardFeedEntries([entry.id])).toBe(1);
await fetchFeed(feedId);
expect(listFeedEntries()).toHaveLength(0);
expect(listFeedEntries('discarded')).toHaveLength(1);
});
it('sanitizes foreign HTML and retains the original source on explicit import', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(validFeed))
);
const feedId = saveFeedSource(null, {
name: 'Feed',
url: 'https://example.org/feed.xml',
isEnabled: true,
refreshIntervalMinutes: 60,
defaultCategoryId: 'cat-jc'
});
await fetchFeed(feedId);
const [entry] = listFeedEntries();
const [pieceId] = importFeedEntries([entry.id], []);
const piece = getPiece(pieceId)!;
expect(piece.summary).toContain('Kurz und gut.');
expect(piece.summary).not.toContain('<script>');
expect(piece.sources).toEqual([
{ label: 'RSS: Originalbeitrag', url: 'https://example.org/post', timecodeSeconds: null }
]);
expect(piece.categoryIds).toEqual(['cat-jc']);
});
it('isolates a broken feed while fetching other due feeds', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async (url: URL) => new Response(url.href.includes('broken') ? '<not-feed />' : validFeed)
)
);
saveFeedSource(null, {
name: 'A broken',
url: 'https://example.org/broken.xml',
isEnabled: true,
refreshIntervalMinutes: 60,
defaultCategoryId: null
});
saveFeedSource(null, {
name: 'B valid',
url: 'https://example.org/valid.xml',
isEnabled: true,
refreshIntervalMinutes: 60,
defaultCategoryId: null
});
await fetchDueFeeds();
expect(listFeedEntries()).toHaveLength(1);
const errors = getDatabase()
.prepare("SELECT count(*) AS count FROM feed_sources WHERE last_error <> ''")
.get() as { count: number };
expect(errors.count).toBe(1);
});
it('blocks loopback targets before sending a request', async () => {
const request = vi.fn();
vi.stubGlobal('fetch', request);
const feedId = saveFeedSource(null, {
name: 'Local',
url: 'http://127.0.0.1/feed.xml',
isEnabled: false,
refreshIntervalMinutes: 60,
defaultCategoryId: null
});
await expect(fetchFeed(feedId)).rejects.toThrow('FEED_BLOCKED');
expect(request).not.toHaveBeenCalled();
});
});
+457
View File
@@ -0,0 +1,457 @@
import { createHash, randomUUID } from 'node:crypto';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import sanitizeHtml from 'sanitize-html';
import { XMLParser } from 'fast-xml-parser';
import { createPiece, getPiece, savePiece } from './content.js';
import { getDatabase } from './database.js';
const MAX_FEED_BYTES = 2 * 1024 * 1024;
const FETCH_TIMEOUT_MS = 8_000;
const MAX_REDIRECTS = 3;
export type FeedSource = {
id: string;
name: string;
url: string;
isEnabled: boolean;
refreshIntervalMinutes: number;
defaultCategoryId: string | null;
lastFetchedAtUtc: string | null;
nextFetchAtUtc: string | null;
lastError: string;
};
export type FeedEntry = {
id: string;
feedId: string;
feedName: string;
title: string;
url: string;
summary: string;
publishedAtUtc: string | null;
status: 'new' | 'imported' | 'discarded';
pieceId: string | null;
defaultCategoryId: string | null;
};
function clean(value: unknown, maximum: number) {
return String(value ?? '')
.trim()
.slice(0, maximum);
}
function validHttpUrl(value: string) {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password;
} catch {
return false;
}
}
function privateIpv4(address: string) {
const p = address.split('.').map(Number);
return (
p[0] === 0 ||
p[0] === 10 ||
p[0] === 127 ||
(p[0] === 169 && p[1] === 254) ||
(p[0] === 172 && p[1] >= 16 && p[1] <= 31) ||
(p[0] === 192 && p[1] === 168) ||
p[0] >= 224
);
}
function privateAddress(address: string) {
if (isIP(address) === 4) return privateIpv4(address);
const value = address.toLowerCase().split('%')[0];
if (value === '::' || value === '::1' || value.startsWith('fc') || value.startsWith('fd'))
return true;
if (/^fe[89ab]/.test(value)) return true;
const mapped = value.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
return mapped ? privateIpv4(mapped) : false;
}
async function assertPublic(url: URL) {
if (!validHttpUrl(url.href) || (url.port && !['80', '443'].includes(url.port)))
throw new Error('FEED_BLOCKED');
const hostname = url.hostname.replace(/^\[|\]$/g, '');
if (hostname === 'localhost' || hostname.endsWith('.localhost')) throw new Error('FEED_BLOCKED');
const addresses = isIP(hostname)
? [{ address: hostname }]
: await lookup(hostname, { all: true, verbatim: true });
if (!addresses.length || addresses.some((entry) => privateAddress(entry.address)))
throw new Error('FEED_BLOCKED');
}
async function fetchXml(
input: string,
headers: Record<string, string>,
redirects = 0
): Promise<{ xml: string; etag: string; lastModified: string; unchanged: boolean }> {
const url = new URL(input);
await assertPublic(url);
const response = await fetch(url, {
headers: {
Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml',
...headers
},
redirect: 'manual',
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
if (redirects >= MAX_REDIRECTS) throw new Error('FEED_REDIRECT');
const location = response.headers.get('location');
if (!location) throw new Error('FEED_FETCH');
return fetchXml(new URL(location, url).href, headers, redirects + 1);
}
if (response.status === 304)
return {
xml: '',
etag: headers['If-None-Match'] ?? '',
lastModified: headers['If-Modified-Since'] ?? '',
unchanged: true
};
if (!response.ok) throw new Error(`FEED_HTTP_${response.status}`);
const declared = Number(response.headers.get('content-length') ?? 0);
if (declared > MAX_FEED_BYTES) throw new Error('FEED_TOO_LARGE');
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length > MAX_FEED_BYTES) throw new Error('FEED_TOO_LARGE');
return {
xml: buffer.toString('utf8'),
etag: response.headers.get('etag') ?? '',
lastModified: response.headers.get('last-modified') ?? '',
unchanged: false
};
}
function values<T>(value: T | T[] | undefined): T[] {
return value === undefined ? [] : Array.isArray(value) ? value : [value];
}
function textFrom(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') return String(value);
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
return textFrom(record['#text'] ?? record.__cdata ?? '');
}
return '';
}
function entryLink(value: unknown) {
for (const link of values(
value as Record<string, unknown> | Record<string, unknown>[] | string | undefined
)) {
if (typeof link === 'string') return link;
if (link && typeof link === 'object') {
const href = clean((link as Record<string, unknown>)['@_href'], 2_000);
const rel = clean((link as Record<string, unknown>)['@_rel'], 30);
if (href && (!rel || rel === 'alternate')) return href;
}
}
return '';
}
function parseDate(value: unknown) {
const raw = textFrom(value);
if (!raw) return null;
const date = new Date(raw);
return Number.isNaN(date.valueOf()) ? null : date.toISOString();
}
function parseFeed(xml: string) {
let document: Record<string, unknown>;
try {
document = new XMLParser({
ignoreAttributes: false,
processEntities: false,
trimValues: true
}).parse(xml) as Record<string, unknown>;
} catch {
throw new Error('INVALID_FEED');
}
const rssChannel = (document.rss as Record<string, unknown> | undefined)?.channel as
Record<string, unknown> | undefined;
const atom = document.feed as Record<string, unknown> | undefined;
if (!rssChannel && !atom) throw new Error('INVALID_FEED');
const rawEntries = values(
(rssChannel?.item ?? atom?.entry) as
Record<string, unknown> | Record<string, unknown>[] | undefined
);
return rawEntries.slice(0, 200).flatMap((entry) => {
const title = clean(textFrom(entry.title), 300);
const url = clean(entryLink(entry.link) || textFrom(entry.link), 2_000);
if (!title || !validHttpUrl(url)) return [];
const guid = clean(textFrom(entry.guid ?? entry.id), 2_000);
const externalId = guid || createHash('sha256').update(url).digest('hex');
const rawSummary = textFrom(entry.description ?? entry.summary ?? entry.content);
const summary = clean(
sanitizeHtml(rawSummary, { allowedTags: [], allowedAttributes: {} }),
5_000
);
return [
{
externalId,
title,
url,
summary,
publishedAtUtc: parseDate(entry.pubDate ?? entry.published ?? entry.updated)
}
];
});
}
export function listFeedSources(): FeedSource[] {
return (
getDatabase()
.prepare(
`SELECT id, name, url, is_enabled, refresh_interval_minutes,
default_category_id, last_fetched_at_utc, next_fetch_at_utc, last_error FROM feed_sources ORDER BY name`
)
.all() as Array<Record<string, unknown>>
).map((row) => ({
id: String(row.id),
name: String(row.name),
url: String(row.url),
isEnabled: row.is_enabled === 1,
refreshIntervalMinutes: Number(row.refresh_interval_minutes),
defaultCategoryId: row.default_category_id ? String(row.default_category_id) : null,
lastFetchedAtUtc: row.last_fetched_at_utc ? String(row.last_fetched_at_utc) : null,
nextFetchAtUtc: row.next_fetch_at_utc ? String(row.next_fetch_at_utc) : null,
lastError: String(row.last_error)
}));
}
export function saveFeedSource(
id: string | null,
input: {
name: string;
url: string;
isEnabled: boolean;
refreshIntervalMinutes: number;
defaultCategoryId: string | null;
}
) {
const name = clean(input.name, 120);
const url = clean(input.url, 2_000);
if (
!name ||
!validHttpUrl(url) ||
!Number.isInteger(input.refreshIntervalMinutes) ||
input.refreshIntervalMinutes < 15 ||
input.refreshIntervalMinutes > 10080
)
throw new Error('INVALID_FEED');
const database = getDatabase();
if (
input.defaultCategoryId &&
!database.prepare('SELECT 1 FROM section_categories WHERE id = ?').get(input.defaultCategoryId)
)
throw new Error('INVALID_TAXONOMY');
const now = new Date().toISOString();
if (!id) {
id = randomUUID();
database
.prepare(
`INSERT INTO feed_sources (id, name, url, is_enabled, refresh_interval_minutes,
default_category_id, next_fetch_at_utc, created_at_utc, updated_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
name,
url,
input.isEnabled ? 1 : 0,
input.refreshIntervalMinutes,
input.defaultCategoryId,
input.isEnabled ? now : null,
now,
now
);
} else {
const result = database
.prepare(
`UPDATE feed_sources SET name = ?, url = ?, is_enabled = ?, refresh_interval_minutes = ?,
default_category_id = ?, next_fetch_at_utc = CASE WHEN ? = 1 THEN COALESCE(next_fetch_at_utc, ?) ELSE NULL END,
updated_at_utc = ? WHERE id = ?`
)
.run(
name,
url,
input.isEnabled ? 1 : 0,
input.refreshIntervalMinutes,
input.defaultCategoryId,
input.isEnabled ? 1 : 0,
now,
now,
id
);
if (!result.changes) throw new Error('NOT_FOUND');
}
return id;
}
export function deleteFeedSource(id: string) {
if (!getDatabase().prepare('DELETE FROM feed_sources WHERE id = ?').run(id).changes)
throw new Error('NOT_FOUND');
}
export function listFeedEntries(status: 'new' | 'imported' | 'discarded' = 'new'): FeedEntry[] {
return (
getDatabase()
.prepare(
`SELECT e.id, e.feed_id, f.name AS feed_name, e.title, e.url, e.summary,
e.published_at_utc, e.status, e.piece_id, f.default_category_id FROM feed_entries e
JOIN feed_sources f ON f.id = e.feed_id WHERE e.status = ? ORDER BY COALESCE(e.published_at_utc, e.first_seen_at_utc) DESC`
)
.all(status) as Array<Record<string, unknown>>
).map((row) => ({
id: String(row.id),
feedId: String(row.feed_id),
feedName: String(row.feed_name),
title: String(row.title),
url: String(row.url),
summary: String(row.summary),
publishedAtUtc: row.published_at_utc ? String(row.published_at_utc) : null,
status: row.status as FeedEntry['status'],
pieceId: row.piece_id ? String(row.piece_id) : null,
defaultCategoryId: row.default_category_id ? String(row.default_category_id) : null
}));
}
export async function fetchFeed(feedId: string) {
const database = getDatabase();
const feed = database.prepare('SELECT * FROM feed_sources WHERE id = ?').get(feedId) as
Record<string, unknown> | undefined;
if (!feed) throw new Error('NOT_FOUND');
const now = new Date();
try {
const response = await fetchXml(String(feed.url), {
...(feed.etag ? { 'If-None-Match': String(feed.etag) } : {}),
...(feed.last_modified ? { 'If-Modified-Since': String(feed.last_modified) } : {})
});
let inserted = 0;
if (!response.unchanged) {
const add =
database.prepare(`INSERT INTO feed_entries (id, feed_id, external_id, title, url, summary,
published_at_utc, first_seen_at_utc, updated_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(feed_id, external_id) DO UPDATE SET title = excluded.title, url = excluded.url,
summary = excluded.summary, published_at_utc = excluded.published_at_utc, updated_at_utc = excluded.updated_at_utc`);
for (const entry of parseFeed(response.xml)) {
const existed = database
.prepare('SELECT 1 FROM feed_entries WHERE feed_id = ? AND external_id = ?')
.get(feedId, entry.externalId);
add.run(
randomUUID(),
feedId,
entry.externalId,
entry.title,
entry.url,
entry.summary,
entry.publishedAtUtc,
now.toISOString(),
now.toISOString()
);
if (!existed) inserted += 1;
}
}
const next = new Date(
now.getTime() + Number(feed.refresh_interval_minutes) * 60_000
).toISOString();
database
.prepare(
`UPDATE feed_sources SET etag = ?, last_modified = ?, last_fetched_at_utc = ?,
next_fetch_at_utc = ?, last_error = '', updated_at_utc = ? WHERE id = ?`
)
.run(
response.etag,
response.lastModified,
now.toISOString(),
next,
now.toISOString(),
feedId
);
return { inserted, unchanged: response.unchanged };
} catch (cause) {
const message = cause instanceof Error ? cause.message.slice(0, 500) : 'FEED_FETCH';
const next = new Date(
now.getTime() + Number(feed.refresh_interval_minutes) * 60_000
).toISOString();
database
.prepare(
'UPDATE feed_sources SET last_fetched_at_utc = ?, next_fetch_at_utc = ?, last_error = ?, updated_at_utc = ? WHERE id = ?'
)
.run(now.toISOString(), next, message, now.toISOString(), feedId);
throw cause;
}
}
export async function fetchDueFeeds() {
const rows = getDatabase()
.prepare(
`SELECT id FROM feed_sources WHERE is_enabled = 1
AND (next_fetch_at_utc IS NULL OR next_fetch_at_utc <= ?) ORDER BY next_fetch_at_utc LIMIT 10`
)
.all(new Date().toISOString()) as { id: string }[];
for (const row of rows) await fetchFeed(row.id).catch(() => undefined);
}
export function importFeedEntries(entryIds: string[], categoryIds: string[]) {
const database = getDatabase();
const categories = [...new Set(categoryIds.filter(Boolean))];
if (categories.length > 20) throw new Error('INVALID_TAXONOMY');
for (const category of categories)
if (!database.prepare('SELECT 1 FROM section_categories WHERE id = ?').get(category))
throw new Error('INVALID_TAXONOMY');
return database.transaction(() =>
entryIds.map((entryId) => {
const entry = database
.prepare(
`SELECT e.*, f.default_category_id FROM feed_entries e JOIN feed_sources f ON f.id = e.feed_id
WHERE e.id = ? AND e.status = 'new'`
)
.get(entryId) as Record<string, unknown> | undefined;
if (!entry) throw new Error('NOT_FOUND');
const pieceId = createPiece('blank');
const piece = getPiece(pieceId)!;
const assigned = categories.length
? categories
: entry.default_category_id
? [String(entry.default_category_id)]
: [];
savePiece(pieceId, piece.revision, {
...piece,
title: String(entry.title),
summary: String(entry.summary),
status: 'inbox',
categoryIds: assigned,
sources: [{ label: 'RSS: Originalbeitrag', url: String(entry.url), timecodeSeconds: null }]
});
database
.prepare(
"UPDATE feed_entries SET status = 'imported', piece_id = ?, updated_at_utc = ? WHERE id = ?"
)
.run(pieceId, new Date().toISOString(), entryId);
return pieceId;
})
)();
}
export function discardFeedEntries(entryIds: string[]) {
const database = getDatabase();
const update = database.prepare(
"UPDATE feed_entries SET status = 'discarded', updated_at_utc = ? WHERE id = ? AND status = 'new'"
);
const now = new Date().toISOString();
return database.transaction(() =>
entryIds.reduce((count, id) => count + update.run(now, id).changes, 0)
)();
}
let schedulerStarted = false;
export function startFeedScheduler() {
if (schedulerStarted || process.env.NODE_ENV === 'test') return;
schedulerStarted = true;
void fetchDueFeeds();
setInterval(() => void fetchDueFeeds(), 60_000).unref();
}
+84 -3
View File
@@ -16,6 +16,7 @@ import {
moveOpenUsage, moveOpenUsage,
presentationChannelAccess, presentationChannelAccess,
presentUsage, presentUsage,
resumeUsage,
rotatePresentationChannelKey, rotatePresentationChannelKey,
selectUsage, selectUsage,
skipCurrent, skipCurrent,
@@ -40,7 +41,7 @@ afterEach(() => {
}); });
describe('live mode', () => { describe('live mode', () => {
it('persists a frozen section countdown across usage changes and reloads', () => { it('keeps elapsed section time while applying an updated section plan', () => {
getDatabase() getDatabase()
.prepare( .prepare(
"UPDATE show_sections SET explicit_duration_seconds = 300 WHERE id = 'section-stories'" "UPDATE show_sections SET explicit_duration_seconds = 300 WHERE id = 'section-stories'"
@@ -59,6 +60,11 @@ describe('live mode', () => {
"UPDATE show_sections SET explicit_duration_seconds = 600 WHERE id = 'section-stories'" "UPDATE show_sections SET explicit_duration_seconds = 600 WHERE id = 'section-stories'"
) )
.run(); .run();
live = getLiveView('show-october', new Date('2026-10-23T18:01:40.000Z'));
expect(live.sectionTimer).toMatchObject({
plannedDurationSeconds: 600,
elapsedSeconds: 100
});
completeCurrent( completeCurrent(
'show-october', 'show-october',
'timer-next-piece', 'timer-next-piece',
@@ -68,7 +74,7 @@ describe('live mode', () => {
live = getLiveView('show-october', new Date('2026-10-23T18:04:00.000Z')); live = getLiveView('show-october', new Date('2026-10-23T18:04:00.000Z'));
expect(live.current?.id).toBe('usage-effects'); expect(live.current?.id).toBe('usage-effects');
expect(live.sectionTimer).toMatchObject({ expect(live.sectionTimer).toMatchObject({
plannedDurationSeconds: 300, plannedDurationSeconds: 600,
elapsedSeconds: 240 elapsedSeconds: 240
}); });
completeCurrent( completeCurrent(
@@ -104,13 +110,54 @@ describe('live mode', () => {
.find((usage) => usage.id === 'usage-console')?.status .find((usage) => usage.id === 'usage-console')?.status
).toBe('done'); ).toBe('done');
selectUsage('show-october', 'usage-console', 'return-to-completed', live.liveRevision); expect(() =>
selectUsage('show-october', 'usage-console', 'return-to-completed', live.liveRevision)
).toThrow('INVALID_STATUS');
resumeUsage('show-october', 'usage-console', 'resume-completed', live.liveRevision);
expect(getLiveView('show-october').current).toMatchObject({ expect(getLiveView('show-october').current).toMatchObject({
id: 'usage-console', id: 'usage-console',
status: 'current' status: 'current'
}); });
}); });
it('resumes a completed piece and its earlier section timer without resetting total time', () => {
const database = getDatabase();
database
.prepare(
"UPDATE show_sections SET explicit_duration_seconds = 300 WHERE id = 'section-stories'"
)
.run();
database
.prepare("UPDATE show_sections SET explicit_duration_seconds = 200 WHERE id = 'section-game'")
.run();
database
.prepare(
"UPDATE piece_usages SET show_section_id = 'section-game', position = 0 WHERE id = 'usage-effects'"
)
.run();
const start = new Date('2026-10-23T18:00:00.000Z');
startOrResumeLive('show-october', 'resume-start', 0, start);
completeCurrent('show-october', 'resume-next', 1, new Date('2026-10-23T18:01:00.000Z'));
let live = getLiveView('show-october', new Date('2026-10-23T18:01:30.000Z'));
expect(live.current?.id).toBe('usage-effects');
resumeUsage(
'show-october',
'usage-console',
'resume-earlier',
live.liveRevision,
new Date('2026-10-23T18:01:30.000Z')
);
live = getLiveView('show-october', new Date('2026-10-23T18:02:00.000Z'));
expect(live.current).toMatchObject({ id: 'usage-console', status: 'current' });
expect(live.completedCount).toBe(0);
expect(live.sectionTimer).toMatchObject({
sectionId: 'section-stories',
plannedDurationSeconds: 300,
elapsedSeconds: 90
});
expect(live.totalTimer.elapsedSeconds).toBe(120);
});
it('keeps one stable output channel until its key is explicitly rotated', () => { it('keeps one stable output channel until its key is explicitly rotated', () => {
const key = issuePresentationAccess('show-october'); const key = issuePresentationAccess('show-october');
expect(issuePresentationAccess('show-october')).toBe(key); expect(issuePresentationAccess('show-october')).toBe(key);
@@ -239,6 +286,40 @@ describe('live mode', () => {
expect(media).toBeNull(); expect(media).toBeNull();
expect(await getPresentationMedia(token, 'unknown-image')).toBeNull(); expect(await getPresentationMedia(token, 'unknown-image')).toBeNull();
getDatabase()
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES ('presentation-snapshot', 'usage-console', 'presentation-image',
'Konsole auf dem Tisch', 0, 1)`
)
.run();
getDatabase()
.prepare(
`UPDATE piece_usages SET presentation_mode_snapshot = 'text',
presentation_image_only_snapshot = 1,
presentation_image_url_snapshot = 'https://private.example.test/not-for-image-mode'
WHERE id = 'usage-console'`
)
.run();
presentUsage(
'show-october',
'usage-console',
'present-image-only',
getLiveView('show-october').liveRevision
);
expect(getPresentationView(token)?.content).toMatchObject({
mode: 'image',
imageUrl: '',
images: [{ id: 'presentation-snapshot', altText: 'Konsole auf dem Tisch' }],
sources: []
});
expect(await getPresentationMedia(token, 'presentation-snapshot')).toMatchObject({
mimeType: 'image/png',
byteSize: 10,
altText: 'Konsole auf dem Tisch'
});
const replacementToken = issuePresentationAccess('show-october'); const replacementToken = issuePresentationAccess('show-october');
expect(replacementToken).toBe(token); expect(replacementToken).toBe(token);
expect(getPresentationView(token)?.content?.title).toBe( expect(getPresentationView(token)?.content?.title).toBe(
+106 -28
View File
@@ -149,12 +149,13 @@ function orderedUsages(database: Database.Database, showId: string): OrderedUsag
function usageBelongsToShow(database: Database.Database, showId: string, usageId: string) { function usageBelongsToShow(database: Database.Database, showId: string, usageId: string) {
const row = database const row = database
.prepare( .prepare(
`SELECT s.is_reserve_bank FROM piece_usages u `SELECT s.is_reserve_bank, u.status FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id JOIN show_sections s ON s.id = u.show_section_id
WHERE u.id = ? AND s.show_id = ?` WHERE u.id = ? AND s.show_id = ?`
) )
.get(usageId, showId) as { is_reserve_bank: number } | undefined; .get(usageId, showId) as { is_reserve_bank: number; status: string } | undefined;
if (!row || row.is_reserve_bank) throw new Error('NOT_FOUND'); if (!row || row.is_reserve_bank) throw new Error('NOT_FOUND');
return row;
} }
function closeActiveRun(database: Database.Database, showId: string, now: string) { function closeActiveRun(database: Database.Database, showId: string, now: string) {
@@ -197,10 +198,7 @@ function showTimerRunning(database: Database.Database, showId: string) {
); );
} }
function elapsedRuns( function elapsedRuns(runs: { started_at_utc: string; ended_at_utc: string | null }[], now: string) {
runs: { started_at_utc: string; ended_at_utc: string | null }[],
now: string
) {
return runs.reduce( return runs.reduce(
(total, run) => (total, run) =>
total + total +
@@ -236,9 +234,7 @@ function resumeSectionTimerRun(database: Database.Database, showId: string, now:
.get(showId) as { id: string } | undefined; .get(showId) as { id: string } | undefined;
if (!timer) return; if (!timer) return;
const running = database const running = database
.prepare( .prepare('SELECT 1 FROM live_section_timer_runs WHERE timer_id = ? AND ended_at_utc IS NULL')
'SELECT 1 FROM live_section_timer_runs WHERE timer_id = ? AND ended_at_utc IS NULL'
)
.get(timer.id); .get(timer.id);
if (!running) { if (!running) {
database database
@@ -266,12 +262,46 @@ function syncSectionTimer(
if (!section) throw new Error('NOT_FOUND'); if (!section) throw new Error('NOT_FOUND');
const active = database const active = database
.prepare( .prepare(
'SELECT id, section_id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL' `SELECT id, section_id, planned_duration_seconds
FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL`
) )
.get(showId) as { id: string; section_id: string } | undefined; .get(showId) as
if (active?.section_id === section.id) return; { id: string; section_id: string; planned_duration_seconds: number } | undefined;
if (active?.section_id === section.id) {
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) {
closeSectionTimer(database, showId, now);
} else if (active.planned_duration_seconds !== section.explicit_duration_seconds) {
database
.prepare('UPDATE live_section_timers SET planned_duration_seconds = ? WHERE id = ?')
.run(section.explicit_duration_seconds, active.id);
}
return;
}
if (active) closeSectionTimer(database, showId, now); if (active) closeSectionTimer(database, showId, now);
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) return; if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) return;
const previous = database
.prepare(
`SELECT id FROM live_section_timers WHERE show_id = ? AND section_id = ?
ORDER BY started_at_utc DESC LIMIT 1`
)
.get(showId, section.id) as { id: string } | undefined;
if (previous) {
database
.prepare(
`UPDATE live_section_timers SET planned_duration_seconds = ?, ended_at_utc = NULL
WHERE id = ?`
)
.run(section.explicit_duration_seconds, previous.id);
if (showTimerRunning(database, showId)) {
database
.prepare(
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
VALUES (?, ?, ?)`
)
.run(randomUUID(), previous.id, now);
}
return;
}
const timerId = randomUUID(); const timerId = randomUUID();
database database
.prepare( .prepare(
@@ -280,6 +310,7 @@ function syncSectionTimer(
VALUES (?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?)`
) )
.run(timerId, showId, section.id, section.explicit_duration_seconds, now); .run(timerId, showId, section.id, section.explicit_duration_seconds, now);
if (showTimerRunning(database, showId)) {
database database
.prepare( .prepare(
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc) `INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
@@ -287,6 +318,7 @@ function syncSectionTimer(
) )
.run(randomUUID(), timerId, now); .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);
@@ -335,6 +367,9 @@ function executeCommand(
const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as { const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as {
status: string; status: string;
}; };
if (database.prepare('SELECT 1 FROM show_finalizations WHERE show_id = ?').get(showId)) {
throw new Error('SHOW_COMPLETED');
}
if (show.status === 'completed' && !allowCompleted) throw new Error('SHOW_COMPLETED'); if (show.status === 'completed' && !allowCompleted) throw new Error('SHOW_COMPLETED');
if (state.revision !== expectedRevision) throw new Error('CONFLICT'); if (state.revision !== expectedRevision) throw new Error('CONFLICT');
operation(database, state, now); operation(database, state, now);
@@ -402,6 +437,9 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
const state = ensureLiveState(database, showId, now); const state = ensureLiveState(database, showId, now);
const show = getShow(showId); const show = getShow(showId);
if (!show) throw new Error('NOT_FOUND'); if (!show) throw new Error('NOT_FOUND');
if (state.started_at_utc && !state.ended_at_utc && state.current_usage_id) {
syncSectionTimer(database, showId, state.current_usage_id, now);
}
const durations = actualDurations(database, showId, now); const durations = actualDurations(database, showId, now);
const sections = show.sections.map((section) => ({ const sections = show.sections.map((section) => ({
...section, ...section,
@@ -643,9 +681,7 @@ export function resetSectionTimer(
`SELECT section_id, planned_duration_seconds `SELECT section_id, planned_duration_seconds
FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL` FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL`
) )
.get(showId) as .get(showId) as { section_id: string; planned_duration_seconds: number } | undefined;
| { section_id: string; planned_duration_seconds: number }
| undefined;
if (!timer) throw new Error('NO_SECTION_TIMER'); if (!timer) throw new Error('NO_SECTION_TIMER');
const running = showTimerRunning(database, showId); const running = showTimerRunning(database, showId);
closeSectionTimer(database, showId, now); closeSectionTimer(database, showId, now);
@@ -741,12 +777,10 @@ export function endLive(
) )
.run(now, showId); .run(now, showId);
database database
.prepare( .prepare(`UPDATE show_live_states SET ended_at_utc = ? WHERE show_id = ?`)
`UPDATE show_live_states SET ended_at_utc = ? WHERE show_id = ?`
)
.run(now, showId); .run(now, showId);
database database
.prepare(`UPDATE shows SET status = 'completed', revision = revision + 1, updated_at_utc = ? WHERE id = ?`) .prepare(`UPDATE shows SET revision = revision + 1, updated_at_utc = ? WHERE id = ?`)
.run(now, showId); .run(now, showId);
}, },
nowValue nowValue
@@ -766,7 +800,11 @@ export function resumeEndedLive(
'resume-ended', 'resume-ended',
(database, state, now) => { (database, state, now) => {
if (!state.ended_at_utc) throw new Error('NOT_ENDED'); if (!state.ended_at_utc) throw new Error('NOT_ENDED');
database.prepare(`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?`).run(now, showId); database
.prepare(
`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?`
)
.run(now, showId);
database database
.prepare('UPDATE show_live_states SET ended_at_utc = NULL WHERE show_id = ?') .prepare('UPDATE show_live_states SET ended_at_utc = NULL WHERE show_id = ?')
.run(showId); .run(showId);
@@ -815,7 +853,11 @@ export function startNewLiveRun(
WHERE show_id = ?` WHERE show_id = ?`
) )
.run(now, showId); .run(now, showId);
database.prepare(`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?`).run(now, showId); database
.prepare(
`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?`
)
.run(now, showId);
openShowTimerRun(database, showId, now); openShowTimerRun(database, showId, now);
const target = orderedUsages(database, showId).find((usage) => usage.status === 'open'); const target = orderedUsages(database, showId).find((usage) => usage.status === 'open');
if (target) activateUsage(database, showId, target.id, now); if (target) activateUsage(database, showId, target.id, now);
@@ -927,7 +969,8 @@ export function selectUsage(
(database, state, now) => { (database, state, now) => {
if (!state.started_at_utc) throw new Error('NOT_STARTED'); if (!state.started_at_utc) throw new Error('NOT_STARTED');
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED'); if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
usageBelongsToShow(database, showId, usageId); const target = usageBelongsToShow(database, showId, usageId);
if (target.status !== 'open') throw new Error('INVALID_STATUS');
if (state.current_usage_id === usageId) return; if (state.current_usage_id === usageId) return;
if (state.current_usage_id) { if (state.current_usage_id) {
closeActiveRun(database, showId, now); closeActiveRun(database, showId, now);
@@ -941,6 +984,36 @@ export function selectUsage(
); );
} }
export function resumeUsage(
showId: string,
usageId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'resume-usage',
(database, state, now) => {
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
const target = usageBelongsToShow(database, showId, usageId);
if (target.status !== 'done' && target.status !== 'skipped')
throw new Error('INVALID_STATUS');
if (state.current_usage_id) {
closeActiveRun(database, showId, now);
database
.prepare("UPDATE piece_usages SET status = 'open' WHERE id = ?")
.run(state.current_usage_id);
}
activateUsage(database, showId, usageId, now);
},
nowValue
);
}
export function presentUsage( export function presentUsage(
showId: string, showId: string,
usageId: string | null, usageId: string | null,
@@ -1124,7 +1197,8 @@ function getPresentationViewForShow(showId: string): PresentationView {
const usage = database const usage = database
.prepare( .prepare(
`SELECT u.title_snapshot, u.output_title_snapshot, u.presentation_snapshot, `SELECT u.title_snapshot, u.output_title_snapshot, u.presentation_snapshot,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot, CASE WHEN u.presentation_image_only_snapshot = 1 THEN 'image' ELSE u.presentation_mode_snapshot END AS presentation_mode_snapshot,
u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot, u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot, u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
u.presentation_image_url_snapshot, u.presentation_site_name_snapshot, u.presentation_image_url_snapshot, u.presentation_site_name_snapshot,
@@ -1150,10 +1224,11 @@ function getPresentationViewForShow(showId: string): PresentationView {
} }
| undefined; | undefined;
if (!usage) return { revision: state.presentation_revision, content: null }; if (!usage) return { revision: state.presentation_revision, content: null };
const mode = usage.presentation_mode_snapshot;
return { return {
revision: state.presentation_revision, revision: state.presentation_revision,
content: { content: {
mode: usage.presentation_mode_snapshot, mode,
topics: ( topics: (
database database
.prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position') .prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position')
@@ -1170,7 +1245,7 @@ function getPresentationViewForShow(showId: string): PresentationView {
: null, : null,
previewTitle: usage.presentation_title_meta_snapshot, previewTitle: usage.presentation_title_meta_snapshot,
description: usage.presentation_description_snapshot, description: usage.presentation_description_snapshot,
imageUrl: usage.presentation_image_url_snapshot, imageUrl: mode === 'link' ? usage.presentation_image_url_snapshot : '',
siteName: usage.presentation_site_name_snapshot, siteName: usage.presentation_site_name_snapshot,
provider: usage.presentation_provider_snapshot, provider: usage.presentation_provider_snapshot,
providerId: usage.presentation_provider_id_snapshot, providerId: usage.presentation_provider_id_snapshot,
@@ -1180,7 +1255,8 @@ function getPresentationViewForShow(showId: string): PresentationView {
usage.presentation_source_timecode_snapshot usage.presentation_source_timecode_snapshot
), ),
sources: [], sources: [],
images: ( images: ['text', 'image'].includes(mode)
? (
database database
.prepare( .prepare(
`SELECT id, alt_text FROM usage_media_snapshots `SELECT id, alt_text FROM usage_media_snapshots
@@ -1188,13 +1264,14 @@ function getPresentationViewForShow(showId: string): PresentationView {
) )
.all(state.presentation_usage_id) as { id: string; alt_text: string }[] .all(state.presentation_usage_id) as { id: string; alt_text: string }[]
).map((media) => ({ id: media.id, altText: media.alt_text })) ).map((media) => ({ id: media.id, altText: media.alt_text }))
: []
} }
}; };
} }
export async function getPresentationPreviewImage(token: string) { export async function getPresentationPreviewImage(token: string) {
const view = getPresentationView(token); const view = getPresentationView(token);
if (!view?.content?.imageUrl || view.content.mode === 'text') return null; if (!view?.content?.imageUrl || view.content.mode !== 'link') return null;
try { try {
return await fetchPresentationImage(view.content.imageUrl); return await fetchPresentationImage(view.content.imageUrl);
} catch { } catch {
@@ -1218,7 +1295,8 @@ export async function getPresentationMedia(
JOIN show_sections s ON s.id = u.show_section_id JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media pm ON pm.id = ums.original_media_id JOIN piece_media pm ON pm.id = ums.original_media_id
WHERE state.show_id = ? AND s.show_id = state.show_id WHERE state.show_id = ? AND s.show_id = state.show_id
AND s.is_reserve_bank = 0 AND ums.id = ? AND ums.is_presentation = 1` AND s.is_reserve_bank = 0 AND ums.id = ? AND ums.is_presentation = 1
AND (u.presentation_image_only_snapshot = 1 OR u.presentation_mode_snapshot = 'text')`
) )
.get(showId, snapshotId) as .get(showId, snapshotId) as
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined; { storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
+1 -1
View File
@@ -8,7 +8,7 @@ import image from 'metascraper-image';
import publisher from 'metascraper-publisher'; import publisher from 'metascraper-publisher';
import title from 'metascraper-title'; import title from 'metascraper-title';
export type PresentationMode = 'text' | 'link' | 'video'; export type PresentationMode = 'text' | 'image' | 'link' | 'video';
export type VideoProvider = 'youtube' | 'tiktok'; export type VideoProvider = 'youtube' | 'tiktok';
export type PresentationMetadata = { export type PresentationMetadata = {
+276
View File
@@ -0,0 +1,276 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { AuthUser } from './auth.js';
import { finalizeShow } from './completion.js';
import { closeDatabase, getDatabase } from './database.js';
import {
authenticateShare,
createShare,
deleteShare,
getSharedMedia,
getSharedShow,
revokeShare,
replaceShareToken,
updateShare
} from './shares.js';
import { addPieceUsage, addShowSection, createShow } from './shows.js';
let directory: string;
const actor: AuthUser = {
id: 'share-actor',
email: 'share@example.test',
displayName: 'Freigabe Test',
role: 'barkeeper',
lockedAtUtc: null
};
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-shares-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 });
const now = new Date().toISOString();
getDatabase()
.prepare(
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, ?, 'unused', 'barkeeper', ?, ?, ?)`
)
.run(actor.id, actor.email, actor.email, actor.displayName, now, now, now);
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
function finalizedShow() {
const created = createShow({
title: 'Freigegebene Sendung',
startsAtUtc: null,
templateId: null
});
const section = addShowSection(created.id, created.revision, {
name: 'Freigabeblock',
categoryId: null,
explicitDurationSeconds: null
});
const usage = addPieceUsage(created.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const reserveUsage = addPieceUsage(created.id, usage.revision, {
pieceId: 'piece-console',
sectionId: null
});
const database = getDatabase();
database.prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(usage.id);
database
.prepare(
`INSERT INTO show_live_states
(show_id, revision, started_at_utc, ended_at_utc, presentation_revision, updated_at_utc)
VALUES (?, 0, ?, ?, 0, ?)`
)
.run(
created.id,
'2026-10-25T18:00:00.000Z',
'2026-10-25T19:00:00.000Z',
'2026-10-25T19:00:00.000Z'
);
finalizeShow({
showId: created.id,
commandId: `finalize-${created.id}`,
expectedShowRevision: reserveUsage.revision,
expectedLiveRevision: 0,
archivePieceIds: [],
actor
});
return { showId: created.id, usageId: usage.id, reserveUsageId: reserveUsage.id };
}
describe('show shares', () => {
it('shares an unfinished show and keeps its current plan visible', async () => {
const created = createShow({
title: 'Offene Freigabe',
startsAtUtc: null,
templateId: null
});
const shared = await createShare(actor, {
showId: created.id,
scope: 'rundown',
password: '',
expiresAtUtc: null
});
const initiallyEmpty = getSharedShow(shared.token);
expect(initiallyEmpty.status).toBe('authorized');
if (initiallyEmpty.status !== 'authorized') throw new Error('share unavailable');
expect(initiallyEmpty.share.isCompleted).toBe(false);
expect(initiallyEmpty.share.sections).toEqual([]);
const section = addShowSection(created.id, created.revision, {
name: 'Nachträglich geplant',
categoryId: null,
explicitDurationSeconds: null
});
addPieceUsage(created.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const updatedPlan = getSharedShow(shared.token);
expect(updatedPlan.status).toBe('authorized');
if (updatedPlan.status !== 'authorized') throw new Error('share unavailable');
expect(updatedPlan.share.sections[0]).toMatchObject({
name: 'Nachträglich geplant',
usages: [{ title: 'Die kleine Konsole mit dem großen Dreh' }]
});
await updateShare(actor, shared.id, {
scope: 'moderation',
passwordMode: 'replace',
password: 'neues-freigabe-passwort',
expiresAtUtc: null
});
expect(getSharedShow(shared.token).status).toBe('password');
const session = await authenticateShare(shared.token, 'neues-freigabe-passwort', '192.0.2.20');
expect(getSharedShow(shared.token, session.token).status).toBe('authorized');
const replacementToken = replaceShareToken(actor, shared.id, created.id);
expect(getSharedShow(shared.token, session.token).status).toBe('unavailable');
expect(getSharedShow(replacementToken).status).toBe('password');
const replacementSession = await authenticateShare(
replacementToken,
'neues-freigabe-passwort',
'192.0.2.20'
);
expect(getSharedShow(replacementToken, replacementSession.token).status).toBe('authorized');
deleteShare(actor, shared.id, created.id);
expect(getSharedShow(replacementToken, replacementSession.token).status).toBe('unavailable');
});
it('projects exactly the selected information scope', async () => {
const prepared = finalizedShow();
const rundown = await createShare(actor, {
showId: prepared.showId,
scope: 'rundown',
password: '',
expiresAtUtc: null
});
const basic = getSharedShow(rundown.token);
expect(basic.status).toBe('authorized');
if (basic.status !== 'authorized') throw new Error('share unavailable');
const basicUsage = basic.share.sections[0].usages[0];
expect(basicUsage).not.toHaveProperty('moderationHtml');
expect(basicUsage).not.toHaveProperty('researchHtml');
expect(JSON.stringify(basic)).not.toContain('originalPieceId');
expect(JSON.stringify(basic)).not.toContain(prepared.showId);
getDatabase()
.prepare(
`INSERT INTO usage_tool_snapshots
(id, usage_id, original_tool_id, name, start_url, work_url, note, position)
VALUES ('private-share-tool', ?, NULL, 'Schnittwerkzeug',
'https://private.example/start', 'https://private.example/work?secret=1',
'Freigegebene Werkzeugnotiz', 0)`
)
.run(prepared.usageId);
const research = await createShare(actor, {
showId: prepared.showId,
scope: 'research',
password: '',
expiresAtUtc: null
});
const full = getSharedShow(research.token);
expect(full.status).toBe('authorized');
if (full.status !== 'authorized') throw new Error('share unavailable');
expect(full.share.sections[0].usages[0]).toHaveProperty('moderationHtml');
expect(full.share.sections[0].usages[0]).toHaveProperty('researchHtml');
expect(full.share.sections[0].usages[0]).not.toHaveProperty('ownNotesHtml');
expect(JSON.stringify(full)).not.toContain('private.example');
expect(JSON.stringify(full)).toContain('Freigegebene Werkzeugnotiz');
});
it('enforces password, expiry and revocation for page and direct media access', async () => {
const prepared = finalizedShow();
const database = getDatabase();
mkdirSync(process.env.SENDEWERK_UPLOAD_DIR!, { recursive: true });
writeFileSync(path.join(process.env.SENDEWERK_UPLOAD_DIR!, 'shared.png'), Buffer.from('image'));
database
.prepare(
`INSERT INTO piece_media
(id, piece_id, storage_name, original_name, mime_type, byte_size, alt_text, position, created_at_utc)
VALUES ('share-media', 'piece-console', 'shared.png', 'shared.png', 'image/png', 5, 'Freigegeben', 99, ?)`
)
.run(new Date().toISOString());
database
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES ('share-snapshot', ?, 'share-media', 'Freigegeben', 99, 1)`
)
.run(prepared.usageId);
database
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES ('reserve-share-snapshot', ?, 'share-media', 'Private Reserve', 99, 1)`
)
.run(prepared.reserveUsageId);
const created = await createShare(actor, {
showId: prepared.showId,
scope: 'research',
password: 'ein-sicheres-passwort',
expiresAtUtc: new Date(Date.now() + 60_000).toISOString()
});
expect(getSharedShow(created.token).status).toBe('password');
expect(getSharedShow(created.token)).not.toHaveProperty('share');
expect(await getSharedMedia(created.token, undefined, 'share-snapshot')).toBeNull();
await expect(authenticateShare(created.token, 'falsch', '127.0.0.1')).rejects.toThrow(
'INVALID_PASSWORD'
);
const session = await authenticateShare(created.token, 'ein-sicheres-passwort', '127.0.0.1');
expect(getSharedShow(created.token, session.token).status).toBe('authorized');
expect((await getSharedMedia(created.token, session.token, 'share-snapshot'))?.byteSize).toBe(
5
);
expect(await getSharedMedia(created.token, session.token, 'foreign-media')).toBeNull();
expect(await getSharedMedia(created.token, session.token, 'reserve-share-snapshot')).toBeNull();
revokeShare(actor, created.id);
expect(getSharedShow(created.token, session.token).status).toBe('unavailable');
expect(await getSharedMedia(created.token, session.token, 'share-snapshot')).toBeNull();
const expired = await createShare(actor, {
showId: prepared.showId,
scope: 'rundown',
password: '',
expiresAtUtc: new Date(Date.now() + 60_000).toISOString()
});
database
.prepare('UPDATE show_shares SET expires_at_utc = ? WHERE id = ?')
.run('2000-01-01T00:00:00.000Z', expired.id);
expect(getSharedShow(expired.token).status).toBe('unavailable');
expect(await getSharedMedia(expired.token, undefined, 'share-snapshot')).toBeNull();
});
it('limits repeated password attempts per share', async () => {
const prepared = finalizedShow();
const created = await createShare(actor, {
showId: prepared.showId,
scope: 'rundown',
password: 'noch-ein-sicheres-passwort',
expiresAtUtc: null
});
for (let attempt = 0; attempt < 5; attempt += 1) {
await expect(authenticateShare(created.token, 'falsch', '192.0.2.10')).rejects.toThrow(
'INVALID_PASSWORD'
);
}
await expect(
authenticateShare(created.token, 'noch-ein-sicheres-passwort', '192.0.2.10')
).rejects.toThrow('RATE_LIMITED');
});
});
+451
View File
@@ -0,0 +1,451 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import {
createToken,
hashPassword,
hashToken,
validPassword,
verifyPassword,
type AuthUser
} from './auth.js';
import { getServerPaths } from './config.js';
import { getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js';
import type { ShowStatus } from './shows.js';
export const SHARE_SESSION_COOKIE = 'sendewerk_share_session';
export const SHARE_SCOPES = ['rundown', 'moderation', 'research'] as const;
export type ShareScope = (typeof SHARE_SCOPES)[number];
const PASSWORD_ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
const SHARE_PASSWORD_ATTEMPT_LIMIT = 5;
const ADDRESS_PASSWORD_ATTEMPT_LIMIT = 20;
type ShareRow = {
id: string;
show_id: string;
token_hash: string;
scope: ShareScope;
password_hash: string | null;
expires_at_utc: string | null;
revoked_at_utc: string | null;
created_at_utc: string;
};
export type ShareListItem = {
id: string;
showId: string;
showTitle: string;
showStatus: ShowStatus;
showStartsAtUtc: string | null;
scope: ShareScope;
isPasswordProtected: boolean;
expiresAtUtc: string | null;
revokedAtUtc: string | null;
createdAtUtc: string;
};
function requireBarkeeperActor(actor: AuthUser): void {
if (actor.role !== 'barkeeper') throw new Error('FORBIDDEN');
}
function validExpiry(value: string | null): string | null {
if (!value) return null;
const expiry = new Date(value);
if (Number.isNaN(expiry.valueOf()) || expiry.valueOf() <= Date.now()) {
throw new Error('INVALID_EXPIRY');
}
return expiry.toISOString();
}
function activeShare(rawToken: string): ShareRow | null {
if (!rawToken) return null;
const now = new Date().toISOString();
return (
(getDatabase()
.prepare(
`SELECT id, show_id, token_hash, scope, password_hash, expires_at_utc,
revoked_at_utc, created_at_utc FROM show_shares
WHERE token_hash = ? AND revoked_at_utc IS NULL
AND (expires_at_utc IS NULL OR expires_at_utc > ?)`
)
.get(hashToken(rawToken), now) as ShareRow | undefined) ?? null
);
}
export function listShares(actor: AuthUser, showId?: string): ShareListItem[] {
requireBarkeeperActor(actor);
const where = showId ? 'WHERE ss.show_id = ?' : '';
return (
getDatabase()
.prepare(
`SELECT ss.id, ss.show_id, sh.title AS show_title, sh.status AS show_status,
sh.starts_at_utc AS show_starts_at_utc, ss.scope, ss.password_hash,
ss.expires_at_utc, ss.revoked_at_utc, ss.created_at_utc
FROM show_shares ss JOIN shows sh ON sh.id = ss.show_id
${where}
ORDER BY ss.created_at_utc DESC`
)
.all(...(showId ? [showId] : [])) as {
id: string;
show_id: string;
show_title: string;
show_status: ShowStatus;
show_starts_at_utc: string | null;
scope: ShareScope;
password_hash: string | null;
expires_at_utc: string | null;
revoked_at_utc: string | null;
created_at_utc: string;
}[]
).map((share) => ({
id: share.id,
showId: share.show_id,
showTitle: share.show_title,
showStatus: share.show_status,
showStartsAtUtc: share.show_starts_at_utc,
scope: share.scope,
isPasswordProtected: Boolean(share.password_hash),
expiresAtUtc: share.expires_at_utc,
revokedAtUtc: share.revoked_at_utc,
createdAtUtc: share.created_at_utc
}));
}
export async function createShare(
actor: AuthUser,
input: { showId: string; scope: string; password: string; expiresAtUtc: string | null }
) {
requireBarkeeperActor(actor);
if (!SHARE_SCOPES.includes(input.scope as ShareScope)) throw new Error('INVALID_SCOPE');
if (input.password && !validPassword(input.password)) throw new Error('INVALID_PASSWORD');
const expiresAtUtc = validExpiry(input.expiresAtUtc);
const database = getDatabase();
if (!database.prepare('SELECT 1 FROM shows WHERE id = ?').get(input.showId)) {
throw new Error('NOT_FOUND');
}
const token = createToken();
const id = randomUUID();
const now = new Date().toISOString();
const passwordHash = input.password ? await hashPassword(input.password) : null;
database
.prepare(
`INSERT INTO show_shares
(id, show_id, token_hash, scope, password_hash, expires_at_utc,
created_by_user_id, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
input.showId,
hashToken(token),
input.scope,
passwordHash,
expiresAtUtc,
actor.id,
now
);
return { id, token };
}
export async function updateShare(
actor: AuthUser,
shareId: string,
input: {
scope: string;
passwordMode: string;
password: string;
expiresAtUtc: string | null;
},
expectedShowId?: string
): Promise<void> {
requireBarkeeperActor(actor);
if (!SHARE_SCOPES.includes(input.scope as ShareScope)) throw new Error('INVALID_SCOPE');
if (!['keep', 'remove', 'replace'].includes(input.passwordMode)) {
throw new Error('INVALID_PASSWORD_MODE');
}
if (input.passwordMode === 'replace' && !validPassword(input.password)) {
throw new Error('INVALID_PASSWORD');
}
const expiresAtUtc = validExpiry(input.expiresAtUtc);
const existing = getDatabase()
.prepare(
'SELECT show_id, password_hash FROM show_shares WHERE id = ? AND revoked_at_utc IS NULL'
)
.get(shareId) as { show_id: string; password_hash: string | null } | undefined;
if (!existing || (expectedShowId && existing.show_id !== expectedShowId))
throw new Error('NOT_FOUND');
const passwordHash =
input.passwordMode === 'keep'
? existing.password_hash
: input.passwordMode === 'replace'
? await hashPassword(input.password)
: null;
const database = getDatabase();
database.transaction(() => {
const result = database
.prepare(
`UPDATE show_shares SET scope = ?, password_hash = ?, expires_at_utc = ?
WHERE id = ? AND revoked_at_utc IS NULL`
)
.run(input.scope, passwordHash, expiresAtUtc, shareId);
if (result.changes !== 1) throw new Error('NOT_FOUND');
database.prepare('DELETE FROM show_share_sessions WHERE share_id = ?').run(shareId);
})();
}
export function replaceShareToken(
actor: AuthUser,
shareId: string,
expectedShowId?: string
): string {
requireBarkeeperActor(actor);
const token = createToken();
const database = getDatabase();
database.transaction(() => {
const result = database
.prepare(
`UPDATE show_shares SET token_hash = ?
WHERE id = ? AND revoked_at_utc IS NULL${expectedShowId ? ' AND show_id = ?' : ''}`
)
.run(hashToken(token), ...(expectedShowId ? [shareId, expectedShowId] : [shareId]));
if (result.changes !== 1) throw new Error('NOT_FOUND');
database.prepare('DELETE FROM show_share_sessions WHERE share_id = ?').run(shareId);
})();
return token;
}
export function deleteShare(actor: AuthUser, shareId: string, expectedShowId?: string): void {
requireBarkeeperActor(actor);
const result = getDatabase()
.prepare(`DELETE FROM show_shares WHERE id = ?${expectedShowId ? ' AND show_id = ?' : ''}`)
.run(...(expectedShowId ? [shareId, expectedShowId] : [shareId]));
if (result.changes !== 1) throw new Error('NOT_FOUND');
}
export function revokeShare(actor: AuthUser, shareId: string): void {
requireBarkeeperActor(actor);
const database = getDatabase();
database.transaction(() => {
const result = database
.prepare('UPDATE show_shares SET revoked_at_utc = ? WHERE id = ? AND revoked_at_utc IS NULL')
.run(new Date().toISOString(), shareId);
if (result.changes !== 1) throw new Error('NOT_FOUND');
database.prepare('DELETE FROM show_share_sessions WHERE share_id = ?').run(shareId);
})();
}
export function shareAccessState(rawToken: string, sessionToken?: string) {
const share = activeShare(rawToken);
if (!share) return { status: 'unavailable' as const };
if (!share.password_hash) return { status: 'authorized' as const, share };
if (!sessionToken) return { status: 'password' as const };
const session = getDatabase()
.prepare(
`SELECT 1 FROM show_share_sessions WHERE token_hash = ? AND share_id = ?
AND expires_at_utc > ?`
)
.get(hashToken(sessionToken), share.id, new Date().toISOString());
return session ? { status: 'authorized' as const, share } : { status: 'password' as const };
}
export async function authenticateShare(rawToken: string, password: string, address: string) {
const share = activeShare(rawToken);
if (!share?.password_hash) throw new Error('SHARE_UNAVAILABLE');
const database = getDatabase();
const now = new Date();
const since = new Date(now.getTime() - PASSWORD_ATTEMPT_WINDOW_MS).toISOString();
const shareKey = hashToken(rawToken);
const addressKey = hashToken(address || 'unknown');
database.prepare('DELETE FROM share_password_attempts WHERE attempted_at_utc < ?').run(since);
const shareAttempts = database
.prepare(
`SELECT count(*) AS count FROM share_password_attempts
WHERE share_key = ? AND attempted_at_utc >= ?`
)
.get(shareKey, since) as { count: number };
const addressAttempts = database
.prepare(
`SELECT count(*) AS count FROM share_password_attempts
WHERE address_key = ? AND attempted_at_utc >= ?`
)
.get(addressKey, since) as { count: number };
if (
shareAttempts.count >= SHARE_PASSWORD_ATTEMPT_LIMIT ||
addressAttempts.count >= ADDRESS_PASSWORD_ATTEMPT_LIMIT
) {
throw new Error('RATE_LIMITED');
}
database
.prepare(
`INSERT INTO share_password_attempts
(id, share_key, address_key, attempted_at_utc) VALUES (?, ?, ?, ?)`
)
.run(randomUUID(), shareKey, addressKey, now.toISOString());
if (!(await verifyPassword(share.password_hash, password))) throw new Error('INVALID_PASSWORD');
const token = createToken();
const defaultExpiry = now.getTime() + 12 * 60 * 60 * 1000;
const shareExpiry = share.expires_at_utc
? new Date(share.expires_at_utc).getTime()
: defaultExpiry;
const expires = new Date(Math.min(defaultExpiry, shareExpiry));
database.prepare('DELETE FROM share_password_attempts WHERE share_key = ?').run(shareKey);
database
.prepare('DELETE FROM show_share_sessions WHERE expires_at_utc <= ?')
.run(now.toISOString());
database
.prepare(
`INSERT INTO show_share_sessions (token_hash, share_id, created_at_utc, expires_at_utc)
VALUES (?, ?, ?, ?)`
)
.run(hashToken(token), share.id, now.toISOString(), expires.toISOString());
return { token, expires };
}
export function getSharedShow(rawToken: string, sessionToken?: string) {
const access = shareAccessState(rawToken, sessionToken);
if (access.status !== 'authorized' || !access.share) return access;
const share = access.share;
const database = getDatabase();
const show = database
.prepare('SELECT title, starts_at_utc, status, completed_at_utc FROM shows WHERE id = ?')
.get(share.show_id) as {
title: string;
starts_at_utc: string | null;
status: ShowStatus;
completed_at_utc: string | null;
};
const sections = database
.prepare(
`SELECT s.id, s.name, s.category_name_snapshot, s.category_color_snapshot,
c.name AS category_name, c.color AS category_color,
s.explicit_duration_seconds, s.position FROM show_sections s
LEFT JOIN section_categories c ON c.id = s.category_id
WHERE s.show_id = ? AND s.is_reserve_bank = 0 ORDER BY s.position`
)
.all(share.show_id) as {
id: string;
name: string;
category_name_snapshot: string | null;
category_color_snapshot: string | null;
category_name: string | null;
category_color: string | null;
explicit_duration_seconds: number | null;
position: number;
}[];
const usageStatement = database.prepare(
`SELECT u.id, u.title_snapshot, u.output_title_snapshot, u.status, u.duration_seconds,
u.intro_snapshot, u.research_snapshot, u.moderation_snapshot, u.interaction_snapshot,
u.transition_snapshot, u.own_notes,
COALESCE((SELECT actual_duration_seconds FROM piece_usage_history WHERE usage_id = u.id), 0)
AS actual_duration_seconds
FROM piece_usages u WHERE u.show_section_id = ? ORDER BY u.position`
);
const sourceStatement = database.prepare(
'SELECT label, url, timecode_seconds FROM usage_source_snapshots WHERE usage_id = ? ORDER BY position'
);
const toolStatement = database.prepare(
`SELECT name, start_url, work_url, note FROM usage_tool_snapshots
WHERE usage_id = ? ORDER BY position`
);
const mediaStatement = database.prepare(
`SELECT id, alt_text FROM usage_media_snapshots
WHERE usage_id = ? AND is_presentation = 1`
);
const includesModeration = share.scope === 'moderation' || share.scope === 'research';
const includesResearch = share.scope === 'research';
return {
status: 'authorized' as const,
share: {
scope: share.scope,
title: show.title,
startsAtUtc: show.starts_at_utc,
completedAtUtc: show.completed_at_utc,
isCompleted: show.status === 'completed',
sections: sections.map((section) => {
const usages = usageStatement.all(section.id) as {
id: string;
title_snapshot: string;
output_title_snapshot: string;
status: string;
duration_seconds: number;
actual_duration_seconds: number;
intro_snapshot: string;
research_snapshot: string;
moderation_snapshot: string;
interaction_snapshot: string;
transition_snapshot: string;
own_notes: string;
}[];
return {
name: section.name,
category:
show.status === 'completed' ? section.category_name_snapshot : section.category_name,
categoryColor:
show.status === 'completed' ? section.category_color_snapshot : section.category_color,
explicitDurationSeconds: section.explicit_duration_seconds,
usages: usages.map((usage) => ({
title: usage.title_snapshot,
outputTitle: usage.output_title_snapshot,
status: usage.status,
durationSeconds: usage.duration_seconds,
actualDurationSeconds: usage.actual_duration_seconds,
media: mediaStatement.get(usage.id) as { id: string; alt_text: string } | undefined,
...(includesModeration
? {
introHtml: renderMarkdown(usage.intro_snapshot),
moderationHtml: renderMarkdown(usage.moderation_snapshot),
interactionHtml: renderMarkdown(usage.interaction_snapshot),
transitionHtml: renderMarkdown(usage.transition_snapshot)
}
: {}),
...(includesResearch
? {
researchHtml: renderMarkdown(usage.research_snapshot),
sources: sourceStatement.all(usage.id) as {
label: string;
url: string;
timecode_seconds: number | null;
}[],
tools: (
toolStatement.all(usage.id) as {
name: string;
start_url: string;
work_url: string;
note: string;
}[]
).map((tool) => ({ name: tool.name, note: tool.note }))
}
: {})
}))
};
})
}
};
}
export async function getSharedMedia(
rawToken: string,
sessionToken: string | undefined,
snapshotMediaId: string
) {
const access = shareAccessState(rawToken, sessionToken);
if (access.status !== 'authorized' || !access.share) return null;
const row = getDatabase()
.prepare(
`SELECT pm.storage_name, pm.mime_type, pm.byte_size
FROM usage_media_snapshots ums
JOIN piece_usages u ON u.id = ums.usage_id
JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media pm ON pm.id = ums.original_media_id
WHERE ums.id = ? AND ums.is_presentation = 1 AND s.show_id = ?
AND s.is_reserve_bank = 0`
)
.get(snapshotMediaId, access.share.show_id) as
{ storage_name: string; mime_type: string; byte_size: number } | undefined;
if (!row) return null;
return {
data: await readFile(path.join(getServerPaths().uploads, row.storage_name)),
mimeType: row.mime_type,
byteSize: row.byte_size
};
}
+69 -1
View File
@@ -13,6 +13,7 @@ import {
duplicatePieceUsage, duplicatePieceUsage,
getPieceUsageContexts, getPieceUsageContexts,
getShow, getShow,
listPreparationUsages,
listShowTemplates, listShowTemplates,
movePieceUsage, movePieceUsage,
moveShowSection, moveShowSection,
@@ -60,12 +61,57 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
presentationImageUrl: edited.presentationImageUrl, presentationImageUrl: edited.presentationImageUrl,
presentationSiteName: edited.presentationSiteName, presentationSiteName: edited.presentationSiteName,
presentationMediaId: edited.presentationMediaId, presentationMediaId: edited.presentationMediaId,
preparationStatus: edited.preparationStatus,
preparationEffort: edited.preparationEffort,
ownNotes: edited.ownNotes, ownNotes: edited.ownNotes,
durationSeconds: edited.durationSeconds durationSeconds: edited.durationSeconds
}; };
} }
describe('show builder data layer', () => { describe('show builder data layer', () => {
it('tracks preparation on the concrete usage independently from its original', () => {
const database = getDatabase();
database
.prepare(
"UPDATE pieces SET status = 'preparing', preparation_effort = 'high' WHERE id = 'piece-console'"
)
.run();
database.prepare("UPDATE pieces SET status = 'preparing' WHERE id = 'piece-sounds'").run();
const show = getShow('show-october')!;
const planned = addPieceUsage(show.id, show.revision, {
pieceId: 'piece-console',
sectionId: show.sections[0].id
});
database
.prepare(
"UPDATE pieces SET status = 'ready', preparation_effort = 'low' WHERE id = 'piece-console'"
)
.run();
const usages = listPreparationUsages();
expect(usages.map((usage) => usage.usageId)).toEqual([planned.id]);
expect(usages[0]).toMatchObject({
showId: 'show-october',
showTitle: 'Herbstsendung: Kleine Welten',
sectionName: show.sections[0].name,
title: 'Die kleine Konsole mit dem großen Dreh',
preparationEffort: 'high',
topics: [{ name: 'Gaming' }]
});
expect(usages.some((usage) => usage.title === 'Drei Sounds, ein Spiel')).toBe(false);
const plannedUsage = getShow(show.id)!.sections[0].usages.find(
(usage) => usage.id === planned.id
)!;
updatePieceUsage(
show.id,
planned.id,
planned.revision,
editedUsage(plannedUsage, { preparationStatus: 'ready' })
);
expect(listPreparationUsages()).toEqual([]);
});
it('creates a fundus original and usage atomically and idempotently', () => { it('creates a fundus original and usage atomically and idempotently', () => {
const created = createShow({ title: 'Direkte Planung', startsAtUtc: null, templateId: null }); const created = createShow({ title: 'Direkte Planung', startsAtUtc: null, templateId: null });
const section = addShowSection(created.id, created.revision, { const section = addShowSection(created.id, created.revision, {
@@ -253,7 +299,29 @@ describe('show builder data layer', () => {
getDatabase() getDatabase()
.prepare("UPDATE piece_usages SET status = 'skipped' WHERE id = ?") .prepare("UPDATE piece_usages SET status = 'skipped' WHERE id = ?")
.run(skipped.id); .run(skipped.id);
getDatabase().prepare("UPDATE shows SET status = 'completed' WHERE id = ?").run(completed.id); getDatabase()
.prepare(
"UPDATE shows SET status = 'completed', completed_at_utc = starts_at_utc WHERE id = ?"
)
.run(completed.id);
getDatabase()
.prepare(
`INSERT INTO show_finalizations
(show_id, command_id, source_revision, finalized_at_utc)
SELECT id, 'test-finalization', revision, starts_at_utc FROM shows WHERE id = ?`
)
.run(completed.id);
getDatabase()
.prepare(
`INSERT INTO piece_usage_history
(usage_id, show_id, original_piece_id, show_title_snapshot, usage_title_snapshot,
used_at_utc, actual_duration_seconds)
SELECT u.id, sh.id, u.original_piece_id, sh.title, u.title_snapshot,
sh.starts_at_utc, 0 FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id WHERE u.id = ?`
)
.run(done.id);
const active = createShow({ title: 'Aktuelle Planung', startsAtUtc: null, templateId: null }); const active = createShow({ title: 'Aktuelle Planung', startsAtUtc: null, templateId: null });
const activeSection = addShowSection(active.id, active.revision, { const activeSection = addShowSection(active.id, active.revision, {
+380 -59
View File
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { createPiece } from './content.js'; import { createPiece } from './content.js';
import { getDatabase } from './database.js'; import { getDatabase } from './database.js';
import { detectVideoProvider, type PresentationMode } from './presentation.js'; import { detectVideoProvider, type PresentationMode, type VideoProvider } from './presentation.js';
export const SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const; export const SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const;
export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const; export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const;
@@ -10,6 +10,8 @@ export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const;
export type ShowStatus = (typeof SHOW_STATUSES)[number]; export type ShowStatus = (typeof SHOW_STATUSES)[number];
export type EditableShowStatus = Exclude<ShowStatus, 'completed'>; export type EditableShowStatus = Exclude<ShowStatus, 'completed'>;
export type UsageStatus = (typeof USAGE_STATUSES)[number]; export type UsageStatus = (typeof USAGE_STATUSES)[number];
export type UsagePreparationStatus = 'preparing' | 'ready';
export type PreparationEffort = 'low' | 'medium' | 'high' | null;
export type ShowListItem = { export type ShowListItem = {
id: string; id: string;
@@ -21,12 +23,24 @@ export type ShowListItem = {
sectionCount: number; sectionCount: number;
}; };
export type PreparationUsage = {
usageId: string;
showId: string;
showTitle: string;
showStartsAtUtc: string | null;
sectionName: string;
title: string;
preparationEffort: PreparationEffort;
topics: UsageTopic[];
};
export type ShowTemplateSection = { export type ShowTemplateSection = {
id: string; id: string;
name: string; name: string;
categoryId: string | null; categoryId: string | null;
position: number; position: number;
explicitDurationSeconds: number | null; explicitDurationSeconds: number | null;
twitchTags: string[];
}; };
export type ShowTemplate = { export type ShowTemplate = {
@@ -41,6 +55,8 @@ export type PieceUsage = {
originalPieceId: string | null; originalPieceId: string | null;
position: number; position: number;
status: UsageStatus; status: UsageStatus;
preparationStatus: UsagePreparationStatus;
preparationEffort: PreparationEffort;
durationSeconds: number; durationSeconds: number;
title: string; title: string;
outputTitle: string; outputTitle: string;
@@ -58,7 +74,7 @@ export type PieceUsage = {
presentationDescription: string; presentationDescription: string;
presentationImageUrl: string; presentationImageUrl: string;
presentationSiteName: string; presentationSiteName: string;
presentationProvider: string; presentationProvider: VideoProvider | '';
presentationProviderId: string; presentationProviderId: string;
ownNotes: string; ownNotes: string;
sources: { id: string; label: string; url: string; timecodeSeconds: number | null }[]; sources: { id: string; label: string; url: string; timecodeSeconds: number | null }[];
@@ -106,6 +122,7 @@ export type ShowSection = {
categoryColor: string | null; categoryColor: string | null;
position: number; position: number;
explicitDurationSeconds: number | null; explicitDurationSeconds: number | null;
twitchTags: string[];
durationSeconds: number; durationSeconds: number;
usages: PieceUsage[]; usages: PieceUsage[];
}; };
@@ -120,6 +137,7 @@ export type Show = {
revision: number; revision: number;
createdAtUtc: string; createdAtUtc: string;
updatedAtUtc: string; updatedAtUtc: string;
completedAtUtc: string | null;
sections: ShowSection[]; sections: ShowSection[];
reserve: PieceUsage[]; reserve: PieceUsage[];
totalDurationSeconds: number; totalDurationSeconds: number;
@@ -143,6 +161,7 @@ export type ShowSectionInput = {
name: string; name: string;
categoryId: string | null; categoryId: string | null;
explicitDurationSeconds: number | null; explicitDurationSeconds: number | null;
twitchTags?: string[];
position?: number; position?: number;
}; };
@@ -171,6 +190,8 @@ export type PieceUsageUpdate = {
presentationSiteName: string; presentationSiteName: string;
ownNotes: string; ownNotes: string;
durationSeconds: number; durationSeconds: number;
preparationStatus?: UsagePreparationStatus;
preparationEffort?: PreparationEffort;
tools?: { id: string; workUrl: string; note: string }[]; tools?: { id: string; workUrl: string; note: string }[];
presentationMediaId?: string | null; presentationMediaId?: string | null;
}; };
@@ -193,6 +214,7 @@ type ShowRow = {
revision: number; revision: number;
created_at_utc: string; created_at_utc: string;
updated_at_utc: string; updated_at_utc: string;
completed_at_utc: string | null;
}; };
type SectionRow = { type SectionRow = {
@@ -201,8 +223,11 @@ type SectionRow = {
category_id: string | null; category_id: string | null;
category: string | null; category: string | null;
category_color: string | null; category_color: string | null;
category_name_snapshot: string | null;
category_color_snapshot: string | null;
position: number; position: number;
explicit_duration_seconds: number | null; explicit_duration_seconds: number | null;
twitch_tags: string;
is_reserve_bank: number; is_reserve_bank: number;
}; };
@@ -212,6 +237,8 @@ type UsageRow = {
original_piece_id: string | null; original_piece_id: string | null;
position: number; position: number;
status: UsageStatus; status: UsageStatus;
preparation_status_snapshot: UsagePreparationStatus;
preparation_effort_snapshot: PreparationEffort;
duration_seconds: number; duration_seconds: number;
title_snapshot: string; title_snapshot: string;
output_title_snapshot: string; output_title_snapshot: string;
@@ -222,6 +249,7 @@ type UsageRow = {
transition_snapshot: string; transition_snapshot: string;
presentation_snapshot: string; presentation_snapshot: string;
presentation_mode_snapshot: PresentationMode; presentation_mode_snapshot: PresentationMode;
presentation_image_only_snapshot: number;
presentation_source_label_snapshot: string; presentation_source_label_snapshot: string;
presentation_source_url_snapshot: string; presentation_source_url_snapshot: string;
presentation_source_timecode_snapshot: number | null; presentation_source_timecode_snapshot: number | null;
@@ -229,7 +257,7 @@ type UsageRow = {
presentation_description_snapshot: string; presentation_description_snapshot: string;
presentation_image_url_snapshot: string; presentation_image_url_snapshot: string;
presentation_site_name_snapshot: string; presentation_site_name_snapshot: string;
presentation_provider_snapshot: string; presentation_provider_snapshot: VideoProvider | '';
presentation_provider_id_snapshot: string; presentation_provider_id_snapshot: string;
own_notes: string; own_notes: string;
}; };
@@ -250,6 +278,14 @@ function validDuration(value: number | null): number | null {
return value; return value;
} }
function validTwitchTags(values: string[]) {
const tags = [...new Set(values.map((tag) => tag.trim()).filter(Boolean))];
if (tags.length > 10 || tags.some((tag) => tag.length > 25 || tag.includes(','))) {
throw new Error('INVALID_TWITCH_TAGS');
}
return tags;
}
function validPosition(value: number, maximum: number): number { function validPosition(value: number, maximum: number): number {
if (!Number.isInteger(value) || value < 0) throw new Error('INVALID_POSITION'); if (!Number.isInteger(value) || value < 0) throw new Error('INVALID_POSITION');
return Math.min(value, maximum); return Math.min(value, maximum);
@@ -273,7 +309,7 @@ function validPresentation(
imageUrl: string, imageUrl: string,
siteName: string siteName: string
) { ) {
if (!['text', 'link', 'video'].includes(mode)) throw new Error('INVALID_PRESENTATION'); if (!['text', 'image', 'link', 'video'].includes(mode)) throw new Error('INVALID_PRESENTATION');
const cleanUrl = cleanText(sourceUrl, 2_000, 'presentation_source'); const cleanUrl = cleanText(sourceUrl, 2_000, 'presentation_source');
const cleanImageUrl = cleanText(imageUrl, 2_000, 'presentation_image'); const cleanImageUrl = cleanText(imageUrl, 2_000, 'presentation_image');
for (const value of [cleanUrl, cleanImageUrl]) { for (const value of [cleanUrl, cleanImageUrl]) {
@@ -285,7 +321,7 @@ function validPresentation(
throw new Error('INVALID_PRESENTATION'); throw new Error('INVALID_PRESENTATION');
} }
} }
if (mode !== 'text' && !cleanUrl) throw new Error('INVALID_PRESENTATION'); if ((mode === 'link' || mode === 'video') && !cleanUrl) throw new Error('INVALID_PRESENTATION');
if ( if (
timecodeSeconds !== null && timecodeSeconds !== null &&
(!Number.isInteger(timecodeSeconds) || timecodeSeconds < 0 || timecodeSeconds > 86_400) (!Number.isInteger(timecodeSeconds) || timecodeSeconds < 0 || timecodeSeconds > 86_400)
@@ -442,6 +478,8 @@ function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage {
originalPieceId: row.original_piece_id, originalPieceId: row.original_piece_id,
position: row.position, position: row.position,
status: row.status, status: row.status,
preparationStatus: row.preparation_status_snapshot,
preparationEffort: row.preparation_effort_snapshot,
durationSeconds: row.duration_seconds, durationSeconds: row.duration_seconds,
title: row.title_snapshot, title: row.title_snapshot,
outputTitle: row.output_title_snapshot, outputTitle: row.output_title_snapshot,
@@ -713,7 +751,11 @@ function copyUsageRelations(database: Database.Database, sourceUsageId: string,
.run(usageId, sourceUsageId); .run(usageId, sourceUsageId);
} }
export function listShows(): ShowListItem[] { export function listShows(statuses?: ShowStatus[]): ShowListItem[] {
const requestedStatuses = statuses?.filter((status) => SHOW_STATUSES.includes(status));
const where = requestedStatuses?.length
? `WHERE sh.status IN (${requestedStatuses.map(() => '?').join(', ')})`
: '';
return ( return (
getDatabase() getDatabase()
.prepare( .prepare(
@@ -724,10 +766,11 @@ export function listShows(): ShowListItem[] {
(SELECT sum(u.duration_seconds) FROM piece_usages u WHERE u.show_section_id = s.id)) (SELECT sum(u.duration_seconds) FROM piece_usages u WHERE u.show_section_id = s.id))
ELSE 0 END), 0) AS total_duration_seconds ELSE 0 END), 0) AS total_duration_seconds
FROM shows sh LEFT JOIN show_sections s ON s.show_id = sh.id FROM shows sh LEFT JOIN show_sections s ON s.show_id = sh.id
${where}
GROUP BY sh.id GROUP BY sh.id
ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc DESC, sh.created_at_utc DESC` ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc DESC, sh.created_at_utc DESC`
) )
.all() as { .all(...(requestedStatuses ?? [])) as {
id: string; id: string;
title: string; title: string;
starts_at_utc: string | null; starts_at_utc: string | null;
@@ -747,13 +790,57 @@ export function listShows(): ShowListItem[] {
})); }));
} }
export function listPreparationUsages(): PreparationUsage[] {
const database = getDatabase();
const rows = database
.prepare(
`SELECT u.id AS usage_id, sh.id AS show_id, sh.title AS show_title,
sh.starts_at_utc AS show_starts_at_utc, s.name AS section_name,
u.title_snapshot, u.preparation_effort_snapshot
FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id
WHERE sh.status IN ('draft', 'ready', 'live')
AND s.is_reserve_bank = 0 AND u.preparation_status_snapshot = 'preparing'
ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc,
CASE u.preparation_effort_snapshot
WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END,
s.position, u.position`
)
.all() as {
usage_id: string;
show_id: string;
show_title: string;
show_starts_at_utc: string | null;
section_name: string;
title_snapshot: string;
preparation_effort_snapshot: PreparationUsage['preparationEffort'];
}[];
const topics = database.prepare(
'SELECT name, icon_name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position'
);
return rows.map((row) => ({
usageId: row.usage_id,
showId: row.show_id,
showTitle: row.show_title,
showStartsAtUtc: row.show_starts_at_utc,
sectionName: row.section_name,
title: row.title_snapshot,
preparationEffort: row.preparation_effort_snapshot,
topics: (topics.all(row.usage_id) as { name: string; icon_name: string }[]).map((topic) => ({
name: topic.name,
iconName: topic.icon_name
}))
}));
}
export function listShowTemplates(): ShowTemplate[] { export function listShowTemplates(): ShowTemplate[] {
const database = getDatabase(); const database = getDatabase();
const templates = database const templates = database
.prepare('SELECT id, name, description FROM show_templates ORDER BY name') .prepare('SELECT id, name, description FROM show_templates ORDER BY name')
.all() as Omit<ShowTemplate, 'sections'>[]; .all() as Omit<ShowTemplate, 'sections'>[];
const sectionStatement = database.prepare( const sectionStatement = database.prepare(
`SELECT id, name, category_id, position, explicit_duration_seconds `SELECT id, name, category_id, position, explicit_duration_seconds, twitch_tags
FROM show_template_sections WHERE template_id = ? ORDER BY position` FROM show_template_sections WHERE template_id = ? ORDER BY position`
); );
return templates.map((template) => ({ return templates.map((template) => ({
@@ -765,13 +852,15 @@ export function listShowTemplates(): ShowTemplate[] {
category_id: string | null; category_id: string | null;
position: number; position: number;
explicit_duration_seconds: number | null; explicit_duration_seconds: number | null;
twitch_tags: string;
}[] }[]
).map((section) => ({ ).map((section) => ({
id: section.id, id: section.id,
name: section.name, name: section.name,
categoryId: section.category_id, categoryId: section.category_id,
position: section.position, position: section.position,
explicitDurationSeconds: section.explicit_duration_seconds explicitDurationSeconds: section.explicit_duration_seconds,
twitchTags: section.twitch_tags ? section.twitch_tags.split(',') : []
})) }))
})); }));
} }
@@ -781,14 +870,15 @@ export function getShow(id: string): Show | null {
const show = database const show = database
.prepare( .prepare(
`SELECT id, title, starts_at_utc, twitch_title, announcement_text, status, revision, `SELECT id, title, starts_at_utc, twitch_title, announcement_text, status, revision,
created_at_utc, updated_at_utc FROM shows WHERE id = ?` created_at_utc, updated_at_utc, completed_at_utc FROM shows WHERE id = ?`
) )
.get(id) as ShowRow | undefined; .get(id) as ShowRow | undefined;
if (!show) return null; if (!show) return null;
const sectionRows = database const sectionRows = database
.prepare( .prepare(
`SELECT s.id, s.name, s.category_id, c.name AS category, c.color AS category_color, `SELECT s.id, s.name, s.category_id, c.name AS category, c.color AS category_color,
s.position, s.explicit_duration_seconds, s.is_reserve_bank s.category_name_snapshot, s.category_color_snapshot,
s.position, s.explicit_duration_seconds, s.twitch_tags, s.is_reserve_bank
FROM show_sections s LEFT JOIN section_categories c ON c.id = s.category_id FROM show_sections s LEFT JOIN section_categories c ON c.id = s.category_id
WHERE s.show_id = ? ORDER BY s.position` WHERE s.show_id = ? ORDER BY s.position`
) )
@@ -796,9 +886,12 @@ export function getShow(id: string): Show | null {
const usageRows = database const usageRows = database
.prepare( .prepare(
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status, `SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
u.preparation_status_snapshot, u.preparation_effort_snapshot,
u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot, u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot,
u.moderation_snapshot, u.interaction_snapshot, u.transition_snapshot, u.presentation_snapshot, u.moderation_snapshot, u.interaction_snapshot, u.transition_snapshot, u.presentation_snapshot,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot, CASE WHEN u.presentation_image_only_snapshot = 1 THEN 'image' ELSE u.presentation_mode_snapshot END AS presentation_mode_snapshot,
u.presentation_image_only_snapshot,
u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot, u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot, u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
u.presentation_image_url_snapshot, u.presentation_site_name_snapshot, u.presentation_image_url_snapshot, u.presentation_site_name_snapshot,
@@ -821,10 +914,17 @@ export function getShow(id: string): Show | null {
id: row.id, id: row.id,
name: row.name, name: row.name,
categoryId: row.category_id, categoryId: row.category_id,
category: row.category, category:
categoryColor: row.category_color, show.status === 'completed'
? row.category_name_snapshot
: (row.category_name_snapshot ?? row.category),
categoryColor:
show.status === 'completed'
? row.category_color_snapshot
: (row.category_color_snapshot ?? row.category_color),
position: row.position, position: row.position,
explicitDurationSeconds: row.explicit_duration_seconds, explicitDurationSeconds: row.explicit_duration_seconds,
twitchTags: row.twitch_tags ? row.twitch_tags.split(',') : [],
durationSeconds: durationSeconds:
row.explicit_duration_seconds ?? row.explicit_duration_seconds ??
usages.reduce((total, usage) => total + usage.durationSeconds, 0), usages.reduce((total, usage) => total + usage.durationSeconds, 0),
@@ -842,6 +942,7 @@ export function getShow(id: string): Show | null {
revision: show.revision, revision: show.revision,
createdAtUtc: show.created_at_utc, createdAtUtc: show.created_at_utc,
updatedAtUtc: show.updated_at_utc, updatedAtUtc: show.updated_at_utc,
completedAtUtc: show.completed_at_utc,
sections, sections,
reserve: reserveRow ? (usagesBySection.get(reserveRow.id) ?? []) : [], reserve: reserveRow ? (usagesBySection.get(reserveRow.id) ?? []) : [],
totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0) totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0)
@@ -868,14 +969,9 @@ export function getPieceUsageContexts(
const database = getDatabase(); const database = getDatabase();
const history = database const history = database
.prepare( .prepare(
`SELECT u.id AS usage_id, u.original_piece_id, sh.id AS show_id, `SELECT usage_id, original_piece_id, show_id,
sh.title AS show_title, COALESCE(sh.starts_at_utc, sh.updated_at_utc) AS used_at_utc show_title_snapshot AS show_title, used_at_utc
FROM piece_usages u FROM piece_usage_history ORDER BY used_at_utc DESC, usage_id DESC`
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id
WHERE u.original_piece_id IS NOT NULL AND u.status = 'done'
AND sh.status = 'completed' AND s.is_reserve_bank = 0
ORDER BY used_at_utc DESC, u.id DESC`
) )
.all() as { .all() as {
usage_id: string; usage_id: string;
@@ -945,7 +1041,7 @@ export function createShow(input: CreateShowInput): CreatedMutationResult {
const templateSections = input.templateId const templateSections = input.templateId
? (database ? (database
.prepare( .prepare(
`SELECT name, category_id, position, explicit_duration_seconds `SELECT name, category_id, position, explicit_duration_seconds, twitch_tags
FROM show_template_sections WHERE template_id = ? ORDER BY position` FROM show_template_sections WHERE template_id = ? ORDER BY position`
) )
.all(input.templateId) as { .all(input.templateId) as {
@@ -953,11 +1049,12 @@ export function createShow(input: CreateShowInput): CreatedMutationResult {
category_id: string | null; category_id: string | null;
position: number; position: number;
explicit_duration_seconds: number | null; explicit_duration_seconds: number | null;
twitch_tags: string;
}[]) }[])
: []; : [];
const insertSection = database.prepare( const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position, `INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?)` explicit_duration_seconds, twitch_tags, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
); );
for (const section of templateSections) { for (const section of templateSections) {
insertSection.run( insertSection.run(
@@ -967,15 +1064,154 @@ export function createShow(input: CreateShowInput): CreatedMutationResult {
section.category_id, section.category_id,
section.position, section.position,
section.explicit_duration_seconds, section.explicit_duration_seconds,
section.twitch_tags,
0 0
); );
} }
insertSection.run(randomUUID(), id, 'Reservebank', null, templateSections.length, null, 1); insertSection.run(randomUUID(), id, 'Reservebank', null, templateSections.length, null, '', 1);
}); });
create(); create();
return { id, revision: 1, updatedAtUtc: now }; return { id, revision: 1, updatedAtUtc: now };
} }
export function duplicateCompletedShow(
sourceShowId: string,
commandId: string
): CreatedMutationResult {
if (!commandId || commandId.length > 200) throw new Error('INVALID_COMMAND');
const database = getDatabase();
return database.transaction(() => {
const duplicate = database
.prepare(
`SELECT created_show_id FROM show_duplication_commands
WHERE source_show_id = ? AND command_id = ?`
)
.get(sourceShowId, commandId) as { created_show_id: string } | undefined;
if (duplicate) {
const show = database
.prepare('SELECT revision, updated_at_utc FROM shows WHERE id = ?')
.get(duplicate.created_show_id) as { revision: number; updated_at_utc: string };
return {
id: duplicate.created_show_id,
revision: show.revision,
updatedAtUtc: show.updated_at_utc
};
}
const source = database
.prepare(
`SELECT sh.title, sh.twitch_title, sh.announcement_text
FROM shows sh JOIN show_finalizations f ON f.show_id = sh.id WHERE sh.id = ?`
)
.get(sourceShowId) as
{ title: string; twitch_title: string; announcement_text: string } | undefined;
if (!source) throw new Error('NOT_FOUND');
const id = randomUUID();
const now = new Date().toISOString();
const title = `${source.title} (Kopie)`.slice(0, 200);
database
.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text,
status, revision, created_at_utc, updated_at_utc)
VALUES (?, ?, NULL, ?, ?, 'draft', 1, ?, ?)`
)
.run(id, title, source.twitch_title, source.announcement_text, now, now);
const sections = database
.prepare(
`SELECT id, name, category_id, category_name_snapshot, category_color_snapshot,
position, explicit_duration_seconds, twitch_tags, is_reserve_bank
FROM show_sections WHERE show_id = ? ORDER BY position`
)
.all(sourceShowId) as {
id: string;
name: string;
category_id: string | null;
category_name_snapshot: string | null;
category_color_snapshot: string | null;
position: number;
explicit_duration_seconds: number | null;
twitch_tags: string;
is_reserve_bank: number;
}[];
const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, category_name_snapshot,
category_color_snapshot, position, explicit_duration_seconds, twitch_tags, is_reserve_bank)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
const usages = database.prepare(
'SELECT * FROM piece_usages WHERE show_section_id = ? ORDER BY position'
);
const insertUsage = database.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_image_only_snapshot,
preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const section of sections) {
const sectionId = randomUUID();
insertSection.run(
sectionId,
id,
section.name,
section.category_id,
section.category_name_snapshot,
section.category_color_snapshot,
section.position,
section.explicit_duration_seconds,
section.twitch_tags,
section.is_reserve_bank
);
for (const usage of usages.all(section.id) as (UsageRow & { is_reserve: number })[]) {
const usageId = randomUUID();
insertUsage.run(
usageId,
sectionId,
usage.original_piece_id,
usage.position,
usage.duration_seconds,
usage.title_snapshot,
usage.output_title_snapshot,
usage.intro_snapshot,
usage.research_snapshot,
usage.moderation_snapshot,
usage.interaction_snapshot,
usage.transition_snapshot,
usage.presentation_snapshot,
usage.presentation_mode_snapshot,
usage.presentation_source_label_snapshot,
usage.presentation_source_url_snapshot,
usage.presentation_source_timecode_snapshot,
usage.presentation_title_meta_snapshot,
usage.presentation_description_snapshot,
usage.presentation_image_url_snapshot,
usage.presentation_site_name_snapshot,
usage.presentation_provider_snapshot,
usage.presentation_provider_id_snapshot,
usage.presentation_image_only_snapshot,
usage.preparation_status_snapshot,
usage.preparation_effort_snapshot,
usage.own_notes,
usage.is_reserve
);
copyUsageRelations(database, usage.id, usageId);
}
}
database
.prepare(
`INSERT INTO show_duplication_commands
(source_show_id, command_id, created_show_id, created_at_utc) VALUES (?, ?, ?, ?)`
)
.run(sourceShowId, commandId, id, now);
return { id, revision: 1, updatedAtUtc: now };
})();
}
export function saveShowMetadata( export function saveShowMetadata(
showId: string, showId: string,
expectedRevision: number, expectedRevision: number,
@@ -1006,6 +1242,7 @@ export function addShowSection(
): CreatedMutationResult { ): CreatedMutationResult {
const name = cleanText(input.name, 200, 'section_name', true); const name = cleanText(input.name, 200, 'section_name', true);
const duration = validDuration(input.explicitDurationSeconds); const duration = validDuration(input.explicitDurationSeconds);
const twitchTags = validTwitchTags(input.twitchTags ?? []);
const database = getDatabase(); const database = getDatabase();
requireCategory(database, input.categoryId); requireCategory(database, input.categoryId);
return database.transaction(() => { return database.transaction(() => {
@@ -1030,9 +1267,9 @@ export function addShowSection(
database database
.prepare( .prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position, `INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, 0)` explicit_duration_seconds, twitch_tags, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?, 0)`
) )
.run(id, showId, name, input.categoryId, temporaryPosition, duration); .run(id, showId, name, input.categoryId, temporaryPosition, duration, twitchTags.join(','));
visibleIds.splice(position, 0, id); visibleIds.splice(position, 0, id);
const reserveId = reserveSectionId(database, showId); const reserveId = reserveSectionId(database, showId);
normalizePositions(database, 'show_sections', [...visibleIds, reserveId]); normalizePositions(database, 'show_sections', [...visibleIds, reserveId]);
@@ -1048,6 +1285,7 @@ export function updateShowSection(
): MutationResult { ): MutationResult {
const name = cleanText(input.name, 200, 'section_name', true); const name = cleanText(input.name, 200, 'section_name', true);
const duration = validDuration(input.explicitDurationSeconds); const duration = validDuration(input.explicitDurationSeconds);
const twitchTags = validTwitchTags(input.twitchTags ?? []);
const database = getDatabase(); const database = getDatabase();
requireCategory(database, input.categoryId); requireCategory(database, input.categoryId);
return database.transaction(() => { return database.transaction(() => {
@@ -1055,10 +1293,11 @@ export function updateShowSection(
sectionBelongsToShow(database, showId, sectionId); sectionBelongsToShow(database, showId, sectionId);
database database
.prepare( .prepare(
`UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ? `UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ?, twitch_tags = ?,
category_name_snapshot = NULL, category_color_snapshot = NULL
WHERE id = ?` WHERE id = ?`
) )
.run(name, input.categoryId, duration, sectionId); .run(name, input.categoryId, duration, twitchTags.join(','), sectionId);
return result; return result;
})(); })();
} }
@@ -1130,12 +1369,14 @@ export function addPieceUsage(
const piece = database const piece = database
.prepare( .prepare(
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes, `SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes,
p.interaction_text, p.transition_text, p.presentation_text, p.status, p.presentation_mode, p.interaction_text, p.transition_text, p.presentation_text, p.status,
CASE WHEN p.presentation_image_only = 1 THEN 'image' ELSE p.presentation_mode END AS presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label, COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url, COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode, ps.timecode_seconds AS presentation_source_timecode,
p.presentation_title, p.presentation_description, p.presentation_image_url, p.presentation_title, p.presentation_description, p.presentation_image_url,
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id p.presentation_site_name, p.presentation_provider, p.presentation_provider_id,
p.preparation_effort
FROM pieces p LEFT JOIN piece_sources ps ON ps.piece_id = p.id FROM pieces p LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position WHERE p.id = ?` AND ps.position = p.presentation_source_position WHERE p.id = ?`
) )
@@ -1161,6 +1402,7 @@ export function addPieceUsage(
presentation_provider: string; presentation_provider: string;
presentation_provider_id: string; presentation_provider_id: string;
status: string; status: string;
preparation_effort: PreparationEffort;
} }
| undefined; | undefined;
if (!piece) throw new Error('NOT_FOUND'); if (!piece) throw new Error('NOT_FOUND');
@@ -1171,7 +1413,7 @@ export function addPieceUsage(
) { ) {
throw new Error('NOT_FOUND'); throw new Error('NOT_FOUND');
} }
if (piece.status !== 'ready') throw new Error('PIECE_NOT_READY'); if (!['preparing', 'ready'].includes(piece.status)) throw new Error('PIECE_NOT_READY');
const ids = ( const ids = (
database database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position') .prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
@@ -1184,12 +1426,13 @@ export function addPieceUsage(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, `INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot, moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot, presentation_mode_snapshot, presentation_image_only_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) presentation_provider_snapshot, presentation_provider_id_snapshot,
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)` preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
) )
.run( .run(
id, id,
@@ -1205,7 +1448,8 @@ export function addPieceUsage(
piece.interaction_text, piece.interaction_text,
piece.transition_text, piece.transition_text,
piece.presentation_text, piece.presentation_text,
piece.presentation_mode, piece.presentation_mode === 'image' ? 'text' : piece.presentation_mode,
piece.presentation_mode === 'image' ? 1 : 0,
piece.presentation_source_label, piece.presentation_source_label,
piece.presentation_source_url, piece.presentation_source_url,
piece.presentation_source_timecode, piece.presentation_source_timecode,
@@ -1215,6 +1459,8 @@ export function addPieceUsage(
piece.presentation_site_name, piece.presentation_site_name,
piece.presentation_provider, piece.presentation_provider,
piece.presentation_provider_id, piece.presentation_provider_id,
piece.status,
piece.preparation_effort,
input.sectionId === null ? 1 : 0 input.sectionId === null ? 1 : 0
); );
snapshotPieceRelations(database, id, input.pieceId); snapshotPieceRelations(database, id, input.pieceId);
@@ -1291,11 +1537,12 @@ export function duplicatePieceUsage(
.prepare( .prepare(
`SELECT original_piece_id, status, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, `SELECT original_piece_id, status, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot,
research_snapshot, moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot, research_snapshot, moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot, presentation_mode_snapshot, presentation_image_only_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_provider_snapshot, presentation_provider_id_snapshot,
preparation_status_snapshot, preparation_effort_snapshot,
own_notes, is_reserve FROM piece_usages WHERE id = ?` own_notes, is_reserve FROM piece_usages WHERE id = ?`
) )
.get(usageId) as Omit<UsageRow, 'id' | 'show_section_id' | 'position'> & { .get(usageId) as Omit<UsageRow, 'id' | 'show_section_id' | 'position'> & {
@@ -1314,12 +1561,13 @@ export function duplicatePieceUsage(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, `INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot, moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot, presentation_mode_snapshot, presentation_image_only_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) presentation_provider_snapshot, presentation_provider_id_snapshot,
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
) )
.run( .run(
id, id,
@@ -1336,6 +1584,7 @@ export function duplicatePieceUsage(
source.transition_snapshot, source.transition_snapshot,
source.presentation_snapshot, source.presentation_snapshot,
source.presentation_mode_snapshot, source.presentation_mode_snapshot,
source.presentation_image_only_snapshot,
source.presentation_source_label_snapshot, source.presentation_source_label_snapshot,
source.presentation_source_url_snapshot, source.presentation_source_url_snapshot,
source.presentation_source_timecode_snapshot, source.presentation_source_timecode_snapshot,
@@ -1345,6 +1594,8 @@ export function duplicatePieceUsage(
source.presentation_site_name_snapshot, source.presentation_site_name_snapshot,
source.presentation_provider_snapshot, source.presentation_provider_snapshot,
source.presentation_provider_id_snapshot, source.presentation_provider_id_snapshot,
source.preparation_status_snapshot,
source.preparation_effort_snapshot,
source.own_notes, source.own_notes,
source.is_reserve source.is_reserve
); );
@@ -1367,12 +1618,14 @@ export function refreshPieceUsageFromOriginal(
const source = database const source = database
.prepare( .prepare(
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, `SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text,
p.moderation_notes, p.interaction_text, p.transition_text, p.presentation_text, p.presentation_mode, p.moderation_notes, p.interaction_text, p.transition_text, p.presentation_text,
CASE WHEN p.presentation_image_only = 1 THEN 'image' ELSE p.presentation_mode END AS presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label, COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url, COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode, ps.timecode_seconds AS presentation_source_timecode,
p.presentation_title, p.presentation_description, p.presentation_image_url, p.presentation_title, p.presentation_description, p.presentation_image_url,
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id p.presentation_site_name, p.presentation_provider, p.presentation_provider_id,
p.status, p.preparation_effort
FROM piece_usages u JOIN pieces p ON p.id = u.original_piece_id FROM piece_usages u JOIN pieces p ON p.id = u.original_piece_id
LEFT JOIN piece_sources ps ON ps.piece_id = p.id LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position AND ps.position = p.presentation_source_position
@@ -1399,19 +1652,23 @@ export function refreshPieceUsageFromOriginal(
presentation_site_name: string; presentation_site_name: string;
presentation_provider: string; presentation_provider: string;
presentation_provider_id: string; presentation_provider_id: string;
status: string;
preparation_effort: PreparationEffort;
} }
| undefined; | undefined;
if (!source) throw new Error('ORIGINAL_NOT_FOUND'); if (!source) throw new Error('ORIGINAL_NOT_FOUND');
if (!['preparing', 'ready'].includes(source.status)) throw new Error('PIECE_NOT_READY');
database database
.prepare( .prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?, `UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
research_snapshot = ?, moderation_snapshot = ?, interaction_snapshot = ?, transition_snapshot = ?, research_snapshot = ?, moderation_snapshot = ?, interaction_snapshot = ?, transition_snapshot = ?,
presentation_snapshot = ?, presentation_mode_snapshot = ?, presentation_snapshot = ?, presentation_mode_snapshot = ?, presentation_image_only_snapshot = ?,
presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?, presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?,
presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?, presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
presentation_description_snapshot = ?, presentation_image_url_snapshot = ?, presentation_description_snapshot = ?, presentation_image_url_snapshot = ?,
presentation_site_name_snapshot = ?, presentation_provider_snapshot = ?, presentation_site_name_snapshot = ?, presentation_provider_snapshot = ?,
presentation_provider_id_snapshot = ? WHERE id = ?` presentation_provider_id_snapshot = ?, preparation_status_snapshot = ?,
preparation_effort_snapshot = ? WHERE id = ?`
) )
.run( .run(
source.title, source.title,
@@ -1423,7 +1680,8 @@ export function refreshPieceUsageFromOriginal(
source.interaction_text, source.interaction_text,
source.transition_text, source.transition_text,
source.presentation_text, source.presentation_text,
source.presentation_mode, source.presentation_mode === 'image' ? 'text' : source.presentation_mode,
source.presentation_mode === 'image' ? 1 : 0,
source.presentation_source_label, source.presentation_source_label,
source.presentation_source_url, source.presentation_source_url,
source.presentation_source_timecode, source.presentation_source_timecode,
@@ -1433,6 +1691,8 @@ export function refreshPieceUsageFromOriginal(
source.presentation_site_name, source.presentation_site_name,
source.presentation_provider, source.presentation_provider,
source.presentation_provider_id, source.presentation_provider_id,
source.status,
source.preparation_effort,
usageId usageId
); );
snapshotPieceRelations( snapshotPieceRelations(
@@ -1457,6 +1717,18 @@ export function updatePieceUsage(
const title = cleanText(input.title, 200, 'title', true); const title = cleanText(input.title, 200, 'title', true);
const outputTitle = cleanText(input.outputTitle, 300, 'output_title'); const outputTitle = cleanText(input.outputTitle, 300, 'output_title');
const duration = validDuration(input.durationSeconds)!; const duration = validDuration(input.durationSeconds)!;
if (
input.preparationStatus !== undefined &&
!['preparing', 'ready'].includes(input.preparationStatus)
) {
throw new Error('INVALID_PREPARATION_STATUS');
}
if (
input.preparationEffort !== undefined &&
![null, 'low', 'medium', 'high'].includes(input.preparationEffort)
) {
throw new Error('INVALID_PREPARATION_EFFORT');
}
const values = { const values = {
intro: cleanText(input.intro, 50_000, 'intro'), intro: cleanText(input.intro, 50_000, 'intro'),
research: cleanText(input.research, 50_000, 'research'), research: cleanText(input.research, 50_000, 'research'),
@@ -1467,7 +1739,7 @@ export function updatePieceUsage(
presentation: cleanText(input.presentation, 50_000, 'presentation'), presentation: cleanText(input.presentation, 50_000, 'presentation'),
ownNotes: cleanText(input.ownNotes, 50_000, 'notes') ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
}; };
const presentation = validPresentation( const submittedPresentation = validPresentation(
input.presentationMode, input.presentationMode,
input.presentationSourceLabel, input.presentationSourceLabel,
input.presentationSourceUrl, input.presentationSourceUrl,
@@ -1481,16 +1753,68 @@ export function updatePieceUsage(
return database.transaction(() => { return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision); const result = bumpShow(database, showId, expectedRevision);
usageSection(database, showId, usageId); usageSection(database, showId, usageId);
const currentPreparation = database
.prepare(
`SELECT preparation_status_snapshot, preparation_effort_snapshot
FROM piece_usages WHERE id = ?`
)
.get(usageId) as {
preparation_status_snapshot: UsagePreparationStatus;
preparation_effort_snapshot: PreparationEffort;
};
let presentation = submittedPresentation;
if (presentation.mode === 'link' || presentation.mode === 'video') {
const source = database
.prepare(
`SELECT label, url, timecode_seconds FROM usage_source_snapshots
WHERE usage_id = ? AND url = ?`
)
.get(usageId, presentation.sourceUrl) as
{ label: string; url: string; timecode_seconds: number | null } | undefined;
if (!source) throw new Error('INVALID_PRESENTATION');
presentation = validPresentation(
presentation.mode,
source.label,
source.url,
source.timecode_seconds,
presentation.title,
presentation.description,
presentation.imageUrl,
presentation.siteName
);
}
const selectedMediaId =
input.presentationMediaId === undefined
? ((
database
.prepare(
'SELECT id FROM usage_media_snapshots WHERE usage_id = ? AND is_presentation = 1'
)
.get(usageId) as { id: string } | undefined
)?.id ?? null)
: input.presentationMediaId;
if (
selectedMediaId !== null &&
!database
.prepare('SELECT 1 FROM usage_media_snapshots WHERE id = ? AND usage_id = ?')
.get(selectedMediaId, usageId)
) {
throw new Error('INVALID_PRESENTATION_MEDIA');
}
if (presentation.mode === 'image' && !selectedMediaId) {
throw new Error('INVALID_PRESENTATION_MEDIA');
}
database database
.prepare( .prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?, `UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
moderation_snapshot = ?, interaction_snapshot = COALESCE(?, interaction_snapshot), transition_snapshot = ?, presentation_snapshot = ?, moderation_snapshot = ?, interaction_snapshot = COALESCE(?, interaction_snapshot), transition_snapshot = ?, presentation_snapshot = ?,
presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?, presentation_mode_snapshot = ?, presentation_image_only_snapshot = ?, presentation_source_label_snapshot = ?,
presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?, presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?,
presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?, presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?,
presentation_image_url_snapshot = ?, presentation_site_name_snapshot = ?, presentation_image_url_snapshot = ?, presentation_site_name_snapshot = ?,
presentation_provider_snapshot = ?, presentation_provider_id_snapshot = ?, presentation_provider_snapshot = ?, presentation_provider_id_snapshot = ?,
own_notes = ?, duration_seconds = ? WHERE id = ?` own_notes = ?, duration_seconds = ?, preparation_status_snapshot = ?,
preparation_effort_snapshot = ? WHERE id = ?`
) )
.run( .run(
title, title,
@@ -1501,7 +1825,8 @@ export function updatePieceUsage(
values.interaction, values.interaction,
values.transition, values.transition,
values.presentation, values.presentation,
presentation.mode, presentation.mode === 'image' ? 'text' : presentation.mode,
presentation.mode === 'image' ? 1 : 0,
presentation.sourceLabel, presentation.sourceLabel,
presentation.sourceUrl, presentation.sourceUrl,
presentation.timecodeSeconds, presentation.timecodeSeconds,
@@ -1513,6 +1838,10 @@ export function updatePieceUsage(
presentation.providerId, presentation.providerId,
values.ownNotes, values.ownNotes,
duration, duration,
input.preparationStatus ?? currentPreparation.preparation_status_snapshot,
input.preparationEffort === undefined
? currentPreparation.preparation_effort_snapshot
: input.preparationEffort,
usageId usageId
); );
if (input.tools) { if (input.tools) {
@@ -1545,23 +1874,15 @@ export function updatePieceUsage(
} }
} }
if (input.presentationMediaId !== undefined) { if (input.presentationMediaId !== undefined) {
if (
input.presentationMediaId !== null &&
!database
.prepare('SELECT 1 FROM usage_media_snapshots WHERE id = ? AND usage_id = ?')
.get(input.presentationMediaId, usageId)
) {
throw new Error('INVALID_PRESENTATION_MEDIA');
}
database database
.prepare('UPDATE usage_media_snapshots SET is_presentation = 0 WHERE usage_id = ?') .prepare('UPDATE usage_media_snapshots SET is_presentation = 0 WHERE usage_id = ?')
.run(usageId); .run(usageId);
if (input.presentationMediaId !== null) { if (selectedMediaId !== null) {
database database
.prepare( .prepare(
'UPDATE usage_media_snapshots SET is_presentation = 1 WHERE id = ? AND usage_id = ?' 'UPDATE usage_media_snapshots SET is_presentation = 1 WHERE id = ? AND usage_id = ?'
) )
.run(input.presentationMediaId, usageId); .run(selectedMediaId, usageId);
} }
} }
return result; return result;
+97
View File
@@ -0,0 +1,97 @@
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 type { AuthUser } from './auth.js';
import { closeDatabase, getDatabase } from './database.js';
import {
authenticateTechnical,
createTechnicalAccess,
idempotentResponse,
revokeTechnicalAccess
} from './technical-access.js';
let directory: string;
let actor: AuthUser;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-api-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 });
const now = new Date().toISOString();
getDatabase()
.prepare(
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc) VALUES ('test-user', 'test@example.org',
'test@example.org', 'Test', 'not-used', 'barkeeper', ?, ?, ?)`
)
.run(now, now, now);
actor = {
id: 'test-user',
email: 'test@example.org',
displayName: 'Test',
role: 'barkeeper',
lockedAtUtc: null
};
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
function request(token: string, key?: string) {
return new Request('https://sendewerk.test/api/v1/pieces', {
headers: { Authorization: `Bearer ${token}`, ...(key ? { 'Idempotency-Key': key } : {}) }
});
}
describe('technical API access', () => {
it('enforces permissions and revocation without storing the raw token', () => {
const created = createTechnicalAccess(actor, 'Leser', 'read');
expect(authenticateTechnical(request(created.token))).toMatchObject({
name: 'Leser',
permissions: 'read'
});
expect(() => authenticateTechnical(request(created.token), true)).toThrow('API_FORBIDDEN');
expect(
getDatabase()
.prepare('SELECT token_hash FROM technical_accesses WHERE id = ?')
.get(created.id)
).not.toMatchObject({ token_hash: created.token });
revokeTechnicalAccess(actor, created.id);
expect(() => authenticateTechnical(request(created.token))).toThrow('API_UNAUTHORIZED');
});
it('replays an identical idempotent write and rejects a changed payload', () => {
const created = createTechnicalAccess(actor, 'Schreiber', 'read_write');
const technical = authenticateTechnical(request(created.token), true);
let calls = 0;
const first = idempotentResponse(
technical,
request(created.token, 'command-1'),
{ title: 'A' },
() => ({ status: 201, body: { calls: ++calls } })
);
const replay = idempotentResponse(
technical,
request(created.token, 'command-1'),
{ title: 'A' },
() => ({ status: 201, body: { calls: ++calls } })
);
expect(first.duplicate).toBe(false);
expect(replay).toMatchObject({ duplicate: true, body: { calls: 1 } });
expect(calls).toBe(1);
expect(() =>
idempotentResponse(technical, request(created.token, 'command-1'), { title: 'B' }, () => ({
status: 201,
body: {}
}))
).toThrow('API_IDEMPOTENCY_CONFLICT');
});
});
+151
View File
@@ -0,0 +1,151 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type { AuthUser } from './auth.js';
import { getDatabase } from './database.js';
export type TechnicalPermission = 'read' | 'read_write';
export type TechnicalActor = { id: string; name: string; permissions: TechnicalPermission };
function tokenHash(token: string) {
return createHash('sha256').update(token).digest('hex');
}
export function listTechnicalAccesses() {
return getDatabase()
.prepare(
`SELECT id, name, permissions, created_at_utc AS createdAtUtc,
last_used_at_utc AS lastUsedAtUtc, revoked_at_utc AS revokedAtUtc FROM technical_accesses ORDER BY name`
)
.all() as {
id: string;
name: string;
permissions: TechnicalPermission;
createdAtUtc: string;
lastUsedAtUtc: string | null;
revokedAtUtc: string | null;
}[];
}
export function createTechnicalAccess(actor: AuthUser, nameValue: string, permissions: string) {
const name = nameValue.trim();
if (!name || name.length > 120 || !['read', 'read_write'].includes(permissions))
throw new Error('INVALID_ACCESS');
const token = randomBytes(32).toString('base64url');
const id = randomUUID();
const now = new Date().toISOString();
getDatabase()
.prepare(
`INSERT INTO technical_accesses
(id, name, token_hash, permissions, created_by_user_id, created_at_utc) VALUES (?, ?, ?, ?, ?, ?)`
)
.run(id, name, tokenHash(token), permissions, actor.id, now);
auditTechnical(
{ id: actor.id, name: actor.displayName, permissions: 'read_write' },
'technical_access.create',
'technical_access',
id,
'user'
);
return { id, token };
}
export function revokeTechnicalAccess(actor: AuthUser, id: string) {
const result = getDatabase()
.prepare(
'UPDATE technical_accesses SET revoked_at_utc = ? WHERE id = ? AND revoked_at_utc IS NULL'
)
.run(new Date().toISOString(), id);
if (!result.changes) throw new Error('NOT_FOUND');
auditTechnical(
{ id: actor.id, name: actor.displayName, permissions: 'read_write' },
'technical_access.revoke',
'technical_access',
id,
'user'
);
}
export function authenticateTechnical(request: Request, write = false): TechnicalActor {
const authorization = request.headers.get('authorization') ?? '';
const token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : '';
if (!token) throw new Error('API_UNAUTHORIZED');
const row = getDatabase()
.prepare(
`SELECT id, name, permissions FROM technical_accesses
WHERE token_hash = ? AND revoked_at_utc IS NULL`
)
.get(tokenHash(token)) as TechnicalActor | undefined;
if (!row) throw new Error('API_UNAUTHORIZED');
if (write && row.permissions !== 'read_write') throw new Error('API_FORBIDDEN');
getDatabase()
.prepare('UPDATE technical_accesses SET last_used_at_utc = ? WHERE id = ?')
.run(new Date().toISOString(), row.id);
return row;
}
export function auditTechnical(
actor: TechnicalActor,
action: string,
targetType: string,
targetId: string,
actorType: 'technical' | 'user' = 'technical'
) {
getDatabase()
.prepare(
`INSERT INTO audit_events (id, actor_type, actor_id, action, target_type, target_id, created_at_utc)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(
randomUUID(),
actorType,
actor.id,
action.slice(0, 120),
targetType.slice(0, 80),
targetId.slice(0, 200),
new Date().toISOString()
);
}
export function idempotentResponse(
actor: TechnicalActor,
request: Request,
body: unknown,
operation: () => { status: number; body: unknown }
) {
const key = request.headers.get('idempotency-key')?.trim() ?? '';
if (!key || key.length > 200) throw new Error('API_IDEMPOTENCY_REQUIRED');
const hash = createHash('sha256')
.update(`${request.method} ${new URL(request.url).pathname}\n${JSON.stringify(body)}`)
.digest('hex');
const database = getDatabase();
return database.transaction(() => {
const existing = database
.prepare(
'SELECT request_hash, status_code, response_json FROM api_idempotency WHERE access_id = ? AND request_key = ?'
)
.get(actor.id, key) as
{ request_hash: string; status_code: number; response_json: string } | undefined;
if (existing) {
if (existing.request_hash !== hash) throw new Error('API_IDEMPOTENCY_CONFLICT');
return {
status: existing.status_code,
body: JSON.parse(existing.response_json),
duplicate: true
};
}
const result = operation();
database
.prepare(
`INSERT INTO api_idempotency
(access_id, request_key, request_hash, status_code, response_json, created_at_utc) VALUES (?, ?, ?, ?, ?, ?)`
)
.run(
actor.id,
key,
hash,
result.status,
JSON.stringify(result.body),
new Date().toISOString()
);
return { ...result, duplicate: false };
})();
}
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { berlinLocalToUtc } from './time.js';
describe('Berlin local time conversion', () => {
it('converts valid civil time and rejects normalized or nonexistent values', () => {
expect(berlinLocalToUtc('2026-10-25T20:00')).toBe('2026-10-25T19:00:00.000Z');
expect(berlinLocalToUtc('')).toBeNull();
expect(() => berlinLocalToUtc('2026-02-31T12:00')).toThrow('INVALID_TIMESTAMP');
expect(() => berlinLocalToUtc('2026-03-29T02:30', 'INVALID_EXPIRY')).toThrow('INVALID_EXPIRY');
expect(() => berlinLocalToUtc('2026-10-25T02:30', 'INVALID_EXPIRY')).toThrow('INVALID_EXPIRY');
});
});
+44
View File
@@ -0,0 +1,44 @@
export function berlinLocalToUtc(value: string, errorCode = 'INVALID_TIMESTAMP'): string | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value);
if (!match) throw new Error(errorCode);
const parts = match.slice(1).map(Number);
const localMilliseconds = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]);
let candidate = localMilliseconds;
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
});
const localParts = (date: Date) => {
const formatted = formatter.formatToParts(date);
const read = (type: Intl.DateTimeFormatPartTypes) =>
formatted.find((part) => part.type === type)?.value ?? '';
return {
key: `${read('year')}-${read('month')}-${read('day')}T${read('hour')}:${read('minute')}`,
milliseconds: Date.UTC(
Number(read('year')),
Number(read('month')) - 1,
Number(read('day')),
Number(read('hour')),
Number(read('minute'))
)
};
};
for (let attempt = 0; attempt < 2; attempt += 1) {
candidate += localMilliseconds - localParts(new Date(candidate)).milliseconds;
}
const result = new Date(candidate);
if (localParts(result).key !== value) throw new Error(errorCode);
if (
localParts(new Date(candidate - 60 * 60 * 1000)).key === value ||
localParts(new Date(candidate + 60 * 60 * 1000)).key === value
) {
throw new Error(errorCode);
}
return result.toISOString();
}
+5 -1
View File
@@ -1,7 +1,11 @@
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals, url }) => ({ export const load: LayoutServerLoad = ({ locals, url }) => ({
user: url.pathname.startsWith('/heinzlive/') ? null : locals.user, user:
url.pathname.startsWith('/heinzlive/') || url.pathname.startsWith('/freigabe/')
? null
: locals.user,
presentationMode: url.pathname.startsWith('/heinzlive/'), presentationMode: url.pathname.startsWith('/heinzlive/'),
guestMode: url.pathname.startsWith('/freigabe/'),
liveControllerMode: url.pathname === '/live' liveControllerMode: url.pathname === '/live'
}); });
+11
View File
@@ -34,6 +34,17 @@
{#if data.presentationMode} {#if data.presentationMode}
{@render children()} {@render children()}
{:else if data.guestMode}
<div class="guest-shell">
<header class="auth-header">
<span class="brand"
><span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span><span
>Sendewerk</span
></span
><ThemeToggle />
</header>
{@render children()}
</div>
{:else if data.user && data.liveControllerMode} {:else if data.user && data.liveControllerMode}
<main id="main-content" class="live-controller-shell">{@render children()}</main> <main id="main-content" class="live-controller-shell">{@render children()}</main>
{:else if data.user} {:else if data.user}
+105
View File
@@ -0,0 +1,105 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const id = { name: 'id', in: 'path', required: true, schema: { type: 'string' } };
const usageId = { name: 'usageId', in: 'path', required: true, schema: { type: 'string' } };
const idempotency = {
name: 'Idempotency-Key',
in: 'header',
required: true,
schema: { type: 'string' }
};
const ok = { '200': { description: 'Erfolgreich' } };
const jsonBody = (schema: Record<string, unknown>) => ({
required: true,
content: { 'application/json': { schema } }
});
export const GET: RequestHandler = () =>
json({
openapi: '3.1.0',
info: {
title: 'Sendewerk API',
version: '1.0.0',
description:
'Versionierte API für technische Redaktionszugänge. Schreibzugriffe benötigen einen Idempotency-Key.'
},
servers: [{ url: '/api/v1' }],
security: [{ bearerAuth: [] }],
components: {
securitySchemes: {
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'Sendewerk technical token' }
},
schemas: {
PieceWrite: {
type: 'object',
properties: {
title: { type: 'string' },
summary: { type: 'string' },
status: { enum: ['inbox', 'preparing', 'ready'] },
durationSeconds: { type: 'integer', minimum: 0 }
}
},
ShowWrite: {
type: 'object',
required: ['title'],
properties: {
title: { type: 'string' },
startsAtUtc: { type: ['string', 'null'], format: 'date-time' },
templateId: { type: ['string', 'null'] }
}
}
}
},
paths: {
'/pieces': {
get: { summary: 'Pieces lesen', responses: ok },
post: {
summary: 'Piece anlegen',
parameters: [idempotency],
requestBody: jsonBody({ $ref: '#/components/schemas/PieceWrite' }),
responses: { '201': { description: 'Angelegt' } }
}
},
'/pieces/{id}': {
get: { summary: 'Piece lesen', parameters: [id], responses: ok },
patch: {
summary: 'Piece ändern',
parameters: [id],
requestBody: jsonBody({
allOf: [
{ $ref: '#/components/schemas/PieceWrite' },
{ type: 'object', required: ['revision'] }
]
}),
responses: ok
}
},
'/shows': {
get: { summary: 'Sendungen lesen', responses: ok },
post: {
summary: 'Sendung anlegen',
parameters: [idempotency],
requestBody: jsonBody({ $ref: '#/components/schemas/ShowWrite' }),
responses: { '201': { description: 'Angelegt' } }
}
},
'/shows/{id}': { get: { summary: 'Sendung lesen', parameters: [id], responses: ok } },
'/shows/{id}/usages': {
post: {
summary: 'Piece einplanen',
parameters: [id, idempotency],
requestBody: jsonBody({ type: 'object', required: ['revision', 'pieceId'] }),
responses: { '201': { description: 'Eingeplant' } }
}
},
'/shows/{id}/usages/{usageId}': {
patch: {
summary: 'Einplanung sortieren',
parameters: [id, usageId, idempotency],
requestBody: jsonBody({ type: 'object', required: ['revision', 'position'] }),
responses: ok
}
}
}
});
+69
View File
@@ -0,0 +1,69 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure, pageInput } from '#lib/server/api-http.js';
import {
createPiece,
getPiece,
listPieces,
savePiece,
type PieceStatus
} from '#lib/server/content.js';
import {
auditTechnical,
authenticateTechnical,
idempotentResponse
} from '#lib/server/technical-access.js';
export const GET: RequestHandler = ({ request, url }) => {
try {
authenticateTechnical(request);
const { limit, offset } = pageInput(url);
const status = url.searchParams.get('status') as PieceStatus | null;
const statuses = status ? [status] : (['inbox', 'preparing', 'ready'] as PieceStatus[]);
const all = listPieces({ statuses, query: url.searchParams.get('q') ?? '' });
return json({
data: all.slice(offset, offset + limit),
pagination: { limit, offset, total: all.length }
});
} catch (cause) {
return apiFailure(cause);
}
};
export const POST: RequestHandler = async ({ request }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const result = idempotentResponse(actor, request, body, () => {
const id = createPiece('blank');
const piece = getPiece(id)!;
savePiece(id, piece.revision, {
...piece,
title: String(body.title ?? ''),
summary: String(body.summary ?? ''),
status: ['inbox', 'preparing', 'ready'].includes(String(body.status))
? (body.status as 'inbox' | 'preparing' | 'ready')
: 'preparing',
durationSeconds: Number(body.durationSeconds ?? 0),
sources: Array.isArray(body.sources)
? body.sources.map((source) => ({
label: String((source as Record<string, unknown>).label ?? ''),
url: String((source as Record<string, unknown>).url ?? ''),
timecodeSeconds:
(source as Record<string, unknown>).timecodeSeconds == null
? null
: Number((source as Record<string, unknown>).timecodeSeconds)
}))
: []
});
auditTechnical(actor, 'piece.create', 'piece', id);
return { status: 201, body: { data: getPiece(id) } };
});
return json(result.body, {
status: result.status,
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
});
} catch (cause) {
return apiFailure(cause);
}
};
+44
View File
@@ -0,0 +1,44 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure } from '#lib/server/api-http.js';
import { getPiece, savePiece } from '#lib/server/content.js';
import { auditTechnical, authenticateTechnical } from '#lib/server/technical-access.js';
export const GET: RequestHandler = ({ request, params }) => {
try {
authenticateTechnical(request);
const piece = getPiece(params.id);
if (!piece) throw new Error('NOT_FOUND');
return json({ data: piece });
} catch (cause) {
return apiFailure(cause);
}
};
export const PATCH: RequestHandler = async ({ request, params }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const piece = getPiece(params.id);
if (!piece) throw new Error('NOT_FOUND');
if (piece.status === 'archived') throw new Error('INVALID_STATUS');
if (Number(body.revision) !== piece.revision) throw new Error('CONFLICT');
const status =
body.status !== undefined
? (String(body.status) as 'inbox' | 'preparing' | 'ready')
: piece.status;
savePiece(params.id, piece.revision, {
...piece,
status,
...(body.title !== undefined ? { title: String(body.title) } : {}),
...(body.summary !== undefined ? { summary: String(body.summary) } : {}),
...(body.durationSeconds !== undefined
? { durationSeconds: Number(body.durationSeconds) }
: {})
});
auditTechnical(actor, 'piece.update', 'piece', params.id);
return json({ data: getPiece(params.id) });
} catch (cause) {
return apiFailure(cause);
}
};
+45
View File
@@ -0,0 +1,45 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure, pageInput } from '#lib/server/api-http.js';
import { createShow, getShow, listShows } from '#lib/server/shows.js';
import {
auditTechnical,
authenticateTechnical,
idempotentResponse
} from '#lib/server/technical-access.js';
export const GET: RequestHandler = ({ request, url }) => {
try {
authenticateTechnical(request);
const { limit, offset } = pageInput(url);
const all = listShows();
return json({
data: all.slice(offset, offset + limit),
pagination: { limit, offset, total: all.length }
});
} catch (cause) {
return apiFailure(cause);
}
};
export const POST: RequestHandler = async ({ request }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const result = idempotentResponse(actor, request, body, () => {
const created = createShow({
title: String(body.title ?? ''),
startsAtUtc: body.startsAtUtc ? String(body.startsAtUtc) : null,
templateId: body.templateId ? String(body.templateId) : null
});
auditTechnical(actor, 'show.create', 'show', created.id);
return { status: 201, body: { data: getShow(created.id) } };
});
return json(result.body, {
status: result.status,
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
});
} catch (cause) {
return apiFailure(cause);
}
};
+16
View File
@@ -0,0 +1,16 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiFailure } from '#lib/server/api-http.js';
import { getShow } from '#lib/server/shows.js';
import { authenticateTechnical } from '#lib/server/technical-access.js';
export const GET: RequestHandler = ({ request, params }) => {
try {
authenticateTechnical(request);
const show = getShow(params.id);
if (!show) throw new Error('NOT_FOUND');
return json({ data: show });
} catch (cause) {
return apiFailure(cause);
}
};
@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure } from '#lib/server/api-http.js';
import { addPieceUsage, getShow } from '#lib/server/shows.js';
import {
auditTechnical,
authenticateTechnical,
idempotentResponse
} from '#lib/server/technical-access.js';
export const POST: RequestHandler = async ({ request, params }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const result = idempotentResponse(actor, request, body, () => {
const created = addPieceUsage(params.id, Number(body.revision), {
pieceId: String(body.pieceId ?? ''),
sectionId: body.sectionId ? String(body.sectionId) : null,
position: body.position == null ? undefined : Number(body.position)
});
auditTechnical(actor, 'usage.create', 'usage', created.id);
return { status: 201, body: { data: getShow(params.id), usageId: created.id } };
});
return json(result.body, {
status: result.status,
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
});
} catch (cause) {
return apiFailure(cause);
}
};
@@ -0,0 +1,30 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure } from '#lib/server/api-http.js';
import { getShow, movePieceUsage } from '#lib/server/shows.js';
import {
auditTechnical,
authenticateTechnical,
idempotentResponse
} from '#lib/server/technical-access.js';
export const PATCH: RequestHandler = async ({ request, params }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const result = idempotentResponse(actor, request, body, () => {
movePieceUsage(params.id, params.usageId, Number(body.revision), {
sectionId: body.sectionId ? String(body.sectionId) : null,
position: Number(body.position)
});
auditTechnical(actor, 'usage.move', 'usage', params.usageId);
return { status: 200, body: { data: getShow(params.id) } };
});
return json(result.body, {
status: result.status,
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
});
} catch (cause) {
return apiFailure(cause);
}
};
+19 -1
View File
@@ -1,11 +1,15 @@
import { fail } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireUser } from '#lib/server/auth.js';
import { listPieces, setPieceArchived, setPieceDeleted } from '#lib/server/content.js'; import { listPieces, setPieceArchived, setPieceDeleted } from '#lib/server/content.js';
import { duplicateCompletedShow, listShows } from '#lib/server/shows.js';
export const load: PageServerLoad = ({ locals }) => { export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user); requireUser(locals.user);
return { return {
completedShows: listShows(['completed']),
duplicateCommandId: randomUUID(),
pieces: listPieces({ statuses: ['archived'] }), pieces: listPieces({ statuses: ['archived'] }),
deletedPieces: listPieces({ deletedPieces: listPieces({
statuses: ['inbox', 'preparing', 'ready', 'archived'], statuses: ['inbox', 'preparing', 'ready', 'archived'],
@@ -34,5 +38,19 @@ export const actions = {
} catch { } catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' }); return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' });
} }
},
duplicateShow: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
const duplicate = duplicateCompletedShow(
String(data.get('showId') || ''),
String(data.get('commandId') || '')
);
redirect(303, `/sendungen/${duplicate.id}`);
} catch (cause) {
if (cause && typeof cause === 'object' && 'status' in cause) throw cause;
return fail(400, { error: 'Die Sendung konnte nicht dupliziert werden.' });
}
} }
} satisfies Actions; } satisfies Actions;
+22 -1
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import { resolve } from '$app/paths';
import { formatDuration } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
</script> </script>
@@ -9,10 +11,29 @@
<div class="page-wrap narrow-page"> <div class="page-wrap narrow-page">
<header class="page-heading"> <header class="page-heading">
<h1>Archiv</h1> <h1>Archiv</h1>
<p class="page-intro">Abgelegte Pieces; abgeschlossene Sendungen folgen in Etappe 6.</p> <p class="page-intro">Stabile Sendungshistorie und bewusst abgelegte Fundus-Pieces.</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}
<section aria-labelledby="completed-shows-title">
<h2 id="completed-shows-title" class="section-heading">Abgeschlossene Sendungen</h2>
{#if data.completedShows.length}<div class="archive-list">
{#each data.completedShows as show (show.id)}<article>
<div>
<h2><a href={resolve('/sendungen/[id]', { id: show.id })}>{show.title}</a></h2>
<p>
{show.sectionCount} Abschnitte · {formatDuration(show.totalDurationSeconds)} Planzeit
</p>
</div>
<form method="POST" action="?/duplicateShow">
<input type="hidden" name="showId" value={show.id} />
<input type="hidden" name="commandId" value={data.duplicateCommandId} />
<button class="secondary-button" type="submit">Als neue Sendung duplizieren</button>
</form>
</article>{/each}
</div>{:else}<p class="context-empty">Noch keine Sendung historisch abgeschlossen.</p>{/if}
</section>
<h2 class="section-heading">Archivierte Pieces</h2>
{#if data.pieces.length}<div class="archive-list"> {#if data.pieces.length}<div class="archive-list">
{#each data.pieces as piece (piece.id)}<article> {#each data.pieces as piece (piece.id)}<article>
<div> <div>
+29 -2
View File
@@ -1,11 +1,16 @@
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireUser } from '#lib/server/auth.js';
import { captureInbox, listPieces, setPieceDeleted } from '#lib/server/content.js'; import { captureInbox, getTaxonomy, listPieces, setPieceDeleted } from '#lib/server/content.js';
import { discardFeedEntries, importFeedEntries, listFeedEntries } from '#lib/server/feeds.js';
export const load: PageServerLoad = ({ locals }) => { export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user); requireUser(locals.user);
return { pieces: listPieces({ statuses: ['inbox'] }) }; return {
pieces: listPieces({ statuses: ['inbox'] }),
feedEntries: listFeedEntries(),
taxonomy: getTaxonomy()
};
}; };
export const actions = { export const actions = {
@@ -36,5 +41,27 @@ export const actions = {
} catch { } catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte neu laden.' }); return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte neu laden.' });
} }
},
importFeeds: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
const entryIds = data.getAll('entryIds').map(String);
if (!entryIds.length) return fail(400, { feedError: 'Mindestens einen Beitrag auswählen.' });
try {
const pieceIds = importFeedEntries(entryIds, data.getAll('categoryIds').map(String));
return {
feedSuccess: `${pieceIds.length} ${pieceIds.length === 1 ? 'Beitrag' : 'Beiträge'} in den Eingang übernommen.`
};
} catch {
return fail(400, { feedError: 'Die Auswahl konnte nicht übernommen werden.' });
}
},
discardFeeds: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
const entryIds = data.getAll('entryIds').map(String);
if (!entryIds.length) return fail(400, { feedError: 'Mindestens einen Beitrag auswählen.' });
const count = discardFeedEntries(entryIds);
return { feedSuccess: `${count} ${count === 1 ? 'Beitrag' : 'Beiträge'} verworfen.` };
} }
} satisfies Actions; } satisfies Actions;
+116
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- RSS links are validated external URLs. */
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -43,6 +44,65 @@
<button class="primary-button" type="submit">Im Eingang ablegen</button> <button class="primary-button" type="submit">Im Eingang ablegen</button>
</form> </form>
</section> </section>
<section class="quiet-panel feed-inbox">
<div class="panel-heading">
<div>
<p class="eyebrow">RSS-Importeingang</p>
<h2>Beiträge sichten</h2>
</div>
<span>{data.feedEntries.length} neu</span>
</div>
<p>Beiträge bleiben hier getrennt, bis du sie auswählst und in den Fundus übernimmst.</p>
{#if form?.feedError}<div class="form-message error" role="alert">{form.feedError}</div>{/if}
{#if form?.feedSuccess}<div class="form-message success" role="status">
{form.feedSuccess}
</div>{/if}
{#if data.feedEntries.length}
<form method="POST" class="feed-selection">
<fieldset class="feed-categories">
<legend>Kategorien für die Auswahl <span>(Feed-Standard gilt ohne Auswahl)</span></legend>
{#each data.taxonomy.categories as category (category.id)}
<label
><input type="checkbox" name="categoryIds" value={category.id} />
{category.name}</label
>
{/each}
</fieldset>
<div class="feed-entry-list">
{#each data.feedEntries as entry (entry.id)}
<label class="feed-entry">
<input type="checkbox" name="entryIds" value={entry.id} />
<span
><small
>{entry.feedName}{entry.publishedAtUtc
? ` · ${new Date(entry.publishedAtUtc).toLocaleDateString('de-DE')}`
: ''}</small
><strong>{entry.title}</strong>{#if entry.summary}<span>{entry.summary}</span>{/if}
<a
href={entry.url}
target="_blank"
rel="noreferrer"
onclick={(event) => event.stopPropagation()}>Original öffnen</a
></span
>
</label>
{/each}
</div>
<div class="feed-actions">
<button class="primary-button" type="submit" formaction="?/importFeeds"
>Auswahl übernehmen</button
>
<button class="text-button danger-text" type="submit" formaction="?/discardFeeds"
>Auswahl verwerfen</button
>
</div>
</form>
{:else}
<p class="empty-copy">
Keine neuen Feed-Beiträge. Aktivierte Feeds werden automatisch abgerufen.
</p>
{/if}
</section>
{#if data.pieces.length} {#if data.pieces.length}
<section class="inbox-list"> <section class="inbox-list">
<h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2> <h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2>
@@ -81,3 +141,59 @@
<p>Neue Gedanken und Links können oben direkt aufgenommen werden.</p> <p>Neue Gedanken und Links können oben direkt aufgenommen werden.</p>
</section>{/if} </section>{/if}
</div> </div>
<style>
.feed-inbox {
display: grid;
gap: 1rem;
margin-block: 1.5rem;
}
.feed-selection,
.feed-entry-list {
display: grid;
gap: 0.75rem;
}
.feed-categories {
display: flex;
flex-wrap: wrap;
gap: 0.6rem 1rem;
border: 0;
padding: 0;
}
.feed-categories legend {
width: 100%;
font-weight: 700;
margin-bottom: 0.45rem;
}
.feed-categories legend span {
font-weight: 400;
color: var(--color-text-muted);
}
.feed-entry {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.8rem;
align-items: start;
padding: 0.9rem;
border: 1px solid var(--color-border);
border-radius: 0.7rem;
cursor: pointer;
}
.feed-entry > span {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.feed-entry small {
color: var(--color-text-muted);
}
.feed-entry a {
width: fit-content;
}
.feed-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
</style>
@@ -0,0 +1,31 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { getAuthConfig } from '#lib/server/config.js';
import { authenticateShare, getSharedShow, SHARE_SESSION_COOKIE } from '#lib/server/shares.js';
export const load: PageServerLoad = ({ params, cookies }) => {
return getSharedShow(params.token, cookies.get(SHARE_SESSION_COOKIE));
};
export const actions = {
password: async ({ params, request, cookies, getClientAddress }) => {
const data = await request.formData();
try {
const session = await authenticateShare(
params.token,
String(data.get('password') || ''),
getClientAddress()
);
cookies.set(SHARE_SESSION_COOKIE, session.token, {
path: `/freigabe/${params.token}`,
httpOnly: true,
sameSite: 'strict',
secure: getAuthConfig().secureCookies,
expires: session.expires
});
return { success: true };
} catch {
return fail(401, { error: 'Passwort falsch oder Freigabe nicht mehr verfügbar.' });
}
}
} satisfies Actions;
+141
View File
@@ -0,0 +1,141 @@
<script lang="ts">
import { page } from '$app/state';
import { formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
function berlinDate(value: string | null) {
if (!value) return 'Termin nicht angegeben';
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'full',
timeStyle: 'short'
}).format(new Date(value));
}
</script>
<svelte:head><title>Sendungsfreigabe | Sendewerk</title></svelte:head>
<main class="share-page">
{#if data.status === 'unavailable'}
<section class="quiet-panel share-gate">
<p class="eyebrow">Lesefreigabe</p>
<h1>Nicht verfügbar</h1>
<p>Dieser Link ist ungültig, abgelaufen oder wurde widerrufen.</p>
</section>
{:else if data.status === 'password'}
<section class="quiet-panel share-gate">
<p class="eyebrow">Geschützte Lesefreigabe</p>
<h1>Passwort erforderlich</h1>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form method="POST" action="?/password" class="compact-form">
<label
>Passwort<input
name="password"
type="password"
required
autocomplete="current-password"
/></label
>
<button class="primary-button" type="submit">Sendung öffnen</button>
</form>
</section>
{:else}
<header class="share-heading">
<p class="eyebrow">
{data.share.isCompleted ? 'Abgeschlossene Sendung' : 'Aktuelle Sendungsplanung'} · Nur lesen
</p>
<h1>{data.share.title}</h1>
<p>{berlinDate(data.share.startsAtUtc)} Uhr</p>
</header>
<div class="shared-rundown">
{#each data.share.sections as section (section)}
<section class="quiet-panel shared-section">
<header>
<div>
{#if section.category}<small
style={`--tax-color: ${section.categoryColor ?? '#67dde8'}`}
>{section.category}</small
>{/if}
<h2>{section.name}</h2>
</div>
{#if section.explicitDurationSeconds !== null}<span
>{formatDuration(section.explicitDurationSeconds)}</span
>{/if}
</header>
{#each section.usages as usage (usage)}
<article class="shared-usage">
<div class="shared-usage-heading">
<div>
<small
>{usage.status === 'done'
? 'Gesendet'
: usage.status === 'skipped'
? 'Übersprungen'
: 'Offen'}</small
>
<h3>{usage.title}</h3>
</div>
<span>{formatDuration(usage.actualDurationSeconds || usage.durationSeconds)}</span>
</div>
{#if usage.media}<img
src={`${page.url.pathname}/media/${usage.media.id}`}
alt={usage.media.alt_text}
/>{/if}
{#if 'introHtml' in usage && usage.introHtml}<div class="shared-copy">
<strong>Einleitung</strong>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div>{@html usage.introHtml}</div>
</div>{/if}
{#if 'moderationHtml' in usage && usage.moderationHtml}<div class="shared-copy">
<strong>Moderation</strong>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div>{@html usage.moderationHtml}</div>
</div>{/if}
{#if 'interactionHtml' in usage && usage.interactionHtml}<div class="shared-copy">
<strong>Interaktion</strong>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div>{@html usage.interactionHtml}</div>
</div>{/if}
{#if 'transitionHtml' in usage && usage.transitionHtml}<div class="shared-copy">
<strong>Überleitung</strong>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div>{@html usage.transitionHtml}</div>
</div>{/if}
{#if 'researchHtml' in usage && usage.researchHtml}<details>
<summary>Recherche</summary>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="shared-copy">{@html usage.researchHtml}</div>
</details>{/if}
{#if 'sources' in usage && usage.sources?.length}<details>
<summary>Quellen</summary>
<ul>
{#each usage.sources ?? [] as source (source)}<li>
<!-- External source URLs are validated and stored server-side. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} rel="noreferrer">{source.label || source.url}</a>
</li>{/each}
</ul>
</details>{/if}
{#if 'tools' in usage && usage.tools?.length}<details>
<summary>Werkzeuge</summary>
<ul>
{#each usage.tools ?? [] as tool (tool)}<li>
<strong>{tool.name}</strong>{#if tool.note}
· {tool.note}{/if}
</li>{/each}
</ul>
</details>{/if}
</article>
{/each}
</section>
{/each}
</div>
{/if}
</main>
@@ -0,0 +1,18 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getSharedMedia, SHARE_SESSION_COOKIE } from '#lib/server/shares.js';
export const GET: RequestHandler = async ({ params, cookies }) => {
const media = await getSharedMedia(params.token, cookies.get(SHARE_SESSION_COOKIE), params.id);
if (!media) error(404, 'Bild nicht gefunden');
return new Response(media.data, {
headers: {
'Content-Type': media.mimeType,
'Content-Length': String(media.byteSize),
'Cache-Control': 'private, no-store',
'Content-Security-Policy': "default-src 'none'; sandbox",
'Cross-Origin-Resource-Policy': 'same-origin',
'X-Content-Type-Options': 'nosniff'
}
});
};
+1
View File
@@ -75,6 +75,7 @@ function pieceInput(data: FormData): PieceInput {
summary: text(data, 'summary'), summary: text(data, 'summary'),
formatId: text(data, 'formatId') || null, formatId: text(data, 'formatId') || null,
status: text(data, 'status') as EditablePieceStatus, status: text(data, 'status') as EditablePieceStatus,
preparationEffort: (text(data, 'preparationEffort') || null) as PieceInput['preparationEffort'],
durationSeconds: durationInSeconds(data), durationSeconds: durationInSeconds(data),
intro: text(data, 'intro'), intro: text(data, 'intro'),
researchText: text(data, 'researchText'), researchText: text(data, 'researchText'),
+77 -34
View File
@@ -77,14 +77,19 @@
: draft.presentationProvider === 'tiktok' && draft.presentationProviderId : draft.presentationProvider === 'tiktok' && draft.presentationProviderId
? `https://www.tiktok.com/player/v1/${draft.presentationProviderId}` ? `https://www.tiktok.com/player/v1/${draft.presentationProviderId}`
: '', : '',
images: selectedMedia ? [{ id: selectedMedia.id, altText: selectedMedia.altText }] : [] images:
selectedMedia && ['text', 'image'].includes(draft.presentationMode)
? [{ id: selectedMedia.id, altText: selectedMedia.altText }]
: []
}); });
let presentationPreviewImage = $derived( let presentationPreviewImage = $derived(
selectedMedia selectedMedia && ['text', 'image'].includes(draft.presentationMode)
? draft.presentationMode === 'text' ? draft.presentationMode === 'text'
? '/media' ? '/media'
: `/media/${selectedMedia.id}` : `/media/${selectedMedia.id}`
: sourcePreviewImage : draft.presentationMode === 'link'
? sourcePreviewImage
: ''
); );
let pendingHref: string | null = null; let pendingHref: string | null = null;
let allowNavigation = false; let allowNavigation = false;
@@ -116,8 +121,8 @@
if ( if (
control instanceof HTMLInputElement && control instanceof HTMLInputElement &&
control.name === 'presentationMode' && control.name === 'presentationMode' &&
control.value !== 'text' && ((['link', 'video'].includes(control.value) && draft.presentationSourcePosition === null) ||
draft.presentationSourcePosition === null (control.value === 'image' && !draft.presentationMediaId))
) { ) {
return; return;
} }
@@ -347,6 +352,14 @@
bind:value={durationMinutes} bind:value={durationMinutes}
/></label /></label
> >
<label
>Vorbereitungsaufwand<select
name="preparationEffort"
bind:value={draft.preparationEffort}
><option value={null}>Nicht angegeben</option><option value="low">Gering</option
><option value="medium">Mittel</option><option value="high">Hoch</option></select
></label
>
<label <label
>Format<select name="formatId" bind:value={draft.formatId} >Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option ><option value={null}>Ohne Format</option
@@ -526,23 +539,9 @@
</div> </div>
<div class="piece-presentation-layout"> <div class="piece-presentation-layout">
<div class="piece-presentation-controls"> <div class="piece-presentation-controls">
<label <p class="field-label">Präsentationsart</p>
>Präsentationstitel<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
name="presentationText"
label="Öffentlicher Text"
placeholder="Text für die Publikumsausgabe"
rows={8}
bind:value={draft.presentationText}
/>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung"> <div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label {#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
><input ><input
type="radio" type="radio"
name="presentationMode" name="presentationMode"
@@ -551,9 +550,30 @@
/>{mode[1]}</label />{mode[1]}</label
>{/each} >{/each}
</div> </div>
{#if draft.presentationMode === 'text'}
<label
>Öffentliche Überschrift<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
name="presentationText"
label="Öffentlicher Vorspann"
placeholder="Text für die Publikumsausgabe"
rows={8}
bind:value={draft.presentationText}
/>
{:else}
<input type="hidden" name="outputTitle" value={draft.outputTitle} />
<input type="hidden" name="presentationText" value={draft.presentationText} />
{/if}
{#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
<div class="presentation-settings"> <div class="presentation-settings">
<label <label
>Quelle<select >{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select
name="presentationSourcePosition" name="presentationSourcePosition"
bind:value={draft.presentationSourcePosition} bind:value={draft.presentationSourcePosition}
onchange={presentationSourceChanged} onchange={presentationSourceChanged}
@@ -564,7 +584,7 @@
></label ></label
> >
<label <label
>Kartentitel<input >{draft.presentationMode === 'video' ? 'Videotitel' : 'Kartentitel'}<input
name="presentationTitle" name="presentationTitle"
maxlength="300" maxlength="300"
placeholder="Optionaler Anzeigetitel" placeholder="Optionaler Anzeigetitel"
@@ -572,16 +592,24 @@
oninput={() => (draft.presentationTitleManual = true)} oninput={() => (draft.presentationTitleManual = true)}
/></label /></label
> >
{#if draft.presentationMode === 'link'}
<label <label
>Bild-URL<input >Vorschaubild-URL<input
name="presentationImageUrl" name="presentationImageUrl"
type="url" type="url"
maxlength="2000" maxlength="2000"
placeholder="https://…" placeholder="Wird aus der Linkvorschau geladen"
bind:value={draft.presentationImageUrl} bind:value={draft.presentationImageUrl}
oninput={() => (draft.presentationImageManual = true)} oninput={() => (draft.presentationImageManual = true)}
/></label /></label
> >
{:else}
<input
type="hidden"
name="presentationImageUrl"
value={draft.presentationImageUrl}
/>
{/if}
<label class="wide-field" <label class="wide-field"
>Anreißer<textarea >Anreißer<textarea
name="presentationDescription" name="presentationDescription"
@@ -605,6 +633,20 @@
> >
</div> </div>
</div> </div>
{:else}
<input
type="hidden"
name="presentationSourcePosition"
value={draft.presentationSourcePosition ?? ''}
/>
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
<input
type="hidden"
name="presentationDescription"
value={draft.presentationDescription}
/>
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
{/if}
</div> </div>
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau"> <aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<PresentationDisplay <PresentationDisplay
@@ -631,6 +673,7 @@
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} /> <input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationProvider" value={draft.presentationProvider} /> <input type="hidden" name="presentationProvider" value={draft.presentationProvider} />
<input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} /> <input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} />
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
<input <input
type="hidden" type="hidden"
name="presentationMetadataFetchedAtUtc" name="presentationMetadataFetchedAtUtc"
@@ -639,7 +682,11 @@
</section> </section>
</form> </form>
<section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'presentation'}> <section
class="piece-editor-panel piece-media-panel"
hidden={activeSection !== 'presentation' ||
!['text', 'image'].includes(draft.presentationMode)}
>
<div class="piece-subsection-heading"> <div class="piece-subsection-heading">
<div> <div>
<p class="eyebrow">Hochgeladene Bilder</p> <p class="eyebrow">Hochgeladene Bilder</p>
@@ -647,12 +694,10 @@
</div> </div>
<small>JPEG, PNG, WebP oder GIF bis 5 MB</small> <small>JPEG, PNG, WebP oder GIF bis 5 MB</small>
</div> </div>
{#if data.piece.media.length}<label class="media-none" {#if draft.media.length && draft.presentationMode === 'text'}<label class="media-none"
><input ><input
form="piece-editor-form"
type="radio" type="radio"
name="presentationMediaId" name="presentationMediaChoice"
value=""
checked={!draft.presentationMediaId} checked={!draft.presentationMediaId}
onchange={() => { onchange={() => {
draft.presentationMediaId = null; draft.presentationMediaId = null;
@@ -661,13 +706,11 @@
/> Kein hochgeladenes Bild</label /> Kein hochgeladenes Bild</label
> >
<div class="media-grid"> <div class="media-grid">
{#each data.piece.media as media (media.id)}<figure> {#each draft.media as media (media.id)}<figure>
<label class="media-presentation-choice" <label class="media-presentation-choice"
><input ><input
form="piece-editor-form"
type="radio" type="radio"
name="presentationMediaId" name="presentationMediaChoice"
value={media.id}
checked={draft.presentationMediaId === media.id} checked={draft.presentationMediaId === media.id}
onchange={() => { onchange={() => {
draft.presentationMediaId = media.id; draft.presentationMediaId = media.id;
@@ -14,10 +14,12 @@
<div class="presentation-test-status" role="status">Isolierte Testansicht</div> <div class="presentation-test-status" role="status">Isolierte Testansicht</div>
<PresentationDisplay <PresentationDisplay
content={data.content} content={data.content}
imageSrc={data.content.images.length imageSrc={data.content.mode === 'text' && data.content.images.length
? data.content.mode === 'text'
? '/media' ? '/media'
: `/media/${data.content.images[0].id}` : data.content.mode === 'image' && data.content.images.length
: `/fundus/${data.pieceId}/praesentation/preview-image`} ? `/media/${data.content.images[0].id}`
: data.content.mode === 'link' && data.content.imageUrl
? `/fundus/${data.pieceId}/praesentation/preview-image`
: ''}
/> />
</main> </main>
@@ -7,7 +7,9 @@ import { fetchPresentationImage } from '#lib/server/presentation.js';
export const GET: RequestHandler = async ({ locals, params }) => { export const GET: RequestHandler = async ({ locals, params }) => {
requireUser(locals.user); requireUser(locals.user);
const piece = getPiece(params.id); const piece = getPiece(params.id);
if (!piece?.presentationImageUrl) error(404, 'Vorschaubild nicht verfügbar'); if (piece?.presentationMode !== 'link' || !piece.presentationImageUrl) {
error(404, 'Vorschaubild nicht verfügbar');
}
try { try {
const image = await fetchPresentationImage(piece.presentationImageUrl); const image = await fetchPresentationImage(piece.presentationImageUrl);
return new Response(new Uint8Array(image.data), { return new Response(new Uint8Array(image.data), {
+114 -3
View File
@@ -1,7 +1,8 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { error, fail } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireBarkeeper, requireUser } from '#lib/server/auth.js';
import { finalizeShow, getCompletionSummary } from '#lib/server/completion.js';
import { import {
completeCurrent, completeCurrent,
clearPresentationChannel, clearPresentationChannel,
@@ -15,6 +16,7 @@ import {
rotatePresentationChannelKey, rotatePresentationChannelKey,
resetSectionTimer, resetSectionTimer,
resetTotalTimer, resetTotalTimer,
resumeUsage,
resumeEndedLive, resumeEndedLive,
resumeLive, resumeLive,
selectUsage, selectUsage,
@@ -24,6 +26,14 @@ import {
startOrResumeLive startOrResumeLive
} from '#lib/server/live.js'; } from '#lib/server/live.js';
import { listShows } from '#lib/server/shows.js'; import { listShows } from '#lib/server/shows.js';
import {
createShare,
deleteShare,
listShares,
replaceShareToken,
updateShare
} from '#lib/server/shares.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
function text(data: FormData, name: string) { function text(data: FormData, name: string) {
return String(data.get(name) || ''); return String(data.get(name) || '');
@@ -70,11 +80,19 @@ function attempt(intent: string, operation: () => unknown) {
} }
export const load: PageServerLoad = ({ locals, url }) => { export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user); const actor = requireUser(locals.user);
const showId = url.searchParams.get('show') || listShows()[0]?.id; const showId = url.searchParams.get('show') || listShows()[0]?.id;
if (!showId) error(404, 'Keine Sendung vorhanden'); if (!showId) error(404, 'Keine Sendung vorhanden');
try { try {
return { live: getLiveView(showId), commandId: randomUUID() }; const live = getLiveView(showId);
return {
live,
canManageShares: actor.role === 'barkeeper',
shares: actor.role === 'barkeeper' ? listShares(actor, showId) : [],
commandId: randomUUID(),
completion:
live.endedAtUtc && live.show.status !== 'completed' ? getCompletionSummary(showId) : null
};
} catch (cause) { } catch (cause) {
if (cause instanceof Error && cause.message === 'NOT_FOUND') if (cause instanceof Error && cause.message === 'NOT_FOUND')
error(404, 'Sendung nicht gefunden'); error(404, 'Sendung nicht gefunden');
@@ -83,6 +101,73 @@ export const load: PageServerLoad = ({ locals, url }) => {
}; };
export const actions = { export const actions = {
createShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const share = await createShare(actor, {
showId: text(data, 'showId'),
scope: text(data, 'scope'),
password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
});
return {
intent: 'createShare',
success: 'Leselink angelegt.',
shareUrl: `/freigabe/${share.token}`
};
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'createShare', ...problem });
}
},
updateShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
await updateShare(
actor,
text(data, 'shareId'),
{
scope: text(data, 'scope'),
passwordMode: text(data, 'passwordMode'),
password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
},
text(data, 'showId')
);
return { intent: 'updateShare', success: 'Freigabe aktualisiert.' };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'updateShare', ...problem });
}
},
replaceShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const token = replaceShareToken(actor, text(data, 'shareId'), text(data, 'showId'));
return {
intent: 'replaceShare',
success: 'Ersatzlink angelegt. Der bisherige Link ist ungültig.',
shareUrl: `/freigabe/${token}`
};
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'replaceShare', ...problem });
}
},
deleteShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
deleteShare(actor, text(data, 'shareId'), text(data, 'showId'));
return { intent: 'deleteShare', success: 'Freigabe gelöscht.' };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'deleteShare', ...problem });
}
},
start: async ({ locals, request }) => { start: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
@@ -142,6 +227,20 @@ export const actions = {
endLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) endLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
); );
}, },
finalize: async ({ locals, request }) => {
const actor = requireUser(locals.user);
const data = await request.formData();
return attempt('finalize', () =>
finalizeShow({
showId: text(data, 'showId'),
commandId: text(data, 'commandId'),
expectedShowRevision: Number(data.get('showRevision')),
expectedLiveRevision: Number(data.get('revision')),
archivePieceIds: data.getAll('archivePieceIds').map(String),
actor
})
);
},
resumeEnded: async ({ locals, request }) => { resumeEnded: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
@@ -189,6 +288,18 @@ export const actions = {
) )
); );
}, },
resumeUsage: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('resumeUsage', () =>
resumeUsage(
text(data, 'showId'),
text(data, 'usageId'),
text(data, 'commandId'),
Number(data.get('revision'))
)
);
},
present: async ({ locals, request }) => { present: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
+120 -1
View File
@@ -4,6 +4,7 @@
import { onMount, tick, untrack } from 'svelte'; import { onMount, tick, untrack } from 'svelte';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.svelte'; import ThemeToggle from '#lib/components/ThemeToggle.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
import { formatClock, formatDuration } from '#lib/format.js'; import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -30,6 +31,7 @@
let railCloseButton = $state<HTMLButtonElement>(); let railCloseButton = $state<HTMLButtonElement>();
let liveMenuTrigger = $state<HTMLButtonElement>(); let liveMenuTrigger = $state<HTMLButtonElement>();
let liveMenuPopover = $state<HTMLElement>(); let liveMenuPopover = $state<HTMLElement>();
let completionDialog = $state<HTMLDialogElement>();
let scrollPositions: Record<string, number> = {}; let scrollPositions: Record<string, number> = {};
let outputWindow: Window | null = null; let outputWindow: Window | null = null;
let allUsages = $derived(data.live.sections.flatMap((section) => section.usages)); let allUsages = $derived(data.live.sections.flatMap((section) => section.usages));
@@ -48,6 +50,9 @@
let currentSection = $derived( let currentSection = $derived(
data.live.sections.find((section) => section.id === data.live.current?.sectionId) ?? null data.live.sections.find((section) => section.id === data.live.current?.sectionId) ?? null
); );
let viewedSection = $derived(
data.live.sections.find((section) => section.id === viewedUsage?.sectionId) ?? null
);
let presentedUsage = $derived( let presentedUsage = $derived(
allUsages.find((usage) => usage.id === data.live.presentationUsageId) ?? null allUsages.find((usage) => usage.id === data.live.presentationUsageId) ?? null
); );
@@ -100,6 +105,16 @@
data.live.sectionTimer.sectionId === data.live.current.sectionId data.live.sectionTimer.sectionId === data.live.current.sectionId
) )
); );
async function copyTwitchTags() {
if (!viewedSection?.twitchTags.length) return;
try {
await navigator.clipboard.writeText(viewedSection.twitchTags.join(', '));
popupMessage = 'Twitch-Tags kopiert.';
} catch {
popupMessage = 'Kopieren wurde vom Browser blockiert.';
}
}
let runningOffsetSeconds = $derived( let runningOffsetSeconds = $derived(
data.live.totalTimer.runningSinceUtc ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0 data.live.totalTimer.runningSinceUtc ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0
); );
@@ -144,6 +159,10 @@
}); });
}); });
$effect(() => {
if (!data.completion && completionDialog?.open) completionDialog.close();
});
onMount(() => { onMount(() => {
const key = `sendewerk-live-${data.live.show.id}`; const key = `sendewerk-live-${data.live.show.id}`;
try { try {
@@ -572,6 +591,12 @@
</section> </section>
<section> <section>
<strong>Ausgabekanal</strong> <strong>Ausgabekanal</strong>
{#if data.canManageShares}<ShareManagement
shows={[{ ...data.live.show, startsAtUtc: null }]}
shares={data.shares}
fixedShowId={data.live.show.id}
{form}
/>{/if}
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}> <form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button> <button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
@@ -611,6 +636,12 @@
> >
</form> </form>
{:else if data.live.endedAtUtc} {:else if data.live.endedAtUtc}
{#if data.live.show.status === 'completed'}
<p class="quiet-copy">Diese Sendung ist historisch abgeschlossen und unveränderlich.</p>
<a class="secondary-button" href={resolve('/sendungen/[id]', { id: data.live.show.id })}
>Historische Sendung öffnen</a
>
{:else}
<form method="POST" action={liveAction('resumeEnded')} use:enhance={enhanceCommand}> <form method="POST" action={liveAction('resumeEnded')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} /> <input type="hidden" name="revision" value={data.live.liveRevision} />
@@ -639,6 +670,14 @@
><AppIcon name="arrow-rotate-left" /> Neuer Durchlauf</button ><AppIcon name="arrow-rotate-left" /> Neuer Durchlauf</button
> >
</form> </form>
<button
class="primary-button"
type="button"
onclick={() => completionDialog?.showModal()}
>
<AppIcon name="box-archive" /> Sendung abschließen
</button>
{/if}
{:else} {:else}
<form <form
method="POST" method="POST"
@@ -723,6 +762,49 @@
{#if popupMessage}<div class="live-message form-message" role="status">{popupMessage}</div>{/if} {#if popupMessage}<div class="live-message form-message" role="status">{popupMessage}</div>{/if}
</div> </div>
<dialog bind:this={completionDialog} class="completion-dialog">
<form method="POST" action={liveAction('finalize')} use:enhance={enhanceCommand}>
<header>
<p class="eyebrow">Unveränderliche Historie</p>
<h2>Sendung abschließen</h2>
</header>
<p>
Der aktuelle Ablauf wird dauerhaft eingefroren. Übersprungene Pieces zählen nicht als
gesendet.
</p>
{#if data.completion}
<div class="completion-counts">
<span><strong>{data.completion.doneCount}</strong> gesendet</span><span
><strong>{data.completion.skippedCount}</strong> übersprungen</span
><span><strong>{data.completion.openCount}</strong> offen</span>
</div>
<fieldset>
<legend>Originale bewusst in den Fundus-Archivbereich legen</legend>
{#each data.completion.candidates as candidate (candidate.pieceId)}<label
class="check-row"
><input type="checkbox" name="archivePieceIds" value={candidate.pieceId} /><span
><strong>{candidate.title}</strong>{#if candidate.usageCount > 1}<small
>{candidate.usageCount} gesendete Verwendungen</small
>{/if}</span
></label
>{/each}
{#if !data.completion.candidates.length}<p class="quiet-copy">
Keine archivierbaren Originale vorhanden.
</p>{/if}
</fieldset>
{/if}
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="commandId" value={data.commandId} />
<footer>
<button class="text-button" type="button" onclick={() => completionDialog?.close()}
>Abbrechen</button
><button class="primary-button" type="submit">Historischen Stand abschließen</button>
</footer>
</form>
</dialog>
<div class="live-workspace"> <div class="live-workspace">
{#if viewedUsage}<article {#if viewedUsage}<article
class="live-current" class="live-current"
@@ -732,12 +814,19 @@
> >
<div class="live-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}> <div class="live-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}>
<div class="live-piece-heading"> <div class="live-piece-heading">
<span class="live-piece-position">Piece {viewedPosition}/{data.live.totalCount}</span>
<h1 id="current-title">{viewedUsage.title}</h1> <h1 id="current-title">{viewedUsage.title}</h1>
{#if showTimes}<strong class="live-piece-time"> {#if showTimes}<strong class="live-piece-time">
{formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)} {formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)}
</strong>{/if} </strong>{/if}
</div> </div>
{#if viewedSection?.twitchTags.length}<button
class="text-button live-twitch-tags"
type="button"
onclick={copyTwitchTags}
>
<AppIcon name="copy" />
{viewedSection.twitchTags.join(' · ')}
</button>{/if}
{#if viewedUsage.introHtml}<section class="live-script-section live-intro"> {#if viewedUsage.introHtml}<section class="live-script-section live-intro">
<span>Einleitung</span> <span>Einleitung</span>
@@ -921,6 +1010,22 @@
> >
</button> </button>
<div class="live-run-actions"> <div class="live-run-actions">
{#if usage.status === 'done' || usage.status === 'skipped'}<form
method="POST"
action={liveAction('resumeUsage')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button
class="text-button"
type="submit"
disabled={data.live.isPaused || Boolean(data.live.endedAtUtc)}
>Wiederaufnehmen</button
>
</form>{/if}
{#if usage.status === 'open'}<details class="live-run-order"> {#if usage.status === 'open'}<details class="live-run-order">
<summary aria-label={`${usage.title} sortieren`} <summary aria-label={`${usage.title} sortieren`}
><AppIcon name="ellipsis" /></summary ><AppIcon name="ellipsis" /></summary
@@ -1097,6 +1202,19 @@
: 'Präsentieren'} : 'Präsentieren'}
</button> </button>
</form> </form>
{#if (viewedUsage.status === 'done' || viewedUsage.status === 'skipped') && !viewingLiveCurrent}
<form method="POST" action={liveAction('resumeUsage')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={viewedUsage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button
class="primary-button"
type="submit"
disabled={data.live.isPaused || Boolean(data.live.endedAtUtc)}>Wiederaufnehmen</button
>
</form>
{:else}
<form method="POST" action={liveAction('skip')} use:enhance={enhanceCommand}> <form method="POST" action={liveAction('skip')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} /> <input type="hidden" name="revision" value={data.live.liveRevision} />
@@ -1129,6 +1247,7 @@
: 'Erledigen & Sendungsende'} : 'Erledigen & Sendungsende'}
</button> </button>
</form> </form>
{/if}
{:else if sessionComplete}<strong class="live-finished-label">Ablauf abgeschlossen</strong {:else if sessionComplete}<strong class="live-finished-label">Ablauf abgeschlossen</strong
>{/if} >{/if}
</div> </div>
+9 -33
View File
@@ -2,38 +2,13 @@ import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { berlinDateKey, berlinMonthKey, normalizeMonthKey, shiftMonth } from '#lib/calendar.js'; import { berlinDateKey, berlinMonthKey, normalizeMonthKey, shiftMonth } from '#lib/calendar.js';
import { getShowOverviewView, requireUser, setShowOverviewView } from '#lib/server/auth.js'; import { getShowOverviewView, requireUser, setShowOverviewView } from '#lib/server/auth.js';
import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js'; import {
createShow,
function berlinLocalToUtc(value: string): string | null { listPreparationUsages,
if (!value) return null; listShows,
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value); listShowTemplates
if (!match) throw new Error('INVALID_TIMESTAMP'); } from '#lib/server/shows.js';
const parts = match.slice(1).map(Number); import { berlinLocalToUtc } from '#lib/server/time.js';
const localMilliseconds = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]);
let candidate = localMilliseconds;
for (let attempt = 0; attempt < 2; attempt += 1) {
const formatted = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(new Date(candidate));
const read = (type: Intl.DateTimeFormatPartTypes) =>
Number(formatted.find((part) => part.type === type)?.value);
const represented = Date.UTC(
read('year'),
read('month') - 1,
read('day'),
read('hour'),
read('minute')
);
candidate += localMilliseconds - represented;
}
return new Date(candidate).toISOString();
}
export const load: PageServerLoad = ({ locals, url }) => { export const load: PageServerLoad = ({ locals, url }) => {
const user = requireUser(locals.user); const user = requireUser(locals.user);
@@ -41,7 +16,8 @@ export const load: PageServerLoad = ({ locals, url }) => {
const todayMonth = berlinMonthKey(today); const todayMonth = berlinMonthKey(today);
const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth); const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth);
return { return {
shows: listShows(), shows: listShows(['draft', 'ready', 'live']),
preparationUsages: listPreparationUsages(),
templates: listShowTemplates(), templates: listShowTemplates(),
view: getShowOverviewView(user.id), view: getShowOverviewView(user.id),
selectedMonth, selectedMonth,
+42
View File
@@ -53,6 +53,10 @@
}).format(new Date(`${value}T12:00:00Z`)); }).format(new Date(`${value}T12:00:00Z`));
} }
function preparationHref(showId: string, usageId: string) {
return `${resolve('/sendungen/[id]', { id: showId })}?usage=${encodeURIComponent(usageId)}#usage-${encodeURIComponent(usageId)}`;
}
function statusLabel(status: string) { function statusLabel(status: string) {
if (status === 'ready') return 'Bereit'; if (status === 'ready') return 'Bereit';
if (status === 'draft') return 'Entwurf'; if (status === 'draft') return 'Entwurf';
@@ -125,6 +129,44 @@
</form> </form>
</details> </details>
{#if data.preparationUsages.length}<section
class="show-library preparation-overview"
aria-labelledby="preparation-title"
>
<header>
<h2 id="preparation-title">Noch vorzubereiten</h2>
<span>{data.preparationUsages.length}</span>
</header>
<div class="preparation-list">
{#each data.preparationUsages as usage (usage.usageId)}
<!-- The helper starts from a route resolved against the configured base path. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={preparationHref(usage.showId, usage.usageId)}>
<div class="preparation-copy">
<span
class="preparation-topics"
aria-label={usage.topics.map((topic) => topic.name).join(', ')}
>{#each usage.topics as topic (topic.name)}<AppIcon
name={topic.iconName}
/>{/each}</span
>
<span
><strong>{usage.title}</strong><small>{usage.showTitle} · {usage.sectionName}</small
></span
>
</div>
{#if usage.preparationEffort}<b class={`effort ${usage.preparationEffort}`}
>{usage.preparationEffort === 'low'
? 'Gering'
: usage.preparationEffort === 'medium'
? 'Mittel'
: 'Hoch'}</b
>{/if}
</a>
{/each}
</div>
</section>{/if}
<section class="show-library" aria-labelledby="show-library-title"> <section class="show-library" aria-labelledby="show-library-title">
<header> <header>
<h2 id="show-library-title">{data.view === 'list' ? 'Geplante Sendungen' : 'Kalender'}</h2> <h2 id="show-library-title">{data.view === 'list' ? 'Geplante Sendungen' : 'Kalender'}</h2>
+123 -32
View File
@@ -1,13 +1,23 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { error, fail } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireBarkeeper, requireUser } from '#lib/server/auth.js';
import { getHistoricalActualDurations } from '#lib/server/completion.js';
import { getTaxonomy, listPieces, listPieceTemplates } from '#lib/server/content.js'; import { getTaxonomy, listPieces, listPieceTemplates } from '#lib/server/content.js';
import {
createShare,
deleteShare,
listShares,
replaceShareToken,
updateShare
} from '#lib/server/shares.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
import { import {
addPieceUsage, addPieceUsage,
addShowSection, addShowSection,
createAndAddPieceUsage, createAndAddPieceUsage,
duplicatePieceUsage, duplicatePieceUsage,
duplicateCompletedShow,
getShow, getShow,
getPieceUsageContexts, getPieceUsageContexts,
movePieceUsage, movePieceUsage,
@@ -35,32 +45,6 @@ function pieceDurationInSeconds(data: FormData) {
return Math.round(Number(data.get('durationMinutes')) * 60); return Math.round(Number(data.get('durationMinutes')) * 60);
} }
function berlinLocalToUtc(value: string): string | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value);
if (!match) throw new Error('INVALID_TIMESTAMP');
const parts = match.slice(1).map(Number);
const localMilliseconds = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]);
let candidate = localMilliseconds;
for (let attempt = 0; attempt < 2; attempt += 1) {
const formatted = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(new Date(candidate));
const read = (type: Intl.DateTimeFormatPartTypes) =>
Number(formatted.find((part) => part.type === type)?.value);
candidate +=
localMilliseconds -
Date.UTC(read('year'), read('month') - 1, read('day'), read('hour'), read('minute'));
}
return new Date(candidate).toISOString();
}
function berlinInput(value: string | null) { function berlinInput(value: string | null) {
if (!value) return ''; if (!value) return '';
const parts = new Intl.DateTimeFormat('en-CA', { const parts = new Intl.DateTimeFormat('en-CA', {
@@ -104,6 +88,12 @@ function actionError(cause: unknown) {
}; };
if (code === 'INVALID_DURATION') if (code === 'INVALID_DURATION')
return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' }; return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' };
if (code === 'INVALID_PASSWORD')
return { status: 400, error: 'Ein Freigabepasswort benötigt mindestens 12 Zeichen.' };
if (code === 'INVALID_EXPIRY')
return { status: 400, error: 'Der Ablaufzeitpunkt muss in der Zukunft liegen.' };
if (code === 'INVALID_SCOPE' || code === 'INVALID_PASSWORD_MODE')
return { status: 400, error: 'Die Einstellungen der Freigabe sind ungültig.' };
if (code.startsWith('INVALID_')) if (code.startsWith('INVALID_'))
return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' }; return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' };
return { status: 500, error: 'Die Änderung konnte nicht gespeichert werden.' }; return { status: 500, error: 'Die Änderung konnte nicht gespeichert werden.' };
@@ -118,11 +108,28 @@ function attempt(intent: string, operation: () => unknown) {
} }
export const load: PageServerLoad = ({ locals, params, url }) => { export const load: PageServerLoad = ({ locals, params, url }) => {
requireUser(locals.user); const actor = requireUser(locals.user);
const show = getShow(params.id); const show = getShow(params.id);
if (!show) error(404, 'Sendung nicht gefunden'); if (!show) error(404, 'Sendung nicht gefunden');
const shares = actor.role === 'barkeeper' ? listShares(actor, show.id) : [];
if (show.status === 'completed') {
return {
show,
shares,
canManageShares: actor.role === 'barkeeper',
historical: true,
actualDurations: getHistoricalActualDurations(show.id),
duplicateCommandId: randomUUID(),
startsAtLocal: berlinInput(show.startsAtUtc),
pieces: [],
taxonomy: getTaxonomy(),
pieceTemplates: [],
createCommandId: randomUUID(),
filters: { query: '', categoryId: '', includeUsed: false, includePlanned: false }
};
}
const pieces = listPieces({ const pieces = listPieces({
statuses: ['ready'], statuses: ['preparing', 'ready'],
query: url.searchParams.get('q') ?? '', query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? '' categoryId: url.searchParams.get('category') ?? ''
}); });
@@ -132,6 +139,11 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
); );
return { return {
show, show,
shares,
canManageShares: actor.role === 'barkeeper',
historical: false,
actualDurations: {} as Record<string, number>,
duplicateCommandId: randomUUID(),
startsAtLocal: berlinInput(show.startsAtUtc), startsAtLocal: berlinInput(show.startsAtUtc),
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })), pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
taxonomy: getTaxonomy(), taxonomy: getTaxonomy(),
@@ -147,6 +159,80 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
}; };
export const actions = { export const actions = {
createShare: async ({ locals, params, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const share = await createShare(actor, {
showId: params.id,
scope: text(data, 'scope'),
password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
});
return {
intent: 'createShare',
success: 'Leselink angelegt. Der Link wird nur jetzt vollständig angezeigt.',
shareUrl: `/freigabe/${share.token}`
};
} catch (cause) {
return fail(actionError(cause).status, { intent: 'createShare', ...actionError(cause) });
}
},
updateShare: async ({ locals, params, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
await updateShare(
actor,
text(data, 'shareId'),
{
scope: text(data, 'scope'),
passwordMode: text(data, 'passwordMode'),
password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
},
params.id
);
return { intent: 'updateShare', success: 'Freigabe aktualisiert.' };
} catch (cause) {
return fail(actionError(cause).status, { intent: 'updateShare', ...actionError(cause) });
}
},
replaceShare: async ({ locals, params, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const token = replaceShareToken(actor, text(data, 'shareId'), params.id);
return {
intent: 'replaceShare',
success: 'Ersatzlink angelegt. Der bisherige Link ist ungültig.',
shareUrl: `/freigabe/${token}`
};
} catch (cause) {
return fail(actionError(cause).status, { intent: 'replaceShare', ...actionError(cause) });
}
},
deleteShare: async ({ locals, params, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
deleteShare(actor, text(data, 'shareId'), params.id);
return { intent: 'deleteShare', success: 'Freigabe gelöscht.' };
} catch (cause) {
return fail(actionError(cause).status, { intent: 'deleteShare', ...actionError(cause) });
}
},
duplicateShow: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
const duplicate = duplicateCompletedShow(params.id, text(data, 'commandId'));
redirect(303, `/sendungen/${duplicate.id}`);
} catch (cause) {
if (cause && typeof cause === 'object' && 'status' in cause) throw cause;
return fail(actionError(cause).status, { intent: 'duplicateShow', ...actionError(cause) });
}
},
saveMetadata: async ({ locals, params, request }) => { saveMetadata: async ({ locals, params, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
@@ -167,7 +253,8 @@ export const actions = {
addShowSection(params.id, Number(data.get('revision')), { addShowSection(params.id, Number(data.get('revision')), {
name: text(data, 'name'), name: text(data, 'name'),
categoryId: text(data, 'categoryId') || null, categoryId: text(data, 'categoryId') || null,
explicitDurationSeconds: nullableDuration(data) explicitDurationSeconds: nullableDuration(data),
twitchTags: text(data, 'twitchTags').split(/[\n,]/)
}) })
); );
}, },
@@ -178,7 +265,8 @@ export const actions = {
updateShowSection(params.id, text(data, 'sectionId'), Number(data.get('revision')), { updateShowSection(params.id, text(data, 'sectionId'), Number(data.get('revision')), {
name: text(data, 'name'), name: text(data, 'name'),
categoryId: text(data, 'categoryId') || null, categoryId: text(data, 'categoryId') || null,
explicitDurationSeconds: nullableDuration(data) explicitDurationSeconds: nullableDuration(data),
twitchTags: text(data, 'twitchTags').split(/[\n,]/)
}) })
); );
}, },
@@ -250,6 +338,9 @@ export const actions = {
presentationDescription: text(data, 'presentationDescription'), presentationDescription: text(data, 'presentationDescription'),
presentationImageUrl: text(data, 'presentationImageUrl'), presentationImageUrl: text(data, 'presentationImageUrl'),
presentationSiteName: text(data, 'presentationSiteName'), presentationSiteName: text(data, 'presentationSiteName'),
preparationStatus: text(data, 'preparationStatus') as PieceUsageUpdate['preparationStatus'],
preparationEffort: (text(data, 'preparationEffort') ||
null) as PieceUsageUpdate['preparationEffort'],
presentationMediaId: text(data, 'presentationMediaId') || null, presentationMediaId: text(data, 'presentationMediaId') || null,
tools: toolIds.map((id, index) => ({ tools: toolIds.map((id, index) => ({
id, id,
+180 -28
View File
@@ -7,6 +7,7 @@
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte'; import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte'; import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte';
import { formatClock, formatDuration } from '#lib/format.js'; import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -20,10 +21,10 @@
| null | null
>(null); >(null);
let dropTarget = $state<string | null>(null); let dropTarget = $state<string | null>(null);
let moveSectionForm: HTMLFormElement; let moveSectionForm = $state<HTMLFormElement>();
let moveUsageForm: HTMLFormElement; let moveUsageForm = $state<HTMLFormElement>();
let addUsageForm: HTMLFormElement; let addUsageForm = $state<HTMLFormElement>();
let removeUsageForm: HTMLFormElement; let removeUsageForm = $state<HTMLFormElement>();
let draggedSectionId = $state(''); let draggedSectionId = $state('');
let draggedUsageId = $state(''); let draggedUsageId = $state('');
let draggedPieceId = $state(''); let draggedPieceId = $state('');
@@ -150,7 +151,7 @@
draggedSectionId = dragged.id; draggedSectionId = dragged.id;
dropPosition = position; dropPosition = position;
finishDrag(); finishDrag();
setTimeout(() => moveSectionForm.requestSubmit()); setTimeout(() => moveSectionForm?.requestSubmit());
} }
function dropUsage(sectionId: string | null, position: number) { function dropUsage(sectionId: string | null, position: number) {
@@ -160,12 +161,12 @@
if (dragged.kind === 'piece') { if (dragged.kind === 'piece') {
draggedPieceId = dragged.id; draggedPieceId = dragged.id;
finishDrag(); finishDrag();
setTimeout(() => addUsageForm.requestSubmit()); setTimeout(() => addUsageForm?.requestSubmit());
return; return;
} }
draggedUsageId = dragged.id; draggedUsageId = dragged.id;
finishDrag(); finishDrag();
setTimeout(() => moveUsageForm.requestSubmit()); setTimeout(() => moveUsageForm?.requestSubmit());
} }
function removeDraggedUsage() { function removeDraggedUsage() {
@@ -176,7 +177,7 @@
} }
removedUsageId = dragged.id; removedUsageId = dragged.id;
finishDrag(); finishDrag();
setTimeout(() => removeUsageForm.requestSubmit()); setTimeout(() => removeUsageForm?.requestSubmit());
} }
const enhanceCommand: SubmitFunction = ({ formElement, submitter }) => { const enhanceCommand: SubmitFunction = ({ formElement, submitter }) => {
@@ -205,10 +206,106 @@
<svelte:head><title>{data.show.title} | Sendungen | Sendewerk</title></svelte:head> <svelte:head><title>{data.show.title} | Sendungen | Sendewerk</title></svelte:head>
{#if data.historical}
<div class="page-wrap historical-show-page">
<header class="page-heading show-heading">
<div class="show-heading-copy">
<a class="back-link" href={resolve('/archiv')}><AppIcon name="arrow-left" /> Archiv</a>
<p class="eyebrow">Unveränderliche Sendungshistorie</p>
<h1>{data.show.title}</h1>
<p class="show-date">
{data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'}
</p>
</div>
<div class="show-summary">
<div><strong>{plannedCount}</strong><span>Pieces</span></div>
<div>
<strong>{formatDuration(data.show.totalDurationSeconds)}</strong><span>Planzeit</span>
</div>
<form method="POST" action="?/duplicateShow">
<input type="hidden" name="commandId" value={data.duplicateCommandId} />
<button class="secondary-button" type="submit">Als neue Sendung duplizieren</button>
</form>
</div>
</header>
{#if form?.error && !form.intent?.toLowerCase().includes('share')}<div
class="form-message error"
role="alert"
>
{form.error}
</div>{/if}
{#if data.canManageShares}<ShareManagement
shows={[data.show]}
shares={data.shares}
fixedShowId={data.show.id}
{form}
/>{/if}
<div class="historical-rundown">
{#each data.show.sections as section (section.id)}<section
class="quiet-panel historical-section"
>
<header>
<div>
{#if section.category}<small
style={`--tax-color: ${section.categoryColor ?? '#67dde8'}`}
>{section.category}</small
>{/if}
<h2>{section.name}</h2>
</div>
<strong>{formatDuration(section.durationSeconds)}</strong>
</header>
{#each section.usages as usage (usage.id)}<article class="historical-usage">
<header>
<div>
<small
>{usage.status === 'done'
? 'Gesendet'
: usage.status === 'skipped'
? 'Übersprungen'
: 'Offen beim Abschluss'}</small
>
<h3>{usage.title}</h3>
</div>
<span
>{formatDuration(data.actualDurations[usage.id] ?? 0)} Ist · {formatDuration(
usage.durationSeconds
)} Plan</span
>
</header>
{#if usage.intro}<section>
<strong>Einleitung</strong>
<p>{usage.intro}</p>
</section>{/if}
{#if usage.moderation}<section>
<strong>Moderation</strong>
<p>{usage.moderation}</p>
</section>{/if}
{#if usage.interaction}<section>
<strong>Interaktion</strong>
<p>{usage.interaction}</p>
</section>{/if}
{#if usage.transition}<section>
<strong>Überleitung</strong>
<p>{usage.transition}</p>
</section>{/if}
{#if usage.research}<details>
<summary>Recherche</summary>
<p>{usage.research}</p>
</details>{/if}
{#if usage.ownNotes}<details>
<summary>Eigene Notizen</summary>
<p>{usage.ownNotes}</p>
</details>{/if}
</article>{/each}
</section>{/each}
</div>
</div>
{:else}
<div class="page-wrap builder-page"> <div class="page-wrap builder-page">
<header class="page-heading show-heading"> <header class="page-heading show-heading">
<div class="show-heading-copy"> <div class="show-heading-copy">
<a class="back-link" href={resolve('/sendungen')}><AppIcon name="arrow-left" /> Sendungen</a> <a class="back-link" href={resolve('/sendungen')}><AppIcon name="arrow-left" /> Sendungen</a
>
<h1>{data.show.title}</h1> <h1>{data.show.title}</h1>
<p class="show-date"> <p class="show-date">
{data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'} {data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'}
@@ -225,8 +322,18 @@
</div> </div>
</header> </header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#if form?.error && !form.intent?.toLowerCase().includes('share')}<div
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if} class="form-message error"
role="alert"
>
{form.error}
</div>{/if}
{#if form?.success && !form.intent?.toLowerCase().includes('share')}<div
class="form-message success"
role="status"
>
{form.success}
</div>{/if}
<div class="sr-only" role="status" aria-live="polite">{copyMessage}</div> <div class="sr-only" role="status" aria-live="polite">{copyMessage}</div>
<details class="show-settings"> <details class="show-settings">
@@ -270,8 +377,9 @@
>Ankündigung<span class="copy-field" >Ankündigung<span class="copy-field"
><textarea name="announcementText" maxlength="20000" rows="4" ><textarea name="announcementText" maxlength="20000" rows="4"
>{data.show.announcementText}</textarea >{data.show.announcementText}</textarea
><button type="button" onclick={() => copyText('Ankündigung', data.show.announcementText)} ><button
>Kopieren</button type="button"
onclick={() => copyText('Ankündigung', data.show.announcementText)}>Kopieren</button
></span ></span
></label ></label
> >
@@ -282,6 +390,12 @@
</div> </div>
</form> </form>
</details> </details>
{#if data.canManageShares}<ShareManagement
shows={[data.show]}
shares={data.shares}
fixedShowId={data.show.id}
{form}
/>{/if}
<div class="mobile-pane-switch" aria-label="Arbeitsbereich auswählen"> <div class="mobile-pane-switch" aria-label="Arbeitsbereich auswählen">
<button <button
@@ -317,7 +431,7 @@
<header class="pane-heading"> <header class="pane-heading">
<div> <div>
<p class="eyebrow">Quelle</p> <p class="eyebrow">Quelle</p>
<h2 id="source-title">Sendefertiger Fundus</h2> <h2 id="source-title">Planbarer Fundus</h2>
</div> </div>
<span>{visiblePieces.length}</span> <span>{visiblePieces.length}</span>
</header> </header>
@@ -418,7 +532,8 @@
>{/if}{piece.title} >{/if}{piece.title}
</h3> </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>
{#if piece.isPlannedInCurrentShow}<span class="planned-label">Eingeplant</span>{/if} {#if piece.isPlannedInCurrentShow}<span class="planned-label">Eingeplant</span>{/if}
<PieceUsageHistory history={piece.sentHistory} /> <PieceUsageHistory history={piece.sentHistory} />
@@ -538,13 +653,15 @@
/><input type="hidden" name="position" value={index + 1} /><button /><input type="hidden" name="position" value={index + 1} /><button
type="submit" type="submit"
disabled={index === data.show.sections.length - 1} disabled={index === data.show.sections.length - 1}
aria-label={`${section.name} nach unten`}><AppIcon name="arrow-down" /></button aria-label={`${section.name} nach unten`}
><AppIcon name="arrow-down" /></button
> >
</form> </form>
</div> </div>
{#if section.usages.length}<div class="usage-list"> {#if section.usages.length}<div class="usage-list">
{#each section.usages as usage, usageIndex (usage.id)} {#each section.usages as usage, usageIndex (usage.id)}
<div <div
id={`usage-${usage.id}`}
class="usage-drag-slot" class="usage-drag-slot"
role="presentation" role="presentation"
ondragover={(event) => { ondragover={(event) => {
@@ -593,11 +710,15 @@
</div> </div>
<div class="row-actions"> <div class="row-actions">
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage"> <form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={data.show.revision} /><input <input
type="hidden" type="hidden"
name="usageId" name="revision"
value={usage.id} value={data.show.revision}
/><input type="hidden" name="sectionId" value={section.id} /><input /><input type="hidden" name="usageId" value={usage.id} /><input
type="hidden"
name="sectionId"
value={section.id}
/><input
type="hidden" type="hidden"
name="position" name="position"
value={usageIndex - 1} value={usageIndex - 1}
@@ -609,11 +730,15 @@
> >
</form> </form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage"> <form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={data.show.revision} /><input <input
type="hidden" type="hidden"
name="usageId" name="revision"
value={usage.id} value={data.show.revision}
/><input type="hidden" name="sectionId" value={section.id} /><input /><input type="hidden" name="usageId" value={usage.id} /><input
type="hidden"
name="sectionId"
value={section.id}
/><input
type="hidden" type="hidden"
name="position" name="position"
value={usageIndex + 1} value={usageIndex + 1}
@@ -626,6 +751,7 @@
</form> </form>
<UsageEditorDialog <UsageEditorDialog
{usage} {usage}
open={page.url.searchParams.get('usage') === usage.id}
revision={data.show.revision} revision={data.show.revision}
sectionId={section.id} sectionId={section.id}
sections={data.show.sections} sections={data.show.sections}
@@ -637,14 +763,20 @@
</div> </div>
{/each} {/each}
{#if showsUsagePreview(section.id, section.usages.length)} {#if showsUsagePreview(section.id, section.usages.length)}
<div class="drag-insert-preview usage-insert-preview" data-drop-preview="usage"> <div
class="drag-insert-preview usage-insert-preview"
data-drop-preview="usage"
>
<strong>{draggedTitle()}</strong><small>Hier einfügen</small> <strong>{draggedTitle()}</strong><small>Hier einfügen</small>
</div> </div>
{/if} {/if}
</div>{:else} </div>{:else}
{#if showsUsagePreview(section.id, 0)} {#if showsUsagePreview(section.id, 0)}
<div class="usage-list"> <div class="usage-list">
<div class="drag-insert-preview usage-insert-preview" data-drop-preview="usage"> <div
class="drag-insert-preview usage-insert-preview"
data-drop-preview="usage"
>
<strong>{draggedTitle()}</strong><small>Hier einfügen</small> <strong>{draggedTitle()}</strong><small>Hier einfügen</small>
</div> </div>
</div> </div>
@@ -675,7 +807,12 @@
name="sectionId" name="sectionId"
value={section.id} value={section.id}
/><label /><label
>Name<input name="name" required maxlength="200" value={section.name} /></label >Name<input
name="name"
required
maxlength="200"
value={section.name}
/></label
><label ><label
>Kategorie<select name="categoryId" >Kategorie<select name="categoryId"
><option value="">Ohne Kategorie</option ><option value="">Ohne Kategorie</option
@@ -693,6 +830,13 @@
value={section.explicitDurationSeconds ?? ''} value={section.explicitDurationSeconds ?? ''}
placeholder="Summe der Pieces" placeholder="Summe der Pieces"
/></label /></label
><label
>Twitch-Tags<input
name="twitchTags"
maxlength="300"
value={section.twitchTags.join(', ')}
placeholder="Gaming, Deutsch"
/></label
><button class="secondary-button" type="submit">Abschnitt speichern</button> ><button class="secondary-button" type="submit">Abschnitt speichern</button>
</form> </form>
<form <form
@@ -750,6 +894,12 @@
max="86400" max="86400"
placeholder="optional, Sekunden" placeholder="optional, Sekunden"
/></label /></label
><label
>Twitch-Tags<input
name="twitchTags"
maxlength="300"
placeholder="Gaming, Deutsch"
/></label
><button class="primary-button" type="submit">Abschnitt hinzufügen</button> ><button class="primary-button" type="submit">Abschnitt hinzufügen</button>
</form> </form>
@@ -841,7 +991,8 @@
/><button /><button
class="text-button danger-text" class="text-button danger-text"
type="submit" type="submit"
aria-label={`${usage.title} aus Sendung entfernen`}>Aus Sendung entfernen</button aria-label={`${usage.title} aus Sendung entfernen`}
>Aus Sendung entfernen</button
> >
</form> </form>
<UsageEditorDialog <UsageEditorDialog
@@ -921,3 +1072,4 @@
<input type="hidden" name="usageId" value={removedUsageId} /> <input type="hidden" name="usageId" value={removedUsageId} />
</form> </form>
</div> </div>
{/if}
+156
View File
@@ -8,7 +8,22 @@ 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 {
createShare,
deleteShare,
listShares,
replaceShareToken,
updateShare
} from '#lib/server/shares.js';
import { listShows } from '#lib/server/shows.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js'; import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js';
import { deleteFeedSource, fetchFeed, listFeedSources, saveFeedSource } from '#lib/server/feeds.js';
import {
createTechnicalAccess,
listTechnicalAccesses,
revokeTechnicalAccess
} from '#lib/server/technical-access.js';
import { import {
inviteUser, inviteUser,
isRole, isRole,
@@ -28,6 +43,10 @@ export const load: PageServerLoad = ({ locals }) => {
users: listUsers(), users: listUsers(),
invitations: listOpenInvitations(), invitations: listOpenInvitations(),
tools: listTools(true), tools: listTools(true),
shows: listShows(),
shares: listShares(locals.user!),
feeds: listFeedSources(),
technicalAccesses: listTechnicalAccesses(),
mailTransport: getMailConfig().transport mailTransport: getMailConfig().transport
}; };
}; };
@@ -50,6 +69,12 @@ function actionError(cause: unknown): string {
if (code === 'INVALID_TOOL_FORMAT') if (code === 'INVALID_TOOL_FORMAT')
return 'Mindestens ein gewähltes Format existiert nicht mehr.'; return 'Mindestens ein gewähltes Format existiert nicht mehr.';
if (code === 'INVALID_TOOL') return 'Bitte die Werkzeugdaten prüfen.'; if (code === 'INVALID_TOOL') return 'Bitte die Werkzeugdaten prüfen.';
if (code === 'INVALID_PASSWORD') return 'Ein Freigabepasswort benötigt mindestens 12 Zeichen.';
if (code === 'INVALID_EXPIRY') return 'Der Ablaufzeitpunkt muss in der Zukunft liegen.';
if (code === 'INVALID_SCOPE') return 'Der Informationsumfang ist ungültig.';
if (code === 'INVALID_PASSWORD_MODE') return 'Die Passwortoption ist ungültig.';
if (code === 'INVALID_FEED') return 'Name, URL und Abrufintervall bitte prüfen.';
if (code === 'INVALID_ACCESS') return 'Name und Berechtigung bitte prüfen.';
return 'Die Aktion konnte nicht ausgeführt werden.'; return 'Die Aktion konnte nicht ausgeführt werden.';
} }
@@ -65,6 +90,64 @@ function toolInput(data: FormData): ToolInput {
} }
export const actions = { export const actions = {
createShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const share = await createShare(actor, {
showId: String(data.get('showId') || ''),
scope: String(data.get('scope') || ''),
password: String(data.get('password') || ''),
expiresAtUtc: berlinLocalToUtc(String(data.get('expiresAt') || ''), 'INVALID_EXPIRY')
});
return {
intent: 'createShare',
success: 'Leselink angelegt. Der Link wird nur jetzt vollständig angezeigt.',
shareUrl: `/freigabe/${share.token}`
};
} catch (cause) {
return fail(400, { intent: 'createShare', error: actionError(cause) });
}
},
updateShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
await updateShare(actor, String(data.get('shareId') || ''), {
scope: String(data.get('scope') || ''),
passwordMode: String(data.get('passwordMode') || ''),
password: String(data.get('password') || ''),
expiresAtUtc: berlinLocalToUtc(String(data.get('expiresAt') || ''), 'INVALID_EXPIRY')
});
return { intent: 'updateShare', success: 'Freigabe aktualisiert.' };
} catch (cause) {
return fail(400, { intent: 'updateShare', error: actionError(cause) });
}
},
replaceShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const token = replaceShareToken(actor, String(data.get('shareId') || ''));
return {
intent: 'replaceShare',
success: 'Ersatzlink angelegt. Der bisherige Link ist ungültig.',
shareUrl: `/freigabe/${token}`
};
} catch (cause) {
return fail(400, { intent: 'replaceShare', error: actionError(cause) });
}
},
deleteShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
deleteShare(actor, String(data.get('shareId') || ''));
return { intent: 'deleteShare', success: 'Freigabe gelöscht.' };
} catch (cause) {
return fail(400, { intent: 'deleteShare', error: actionError(cause) });
}
},
invite: async ({ locals, request }) => { invite: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user); const actor = requireBarkeeper(locals.user);
const data = await request.formData(); const data = await request.formData();
@@ -196,5 +279,78 @@ export const actions = {
} catch (cause) { } catch (cause) {
return fail(400, { intent: 'tool', error: actionError(cause) }); return fail(400, { intent: 'tool', error: actionError(cause) });
} }
},
saveFeed: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
try {
saveFeedSource(String(data.get('feedId') || '') || null, {
name: String(data.get('name') || ''),
url: String(data.get('url') || ''),
isEnabled: data.get('isEnabled') === '1',
refreshIntervalMinutes: Number(data.get('refreshIntervalMinutes')),
defaultCategoryId: String(data.get('defaultCategoryId') || '') || null
});
return { intent: 'feed', success: 'Feed gespeichert.' };
} catch (cause) {
return fail(400, { intent: 'feed', error: actionError(cause) });
}
},
deleteFeed: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
try {
deleteFeedSource(String(data.get('feedId') || ''));
return { intent: 'feed', success: 'Feed entfernt.' };
} catch (cause) {
return fail(400, { intent: 'feed', error: actionError(cause) });
}
},
fetchFeed: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
try {
const result = await fetchFeed(String(data.get('feedId') || ''));
return {
intent: 'feed',
success: result.unchanged
? 'Feed ist unverändert.'
: `${result.inserted} neue Beiträge abgerufen.`
};
} catch {
return fail(400, {
intent: 'feed',
error: 'Feed konnte nicht sicher abgerufen oder gelesen werden.'
});
}
},
createTechnicalAccess: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const created = createTechnicalAccess(
actor,
String(data.get('name') || ''),
String(data.get('permissions') || '')
);
return {
intent: 'technicalAccess',
success:
'Technischer Zugang angelegt. Token jetzt sicher hinterlegen; es wird nicht erneut angezeigt.',
technicalToken: created.token
};
} catch (cause) {
return fail(400, { intent: 'technicalAccess', error: actionError(cause) });
}
},
revokeTechnicalAccess: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
revokeTechnicalAccess(actor, String(data.get('accessId') || ''));
return { intent: 'technicalAccess', success: 'Technischer Zugang widerrufen.' };
} catch (cause) {
return fail(400, { intent: 'technicalAccess', error: actionError(cause) });
}
} }
} satisfies Actions; } satisfies Actions;
+275 -3
View File
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import IconPicker from '#lib/components/IconPicker.svelte'; import IconPicker from '#lib/components/IconPicker.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
import ToolEditorDialog from '#lib/components/ToolEditorDialog.svelte'; import ToolEditorDialog from '#lib/components/ToolEditorDialog.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
@@ -18,8 +20,200 @@
<h1>Verwaltung</h1> <h1>Verwaltung</h1>
<p class="page-intro">Konten, Werkzeuge 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 && !form.intent?.toLowerCase().includes('share')}<div
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if} class="form-message error"
role="alert"
>
{form.error}
</div>{/if}
{#if form?.success && !form.intent?.toLowerCase().includes('share')}<div
class="form-message success"
role="status"
>
{form.success}
</div>{/if}
<h2 class="section-heading">Schnittstellen</h2>
<section class="quiet-panel integration-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Import</p>
<h2>RSS-Feeds</h2>
</div>
<span>{data.feeds.length} eingerichtet</span>
</div>
<p>
Aktive Feeds werden im gewählten Intervall abgerufen. Beiträge landen zuerst separat im
Eingang.
</p>
<details class="integration-create">
<summary class="compact-add-button"><AppIcon name="plus" /> Feed hinzufügen</summary>
<form class="integration-form" method="POST" action="?/saveFeed">
<label>Name<input name="name" required maxlength="120" /></label>
<label
>Feed-URL<input
name="url"
type="url"
required
maxlength="2000"
placeholder="https://example.org/feed.xml"
/></label
>
<label
>Intervall in Minuten<input
name="refreshIntervalMinutes"
type="number"
min="15"
max="10080"
value="60"
required
/></label
>
<label
>Standardkategorie<select name="defaultCategoryId"
><option value="">Keine</option
>{#each data.taxonomy.categories as category (category.id)}<option value={category.id}
>{category.name}</option
>{/each}</select
></label
>
<label class="check-row"
><input type="checkbox" name="isEnabled" value="1" checked /> Automatisch abrufen</label
>
<button class="primary-button" type="submit">Feed anlegen</button>
</form>
</details>
<div class="integration-list">
{#each data.feeds as feed (feed.id)}
<details>
<summary
><span
><strong>{feed.name}</strong><small
>{feed.lastError
? `Fehler: ${feed.lastError}`
: feed.lastFetchedAtUtc
? `Zuletzt: ${new Date(feed.lastFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch nicht abgerufen'}</small
></span
><AppIcon name="pen" /></summary
>
<form class="integration-form" method="POST" action="?/saveFeed">
<input type="hidden" name="feedId" value={feed.id} />
<label>Name<input name="name" required maxlength="120" value={feed.name} /></label>
<label
>Feed-URL<input
name="url"
type="url"
required
maxlength="2000"
value={feed.url}
/></label
>
<label
>Intervall in Minuten<input
name="refreshIntervalMinutes"
type="number"
min="15"
max="10080"
value={feed.refreshIntervalMinutes}
required
/></label
>
<label
>Standardkategorie<select name="defaultCategoryId"
><option value="">Keine</option
>{#each data.taxonomy.categories as category (category.id)}<option
value={category.id}
selected={feed.defaultCategoryId === category.id}>{category.name}</option
>{/each}</select
></label
>
<label class="check-row"
><input type="checkbox" name="isEnabled" value="1" checked={feed.isEnabled} /> Automatisch
abrufen</label
>
<button class="secondary-button" type="submit">Speichern</button>
</form>
<div class="row-actions">
<form method="POST" action="?/fetchFeed">
<input type="hidden" name="feedId" value={feed.id} /><button
class="text-button"
type="submit">Jetzt abrufen</button
>
</form>
<form
method="POST"
action="?/deleteFeed"
onsubmit={(event) =>
!confirm('Feed und seinen Importeingang löschen?') && event.preventDefault()}
>
<input type="hidden" name="feedId" value={feed.id} /><button
class="text-button danger-text"
type="submit">Feed löschen</button
>
</form>
</div>
</details>
{/each}
</div>
</section>
<section class="quiet-panel integration-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Externe Assistenz</p>
<h2>Technische Zugänge</h2>
</div>
<a href={resolve('/api/v1/openapi.json')} target="_blank">OpenAPI</a>
</div>
<p>
Tokens gelten ausschließlich für die API. Schreibende Aufrufe werden protokolliert und müssen
idempotent sein.
</p>
{#if form?.technicalToken}<div class="token-reveal" role="status">
<strong>Token, einmalig sichtbar</strong><code>{form.technicalToken}</code>
</div>{/if}
<details class="integration-create">
<summary class="compact-add-button"><AppIcon name="plus" /> Zugang hinzufügen</summary>
<form class="integration-form" method="POST" action="?/createTechnicalAccess">
<label
>Name<input
name="name"
required
maxlength="120"
placeholder="Redaktionsassistenz"
/></label
>
<label
>Berechtigung<select name="permissions"
><option value="read">Nur lesen</option><option value="read_write"
>Lesen und schreiben</option
></select
></label
>
<button class="primary-button" type="submit">Zugang anlegen</button>
</form>
</details>
<div class="integration-list">
{#each data.technicalAccesses as access (access.id)}
<div class="access-row">
<span
><strong>{access.name}</strong><small
>{access.permissions === 'read' ? 'Nur lesen' : 'Lesen und schreiben'} · {access.revokedAtUtc
? 'Widerrufen'
: access.lastUsedAtUtc
? `Zuletzt genutzt ${new Date(access.lastUsedAtUtc).toLocaleString('de-DE')}`
: 'Noch nicht genutzt'}</small
></span
>{#if !access.revokedAtUtc}<form method="POST" action="?/revokeTechnicalAccess">
<input type="hidden" name="accessId" value={access.id} /><button
class="secondary-button"
type="submit">Widerrufen</button
>
</form>{/if}
</div>
{/each}
</div>
</section>
<section class="quiet-panel account-panel"> <section class="quiet-panel account-panel">
<div class="panel-heading"> <div class="panel-heading">
@@ -117,7 +311,8 @@
</section> </section>
</div> </div>
<h2 class="section-heading">Werkzeuge</h2> <h2 class="section-heading">Werkzeuge & Freigaben</h2>
<ShareManagement shows={data.shows} shares={data.shares} {form} catalogPanel />
<section class="quiet-panel tool-management"> <section class="quiet-panel tool-management">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
@@ -260,3 +455,80 @@
</section> </section>
</div> </div>
</div> </div>
<style>
.integration-panel {
display: grid;
gap: 1rem;
margin-block: 1.5rem;
}
.integration-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.8rem;
align-items: end;
}
.integration-create > summary {
width: fit-content;
cursor: pointer;
list-style: none;
}
.integration-create > summary::-webkit-details-marker {
display: none;
}
.integration-create[open] > .integration-form {
margin-top: 0.8rem;
}
.integration-form label:not(.check-row) {
display: grid;
gap: 0.35rem;
}
.check-row {
display: flex;
gap: 0.5rem;
align-items: center;
min-height: 2.6rem;
}
.integration-list {
display: grid;
gap: 0.5rem;
}
.integration-list details,
.access-row {
padding: 0.75rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel-soft);
}
.integration-list summary,
.access-row {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.integration-list summary > span,
.access-row > span {
display: grid;
gap: 0.2rem;
}
.integration-list small {
color: var(--text-muted);
}
.row-actions {
display: flex;
gap: 1rem;
margin-top: 0.75rem;
}
.token-reveal {
display: grid;
gap: 0.4rem;
padding: 0.8rem;
border: 2px solid var(--color-accent);
border-radius: 0.7rem;
overflow-wrap: anywhere;
}
.token-reveal code {
user-select: all;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
{
"name": "Sendewerk",
"short_name": "Sendewerk",
"description": "Redaktion, Sendungsplanung und Live-Regie für heinzlive",
"start_url": "/sendungen",
"display": "standalone",
"background_color": "#0b1220",
"theme_color": "#0b1220",
"lang": "de",
"orientation": "any"
}
+391 -14
View File
@@ -1,6 +1,6 @@
import { expect, test, type Locator, type Page } from '@playwright/test'; import { expect, test, type Locator, type Page } from '@playwright/test';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { readFileSync, readdirSync } from 'node:fs'; import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
const adminEmail = 'barkeeper@example.test'; const adminEmail = 'barkeeper@example.test';
@@ -85,7 +85,8 @@ function addSentHistoryFixture(pieceId: string) {
database.pragma('foreign_keys = ON'); database.pragma('foreign_keys = ON');
const insertShow = database.prepare( const insertShow = database.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status, `INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, '', '', 'completed', 1, ?, ?)` revision, created_at_utc, updated_at_utc, completed_at_utc)
VALUES (?, ?, ?, '', '', 'completed', 1, ?, ?, ?)`
); );
const insertSection = database.prepare( const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position, `INSERT INTO show_sections (id, show_id, name, category_id, position,
@@ -104,9 +105,24 @@ function addSentHistoryFixture(pieceId: string) {
] as const) { ] as const) {
const showId = `e2e-history-${suffix}`; const showId = `e2e-history-${suffix}`;
const sectionId = `e2e-history-section-${suffix}`; const sectionId = `e2e-history-section-${suffix}`;
insertShow.run(showId, title, date, date, date); insertShow.run(showId, title, date, date, date, date);
insertSection.run(sectionId, showId); insertSection.run(sectionId, showId);
insertUsage.run(`e2e-history-usage-${suffix}`, sectionId, pieceId); const usageId = `e2e-history-usage-${suffix}`;
insertUsage.run(usageId, sectionId, pieceId);
database
.prepare(
`INSERT INTO show_finalizations
(show_id, command_id, source_revision, finalized_at_utc) VALUES (?, ?, 1, ?)`
)
.run(showId, `fixture-${suffix}`, date);
database
.prepare(
`INSERT INTO piece_usage_history
(usage_id, show_id, original_piece_id, show_title_snapshot,
usage_title_snapshot, used_at_utc, actual_duration_seconds)
VALUES (?, ?, ?, ?, 'Historische Verwendung', ?, 300)`
)
.run(usageId, showId, pieceId, title, date);
} }
})(); })();
database.close(); database.close();
@@ -117,10 +133,120 @@ function removeSentHistoryFixture() {
if (!dataDirectory) return; if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite')); const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON'); database.pragma('foreign_keys = ON');
database.prepare("DELETE FROM piece_usage_history WHERE show_id LIKE 'e2e-history-%'").run();
database.prepare("DELETE FROM shows WHERE id LIKE 'e2e-history-%'").run(); database.prepare("DELETE FROM shows WHERE id LIKE 'e2e-history-%'").run();
database.close(); database.close();
} }
function addCompletionFixture() {
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.pragma('foreign_keys = ON');
const now = '2026-10-25T19:00:00.000Z';
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
mkdirSync(path.join(dataDirectory, 'uploads'), { recursive: true });
writeFileSync(path.join(dataDirectory, 'uploads', 'e2e-share.png'), png);
database.transaction(() => {
database
.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc)
VALUES ('e2e-completion', 'E2E Abschluss', ?, '', '', 'live', 1, ?, ?)`
)
.run(now, now, now);
database
.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES
('e2e-completion-section', 'e2e-completion', 'Finaler Ablauf', NULL, 0, NULL, 0),
('e2e-completion-reserve', 'e2e-completion', 'Reservebank', NULL, 1, NULL, 1)`
)
.run();
const insertUsage = database.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot,
interaction_snapshot, transition_snapshot, presentation_snapshot, own_notes, is_reserve)
VALUES (?, 'e2e-completion-section', ?, ?, ?, 300, ?, ?, ?, ?, ?, ?, '', ?, 0)`
);
insertUsage.run(
'e2e-completion-done',
'piece-console',
0,
'done',
'Gesendeter Abschlussbeitrag',
'FREIGABE_EINLEITUNG',
'PRIVATE_FREIGABE_RECHERCHE',
'FREIGABE_MODERATION',
'FREIGABE_INTERAKTION',
'FREIGABE_UEBERLEITUNG',
'PRIVATE_FREIGABE_NOTIZ'
);
insertUsage.run(
'e2e-completion-skipped',
'piece-effects',
1,
'skipped',
'Übersprungener Abschlussbeitrag',
'',
'PRIVATE_SKIPPED_RESEARCH',
'',
'',
'',
''
);
database
.prepare(
`INSERT INTO show_live_states
(show_id, revision, started_at_utc, ended_at_utc, presentation_revision, updated_at_utc)
VALUES ('e2e-completion', 0, ?, ?, 0, ?)`
)
.run('2026-10-25T18:00:00.000Z', now, now);
database
.prepare(
`INSERT INTO piece_media
(id, piece_id, storage_name, original_name, mime_type, byte_size, alt_text, position, created_at_utc)
VALUES ('e2e-share-media', 'piece-console', 'e2e-share.png', 'intern.png',
'image/png', ?, 'Freigegebenes Abschlussbild', 77, ?)`
)
.run(png.byteLength, now);
database
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES ('e2e-share-snapshot', 'e2e-completion-done', 'e2e-share-media',
'Freigegebenes Abschlussbild', 0, 1)`
)
.run();
})();
database.close();
}
function removeCompletionFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
database.transaction(() => {
database
.prepare(
`DELETE FROM show_share_sessions WHERE share_id IN (
SELECT id FROM show_shares WHERE show_id = 'e2e-completion'
)`
)
.run();
database.prepare("DELETE FROM show_shares WHERE show_id = 'e2e-completion'").run();
database.prepare("DELETE FROM piece_usage_history WHERE show_id = 'e2e-completion'").run();
database.prepare("DELETE FROM shows WHERE id = 'e2e-completion'").run();
database.prepare("DELETE FROM piece_media WHERE id = 'e2e-share-media'").run();
})();
database.close();
rmSync(path.join(dataDirectory, 'uploads', 'e2e-share.png'), { force: true });
}
function addPresentationFixture() { function addPresentationFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.'); if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
@@ -204,10 +330,10 @@ function addCalendarFixture() {
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status, `INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, '', '', 'draft', 1, ?, ?)` revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, '', '', 'draft', 1, ?, ?)`
); );
const now = '2026-10-24T10:00:00.000Z'; const now = '2026-10-25T10:00:00.000Z';
database.transaction(() => { database.transaction(() => {
insert.run('e2e-calendar-late', 'Kalender Spättermin', '2026-10-24T18:00:00.000Z', now, now); insert.run('e2e-calendar-late', 'Kalender Spättermin', '2026-10-25T18:00:00.000Z', now, now);
insert.run('e2e-calendar-early', 'Kalender Frühtermin', '2026-10-24T08:00:00.000Z', now, now); insert.run('e2e-calendar-early', 'Kalender Frühtermin', '2026-10-25T09:00:00.000Z', now, now);
insert.run( insert.run(
'e2e-calendar-next', 'e2e-calendar-next',
'Kalender Novembertermin', 'Kalender Novembertermin',
@@ -238,6 +364,23 @@ function setSectionDuration(sectionId: string, seconds: number | null) {
database.close(); database.close();
} }
function setUsagePreparation(
usageId: string,
status: 'preparing' | 'ready',
effort: 'low' | 'medium' | 'high' | 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 piece_usages SET preparation_status_snapshot = ?,
preparation_effort_snapshot = ? WHERE id = ?`
)
.run(status, effort, usageId);
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('/');
@@ -539,7 +682,6 @@ test('show overview switches persistently between list, calendar and mobile agen
const octoberDay = page const octoberDay = page
.locator('.show-calendar-day') .locator('.show-calendar-day')
.filter({ hasText: 'Kalender Frühtermin' }); .filter({ hasText: 'Kalender Frühtermin' });
await expect(octoberDay).toHaveClass(/today/);
await expect(octoberDay).toContainText('Kalender Spättermin'); await expect(octoberDay).toContainText('Kalender Spättermin');
await expect(octoberDay.locator('.show-calendar-event')).toHaveCount(2); await expect(octoberDay.locator('.show-calendar-event')).toHaveCount(2);
await expect(octoberDay.locator('.show-calendar-event').first()).toContainText('10:00'); await expect(octoberDay.locator('.show-calendar-event').first()).toContainText('10:00');
@@ -577,6 +719,43 @@ test('show overview switches persistently between list, calendar and mobile agen
} }
}); });
test('preparation opens and updates the concrete scheduled usage', async ({ page }) => {
setUsagePreparation('usage-console', 'preparing', 'high');
try {
await page.goto('/sendungen');
const preparation = page.locator('.preparation-overview');
await expect(preparation).toContainText('Die kleine Konsole mit dem großen Dreh');
await expect(preparation).toContainText('Hoch');
await preparation.getByRole('link', { name: /Die kleine Konsole/ }).click();
await expect(page).toHaveURL(/usage=usage-console/);
const editor = page.getByRole('dialog', {
name: 'Die kleine Konsole mit dem großen Dreh'
});
await expect(editor).toBeVisible();
await editor.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(editor.getByRole('radio', { name: 'Text', exact: true })).toBeChecked();
await editor.getByRole('radio', { name: 'Nur Bild', exact: true }).check();
await expect(editor.getByRole('combobox', { name: 'Quelle/URL', exact: true })).toHaveCount(0);
await editor.getByRole('radio', { name: 'Linkvorschau', exact: true }).check();
await expect(editor.getByRole('combobox', { name: 'Quelle/URL', exact: true })).toBeVisible();
await expect(editor.getByLabel('Kartentitel')).toBeVisible();
await editor.getByRole('radio', { name: 'Video', exact: true }).check();
await expect(editor.getByRole('combobox', { name: 'Video-URL', exact: true })).toBeVisible();
await expect(editor.getByLabel('Videotitel')).toBeVisible();
await editor.getByRole('radio', { name: 'Text', exact: true }).check();
await editor.getByRole('button', { name: 'Sendungstext', exact: true }).click();
await editor.getByText('Dauer und Einplanung', { exact: true }).click();
await editor.getByLabel('Vorbereitungsstatus').selectOption('ready');
await editor.getByRole('button', { name: 'Verwendung speichern' }).click();
await page.goto('/sendungen');
await expect(page.locator('.preparation-overview')).toHaveCount(0);
} finally {
setUsagePreparation('usage-console', 'ready', null);
}
});
test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => { test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus'); await page.goto('/fundus');
@@ -980,6 +1159,7 @@ test('inbox, archive and administration stay concise', async ({ page }) => {
await topicIconPicker.getByText('Icon auswählen', { exact: true }).click(); await topicIconPicker.getByText('Icon auswählen', { exact: true }).click();
const popover = topicIconPicker.locator('.icon-picker-popover'); const popover = topicIconPicker.locator('.icon-picker-popover');
await expect(popover).toBeVisible(); await expect(popover).toBeVisible();
await expect(topicIconPicker.getByPlaceholder('Icon suchen')).toBeFocused();
const popoverBox = await popover.boundingBox(); const popoverBox = await popover.boundingBox();
expect(popoverBox?.x ?? -1).toBeGreaterThanOrEqual(0); expect(popoverBox?.x ?? -1).toBeGreaterThanOrEqual(0);
expect((popoverBox?.x ?? width) + (popoverBox?.width ?? 1)).toBeLessThanOrEqual(width); expect((popoverBox?.x ?? width) + (popoverBox?.width ?? 1)).toBeLessThanOrEqual(width);
@@ -1150,6 +1330,14 @@ test('live control persists state and exposes only explicit presentation data',
}); });
await page.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild'); await page.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild');
await page.getByRole('button', { name: 'Bild hochladen' }).click(); await page.getByRole('button', { name: 'Bild hochladen' }).click();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Nur Bild', exact: true }).check();
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByRole('radio', { name: 'Nur Bild', exact: true })).toBeChecked();
await page.getByRole('radio', { name: 'Text', exact: true }).check();
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.goto('/sendungen/show-october'); await page.goto('/sendungen/show-october');
const plannedConsole = page const plannedConsole = page
.locator('.editable-usage') .locator('.editable-usage')
@@ -1288,8 +1476,11 @@ test('live control persists state and exposes only explicit presentation data',
.locator('.live-run-item.current') .locator('.live-run-item.current')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' }) .filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toBeVisible(); ).toBeVisible();
await expect(page.getByRole('button', { name: 'Überspringen' })).toBeDisabled(); await expect(
await expect(page.getByRole('button', { name: /Erledigen/ })).toBeDisabled(); page.locator('.live-action-bar').getByRole('button', { name: 'Wiederaufnehmen', exact: true })
).toBeVisible();
await expect(page.getByRole('button', { name: 'Überspringen' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /Erledigen/ })).toHaveCount(0);
await expect( await expect(
output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }) output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible(); ).toBeVisible();
@@ -1532,6 +1723,191 @@ test('live control persists state and exposes only explicit presentation data',
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
}); });
test('completion, archive and protected guest sharing work end to end', async ({
page,
browser
}, testInfo) => {
addCompletionFixture();
try {
await page.goto('/sendungen/e2e-completion');
await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const planningShares = page.locator('.share-management');
await expect(planningShares.getByText('Aktualisiert sich bis zum Abschluss')).toBeVisible();
await planningShares.getByLabel('Informationsumfang').selectOption('moderation');
await planningShares.getByRole('button', { name: 'Leselink anlegen' }).click();
const planningSharePath = await planningShares.locator('.share-result a').getAttribute('href');
expect(planningSharePath).toMatch(/^\/freigabe\//);
await page.screenshot({
path: testInfo.outputPath('share-editor-desktop.png'),
fullPage: true
});
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('share-editor-mobile.png'), fullPage: true });
await page.setViewportSize({ width: 1280, height: 900 });
const planningGuest = await browser.newContext();
const planningGuestPage = await planningGuest.newPage();
await planningGuestPage.goto(planningSharePath!);
await expect(planningGuestPage.getByText('Aktuelle Sendungsplanung · Nur lesen')).toBeVisible();
await expect(planningGuestPage.getByText('Gesendeter Abschlussbeitrag')).toBeVisible();
await expect(planningGuestPage.getByText('FREIGABE_MODERATION')).toBeVisible();
const guestThemePreference = await planningGuestPage
.locator('html')
.getAttribute('data-theme-preference');
await planningGuestPage.getByRole('button', { name: /Darstellung:/ }).click();
await expect(planningGuestPage.locator('html')).not.toHaveAttribute(
'data-theme-preference',
guestThemePreference!
);
const planningShareRow = planningShares.locator('.share-row').first();
await planningShareRow.locator('summary').getByText('Ablauf und Moderation').click();
await planningShareRow.getByLabel('Informationsumfang').selectOption('research');
await planningShareRow.locator('select[name="passwordMode"]').selectOption('replace');
await planningShareRow.locator('input[name="password"]').fill('Planung-Freigabe-Passwort');
await planningShareRow.getByRole('button', { name: 'Freigabe speichern' }).click();
await planningGuestPage.reload();
await expect(
planningGuestPage.getByRole('heading', { name: 'Passwort erforderlich' })
).toBeVisible();
page.once('dialog', (dialog) => dialog.accept());
await planningShares
.locator('.share-row')
.first()
.locator('summary')
.getByText('Vollständig mit Recherche')
.click();
await planningShares.getByRole('button', { name: 'Freigabe löschen' }).click();
await planningGuestPage.reload();
await expect(planningGuestPage.getByRole('heading', { name: 'Nicht verfügbar' })).toBeVisible();
await planningGuest.close();
await page.goto('/live?show=e2e-completion');
await page.getByRole('button', { name: 'Menü', exact: true }).click();
await page.getByRole('button', { name: 'Sendung abschließen' }).click();
const completion = page.locator('.completion-dialog');
await expect(completion).toBeVisible();
await expect(completion).toContainText('1 gesendet');
await expect(completion).toContainText('1 übersprungen');
await expect(completion.getByText('Die kleine Konsole mit dem großen Dreh')).toBeVisible();
await expect(completion.getByText('Warum praktische Effekte wieder auffallen')).toHaveCount(0);
await completion.getByRole('button', { name: 'Historischen Stand abschließen' }).click();
await expect(completion).toBeHidden();
await page.goto('/archiv');
await expect(page.getByRole('link', { name: 'E2E Abschluss' })).toBeVisible();
await page.getByRole('link', { name: 'E2E Abschluss' }).click();
await expect(page.getByText('Unveränderliche Sendungshistorie')).toBeVisible();
await expect(page.getByText('Gesendeter Abschlussbeitrag')).toBeVisible();
await expect(page.getByText('Übersprungener Abschlussbeitrag')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sendungsdaten speichern' })).toHaveCount(0);
expect(
await page
.locator('.historical-section')
.first()
.evaluate((element) => Number.parseFloat(getComputedStyle(element).paddingInlineStart))
).toBeGreaterThan(0);
await page.goto('/verwaltung');
const shareCatalog = page.locator('.share-catalog-panel');
await expect(shareCatalog).toBeVisible();
const catalogOrder = await page.evaluate(() => {
const share = document.querySelector('.share-catalog-panel')?.getBoundingClientRect();
const tools = document.querySelector('.tool-management')?.getBoundingClientRect();
return share && tools ? share.bottom <= tools.top : false;
});
expect(catalogOrder).toBe(true);
await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const shareManagement = page.locator('.share-management');
await expect(page.locator('.share-management-dialog')).toBeVisible();
const dialogStyle = await page.locator('.share-management-dialog').evaluate((element) => ({
borderRadius: Number.parseFloat(getComputedStyle(element).borderRadius),
background: getComputedStyle(element).backgroundColor
}));
expect(dialogStyle.borderRadius).toBeGreaterThan(0);
expect(dialogStyle.background).not.toBe('rgba(0, 0, 0, 0)');
await shareManagement.getByLabel('Sendung').selectOption('e2e-completion');
await shareManagement.getByLabel('Informationsumfang').selectOption('moderation');
await shareManagement.getByLabel('Optionales Passwort').fill('E2E-Freigabe-Passwort');
await shareManagement.getByRole('button', { name: 'Leselink anlegen' }).click();
const shareLink = page.locator('.share-result a');
await expect(shareLink).toBeVisible();
const sharePath = await shareLink.getAttribute('href');
expect(sharePath).toMatch(/^\/freigabe\//);
await page.screenshot({ path: testInfo.outputPath('share-admin-desktop.png'), fullPage: true });
const guest = await browser.newContext();
const guestPage = await guest.newPage();
await guestPage.goto(sharePath!);
await expect(guestPage.getByRole('heading', { name: 'Passwort erforderlich' })).toBeVisible();
const passwordHtml = await guestPage.content();
expect(passwordHtml).not.toContain('password_hash');
expect(passwordHtml).not.toContain('e2e-completion');
await guestPage.getByLabel('Passwort').fill('falsch');
await guestPage.getByRole('button', { name: 'Sendung öffnen' }).click();
await expect(guestPage.getByRole('alert')).toContainText('Passwort falsch');
await guestPage.getByLabel('Passwort').fill('E2E-Freigabe-Passwort');
await guestPage.getByRole('button', { name: 'Sendung öffnen' }).click();
await expect(guestPage.getByRole('heading', { name: 'E2E Abschluss' })).toBeVisible();
await expect(guestPage.getByText('FREIGABE_MODERATION')).toBeVisible();
await expect(guestPage.getByText('PRIVATE_FREIGABE_RECHERCHE')).toHaveCount(0);
await expect(guestPage.getByText('PRIVATE_FREIGABE_NOTIZ')).toHaveCount(0);
await expect(guestPage.getByAltText('Freigegebenes Abschlussbild')).toBeVisible();
expect(
await guestPage
.locator('.shared-section')
.first()
.evaluate((element) => Number.parseFloat(getComputedStyle(element).paddingInlineStart))
).toBeGreaterThan(0);
await guestPage.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(guestPage);
await guestPage.screenshot({ path: testInfo.outputPath('share-mobile.png'), fullPage: true });
const imagePath = await guestPage
.getByAltText('Freigegebenes Abschlussbild')
.getAttribute('src');
const sharedImage = await guest.request.get(imagePath!);
expect(sharedImage.status()).toBe(200);
expect(sharedImage.headers()['content-security-policy']).toBe("default-src 'none'; sandbox");
expect((await guest.request.get(`${sharePath}/media/foreign-id`)).status()).toBe(404);
const guestWrite = await guest.request.post('/sendungen/e2e-completion?/saveMetadata', {
maxRedirects: 0,
form: { title: 'Verboten', revision: '2', status: 'draft' }
});
expect(guestWrite.status()).toBe(403);
await page.goto('/verwaltung');
await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const showCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' });
await expect(showCard.getByText('Abgeschlossen')).toBeVisible();
const shareRow = showCard.locator('.share-row').first();
await shareRow.locator('summary').getByText('Ablauf und Moderation').click();
await shareRow.getByLabel('Informationsumfang').selectOption('rundown');
await shareRow.locator('select[name="passwordMode"]').selectOption('remove');
await shareRow.getByRole('button', { name: 'Freigabe speichern' }).click();
await guestPage.reload();
await expect(guestPage.getByRole('heading', { name: 'E2E Abschluss' })).toBeVisible();
await expect(guestPage.getByText('FREIGABE_MODERATION')).toHaveCount(0);
page.once('dialog', (dialog) => dialog.accept());
const updatedCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' });
await updatedCard
.locator('.share-row')
.first()
.locator('summary')
.getByText('Nur Ablauf')
.click();
await updatedCard.getByRole('button', { name: 'Freigabe löschen' }).click();
await guestPage.reload();
await expect(guestPage.getByRole('heading', { name: 'Nicht verfügbar' })).toBeVisible();
expect((await guest.request.get(imagePath!)).status()).toBe(404);
await guest.close();
} finally {
removeCompletionFixture();
}
});
test('pieces autosave safely, detect conflicts and protect uploaded images', async ({ test('pieces autosave safely, detect conflicts and protect uploaded images', async ({
page, page,
browser browser
@@ -1571,7 +1947,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
); );
await openEditorialField(page, 'Präsentationstext'); await openEditorialField(page, 'Präsentationstext');
await page await page
.getByRole('textbox', { name: 'Öffentlicher Text', exact: true }) .getByRole('textbox', { name: 'Öffentlicher Vorspann', exact: true })
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**'); .fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**');
await openEditorialField(page, 'Private Recherche'); await openEditorialField(page, 'Private Recherche');
await page.getByRole('button', { name: 'Quelle hinzufügen' }).click(); await page.getByRole('button', { name: 'Quelle hinzufügen' }).click();
@@ -1719,7 +2095,7 @@ test('a quickly captured video can be written and previewed before classificatio
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Video', exact: true }).check(); await page.getByRole('radio', { name: 'Video', exact: true }).check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0'); await page.getByRole('combobox', { name: 'Video-URL', exact: true }).selectOption('0');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
const previewPromise = page.waitForEvent('popup'); const previewPromise = page.waitForEvent('popup');
@@ -1745,9 +2121,10 @@ test('link preview uses one existing source, persists manual fields and isolates
await page.goto('/fundus/piece-console'); await page.goto('/fundus/piece-console');
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Linkvorschau', exact: true }).check(); await page.getByRole('radio', { name: 'Linkvorschau', exact: true }).check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0'); await page.getByRole('combobox', { name: 'Quelle/URL', exact: true }).selectOption('0');
await page.getByLabel('Kartentitel').fill('Manuell korrigierte Linkvorschau'); await page.getByLabel('Kartentitel').fill('Manuell korrigierte Linkvorschau');
await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.'); await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.');
await page.getByLabel('Vorschaubild-URL').fill('');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload(); await page.reload();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
@@ -1763,7 +2140,7 @@ test('link preview uses one existing source, persists manual fields and isolates
preview.getByRole('heading', { name: 'Manuell korrigierte Linkvorschau' }) preview.getByRole('heading', { name: 'Manuell korrigierte Linkvorschau' })
).toBeVisible(); ).toBeVisible();
await expect(preview.getByText('Ein kurzer manueller Anreißer ohne Vorschaubild.')).toBeVisible(); await expect(preview.getByText('Ein kurzer manueller Anreißer ohne Vorschaubild.')).toBeVisible();
await expect(preview.locator('.output-link-image')).toHaveCount(1); await expect(preview.locator('.output-link-image')).toHaveCount(0);
await preview.screenshot({ await preview.screenshot({
path: testInfo.outputPath('linkvorschau-desktop.png'), path: testInfo.outputPath('linkvorschau-desktop.png'),
fullPage: true fullPage: true