Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5b1c58ba2 | ||
|
|
64f55a8b1f | ||
|
|
a08b02c34b | ||
|
|
6d96900d81 | ||
|
|
d586fbd8ce | ||
|
|
b80dded55d | ||
|
|
fd47910d09 | ||
|
|
01b5606a3b | ||
|
|
fa17187af4 | ||
|
|
b1474bc33d | ||
|
|
ec596d442e | ||
|
|
bd9ffe6faa | ||
|
|
3389ba17f4 | ||
|
|
c05cea3a6d | ||
|
|
4fe61971e4 | ||
|
|
c3033479b5 | ||
|
|
2851c7d936 | ||
|
|
22ae3a68d6 | ||
|
|
dfa236390d |
No files matched your search
@@ -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.0",
|
"version": "0.7.2",
|
||||||
"healthCheckPath": "/healthz",
|
"healthCheckPath": "/healthz",
|
||||||
"httpPort": 3000,
|
"httpPort": 3000,
|
||||||
"addons": {
|
"addons": {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Third-party notices
|
||||||
|
|
||||||
|
## Font Awesome Pro Plus 7.0.0
|
||||||
|
|
||||||
|
The icon metadata in `src/lib/icons/classic-regular.ts` and the SVG sprite in
|
||||||
|
`static/icons/classic-regular.svg` are derived from Font Awesome Pro Plus 7.0.0
|
||||||
|
Classic Regular. Font Awesome Pro is commercial software and remains subject to
|
||||||
|
the Font Awesome Pro commercial license:
|
||||||
|
https://fontawesome.com/license
|
||||||
|
|
||||||
|
The Sendewerk MIT license does not apply to that icon data.
|
||||||
@@ -1,5 +1,98 @@
|
|||||||
# Abnahme
|
# Abnahme
|
||||||
|
|
||||||
|
## Wartungsrelease 0.7.2
|
||||||
|
|
||||||
|
| Bereich | Nachweis | Stand |
|
||||||
|
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||||
|
| RSS-Auswahl im Eingang (#16) | Chromium wählt zwei Beiträge über Quellenfilter und zwei Seiten, öffnet den Originallink ohne Umschalten und importiert nur die Auswahl | erfüllt am 26.10.2026 |
|
||||||
|
| Live-Menü nach Nutzerabsicht (#18) | Chromium prüft laufenden, pausierten und beendeten Zustand, direkte Timersteuerung, eingeklappte Ausgabe und erreichbare Navigation | erfüllt am 26.10.2026 |
|
||||||
|
| RSS-/API-Verwaltung im Katalogstil (#21) | Chromium legt einen Feed an und bearbeitet ihn; API-Zugang, einmaliges Tokenkopieren und Berechtigungsänderung geprüft | erfüllt am 26.10.2026 |
|
||||||
|
| Responsive Gestaltung und Farbschemata | RSS-Auswahl und Feed-Dialog mobil/dunkel, Verwaltung desktop/hell, Live-Menü mobil und bei 670 × 450 Pixeln geprüft | erfüllt am 26.10.2026 |
|
||||||
|
| Erhalt von Daten, Rechten und Sicherheitslogik | 13 gezielte Feed-/Live-/Zugangstests sowie vollständiger Verhaltenstestlauf; keine Schemaänderung | 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`: 20 von 20 Chromium-Abläufen erfolgreich.
|
||||||
|
- Andere Browser und echte Mobilgeräte: offen.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
@@ -257,3 +350,286 @@
|
|||||||
programmatisch geprüft
|
programmatisch geprüft
|
||||||
- vollständige Unit- und E2E-Matrix gemäß Auftrag nicht erneut ausgeführt
|
- vollständige Unit- und E2E-Matrix gemäß Auftrag nicht erneut ausgeführt
|
||||||
- `git diff --check`: erfolgreich
|
- `git diff --check`: erfolgreich
|
||||||
|
|
||||||
|
## Oberflächenkorrektur 0.5.6
|
||||||
|
|
||||||
|
| Kriterium | Nachweis | Stand |
|
||||||
|
| --------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------- |
|
||||||
|
| Gold-/Violettlicht und Glasflächen sichtbar | Fundus und Editor in Light/Dark auf Desktop und Mobil visuell sowie über berechnete Werte geprüft | erfüllt am 22.10.2026 |
|
||||||
|
| Schreibflächen ruhig und eindeutig abgegrenzt | deckende Feldfarbe, Kontur und goldener Fokus im geöffneten Piece-Editor geprüft | erfüllt am 22.10.2026 |
|
||||||
|
| Neuanlage und Suche unmittelbar erreichbar | sichtbare Fundusaktionen sowie geöffnete Vorlagenauswahl auf Desktop und Mobil geprüft | erfüllt am 22.10.2026 |
|
||||||
|
| Zusatzfilter und Zeilenaktionen zugänglich | native Aufklappelemente für Filter und Aktionen per Browserinteraktion geprüft | erfüllt am 22.10.2026 |
|
||||||
|
| Funduszeilen per Tastatur zu öffnen | erster Titeleintrag fokussiert, mit Eingabetaste geöffnet und Editoraufruf geprüft | erfüllt am 22.10.2026 |
|
||||||
|
| Lightmode und Smartphone ohne Seitenüberlauf | Chromium-Messung bei 1440 × 1000 und 390 × 844 Pixeln in beiden Themes | erfüllt am 22.10.2026 |
|
||||||
|
|
||||||
|
## Ausgeführte Prüfungen Oberflächenkorrektur 0.5.6
|
||||||
|
|
||||||
|
- `npm run check`: 0 Fehler und 0 Warnungen
|
||||||
|
- gezielter Chromium-Lauf für Fundus und geöffneten Piece-Editor bei
|
||||||
|
1440 × 1000 und 390 × 844 Pixeln in Light/Dark ohne Konsolenfehler oder
|
||||||
|
horizontalen Seitenüberlauf
|
||||||
|
- Neuanlage, Zusatzfilter, Aktionsmenü, Tastaturöffnung, Glaswerte und goldener
|
||||||
|
Fokus automatisiert kontrolliert; alle erzeugten Desktop-/Mobilaufnahmen
|
||||||
|
visuell geprüft
|
||||||
|
- Produktionsbuild sowie vollständige Unit- und E2E-Matrix gemäß Auftrag nicht
|
||||||
|
ausgeführt
|
||||||
|
- `git diff --check`: erfolgreich
|
||||||
|
|
||||||
|
## Materialkorrektur 0.5.7
|
||||||
|
|
||||||
|
| Kriterium | Nachweis | Stand |
|
||||||
|
| -------------------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------- |
|
||||||
|
| Hintergrundlicht im Arbeitsbereich sichtbar | Fundus und Sendungsplanung ohne Popup in Dark/Light auf Desktop und Mobil visuell geprüft | erfüllt am 23.10.2026 |
|
||||||
|
| Popup erhält die Lichtstimmung | Editor mit leichtem `46%`-Backdrop und `6px` Blur gegen Ansicht ohne Popup verglichen | erfüllt am 23.10.2026 |
|
||||||
|
| Verschachtelte Ablaufkarten werden dunkler | berechnete RGBA-Werte von Außenglas, Abschnitt, Piece und Aktionsgruppe kontrolliert | erfüllt am 23.10.2026 |
|
||||||
|
| Graue Sonderflächen entfernt | aktive Tabs, private Recherche, Quellen-/Materialflächen und Aktionsgruppen visuell geprüft | erfüllt am 23.10.2026 |
|
||||||
|
| Schreibfelder bleiben ruhig und klar | deckende Feldfüllung, feine Kontur und unveränderter Goldfokus in beiden Editorbereichen geprüft | erfüllt am 23.10.2026 |
|
||||||
|
| Lightmode und Smartphone ohne Seitenüberlauf | Chromium-Messung bei 1440 × 1000 und 390 × 844 Pixeln in beiden Themes | erfüllt am 23.10.2026 |
|
||||||
|
|
||||||
|
## Ausgeführte Prüfungen Materialkorrektur 0.5.7
|
||||||
|
|
||||||
|
- `npm run check`: 0 Fehler und 0 Warnungen
|
||||||
|
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
|
||||||
|
- berechnete Styles vor und nach der Korrektur für App-Hintergrund, Navigation,
|
||||||
|
Ablauf-Panel, Abschnitte, Pieces, Aktionsgruppen, Dialog, Backdrop, Tabs,
|
||||||
|
Rechercheflächen und Schreibfelder kontrolliert
|
||||||
|
- gezielter Chromium-Lauf für Sendungsplanung sowie Piece-Editor mit
|
||||||
|
„Sendungstext“ und „Recherche & Quellen“ bei 1440 × 1000 und 390 × 844 Pixeln
|
||||||
|
in Light/Dark ohne Konsolenfehler oder horizontalen Seitenüberlauf
|
||||||
|
- Desktop-/Mobilaufnahmen mit und ohne Popup visuell verglichen
|
||||||
|
- vollständige Funktionstests gemäß Auftrag nicht ausgeführt
|
||||||
|
- `git diff --check`: erfolgreich
|
||||||
|
|
||||||
|
## Redaktionelle Arbeitsabläufe 0.5.8
|
||||||
|
|
||||||
|
| Kriterium | Nachweis | Stand |
|
||||||
|
| ------------------------------------------ | ------------------------------------------------------------------------------------------------ | --------------------- |
|
||||||
|
| Persistente unabhängige Piece-Vorlagen | Servicetests prüfen Snapshot, Revision, Neuanlage, Wiederverwendung und Löschen | erfüllt am 23.10.2026 |
|
||||||
|
| Atomare idempotente Direktanlage | Verhaltenstests prüfen Wiederholung derselben Befehls-ID und vollständigen Rollback | erfüllt am 23.10.2026 |
|
||||||
|
| Interner und öffentlicher Titel getrennt | Service- und Browserabläufe prüfen getrennte Werte, Snapshots und Fallback | erfüllt am 23.10.2026 |
|
||||||
|
| Entfernen betrifft nur konkrete Verwendung | Browser prüft Bestätigung, Rückgabe in Fundus und unverändertes Original | erfüllt am 23.10.2026 |
|
||||||
|
| Beliebige Live-Verwendung auswählbar | Verhaltenstests wählen ersten und erledigten Eintrag über stabile Usage-ID | erfüllt am 23.10.2026 |
|
||||||
|
| Stabiler widerrufbarer OBS-Kanal | Servicetests prüfen Wiederöffnung/Rotation; anonymer Browser prüft Fragmenttausch und Cookie | erfüllt am 23.10.2026 |
|
||||||
|
| Private Quellen und Uploads bleiben intern | HTML- und JSON-Prüfung enthält weder implizite Quellen, Uploadnamen noch interne Redaktionstexte | erfüllt am 23.10.2026 |
|
||||||
|
| Responsive und gleichwertig bedienbar | Chromium prüft Desktop/Mobil, Light/Dark, Drag-and-drop sowie Klick- und Tastaturalternativen | erfüllt am 23.10.2026 |
|
||||||
|
| Reale OBS- und Anbieterwiedergabe | Keine OBS-Installation und keine echten Anbieteraufrufe in der lokalen Prüfumgebung | offen |
|
||||||
|
|
||||||
|
## Ausgeführte Prüfungen 0.5.8
|
||||||
|
|
||||||
|
- `npm run lint`: nach Formatierung erfolgreich
|
||||||
|
- `npm run check`: 0 Fehler und 0 Warnungen
|
||||||
|
- `npm test`: 33 Tests in 6 Dateien erfolgreich
|
||||||
|
- `npm run test:e2e`: 15 von 15 Chromium-Browsertests erfolgreich
|
||||||
|
- Browsermatrix bei 390, 768, 960, 1280 und 1440 Pixeln einschließlich
|
||||||
|
Light/Dark, horizontalem Überlauf, Tastaturbedienung, Planung, Live-Steuerung
|
||||||
|
und anonymer Ausgabe geprüft; Screenshots wurden durch die Suite erzeugt
|
||||||
|
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
|
||||||
|
- `npm run db:migrate`: Migrationen `0007_editorial_workflows.sql` und
|
||||||
|
`0008_durable_planning_commands.sql` erfolgreich auf der zuvor gesicherten
|
||||||
|
lokalen Entwicklungsdatenbank angewendet
|
||||||
|
- `bash -n start.sh`: erfolgreich
|
||||||
|
- reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt;
|
||||||
|
Anbieterrequests wurden in den Browsertests lokal abgefangen
|
||||||
|
|
||||||
|
## Live-Werkbank 0.5.9
|
||||||
|
|
||||||
|
| Kriterium | Nachweis | Stand |
|
||||||
|
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------- | --------------------- |
|
||||||
|
| Favorit ist globale Originaleigenschaft | Service prüft Revision/Status; Browser prüft Stern, Sortierung und weiterhin wirksamen Planfilter | erfüllt am 23.10.2026 |
|
||||||
|
| Werkzeugkatalog und Rechte | Barkeeper-Verwaltung serverseitig geschützt; Browser prüft Anlage, Formatbezug und Deaktivierung | erfüllt am 23.10.2026 |
|
||||||
|
| Deaktivierte Zuordnung bleibt bearbeitbar | Browser öffnet das Piece erneut und findet Werkzeug, Arbeitslink, Notiz und Deaktivierungsstatus | erfüllt am 23.10.2026 |
|
||||||
|
| Relationen werden unveränderlich gesnapshottet | Service ändert Quelle, Werkzeug und Thema und sieht Änderungen erst nach expliziter Aktualisierung | erfüllt am 23.10.2026 |
|
||||||
|
| Private Live-Materialien bleiben privat | Live-Steuerung zeigt Snapshot-Quelle; anonymes HTML/JSON enthält den privaten Materiallink nicht | erfüllt am 23.10.2026 |
|
||||||
|
| Thematische Publikum-Dachzeile | Usage-Themen werden stabil projiziert; Chromium zeigt „Gaming“ oberhalb des Präsentationstitels | erfüllt am 23.10.2026 |
|
||||||
|
| Persistenter Abschnitts-Countdown | kontrollierte Testzeit prüft eingefrorenes Soll, Reload, Piece-Wechsel und Abschluss ohne Reset | erfüllt am 23.10.2026 |
|
||||||
|
| Responsive Bedienung | komplette Browsermatrix und gezielter Werkzeugeditor bei 390 × 844 ohne horizontalen Überlauf | erfüllt am 23.10.2026 |
|
||||||
|
| Reale OBS- und Anbieterwiedergabe | Keine OBS-Installation und keine echten Anbieteraufrufe in der lokalen Prüfumgebung | offen |
|
||||||
|
|
||||||
|
## Ausgeführte Prüfungen 0.5.9
|
||||||
|
|
||||||
|
- `npm run lint`: erfolgreich
|
||||||
|
- `npm run check`: 0 Fehler und 0 Warnungen
|
||||||
|
- `npm test`: 37 Tests in 7 Dateien erfolgreich
|
||||||
|
- `npm run test:e2e`: 16 von 16 Chromium-Browsertests erfolgreich
|
||||||
|
- zusätzlicher gezielter Chromium-Lauf des Werkzeugeditors bei 390 × 844 Pixeln:
|
||||||
|
1 von 1 erfolgreich, ohne horizontalen Überlauf
|
||||||
|
- Browsermatrix bei 390, 768, 960, 1280 und 1440 Pixeln einschließlich
|
||||||
|
Light/Dark, Planung, Live-Steuerung, anonymer Ausgabe und privater Materialien
|
||||||
|
erfolgreich; Screenshots wurden durch die Suite erzeugt
|
||||||
|
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
|
||||||
|
- lokale Entwicklungsdatenbank vorab nach
|
||||||
|
`.data/backups/0.5.9-pre-migration/sendewerk.sqlite` gesichert;
|
||||||
|
`npm run db:migrate` hat `0009_live_workbench.sql` erfolgreich angewendet
|
||||||
|
- `bash -n start.sh` und `git diff --check`: erfolgreich
|
||||||
|
- reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt;
|
||||||
|
Anbieterrequests wurden in den Browsertests lokal abgefangen
|
||||||
|
|
||||||
|
## Editor-Erlebnis 0.5.10
|
||||||
|
|
||||||
|
| Kriterium | Nachweis | Stand |
|
||||||
|
| ------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------- |
|
||||||
|
| Titel direkt und per Tastatur bearbeitbar | Chromium prüft Vorlage, Inline-Titel, Autosave, Schließen und erneutes Öffnen | erfüllt am 23.10.2026 |
|
||||||
|
| Gemeinsame Themen- und Werkzeugauswahl | Browser prüft Auswahl, Speicherung und deaktivierte Werkzeugzuordnung; Svelte-Check ist warnfrei | erfüllt am 23.10.2026 |
|
||||||
|
| Themenicons lokal und persistent | Migration und Servicetests prüfen Iconwert und Snapshot; Verwaltung nutzt lokalen Iconpicker | erfüllt am 23.10.2026 |
|
||||||
|
| Präsentationsbild ausdrücklich gewählt | Browser lädt Bild hoch, aktualisiert Usage und sieht nur dieses Bild in anonymer Ausgabe | erfüllt am 23.10.2026 |
|
||||||
|
| Medien- und Usage-Snapshots bleiben stabil | Servicetest entfernt Originalmedium und erhält bestehende Usage-Ausgabe | erfüllt am 23.10.2026 |
|
||||||
|
| Piece-Löschen ist wiederherstellbar | Servicetest prüft Revision, Soft Delete, Listenausblendung, Medienzugriff und Wiederherstellung | erfüllt am 23.10.2026 |
|
||||||
|
| Live-Verwendungen sicher entfernbar | Servicetests erlauben nur offene, nie gelaufene, nicht aktuelle oder präsentierte Verwendungen | erfüllt am 23.10.2026 |
|
||||||
|
| Leere OBS-Ausgabe ist transparent | Chromium prüft leeren Renderer vor und nach Präsentation sowie versteckten Verbindungsstatus | erfüllt am 23.10.2026 |
|
||||||
|
| Responsive Bedienung | vollständige Browsermatrix prüft 390 bis 1440 Pixel, Light/Dark und horizontalen Überlauf | erfüllt am 23.10.2026 |
|
||||||
|
| Reales OBS, reale Anbieter und echtes iOS | In der lokalen Prüfumgebung nicht verfügbar | offen |
|
||||||
|
| Werkzeugkatalog entspricht Themenverwaltung | Kompakte Iconzeilen und gemeinsamer Anlegen-/Bearbeitendialog wurden auf Desktop/Mobil verglichen | erfüllt am 23.10.2026 |
|
||||||
|
| Vollständige Classic-Regular-Auswahl | Generierung liefert 3.671 Icons; seltenes Icon wurde gesucht, gespeichert und im Piece angezeigt | erfüllt am 23.10.2026 |
|
||||||
|
| Formatwahl nutzt gemeinsamen Picker | Browser ordnet Quiz im Dialog zu und erhält Format sowie Deaktivierungszustand nach Reload | erfüllt am 23.10.2026 |
|
||||||
|
|
||||||
|
## Ausgeführte Prüfungen 0.5.10
|
||||||
|
|
||||||
|
- `npm run lint`: erfolgreich
|
||||||
|
- `npm run check`: 0 Fehler und 0 Warnungen
|
||||||
|
- `npm test`: 40 Tests in 7 Dateien erfolgreich
|
||||||
|
- `npm run test:e2e`: 16 von 16 Chromium-Browsertests erfolgreich
|
||||||
|
- Browsermatrix bei 390, 768, 960, 1280 und 1440 Pixeln einschließlich
|
||||||
|
Light/Dark, mobiler Navigation, Dialogen, Planung, Live-Steuerung, anonymer
|
||||||
|
Ausgabe, Medienauswahl und privater Datenfilterung erfolgreich; Screenshots
|
||||||
|
wurden durch die Suite erzeugt
|
||||||
|
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
|
||||||
|
- lokale Entwicklungsdatenbank vorab nach
|
||||||
|
`.data/backups/0.5.10-pre-migration/sendewerk.sqlite` gesichert;
|
||||||
|
`npm run db:migrate` hat `0010_editor_experience.sql` erfolgreich angewendet
|
||||||
|
- vor der Werkzeugmigration zusätzlich nach
|
||||||
|
`.data/backups/0.5.10-tool-icons-pre-migration/sendewerk.sqlite` gesichert;
|
||||||
|
`npm run db:migrate` hat `0011_tool_icons.sql` erfolgreich angewendet
|
||||||
|
- Werkzeugkatalog, Anlegen-/Bearbeitendialog, geöffnete Iconauswahl und
|
||||||
|
Piece-Werkzeugauswahl bei 1280 × 720 und 390 × 844 Pixeln visuell direkt mit
|
||||||
|
der Themenverwaltung verglichen; keine horizontale Überlauffläche festgestellt
|
||||||
|
- `bash -n start.sh`: erfolgreich
|
||||||
|
- reale OBS-Browserquelle, echte YouTube-/TikTok-Wiedergabe und Safari auf einem
|
||||||
|
iOS-Gerät nicht ausgeführt; Anbieterrequests wurden lokal abgefangen
|
||||||
|
|
||||||
|
## Verwaltungskorrektur 0.5.11
|
||||||
|
|
||||||
|
| Kriterium | Nachweis | Stand |
|
||||||
|
| ------------------------------------------ | ---------------------------------------------------------------------------------------- | --------------------- |
|
||||||
|
| Werkzeugdialog ist initial geschlossen | Chromium prüft den Dialog vor jeder Bedienaktion bei 390 und 1280 Pixeln | erfüllt am 23.10.2026 |
|
||||||
|
| Dialog öffnet nur nach bewusster Aktion | Browser legt ein Werkzeug an und öffnet ein bestehendes Werkzeug anschließend zum Ändern | erfüllt am 23.10.2026 |
|
||||||
|
| Bestehender Werkzeugablauf bleibt erhalten | Browser prüft Icon, Format, Deaktivierung und Piece-Zuordnung nach erneutem Laden | erfüllt am 23.10.2026 |
|
||||||
|
| Themen-Iconpicker bleibt im Viewport | Chromium prüft Öffnen und Auswahl bei 390, 960 und 1280 Pixeln | erfüllt am 23.10.2026 |
|
||||||
|
|
||||||
|
## Kompakte Live-Regie 0.5.12
|
||||||
|
|
||||||
|
| Kriterium | Nachweis | Stand |
|
||||||
|
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||||
|
| Globale Navigation bleibt aus der Regie | Chromium findet weder Seitenleiste noch mobilen App-Header oder Bottomnavigation | erfüllt am 24.10.2026 |
|
||||||
|
| Kleines Desktopfenster bleibt zweispaltig | Chromium prüft Moderation, Ablauf und feste Aktionen bei 900 × 450 Pixeln | erfüllt am 24.10.2026 |
|
||||||
|
| Niedrige Desktophöhe wird berücksichtigt | 900 × 450 aktiviert kompakte Abstände ohne Transform oder CSS-Zoom | erfüllt am 24.10.2026 |
|
||||||
|
| Zweizeiliger Kopf gewinnt Inhaltsfläche | Chromium begrenzt die Kopfhöhe bei 670 × 450 und prüft die unveränderte feste Aktionsleiste | erfüllt am 24.10.2026 |
|
||||||
|
| Lange Kopftitel bleiben kontrolliert | Abschnitt und Folgetitel kürzen einzeilig und öffnen vollständig per Klick, Enter und Escape | erfüllt am 24.10.2026 |
|
||||||
|
| Regiemenü bleibt sichtbar und erreichbar | Browser prüft Viewportgrenzen, Scrollzugang, Sendungsname und Aktionen bei 1280, 670 und 390 Pixeln | erfüllt am 24.10.2026 |
|
||||||
|
| Menü schließt zugänglich | Escape und Außenklick schließen das Popover und führen den Fokus zum Auslöser zurück | erfüllt am 24.10.2026 |
|
||||||
|
| Lange Inhalte scrollen innerhalb der Fläche | Synthetischer Langtext und zusätzliche Ablaufabschnitte erzeugen zwei unabhängige Scroller | erfüllt am 24.10.2026 |
|
||||||
|
| Mobile Regie verwendet ein Ablauf-Sheet | 390 × 700 prüft Endlage, Fokus, Escape, Aktionsschließen und Wechsel auf 900 Pixel | erfüllt am 24.10.2026 |
|
||||||
|
| Leseposition bleibt am Piece | Nach tiefem Scrollen beginnt ein direkt gewähltes anderes Piece am oberen Rand | erfüllt am 24.10.2026 |
|
||||||
|
| Zustände sind ausgeschrieben | Ablauf prüft Kommend, Aktuell, Erledigt und Übersprungen | erfüllt am 24.10.2026 |
|
||||||
|
| Erstes und letztes Piece direkt wählbar | Browser wechselt per stabiler Usage-ID auf Desktop und aus dem mobilen Sheet in beide Richtungen | erfüllt am 24.10.2026 |
|
||||||
|
| Navigation veröffentlicht nicht automatisch | Öffentliche Ausgabe bleibt bei direkter Auswahl unverändert | erfüllt am 24.10.2026 |
|
||||||
|
| Zurück ist reine Betrachtungsnavigation | Browser prüft unverändertes aktuelles Piece, Countdown und Ausgabe sowie gesperrte Fortschrittsaktionen | erfüllt am 24.10.2026 |
|
||||||
|
| Timer und Livezustand überleben Reload | Aktuelles Piece und laufender Abschnittscountdown bleiben nach Neuladen erhalten | erfüllt am 24.10.2026 |
|
||||||
|
| Wiederaufnahme nur bei Unterbrechung | Während eines aktiven Pieces fehlt die Aktion; nach unterbrochenem Stand ist sie prominent | erfüllt am 24.10.2026 |
|
||||||
|
| Sendungsende ist eindeutig | Letztes Piece zeigt Endaktion ohne horizontalen Überlauf; danach fehlen Weiter-Aktionen | erfüllt am 24.10.2026 |
|
||||||
|
| Liste und Kalender nutzen dieselben Daten | Chromium wechselt zwischen beiden Ansichten; Sendungslinks bleiben identisch erreichbar | erfüllt am 24.10.2026 |
|
||||||
|
| Kalenderpräferenz bleibt am Konto | Service- und Browsertest prüfen Standardwert, Speicherung und Reload | erfüllt am 24.10.2026 |
|
||||||
|
| Monat und Berliner Tage sind korrekt | Unit-Tests prüfen Monatsgrenzen und UTC-Zuordnung; Browser prüft Monatswechsel und heutigen Tag | erfüllt am 24.10.2026 |
|
||||||
|
| Mehrere und terminlose Sendungen bleiben sichtbar | Desktopkalender und mobile Agenda zeigen zwei Termine am Tag; eigener Bereich zeigt offenen Termin | erfüllt am 24.10.2026 |
|
||||||
|
|
||||||
|
## Ausgeführte Prüfungen 0.5.12
|
||||||
|
|
||||||
|
- `npm run lint`: erfolgreich
|
||||||
|
- `npm run check`: 0 Fehler und 0 Warnungen
|
||||||
|
- `npm test`: 44 Tests in 8 Dateien erfolgreich
|
||||||
|
- gezielte Kalender- und Live-Regie-Läufe: 2 von 2 Chromium-Tests erfolgreich
|
||||||
|
- `npm run test:e2e`: 17 von 17 Chromium-Browsertests erfolgreich
|
||||||
|
- Browserprüfungen der Live-Regie bei 1440 × 900, 900 × 450, 670 × 450 und
|
||||||
|
390 × 700 Pixeln sowie des Kalenders bei 1440 × 1000 und 390 × 844 Pixeln
|
||||||
|
erfolgreich
|
||||||
|
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
|
||||||
|
- Versionsgleichheit `0.5.12` in Paket, Lockdatei und Cloudron-Manifest geprüft
|
||||||
|
- `bash -n start.sh` und `git diff --check`: erfolgreich
|
||||||
|
- reale OBS-Browserquelle, echte YouTube-/TikTok-Wiedergabe und Safari auf einem
|
||||||
|
iOS-Gerät nicht ausgeführt; Anbieterrequests wurden lokal abgefangen
|
||||||
|
|
||||||
|
## Erweiterte Live-Steuerung 0.5.12
|
||||||
|
|
||||||
|
| Kriterium | Stand |
|
||||||
|
| ---------------------------------------------- | ---------------------------- |
|
||||||
|
| Gemeinsame persistente Pause | implementiert, nicht geprüft |
|
||||||
|
| Abschnitts- und Gesamtreset bleiben unabhängig | implementiert, nicht geprüft |
|
||||||
|
| Gesamt-/Restzeit ohne Doppelerfassung | implementiert, nicht geprüft |
|
||||||
|
| Ende, Wiederaufnahme und neuer Lauf getrennt | implementiert, nicht geprüft |
|
||||||
|
| Privates Feld „Interaktion“ durch alle Kopien | implementiert, nicht geprüft |
|
||||||
|
| Öffentliche Positivliste unverändert | implementiert, nicht geprüft |
|
||||||
|
| Classic-Regular-Icons und Drawer-Schließen | gezielt im Browser geprüft |
|
||||||
|
|
||||||
|
Für die ursprüngliche Erweiterung wurden auf ausdrücklichen Auftrag zunächst
|
||||||
|
keine Prüfungen ausgeführt. Die nachfolgenden Korrekturen weisen inzwischen
|
||||||
|
Migration, Typen, Build, bestehendes Liveverhalten und die betroffenen
|
||||||
|
Oberflächen gezielt nach; die übrigen neuen Timer- und Statusfälle bleiben ohne
|
||||||
|
vollständige erneute Matrix.
|
||||||
|
|
||||||
|
## Live-500-Korrektur 0.5.12
|
||||||
|
|
||||||
|
- Ursache auf einer schreibgeschützt untersuchten lokalen Datenbank bestätigt:
|
||||||
|
`0013` war protokolliert, `live_show_timer_runs` fehlte jedoch.
|
||||||
|
- `0014_live_show_timer_repair.sql` auf einer Kopie dieser Datenbank angewendet
|
||||||
|
und erneut ausgeführt; Tabelle und Indizes wurden angelegt, 13 bestehende
|
||||||
|
Intervalle genau einmal übernommen und ein offenes Intervall erhalten.
|
||||||
|
- `npm run check`: 0 Fehler und 0 Warnungen.
|
||||||
|
- `npm run test:unit -- --run src/lib/server/live.spec.ts`: 6 Tests erfolgreich.
|
||||||
|
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
|
||||||
|
|
||||||
|
## Live-Icons und kompakte Sendungskarten 0.5.12
|
||||||
|
|
||||||
|
- `npm run check`: 0 Fehler und 0 Warnungen.
|
||||||
|
- `npm run test:unit -- --run src/lib/server/live.spec.ts`: 6 Tests erfolgreich.
|
||||||
|
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
|
||||||
|
- Gezielter Chromium-Ablauf der Sendungsübersicht mit Desktopkalender, mobiler
|
||||||
|
Agenda und Rückkehr zur Liste: erfolgreich.
|
||||||
|
- Vollständiger bestehender Live-Regie-Ablauf einschließlich 900 × 450,
|
||||||
|
670 × 450 und 390 × 700 Pixeln: erfolgreich; Iconleiste, Kopfhöhe,
|
||||||
|
Viewportgrenzen und horizontaler Überlauf geprüft.
|
||||||
|
- Zusätzliche visuelle Chromium-Prüfung bei 656 × 400 und 390 × 700 Pixeln:
|
||||||
|
Sendungskarte unter 130 Pixel hoch, Menü-/Ausgabe-/Timericons sichtbar und
|
||||||
|
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
|
||||||
+246
-23
@@ -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,17 +33,17 @@ 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`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft |
|
| Taxonomie | getrennte `section_categories`, `formats`, `topics` samt optionalem Themenicon; Pieces und Vorschläge mehrfach verknüpft |
|
||||||
| Inhalt | `pieces` mit Status, Dauer, Revision, getrennten Textbereichen, Quellen und privaten Medien; Vorlagen sind definierte Startwerte |
|
| Inhalt | `pieces` mit Status, Soft Delete, Favorit, Revision, Texten, Quellen, Werkzeugen und privaten Medien; Vorlagen speichern Snapshots |
|
||||||
| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen sind umgesetzt; Original-ID bleibt Referenz |
|
| Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themen-/Mediensnapshots, Reservebank und Vorlagen |
|
||||||
| Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe |
|
| Live | expliziter Live-Stand, aktuelles Element, tatsächliche Piece-Zeiten, persistente Abschnitts-/Gesamttimer und getrennte Präsentationsausgabe |
|
||||||
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
|
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
|
||||||
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
|
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
|
||||||
| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen |
|
| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen |
|
||||||
@@ -64,6 +64,15 @@ oder eine explizite Abschnittsdauer. Beides wird nie addiert. Reserveelemente
|
|||||||
zählen erst nach dem Einplanen. Geplante und tatsächliche Dauer bleiben
|
zählen erst nach dem Einplanen. Geplante und tatsächliche Dauer bleiben
|
||||||
getrennt.
|
getrennt.
|
||||||
|
|
||||||
|
Ein Live-Abschnittstimer entsteht ausschließlich beim tatsächlichen Eintritt in
|
||||||
|
einen Abschnitt mit positiver expliziter Dauer. Er speichert dieses Soll als
|
||||||
|
unveränderlichen Wert und summiert eigene Laufintervalle; Piece-Wechsel im
|
||||||
|
selben Abschnitt erzeugen deshalb weder Reset noch doppelte Piece-Laufzeit.
|
||||||
|
Der Gesamttimer speichert ebenfalls mehrere Laufintervalle. Eine gemeinsame
|
||||||
|
Pause schließt die offenen Piece-, Abschnitts- und Gesamtintervalle; Fortsetzen
|
||||||
|
öffnet neue Intervalle, ohne die Pause als Laufzeit zu zählen. Abschnitts- und
|
||||||
|
Gesamtreset ersetzen ausschließlich die jeweilige Zeitbasis.
|
||||||
|
|
||||||
## Anmeldung und Rechte
|
## Anmeldung und Rechte
|
||||||
|
|
||||||
Etappe 2 ergänzt lokale Konten mit den festen Rollen `barkeeper` und
|
Etappe 2 ergänzt lokale Konten mit den festen Rollen `barkeeper` und
|
||||||
@@ -90,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
|
||||||
@@ -97,11 +143,25 @@ erhalten ausschließlich kompakte Piece-Daten; private Recherche und vollständi
|
|||||||
Redaktionstexte werden nur an den angemeldeten Editor ausgeliefert. Themen und
|
Redaktionstexte werden nur an den angemeldeten Editor ausgeliefert. Themen und
|
||||||
Abschnittskategorien sind Mehrfachzuordnungen, das Format bleibt eine einzelne
|
Abschnittskategorien sind Mehrfachzuordnungen, das Format bleibt eine einzelne
|
||||||
unabhängige Zuordnung. Quellen werden gemeinsam mit einem Piece atomar ersetzt.
|
unabhängige Zuordnung. Quellen werden gemeinsam mit einem Piece atomar ersetzt.
|
||||||
|
`is_favorite` ist eine globale Eigenschaft des Originals und keine Nutzung oder
|
||||||
|
Historienmarkierung. Der Werkzeugkatalog liegt getrennt von Formaten; Formate
|
||||||
|
sind nur Eignungszuordnungen. `piece_tools` hält pro Piece den privaten
|
||||||
|
Arbeitslink und eine Notiz, während Deaktivierung das Katalogobjekt und alle
|
||||||
|
bestehenden Zuordnungen erhält. Ein serverseitig validiertes `icon_name` gehört
|
||||||
|
zum Werkzeugkatalog und wird im Originaleditor angezeigt, ohne bestehende
|
||||||
|
Usage-Snapshots nachträglich zu verändern.
|
||||||
Ein Piece kann für die Präsentation Text oder genau eine vorhandene Quelle als
|
Ein Piece kann für die Präsentation Text oder genau eine vorhandene Quelle als
|
||||||
Linkkarte beziehungsweise Video auswählen. Abgerufene Metadaten und Kennzeichen
|
Linkkarte beziehungsweise Video auswählen. Abgerufene Metadaten und Kennzeichen
|
||||||
für manuell korrigierte Felder liegen am Piece; ein erneuter Abruf überschreibt
|
für manuell korrigierte Felder liegen am Piece; ein erneuter Abruf überschreibt
|
||||||
diese Felder nicht.
|
diese Felder nicht.
|
||||||
|
|
||||||
|
Piece-Vorlagen liegen als validierte JSON-Snapshots in `piece_templates`. Jeder
|
||||||
|
Speichervorgang kopiert den vollständigen Vorlageninhalt tief und erhöht bei
|
||||||
|
einer Aktualisierung die Revision; spätere Änderungen am Ausgangs-Piece wirken
|
||||||
|
nicht auf die Vorlage zurück. Die leere Vorlage bleibt virtuell. Interner Titel
|
||||||
|
und optionaler Präsentationstitel sind getrennte Felder; ohne öffentlichen Titel
|
||||||
|
verwendet die Präsentationsprojektion den internen Titel als Fallback.
|
||||||
|
|
||||||
Jedes Piece besitzt eine ganzzahlige Revision. Speichern, Archivieren und
|
Jedes Piece besitzt eine ganzzahlige Revision. Speichern, Archivieren und
|
||||||
Medienänderungen verwenden ein bedingtes Update auf die erwartete Revision.
|
Medienänderungen verwenden ein bedingtes Update auf die erwartete Revision.
|
||||||
Eine veraltete Bearbeitung liefert einen Konflikt und verändert weder Piece noch
|
Eine veraltete Bearbeitung liefert einen Konflikt und verändert weder Piece noch
|
||||||
@@ -119,6 +179,15 @@ sind zufällige IDs; Originalnamen werden nie als Pfad verwendet. `/media/[id]`
|
|||||||
prüft die Anmeldung und liefert private Antworten mit `no-store`, `nosniff`,
|
prüft die Anmeldung und liefert private Antworten mit `no-store`, `nosniff`,
|
||||||
Same-Origin-Ressourcenrichtlinie und restriktiver CSP aus.
|
Same-Origin-Ressourcenrichtlinie und restriktiver CSP aus.
|
||||||
|
|
||||||
|
Ein Piece kann genau eines seiner aktiven Medien ausdrücklich als
|
||||||
|
Präsentationsbild auswählen. Die Auswahl liegt in `piece_presentation_media`;
|
||||||
|
das Entfernen setzt am Medium einen Zeitstempel statt die Datei zu löschen.
|
||||||
|
Damit bleiben bereits erzeugte `usage_media_snapshots` auflösbar, während neue
|
||||||
|
Einplanungen und der Originaleditor nur aktive Medien berücksichtigen. Das
|
||||||
|
Soft Delete eines Pieces setzt ebenfalls nur `deleted_at_utc`: normale Listen
|
||||||
|
und neue Einplanungen schließen es aus, bestehende Verwendungsreferenzen und
|
||||||
|
Snapshots bleiben unverändert erhalten.
|
||||||
|
|
||||||
## Sendungsbau
|
## Sendungsbau
|
||||||
|
|
||||||
Etappe 4 bündelt alle Planungsmutationen in `src/lib/server/shows.ts`. Eine
|
Etappe 4 bündelt alle Planungsmutationen in `src/lib/server/shows.ts`. Eine
|
||||||
@@ -128,9 +197,12 @@ erhöht sie atomar. Abgeschlossene Sendungen sind bereits an dieser Grenze gegen
|
|||||||
weitere Bearbeitung geschützt.
|
weitere Bearbeitung geschützt.
|
||||||
|
|
||||||
Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation,
|
Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation,
|
||||||
Überleitung und der vollständige Präsentationszustand aus dem sendefertigen
|
Interaktion, Überleitung und der vollständige Präsentationszustand aus dem sendefertigen
|
||||||
Piece kopiert. Dazu gehören Modus, ausgewählte Quelladresse samt Zeitmarke,
|
Piece kopiert. Dazu gehören Modus, ausgewählte Quelladresse samt Zeitmarke,
|
||||||
Metadaten und validierte Anbieter-ID. Eigene Notizen liegen ausschließlich an
|
Metadaten und validierte Anbieter-ID. Zusätzlich speichern eigene geordnete
|
||||||
|
Tabellen alle Quellen, Werkzeugnamen samt Start-/Arbeitslink und Notiz, die
|
||||||
|
Themennamen samt Icon sowie das ausdrücklich ausgewählte Präsentationsmedium.
|
||||||
|
Eigene Notizen liegen ausschließlich an
|
||||||
der Verwendung. Die Referenz zum Original bleibt für Historie und
|
der Verwendung. Die Referenz zum Original bleibt für Historie und
|
||||||
Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt. Eine explizite
|
Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt. Eine explizite
|
||||||
Aktualisierung aus dem Fundus ersetzt Titel, Dauer, alle fünf Redaktionstexte
|
Aktualisierung aus dem Fundus ersetzt Titel, Dauer, alle fünf Redaktionstexte
|
||||||
@@ -161,6 +233,18 @@ Sendungsvorlagen und ihre Abschnitte sind eigene Tabellen. Beim Anlegen werden
|
|||||||
die Abschnitte mit neuen IDs kopiert; spätere Änderungen an der Sendung können
|
die Abschnitte mit neuen IDs kopiert; spätere Änderungen an der Sendung können
|
||||||
die Vorlage deshalb nicht verändern.
|
die Vorlage deshalb nicht verändern.
|
||||||
|
|
||||||
|
Die Sendungsübersicht leitet Liste, Desktop-Monatsraster und mobile Monatsagenda
|
||||||
|
aus derselben `listShows()`-Abfrage ab. UTC-Zeitpunkte werden erst für Gruppierung
|
||||||
|
und Anzeige nach `Europe/Berlin` in zivile Kalendertage übersetzt. Sendungen ohne
|
||||||
|
Startzeit bleiben in einem eigenen Bereich sichtbar. `users.show_overview_view`
|
||||||
|
speichert ausschließlich die kontobezogene Darstellungspräferenz; der gewählte
|
||||||
|
Monat bleibt URL-Zustand und verändert keine Sendungsdaten.
|
||||||
|
|
||||||
|
Die Direktanlage aus einer Sendung erzeugt Piece und Verwendung innerhalb einer
|
||||||
|
verschachtelbaren SQLite-Transaktion. `planning_commands` speichert die
|
||||||
|
Befehls-ID und das Ergebnis, sodass ein wiederholter Request dieselben IDs
|
||||||
|
zurückgibt und weder ein zweites Piece noch eine zweite Einplanung erzeugt.
|
||||||
|
|
||||||
Die Editorformulare bleiben progressive SvelteKit-Form-Actions, werden im
|
Die Editorformulare bleiben progressive SvelteKit-Form-Actions, werden im
|
||||||
Browser aber mit `use:enhance` gesendet. Der Verwendungseditor liegt in einem
|
Browser aber mit `use:enhance` gesendet. Der Verwendungseditor liegt in einem
|
||||||
nativen Dialog, den ein beschrifteter Icon-Button öffnet. Dadurch bleibt das
|
nativen Dialog, den ein beschrifteter Icon-Button öffnet. Dadurch bleibt das
|
||||||
@@ -190,17 +274,38 @@ liegen als mehrere Start-/Endintervalle je Verwendung vor. Damit bleiben
|
|||||||
Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von der
|
Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von der
|
||||||
geplanten Dauer nachvollziehbar.
|
geplanten Dauer nachvollziehbar.
|
||||||
|
|
||||||
|
`live_show_timer_runs` hält die unabhängige Gesamtzeit. Pause und Fortsetzen
|
||||||
|
behandeln Gesamt-, Abschnitts- und Piece-Intervalle gemeinsam. Beenden schließt
|
||||||
|
laufende Intervalle und setzt `ended_at_utc`, finalisiert die Sendung aber noch
|
||||||
|
nicht. Eine ausdrückliche Wiederaufnahme bewahrt Zeiten und Fortschritt; nur
|
||||||
|
„Neuer Durchlauf“ verwirft Laufintervalle und setzt die Piece-Status zurück. Die
|
||||||
|
Restzeitschätzung verwendet je Abschnitt entweder die positive Abschnittsdauer
|
||||||
|
oder die verbleibenden Piece-Dauern und zählt beide nie 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
|
Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt
|
||||||
`/heinzlive/[token]` liegt dagegen in einem reduzierten Layout ohne Navigation
|
den stabilen Kanal `/heinzlive/default` in einem reduzierten Layout ohne
|
||||||
oder Nutzerdaten. In SQLite liegt nur der SHA-256-Hash des 256-Bit-Tokens; ein
|
Navigation oder Nutzerdaten. Der geheime 256-Bit-Schlüssel steht nur im
|
||||||
neu erzeugter Ausgabezugang widerruft den vorherigen. Page, Polling-JSON und
|
URL-Fragment und wird per POST gegen ein `HttpOnly`-, `SameSite=Strict`-Cookie
|
||||||
Medienroute leiten ihre Daten aus einer eigenen Positivliste ab. Bilder werden
|
getauscht. SQLite speichert seinen SHA-256-Hash für die Zugangsprüfung sowie den
|
||||||
nur ausgeliefert, wenn sie zum aktuell ausdrücklich präsentierten Piece gehören.
|
rohen Schlüssel, weil die angemeldete Steuerung dieselbe stabile OBS-Adresse
|
||||||
Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den
|
erneut ausgeben können muss. Datenbank und Sicherungen sind deshalb Teil der
|
||||||
Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den
|
Geheimnisgrenze. Eine Rotation widerruft den bisherigen Schlüssel, normales
|
||||||
gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen
|
erneutes Öffnen behält ihn bei.
|
||||||
erst nach einer ausdrücklichen Snapshot-Aktualisierung.
|
Das private Markdownfeld „Interaktion“ wird am Original und als
|
||||||
|
Verwendungssnapshot gespeichert und ausschließlich in der authentifizierten
|
||||||
|
Live-Regie gerendert. Page, Polling-JSON und Proxyrouten leiten ihre Daten aus einer eigenen
|
||||||
|
Positivliste ab. Private Quellen-/Werkzeug-Snapshots, Arbeitslinks, Notizen und
|
||||||
|
Uploads werden nicht veröffentlicht; nur Themennamen sowie die ausdrücklich
|
||||||
|
gewählte Link- oder Videoquelle und deren erlaubte Vorschau-URL können die
|
||||||
|
Grenze passieren.
|
||||||
|
Lokale Betrachtungsnavigation, Fortschaltung oder Wiederaufnahme verändern den
|
||||||
|
Präsentationsstand nicht. Zurück und die Auswahl einer Ablaufzeile wechseln nur
|
||||||
|
den im Browser betrachteten Verwendungssnapshot; tatsächliches Live-Piece,
|
||||||
|
Status, Timer und Ausgabe bleiben serverseitig unverändert. Die öffentliche
|
||||||
|
Projektion liest ausschließlich den ausdrücklich präsentierten
|
||||||
|
Verwendungssnapshot; spätere Änderungen am Original erscheinen erst nach einer
|
||||||
|
ausdrücklichen Snapshot-Aktualisierung.
|
||||||
|
|
||||||
Linkmetadaten werden nur auf ausdrückliche Aktion durch
|
Linkmetadaten werden nur auf ausdrückliche Aktion durch
|
||||||
`src/lib/server/presentation.ts` geladen. Jeder HTTP(S)-Abruf löst den Zielhost
|
`src/lib/server/presentation.ts` geladen. Jeder HTTP(S)-Abruf löst den Zielhost
|
||||||
@@ -226,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.
|
||||||
@@ -235,10 +386,42 @@ die untere Hauptnavigation die Seitenleiste vollständig. Auf breiten Ansichten
|
|||||||
stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie
|
stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie
|
||||||
über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale
|
über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale
|
||||||
Desktopfenster ohne horizontalen Überlauf bedienbar bleiben.
|
Desktopfenster ohne horizontalen Überlauf bedienbar bleiben.
|
||||||
|
Die Sendungsübersicht schaltet über zwei gleichwertige Formularaktionen zwischen
|
||||||
|
Liste und Kalender um. Auf breiten Ansichten erscheint ein siebenspaltiges
|
||||||
|
Monatsraster ohne Termin-Drag-and-drop; bis `52rem` ersetzt es eine lineare,
|
||||||
|
touchfreundliche Agenda desselben Monats.
|
||||||
Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
|
Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
|
||||||
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
|
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
|
||||||
ohne interne Anwendungsnavigation.
|
ohne interne Anwendungsnavigation.
|
||||||
|
|
||||||
|
Die private Route `/live` verwendet ebenfalls einen eigenen authentifizierten
|
||||||
|
Layoutmodus ohne allgemeine Seitenleiste, mobilen App-Header oder
|
||||||
|
Bottomnavigation. Ihre Regiefläche belegt die verfügbare Viewporthöhe als Raster
|
||||||
|
aus fester Statuszeile, unabhängig scrollender Piece- und Ablaufspalte sowie
|
||||||
|
fester Aktionszeile. Die Breite entscheidet über zwei Spalten oder das mobile
|
||||||
|
Sheet; eine geringe Desktophöhe verdichtet zusätzlich Abstände und Leisten,
|
||||||
|
ohne Schrift oder Oberfläche zu skalieren. Unterhalb des schmalen Breakpoints
|
||||||
|
wird ausschließlich die Ablaufspalte als modales Sheet mit Fokusbegrenzung
|
||||||
|
eingeblendet; beim Wechsel auf einen breiteren Viewport wird dieser Modalzustand
|
||||||
|
aufgehoben. Die lokale Betrachtungsnavigation ist keine Live-Action; erst
|
||||||
|
Präsentieren, Überspringen und Erledigen schreiben den jeweiligen getrennten
|
||||||
|
Zustand. Die öffentliche `/heinzlive`-Projektion bleibt unverändert. Lokale
|
||||||
|
Lesepositionen sind an die jeweilige Verwendung gebunden und werden nicht
|
||||||
|
zwischen Pieces übertragen.
|
||||||
|
|
||||||
|
Der Live-Kopf besteht aus zwei regulären Rasterzeilen. Abschnitt und folgendes
|
||||||
|
Piece sind einzeilig gekürzte, fokussierbare Schaltflächen, die ihren vollständigen
|
||||||
|
Text lokal aufklappen. Das Regiemenü wird bei Bedarf als fest am Viewport
|
||||||
|
positionierter Dialog neben dem Kopf gerendert. Dadurch unterliegt es weder dem
|
||||||
|
Overflow der Regiefläche noch dem durch Glasfilter erzeugten Stacking Context;
|
||||||
|
seine maximale Höhe bleibt an den Viewport gebunden und intern scrollbar.
|
||||||
|
|
||||||
|
Icons werden aus dem lokalen Font-Awesome-Classic-Regular-Sprite unter
|
||||||
|
`static/icons` gerendert. Die Verwaltung lädt zusätzlich die durchsuchbaren
|
||||||
|
Metadaten aller 3.671 Icons, zeigt je Suchergebnis aber höchstens 120 Einträge
|
||||||
|
gleichzeitig. Allgemeine Seiten wie Login oder Navigation laden den Katalog
|
||||||
|
nicht in ihren Client-Chunk.
|
||||||
|
|
||||||
Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles
|
Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles
|
||||||
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung,
|
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung,
|
||||||
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche.
|
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche.
|
||||||
@@ -258,3 +441,43 @@ 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.
|
||||||
|
|
||||||
|
## Bedienkorrekturen 0.7.2
|
||||||
|
|
||||||
|
Der RSS-Auswahldialog erhält weiterhin ausschließlich `new`-Einträge aus
|
||||||
|
`listFeedEntries`. Quellenfilter, Seiteneinteilung und noch nicht bestätigte
|
||||||
|
Auswahl sind flüchtiger Browserzustand. Erst das Formular im erreichbaren
|
||||||
|
Dialogfuß übergibt die gewählten IDs an die unveränderte transaktionale
|
||||||
|
Übernahme; dadurch bleiben persistenter Dublettenschutz, Herkunft und
|
||||||
|
Standardkategorien die einzige fachliche Wahrheit.
|
||||||
|
|
||||||
|
Feed- und Zugangsdialoge verwenden dieselben globalen Management-Dialog- und
|
||||||
|
Katalogstile wie Werkzeuge. Das Bearbeiten eines technischen Zugangs ändert nur
|
||||||
|
Name und Umfang des bestehenden gehashten Tokens und wird auditiert. Die
|
||||||
|
Live-Route projiziert zusätzlich nur, ob der feste Ausgabekanal getrennt, mit
|
||||||
|
dieser oder mit einer anderen Sendung verbunden ist. Das Öffnen von Menü und
|
||||||
|
Ausgabedetails führt selbst keine Mutation aus.
|
||||||
@@ -276,3 +276,259 @@ Quellen:
|
|||||||
- <https://developers.tiktok.com/doc/embed-player>
|
- <https://developers.tiktok.com/doc/embed-player>
|
||||||
- <https://developers.tiktok.com/doc/embed-videos>
|
- <https://developers.tiktok.com/doc/embed-videos>
|
||||||
- <https://owasp.org/www-community/attacks/Server_Side_Request_Forgery>
|
- <https://owasp.org/www-community/attacks/Server_Side_Request_Forgery>
|
||||||
|
|
||||||
|
## D-020: Stabile Ausgabekanal-Adresse mit Fragment-Schlüssel
|
||||||
|
|
||||||
|
OBS soll eine dauerhaft konfigurierte Adresse verwenden können, ohne dass ein
|
||||||
|
Zugangsschlüssel in HTTP-Requests, Serverlogs oder Referer-Headern erscheint.
|
||||||
|
Darum bleibt die Kanaladresse `/heinzlive/default` stabil und trägt den
|
||||||
|
zufälligen Schlüssel ausschließlich als URL-Fragment. Ein kleiner Browser-POST
|
||||||
|
tauscht ihn gegen ein `HttpOnly`-, `SameSite=Strict`-Cookie; danach greifen
|
||||||
|
Seite, Polling und erlaubte Proxys nur mit diesem Cookie zu. Für die Prüfung
|
||||||
|
liegt ein SHA-256-Hash vor. Der rohe Schlüssel muss zusätzlich in SQLite
|
||||||
|
gespeichert werden, weil die angemeldete Steuerung dieselbe stabile OBS-Adresse
|
||||||
|
nach einem Neustart erneut ausgeben können muss. Damit sind Datenbank und
|
||||||
|
Sicherungen bewusst Teil der Geheimnisgrenze. Wiederholtes Öffnen behält den
|
||||||
|
Schlüssel, eine ausdrückliche Rotation widerruft ihn sofort.
|
||||||
|
|
||||||
|
Die öffentliche Projektion folgt einer engeren Positivliste als der interne
|
||||||
|
Piece-Snapshot. Weder private Uploads noch alle Piece-Quellen werden implizit
|
||||||
|
veröffentlicht. Passieren dürfen nur öffentlicher Text, Präsentationstitel sowie
|
||||||
|
die ausdrücklich gewählte und erneut validierte Link- oder Videoquelle mit
|
||||||
|
erlaubten Vorschaufeldern.
|
||||||
|
|
||||||
|
## D-021: Vorlagen-Snapshots und idempotente Direktanlage
|
||||||
|
|
||||||
|
Persistente Piece-Vorlagen speichern validierte, tief kopierte JSON-Snapshots
|
||||||
|
mit eigener Revision. Damit bleiben sie unabhängig von späteren Änderungen am
|
||||||
|
Ausgangs-Piece, ohne für jedes optionale Piece-Feld eine zweite Tabellenstruktur
|
||||||
|
zu benötigen. Die virtuelle leere Vorlage wird nicht persistiert.
|
||||||
|
|
||||||
|
Eine Direktanlage im Sendungsplan ist ein einzelner fachlicher Befehl. Piece,
|
||||||
|
Verwendung und Sendungsrevision werden in derselben SQLite-Transaktion
|
||||||
|
geschrieben. `planning_commands` macht die Befehls-ID eindeutig und speichert
|
||||||
|
das Ergebnis, damit Wiederholungen nach Verbindungsabbrüchen keine Dubletten
|
||||||
|
erzeugen. Interner und öffentlicher Titel werden getrennt gespeichert und beim
|
||||||
|
Einplanen getrennt in den Verwendungssnapshot kopiert.
|
||||||
|
|
||||||
|
## D-022: Werkzeuge und Live-Abschnittszeit als eigene Snapshot-Grenzen
|
||||||
|
|
||||||
|
Favoriten bleiben ein boolesches Merkmal des Fundus-Originals. Sie ändern weder
|
||||||
|
Revision, Status noch Verwendungshistorie und werden nicht pro Konto
|
||||||
|
dupliziert. Der Werkzeugkatalog ist eine eigene Entität: Formatbeziehungen
|
||||||
|
beschreiben nur die Eignung, während Piece-Zuordnungen den privaten Arbeitslink
|
||||||
|
und die Notiz tragen. Katalogeinträge werden deaktiviert statt gelöscht, damit
|
||||||
|
bestehende Piece- und Sendungsbezüge lesbar bleiben.
|
||||||
|
|
||||||
|
Beim Einplanen kopieren eigene Relationstabellen alle Quellen, Werkzeuge und
|
||||||
|
sortierten Themennamen an die Verwendung. Damit können Katalognamen, URLs,
|
||||||
|
Themen und Originalzuordnungen später geändert werden, ohne eine vorbereitete
|
||||||
|
Sendung still umzuschreiben. Nur die bewusste Aktion **Aus Fundus aktualisieren**
|
||||||
|
ersetzt diese Snapshots. Die öffentliche Positivliste darf daraus ausschließlich
|
||||||
|
die Themen-Dachzeile übernehmen; private Materiallinks und Werkzeugdaten bleiben
|
||||||
|
auf der authentifizierten Live-Route.
|
||||||
|
|
||||||
|
Der Abschnitts-Countdown verwendet von den Piece-Laufintervallen getrennte
|
||||||
|
`live_section_timers` und `live_section_timer_runs`. Beim ersten tatsächlichen
|
||||||
|
Eintritt wird eine positive explizite Abschnittsdauer eingefroren. Weitere
|
||||||
|
Pieces desselben Abschnitts nutzen denselben Timer; Abschnittswechsel schließen
|
||||||
|
ihn. So bleiben Reload und Wiederaufnahme persistent, ohne die vorhandene
|
||||||
|
Piece-Zeiterfassung doppelt zu zählen.
|
||||||
|
|
||||||
|
## D-023: Ausdrückliche Medienauswahl und wiederherstellbares Entfernen
|
||||||
|
|
||||||
|
Ein privater Piece-Upload wird nicht allein durch seine Existenz öffentlich.
|
||||||
|
Das Original verweist ausdrücklich auf höchstens ein aktives
|
||||||
|
Präsentationsmedium. Beim Einplanen oder bewussten Aktualisieren wird diese
|
||||||
|
Auswahl mit Medium-ID und Alternativtext an die Verwendung kopiert. Die
|
||||||
|
öffentliche Route akzeptiert nur das Medium des aktuell präsentierten
|
||||||
|
Usage-Snapshots; Originalname, interner Speicherpfad und andere Uploads bleiben
|
||||||
|
außerhalb der Projektion.
|
||||||
|
|
||||||
|
Medien und Pieces werden nicht physisch gelöscht, solange bestehende
|
||||||
|
Sendungsfassungen auf ihre unveränderlichen Snapshots angewiesen sein können.
|
||||||
|
Zeitstempel blenden sie aus aktuellen Arbeitslisten und neuen Einplanungen aus,
|
||||||
|
erhalten aber Referenzen und ermöglichen die Wiederherstellung von Pieces. Eine
|
||||||
|
Verwendung oder ein Abschnitt darf dagegen nur entfernt werden, solange keine
|
||||||
|
betroffene Verwendung jemals live gelaufen ist und weder aktuell noch
|
||||||
|
präsentiert wird.
|
||||||
|
|
||||||
|
Der lokale Iconkatalog enthält die vollständige Classic-Regular-Familie mit
|
||||||
|
3.671 Icons. Ein generierter Metadatenkatalog ermöglicht Suche und serverseitige
|
||||||
|
Validierung, während ein statischer SVG-Sprite über den gemeinsamen
|
||||||
|
`currentColor`-Renderer dargestellt wird. So lädt die Navigation nicht sämtliche
|
||||||
|
Pfaddaten in ihren JavaScript-Chunk. Es gibt weder einen externen Laufzeitabruf
|
||||||
|
noch eine Laufzeitabhängigkeit vom benachbarten Quellverzeichnis; Herkunft und
|
||||||
|
Lizenz stehen in `THIRD_PARTY_NOTICES.md`.
|
||||||
|
|
||||||
|
## D-024: Private Live-Regie als fokussierter Viewport
|
||||||
|
|
||||||
|
Die authentifizierte Live-Steuerung nutzt einen eigenen Root-Layoutmodus und
|
||||||
|
rendert die allgemeine App-Navigation nicht. Kopf- und Aktionszeile bleiben fest;
|
||||||
|
Moderation und Ablauf sind die einzigen unabhängig scrollenden Arbeitsflächen.
|
||||||
|
Die Umschaltung zum mobilen Sheet hängt von der Fensterbreite ab, damit ein
|
||||||
|
breites, niedriges Desktopfenster weiterhin zwei Spalten verwendet. Die Höhe
|
||||||
|
steuert dort unabhängig die Dichte von Abständen und Bedienleisten. Textgröße
|
||||||
|
und Gesamtoberfläche werden nicht skaliert.
|
||||||
|
|
||||||
|
Der Kopf verwendet genau zwei kompakte Informationszeilen. Lange Kontexttitel
|
||||||
|
bleiben im Ruhezustand einzeilig und werden auf bewusste Klick- oder
|
||||||
|
Tastaturaktion lokal vollständig sichtbar. Das Regiemenü ist kein verschachteltes
|
||||||
|
`details` innerhalb der Glasfläche, sondern ein explizit gesteuertes,
|
||||||
|
viewportgebundenes Popover. So bleiben Z-Index, Maximalhöhe, Escape,
|
||||||
|
Außenklick und Fokus-Rückkehr unabhängig von Overflow und Filter-Stacking-
|
||||||
|
Contexts kontrollierbar.
|
||||||
|
|
||||||
|
Diese Darstellung ist keine neue Live-Zustandsgrenze. Zurück und die direkte
|
||||||
|
Auswahl im Ablauf sind reine lokale Betrachtungsnavigation. Sie verändern weder
|
||||||
|
aktuelles Live-Piece noch Status, Zeitintervalle, Abschnittstimer oder Ausgabe.
|
||||||
|
Während ein anderes Piece betrachtet wird, bleiben Fortschrittsaktionen
|
||||||
|
gesperrt; Präsentieren darf dagegen weiterhin ausdrücklich genau das betrachtete
|
||||||
|
Piece ausgeben. Start, Erledigen, Überspringen, Reserveeinfügung und Präsentation
|
||||||
|
bleiben serverseitige Actions mit ihren bisherigen Revisions- und
|
||||||
|
Idempotenzregeln.
|
||||||
|
|
||||||
|
## D-025: Gemeinsame Sendungsdaten für Liste und Kalender
|
||||||
|
|
||||||
|
Listenansicht, Desktop-Monatsraster und mobile Monatsagenda werden aus derselben
|
||||||
|
kompakten Sendungsliste abgeleitet. Es gibt kein zweites Kalenderdatenmodell und
|
||||||
|
keine Bibliothek für Terminverwaltung. UTC-Zeitpunkte werden mit
|
||||||
|
`Europe/Berlin` einem zivilen Tag zugeordnet; der ausgewählte Monat bleibt als
|
||||||
|
URL-Parameter navigierbar. Mehrere Sendungen eines Tages werden vollständig und
|
||||||
|
chronologisch gezeigt, Sendungen ohne Startzeit separat.
|
||||||
|
|
||||||
|
Die Präferenz `list` oder `calendar` gehört zum angemeldeten Konto und wird als
|
||||||
|
kleines geprüftes Feld an `users` gespeichert. Sie ist weder Bestandteil einer
|
||||||
|
Sendung noch Browserzustand. Der Kalender dient nur der Navigation und erhält
|
||||||
|
bewusst kein Drag-and-drop, damit eine Terminänderung ausschließlich über die
|
||||||
|
bestehende serverseitig validierte Sendungsbearbeitung erfolgt.
|
||||||
|
|
||||||
|
## D-026: Gemeinsame Live-Pause und getrennte Zeitbasen
|
||||||
|
|
||||||
|
Piece-Laufzeit, Abschnittszeit und Gesamtzeit bleiben getrennte persistente
|
||||||
|
Intervallmengen. Pause schließt in einer Live-Transaktion alle offenen
|
||||||
|
Intervalle, Fortsetzen eröffnet sie am gespeicherten Fortschritt erneut. Dadurch
|
||||||
|
zählt weder Browserwechsel noch eine beliebig lange Pause zur Sendezeit. Ein
|
||||||
|
Abschnittsreset ersetzt nur den aktiven Abschnittstimer; ein Gesamtreset löscht
|
||||||
|
nur die Gesamtlaufintervalle. Beide ändern weder Piece-Status noch Reihenfolge,
|
||||||
|
Restplanung oder Präsentationsstand.
|
||||||
|
|
||||||
|
Die Restzeit ist eine Ableitung, kein zweiter Ablaufplan. Für jeden verbleibenden
|
||||||
|
Abschnitt gilt entweder dessen positive explizite Dauer oder die Summe seiner
|
||||||
|
verbleibenden Piece-Dauern. Bereits abgeschlossene Abschnitte werden ausgelassen,
|
||||||
|
ungeplante Abschnitte werden sichtbar gezählt und eine Überziehung reduziert
|
||||||
|
nicht die geplante Zeit kommender Inhalte.
|
||||||
|
|
||||||
|
`ended_at_utc` beendet einen Durchlauf, nicht automatisch dessen offene Pieces.
|
||||||
|
Wiederaufnahme und neuer Durchlauf sind deshalb getrennte Befehle: Erstere
|
||||||
|
bewahrt Intervalle und Fortschritt, letzterer setzt beides ausdrücklich zurück.
|
||||||
|
Das private Markdownfeld „Interaktion“ folgt derselben Original-/Snapshotgrenze
|
||||||
|
wie Moderation und Überleitung, ist aber nicht Teil der öffentlichen
|
||||||
|
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`.
|
||||||
|
|
||||||
|
## D-033: Auswahlzustand flüchtig, Verwaltungslogik gemeinsam
|
||||||
|
|
||||||
|
Quellenfilter, Pagination und Mehrfachauswahl des RSS-Dialogs bleiben lokaler
|
||||||
|
UI-Zustand. Persistiert wird erst durch die bestehende transaktionale
|
||||||
|
Importaktion, damit Blättern oder Öffnen eines externen Links niemals
|
||||||
|
redaktionelle Inhalte erzeugt. Die Verwaltung ruft weiterhin dieselben Feed-
|
||||||
|
und Zugangsdienste auf; neue Dialoge ersetzen nur die dauerhaft ausgebreiteten
|
||||||
|
Formulare. Ein technischer Zugang darf Name und Umfang ändern, behält dabei aber
|
||||||
|
seinen ausschließlich gehasht gespeicherten Token und erzeugt ein Auditereignis.
|
||||||
|
|
||||||
|
Das Live-Menü zeigt einen rein gelesenen Verbindungsstatus des Ausgabekanals.
|
||||||
|
Nur die bereits vorhandenen expliziten Verbinden-, Rotieren- und Trennenaktionen
|
||||||
|
verändern öffentliche Ausgabe. Navigation zur Planung bleibt davon getrennt.
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
# Release Notes
|
||||||
|
|
||||||
|
## 0.7.2 - RSS-Auswahl, Live-Menü und Schnittstellenverwaltung
|
||||||
|
|
||||||
|
Veröffentlicht am 26. Oktober 2026
|
||||||
|
|
||||||
|
- Der Eingang zeigt neue RSS-Beiträge in einer kompakten Katalogfläche. Ein
|
||||||
|
großzügiger, mobil vollflächiger Dialog filtert mehrere Quellen vor der
|
||||||
|
Seiteneinteilung, zeigt 20 Beiträge je Seite und behält die barrierefrei
|
||||||
|
erkennbare Auswahl über Quellen- und Seitenwechsel. Erst die ausdrückliche
|
||||||
|
Übernahme erzeugt Eingangspieces; Herkunft, Kategorien, Verwerfen und
|
||||||
|
Dublettenschutz bleiben erhalten (#16).
|
||||||
|
- Das Live-Menü folgt der Nutzerabsicht: Sendungssteuerung, kompakte Ansicht,
|
||||||
|
Lesefreigaben, standardmäßig eingeklappte OBS-Ausgabe und getrennte Navigation
|
||||||
|
sind klar geordnet. Pause/Fortsetzen sowie Abschnitts- und Gesamtreset liegen
|
||||||
|
direkt bei den Timern. Laufend, pausiert und beendet werden eindeutig
|
||||||
|
unterschieden; Navigation allein verändert weder Lauf noch Ausgabe (#18).
|
||||||
|
- RSS-Feeds und technische API-Zugänge verwenden Panel, Typografie,
|
||||||
|
Eintragskarten, Classic-Regular-Icons und Dialograhmen des Werkzeugkatalogs.
|
||||||
|
Feeds und Zugänge lassen sich in Dialogen anlegen und bearbeiten; Abruf,
|
||||||
|
Löschen beziehungsweise Widerruf bleiben kompakte Iconaktionen. Neue
|
||||||
|
API-Tokens erscheinen unmittelbar einmalig und kopierbar, während weiterhin
|
||||||
|
nur ihr Hash gespeichert wird (#21).
|
||||||
|
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.2`. Es ist keine
|
||||||
|
Datenbankmigration erforderlich. 60 Verhaltenstests und 20 Chromium-Abläufe
|
||||||
|
sind erfolgreich; echte Browser-/Geräteprüfungen außerhalb von Chromium
|
||||||
|
bleiben offen.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
Veröffentlicht am 24. Oktober 2026
|
||||||
|
|
||||||
|
Sendewerk 0.5.12 macht aus der privaten Live-Steuerung eine kompakte Regieansicht
|
||||||
|
für Vollbild, Smartphone und kleine Desktopfenster neben OBS oder Firebot. Die
|
||||||
|
Sendungsübersicht erhält zusätzlich eine kontobezogen gespeicherte Listen- und
|
||||||
|
Kalenderansicht. Die öffentliche heinzlive-Ausgabe und die gespeicherten
|
||||||
|
Livezustände bleiben technisch getrennt.
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
|
||||||
|
- Ausgabe, Gesamtzeit, Abschnittszeit und Menü tragen in der Live-Kopfleiste
|
||||||
|
jetzt die passenden lokalen Classic-Regular-Icons. Pause beziehungsweise
|
||||||
|
Fortsetzen ist zusätzlich als direkter Iconbutton neben der Abschnittszeit
|
||||||
|
erreichbar.
|
||||||
|
- Die Live-Regie verwendet die gesamte Fensterfläche ohne normale
|
||||||
|
App-Seitenleiste, mobilen App-Header oder Bottomnavigation.
|
||||||
|
- Zwei kompakte Kopfzeilen halten Abschnitt, „Danach“, Menü, Ausgabe,
|
||||||
|
Abschnittscountdown und Ablauf gleichzeitig erreichbar. Der Sendungsname ist
|
||||||
|
in das Menü gewandert.
|
||||||
|
- Lange Abschnitts- und Folgetitel bleiben einzeilig gekürzt und lassen sich per
|
||||||
|
Klick oder Tastatur vollständig aufklappen, ohne benachbarte Bedienelemente zu
|
||||||
|
überlagern.
|
||||||
|
- Moderation und Ablauf bleiben auch bei einem nur 900 × 450 Pixel großen
|
||||||
|
Desktopfenster nebeneinander. Lange Inhalte scrollen innerhalb ihrer
|
||||||
|
Arbeitsfläche; Kopf- und Aktionszeile bleiben sichtbar.
|
||||||
|
- Niedrige Desktopfenster verdichten Abstände und Bedienleisten anhand der
|
||||||
|
verfügbaren Höhe, ohne Text oder die gesamte Oberfläche zu skalieren.
|
||||||
|
- Die feste Fußleiste ist auf „Zurück“, „Präsentieren“, „Überspringen“ und
|
||||||
|
„Erledigen & weiter“ reduziert. Das nächste Piece steht in der Kopfzeile, das
|
||||||
|
Leeren der Präsentation direkt am Ausgabezustand.
|
||||||
|
- Auf dem Smartphone öffnet „Ablauf · Piece X/Y“ den Ablauf als separates Sheet,
|
||||||
|
während das aktuelle Piece die Hauptfläche bleibt. Fokusführung, Escape und
|
||||||
|
der Wechsel zurück auf eine breite Ansicht schließen den Modalzustand sauber.
|
||||||
|
|
||||||
|
### Moderation und Materialien
|
||||||
|
|
||||||
|
- Interner Titel, Einleitung, Moderationsstichpunkte und Überleitung erscheinen
|
||||||
|
in klarer Lesereihenfolge. Leere Bereiche werden weggelassen.
|
||||||
|
- Recherche, Quellen, Werkzeuge und eigene Notizen sind kompakt beschriftet und
|
||||||
|
werden nur bei Bedarf geöffnet.
|
||||||
|
- Die Prompter-Schriftgröße bleibt einstellbar und wird weiterhin lokal
|
||||||
|
gespeichert. Texte werden nicht automatisch verkleinert.
|
||||||
|
- Piece-Position, Piece-Titel und Zeit stehen in einer gemeinsamen kompakten
|
||||||
|
Kopfzeile; dadurch beginnt die Einleitung weiter oben.
|
||||||
|
|
||||||
|
### Ablauf und Liveverhalten
|
||||||
|
|
||||||
|
- Pause und Fortsetzen steuern Piece-, Abschnitts- und Gesamtzeit gemeinsam und
|
||||||
|
zählen Pausen auch nach Reload oder Navigation nicht mit.
|
||||||
|
- Gelaufene Gesamtzeit und geschätzte Restzeit lassen sich direkt umschalten;
|
||||||
|
die Auswahl bleibt lokal erhalten. Ungeplante verbleibende Abschnitte sind
|
||||||
|
sichtbar, ohne Abschnitts- und Piece-Dauern doppelt zu zählen.
|
||||||
|
- Abschnitts- und Gesamtzeit lassen sich getrennt nach Bestätigung zurücksetzen.
|
||||||
|
Ablaufposition, Piece-Status, Restplanung und Präsentation bleiben dabei
|
||||||
|
unverändert.
|
||||||
|
- Sendungen lassen sich ausdrücklich vorbereiten, starten, pausieren und
|
||||||
|
beenden. Nach dem Ende sind „Lauf fortsetzen“ und „Neuer Durchlauf“ getrennte
|
||||||
|
Aktionen; offene Pieces werden beim Beenden nicht automatisch erledigt.
|
||||||
|
- Abschnitte sind kompakt aufklappbar; der aktuelle Abschnitt ist geöffnet und
|
||||||
|
das aktive Piece bleibt in der Ablaufspalte sichtbar.
|
||||||
|
- Pieces kennzeichnen tatsächliches Live-Piece, lokal betrachtetes Piece und
|
||||||
|
ausgegebenes Piece getrennt. Jeder Eintrag lässt sich zur Betrachtung wählen,
|
||||||
|
einschließlich des ersten Eintrags.
|
||||||
|
- Die Reserve bleibt zusammengeklappt erreichbar. Dauerhafte Sortierpfeile und
|
||||||
|
die separate große „Als Nächstes“-Karte entfallen; Umsortieren bleibt über
|
||||||
|
eine kompakte Aktion am jeweiligen Eintrag erreichbar.
|
||||||
|
- Die lokal gespeicherte Leseposition gehört zum jeweiligen Piece. Eine direkte
|
||||||
|
Auswahl beginnt daher oben, ohne die Position des vorherigen Textes zu erben.
|
||||||
|
- „Zurück“ und direkte Ablaufwahl verändern weder aktuelles Live-Piece noch
|
||||||
|
Erledigungsstatus, Timer oder Ausgabe. Bei einem nur betrachteten früheren
|
||||||
|
Piece bleiben Überspringen und Erledigen gesperrt. Nur „Präsentieren“ oder
|
||||||
|
„Präsentation leeren“ verändert die öffentliche Ausgabe.
|
||||||
|
- Abschnittstimer, tatsächliche Laufzeiten, Livefortschritt und Wiederaufnahme
|
||||||
|
verwenden unverändert die vorhandenen persistenten Zustände.
|
||||||
|
- „Live-Stand wiederaufnehmen“ erscheint nur bei einem tatsächlich
|
||||||
|
unterbrochenen Ablauf, nicht während eines aktiven Pieces.
|
||||||
|
|
||||||
|
### Einstellungen
|
||||||
|
|
||||||
|
- Ausgabekanal verbinden, Schlüssel erneuern, Kanal trennen, Theme wechseln,
|
||||||
|
Zeiten ein-/ausblenden und „Zur Planung“ bleiben im Regiemenü erreichbar.
|
||||||
|
- Das Regiemenü erscheint als viewportgebundenes Popover sicher über den
|
||||||
|
Glasflächen. Es bleibt in kleinen Fenstern scrollbar erreichbar und schließt
|
||||||
|
mit Escape oder Außenklick; der Fokus kehrt zum Menüauslöser zurück.
|
||||||
|
|
||||||
|
### Interaktion
|
||||||
|
|
||||||
|
- „Interaktion“ ergänzt Piece und Sendungsfassung als optionales privates
|
||||||
|
Markdownfeld zwischen Moderationsstichpunkten und Überleitung.
|
||||||
|
- Vorlagen, Duplikate, Einplanungen und die bewusste Aktualisierung aus dem
|
||||||
|
Fundus übernehmen das Feld jeweils als unabhängigen Snapshot.
|
||||||
|
- Die private Live-Regie zeigt das Feld nur bei Inhalt. Die öffentliche
|
||||||
|
heinzlive-Ausgabe übernimmt es nicht.
|
||||||
|
|
||||||
|
### Sendungsübersicht
|
||||||
|
|
||||||
|
- Sendungskarten bleiben auf kleinen Bildschirmen kompakt: Status, Titel und
|
||||||
|
Termin stehen neben den horizontal angeordneten Abschnitts- und Zeitangaben,
|
||||||
|
statt die Karte durch vollständiges Stapeln unnötig zu erhöhen.
|
||||||
|
- „Liste | Kalender“ schaltet zwischen der bekannten Sendungsliste und einer
|
||||||
|
Monatsansicht um; die Auswahl bleibt am angemeldeten Konto gespeichert.
|
||||||
|
- Auf dem Desktop zeigt ein vollständiges Montag-bis-Sonntag-Raster alle Termine
|
||||||
|
des Monats. Mehrere Sendungen pro Tag bleiben vollständig sichtbar und nach
|
||||||
|
Uhrzeit sortiert.
|
||||||
|
- Auf dem Smartphone wird derselbe Monat als chronologische Agenda dargestellt.
|
||||||
|
Vorheriger Monat, nächster Monat und der Sprung zu heute bleiben erreichbar.
|
||||||
|
- Die Gruppierung verwendet `Europe/Berlin`. Sendungen ohne Startzeit stehen in
|
||||||
|
einem eigenen Bereich und verschwinden weder aus Liste noch Kalender.
|
||||||
|
- Der Kalender ist eine Navigationsansicht. Termine werden weiterhin nur über
|
||||||
|
die bestehende Sendungsbearbeitung geändert; Termin-Drag-and-drop wurde nicht
|
||||||
|
ergänzt.
|
||||||
|
|
||||||
|
### Update und Kompatibilität
|
||||||
|
|
||||||
|
- `0012_show_overview_view.sql` ergänzt ausschließlich die kontobezogene
|
||||||
|
Listen-/Kalenderpräferenz mit dem sicheren Standardwert `list`.
|
||||||
|
- Es ist keine Änderung der Konfiguration erforderlich.
|
||||||
|
- `0013_live_controls_and_interaction.sql` ergänzt das private Interaktionsfeld
|
||||||
|
und persistente Gesamtzeitintervalle. Bestehende Piece-Laufintervalle werden
|
||||||
|
als Gesamtzeit übernommen.
|
||||||
|
- Bestehende Sendungen, Livezustände, Abschnittstimer, Ausgabekanäle und
|
||||||
|
Präsentationssnapshots bleiben erhalten.
|
||||||
|
- Paket, Lockdatei und Cloudron-Manifest tragen gemeinsam Version `0.5.12`.
|
||||||
|
|
||||||
|
### Prüfung
|
||||||
|
|
||||||
|
- Die ergänzte Iconleiste, Pause/Fortsetzen und die kompakte Sendungskarte wurden
|
||||||
|
gezielt in Chromium bei 670 × 450, 390 × 700 und 656 × 400 Pixeln geprüft.
|
||||||
|
Typprüfung, sechs Live-Verhaltenstests und Produktionsbuild sind erfolgreich.
|
||||||
|
- Die vollständige Matrix wurde für diese Ergänzung nicht erneut ausgeführt; die
|
||||||
|
folgenden Gesamtnachweise stammen vom vorherigen 0.5.12-Stand.
|
||||||
|
- 44 Unit- und Verhaltenstests sowie 17 Chromium-E2E-Abläufe sind erfolgreich.
|
||||||
|
- Die Live-Regie wurde bei 1440 × 900, 900 × 450, 670 × 450 und 390 × 700 Pixeln
|
||||||
|
mit langen Titeln, sichtbarem Regiemenü, unabhängigen Scrollflächen und
|
||||||
|
mobilem Ablauf-Sheet geprüft; der Kalender zusätzlich bei 1440 × 1000 und
|
||||||
|
390 × 844 Pixeln.
|
||||||
|
- Reales OBS, echte YouTube-/TikTok-Wiedergabe und Safari auf einem iOS-Gerät
|
||||||
|
standen nicht zur Verfügung.
|
||||||
|
|
||||||
|
## 0.5.11 - Verwaltungskorrektur
|
||||||
|
|
||||||
|
Veröffentlicht am 23. Oktober 2026
|
||||||
|
|
||||||
|
- Der Dialog „Werkzeug hinzufügen“ bleibt beim Aufruf der Verwaltungsseite
|
||||||
|
wieder zuverlässig geschlossen.
|
||||||
|
- Der Dialog erscheint erst, wenn ein neues Werkzeug angelegt oder ein
|
||||||
|
bestehendes Werkzeug bearbeitet wird.
|
||||||
|
- Die Iconauswahl unter „Inhaltliche Zuordnung“ bleibt auf Desktop und
|
||||||
|
Smartphone vollständig im sichtbaren Browserbereich.
|
||||||
|
- Die Korrektur gilt gleichermaßen für Desktop- und Smartphone-Ansichten und
|
||||||
|
verändert keine vorhandenen Werkzeuge oder Zuordnungen.
|
||||||
|
|
||||||
|
## 0.5.10 - Editor-Erlebnis
|
||||||
|
|
||||||
|
Veröffentlicht am 23. Oktober 2026
|
||||||
|
|
||||||
|
Sendewerk 0.5.10 beschleunigt die tägliche Redaktionsarbeit und schärft die
|
||||||
|
Grenze zwischen privaten Medien und der öffentlichen Präsentation. Titel,
|
||||||
|
Themen, Werkzeuge und Präsentationsbilder lassen sich direkter bearbeiten; die
|
||||||
|
wichtigsten Listen und Dialoge benötigen auf Desktop und Smartphone weniger
|
||||||
|
Platz. Bestehende Sendungsfassungen bleiben bei späteren Änderungen am Fundus
|
||||||
|
stabil.
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
|
||||||
|
- Titel von Fundus- und Sendungsfassungen lassen sich direkt in der jeweiligen
|
||||||
|
Ansicht ändern. Die Bedienung funktioniert auch vollständig per Tastatur.
|
||||||
|
- Themen und Werkzeuge verwenden kompakte, durchsuchbare Auswahlflächen. Der
|
||||||
|
Werkzeugkatalog folgt nun demselben Bedienmuster wie die Themenverwaltung.
|
||||||
|
- Für Themen, Werkzeuge, Navigation und Aktionen stehen 3.671 lokal
|
||||||
|
bereitgestellte Classic-Regular-Icons zur Verfügung. Zur Laufzeit werden
|
||||||
|
keine Icons von externen Diensten geladen.
|
||||||
|
- Präsentationsbilder werden bewusst aus den aktiven Piece-Medien gewählt und
|
||||||
|
beim Einplanen in die Sendungsfassung übernommen. Private Uploads werden nicht
|
||||||
|
allein durch das Hochladen öffentlich.
|
||||||
|
- Pieces lassen sich wiederherstellbar löschen. Bereits vorbereitete
|
||||||
|
Sendungsfassungen und ihre Medien-Snapshots bleiben dabei erhalten.
|
||||||
|
- Die heinzlive-Ausgabe kann ausdrücklich geleert werden. Im leeren Zustand ist
|
||||||
|
sie vollständig transparent und zeigt auch keinen Verbindungsstatus.
|
||||||
|
|
||||||
|
### Redaktion und Verwaltung
|
||||||
|
|
||||||
|
- Der Werkzeugkatalog entspricht der Themenverwaltung: kompakte Glasfläche,
|
||||||
|
Icon, Name, Status und Bearbeitungsaktion statt dauerhaft offener Formulare.
|
||||||
|
- Anlegen und Bearbeiten eines Werkzeugs verwenden denselben responsiven Dialog
|
||||||
|
für Name, Start-URL, Beschreibung, Icon, passende Formate und Aktivstatus.
|
||||||
|
- Die Formatzuordnung nutzt dieselbe durchsuchbare Mehrfachauswahl wie andere
|
||||||
|
Zuordnungen. Deaktivierte Werkzeuge und bestehende Piece-Zuordnungen bleiben
|
||||||
|
erhalten.
|
||||||
|
- Fundus, Eingang, Archiv, Vorlagen und Sendungsansichten sind kompakter und
|
||||||
|
bleiben per Klick, Touch und Tastatur bedienbar.
|
||||||
|
|
||||||
|
### Pieces und Präsentation
|
||||||
|
|
||||||
|
- Präsentationsbilder werden ausdrücklich aus aktiven Piece-Medien ausgewählt
|
||||||
|
und unveränderlich an Sendungsfassungen kopiert.
|
||||||
|
- Entfernte Medien bleiben für bestehende Sendungsfassungen verfügbar, werden
|
||||||
|
aber nicht mehr für neue Einplanungen angeboten.
|
||||||
|
- Pieces können wiederherstellbar gelöscht werden. Offene, nie live gelaufene
|
||||||
|
Verwendungen und Abschnitte lassen sich unter geschützten Bedingungen
|
||||||
|
entfernen.
|
||||||
|
- Themenicons werden zusammen mit der jeweiligen Sendungsfassung gespeichert.
|
||||||
|
Spätere Änderungen an Thema, Werkzeug oder Original-Piece verändern eine
|
||||||
|
vorbereitete Sendung weiterhin nur nach ausdrücklicher Aktualisierung aus dem
|
||||||
|
Fundus.
|
||||||
|
|
||||||
|
### Live-Ausgabe
|
||||||
|
|
||||||
|
- Eine leere heinzlive-Ausgabe ist vollständig transparent und zeigt weder
|
||||||
|
Renderer noch Verbindungsstatus.
|
||||||
|
- Eine aktive Präsentation lässt sich unabhängig vom aktuell laufenden Piece
|
||||||
|
leeren.
|
||||||
|
|
||||||
|
### Update und Kompatibilität
|
||||||
|
|
||||||
|
- `0010_editor_experience.sql` ergänzt Themenicons, Medien-Snapshots und Soft
|
||||||
|
Delete.
|
||||||
|
- `0011_tool_icons.sql` ergänzt persistente Werkzeugicons mit einem sicheren
|
||||||
|
Standardwert. Werkzeug-IDs, Formatbeziehungen, Piece-Zuordnungen und
|
||||||
|
Deaktivierungszustände bleiben erhalten.
|
||||||
|
- Die Migrationen laufen beim nächsten Serverstart automatisch. Vor dem Update
|
||||||
|
sollte wie üblich eine konsistente Sicherung der SQLite-Datenbank und des
|
||||||
|
Upload-Verzeichnisses angelegt werden.
|
||||||
|
- Für dieses Release sind keine manuellen Datenkonvertierungen und keine
|
||||||
|
Änderungen an der Konfiguration erforderlich.
|
||||||
|
|
||||||
|
### Prüfung
|
||||||
|
|
||||||
|
- 40 Unit- und Verhaltenstests sowie 16 Chromium-E2E-Abläufe erfolgreich.
|
||||||
|
- Werkzeugverwaltung, Iconauswahl und Piece-Werkzeugauswahl wurden zusätzlich
|
||||||
|
bei 1280 × 720 und 390 × 844 Pixeln visuell mit der Themenverwaltung
|
||||||
|
verglichen.
|
||||||
|
- Reales OBS, echte YouTube-/TikTok-Wiedergabe und Safari auf einem iOS-Gerät
|
||||||
|
standen nicht zur Verfügung.
|
||||||
|
|
||||||
|
### Noch nicht enthalten
|
||||||
|
|
||||||
|
Der Abschluss und die Archivierung vollständiger Sendungen, externe
|
||||||
|
Lesefreigaben, RSS-Import und API gehören zu späteren Etappen und sind nicht Teil
|
||||||
|
von 0.5.10.
|
||||||
+339
-3
@@ -2,10 +2,346 @@
|
|||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
|
## Bedienkorrekturen 0.7.2
|
||||||
|
|
||||||
|
- Der RSS-Importeingang ist eine kompakte Katalogfläche. Die eigentliche Auswahl
|
||||||
|
öffnet einen responsiven Dialog mit kombinierbaren Quellenfiltern, 20 Beiträgen
|
||||||
|
pro Seite, tastaturbedienbaren Auswahlkarten und einer festen bewussten
|
||||||
|
Übernahme. Auswahl bleibt beim Filtern und Blättern erhalten; importierte oder
|
||||||
|
verworfene Beiträge verschwinden über den bestehenden persistenten Status.
|
||||||
|
- Das Live-Menü trennt Sendungssteuerung, Ansicht, Lesefreigaben,
|
||||||
|
OBS-Ausgabekanal und Navigation. Pause/Fortsetzen und beide Timerresets liegen
|
||||||
|
bei den Zeitflächen. Der Menüstatus unterscheidet Entwurf, Bereit, Läuft,
|
||||||
|
Pausiert, Lauf beendet und historischen Abschluss; der OBS-Bereich bleibt bis
|
||||||
|
zur bewussten Aktion eingeklappt.
|
||||||
|
- Feedquellen und technische Zugänge verwenden dieselben Katalogkarten und
|
||||||
|
Dialoggrundlagen wie Werkzeuge. Beide lassen sich anlegen und bearbeiten;
|
||||||
|
Feeds behalten Abruf/Löschen, Zugänge ihren sofortigen Widerruf. Ein neuer
|
||||||
|
API-Token wird im Dialog einmalig angezeigt und kann direkt kopiert werden.
|
||||||
|
|
||||||
|
Geprüft wurden 60 von 60 Verhaltenstests, Lint, Typprüfung, Produktionsbuild und
|
||||||
|
20 von 20 Chromium-Abläufe. Der neue Ablauf prüft RSS-Auswahl über mehrere
|
||||||
|
Quellen und Seiten, Übernahme ohne erneutes Angebot, Feed- und Zugangsanlage
|
||||||
|
samt Bearbeitung und Tokenkopie. Das Live-Menü wurde laufend, pausiert und
|
||||||
|
beendet sowie bei kleinem Desktopfenster und mobil geprüft. Die betroffenen
|
||||||
|
Flächen wurden in hellem und dunklem Farbschema kontrolliert. Andere Browser und
|
||||||
|
echte Mobilgeräte bleiben offen.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- die Live-Kopfleiste zeigt Classic-Regular-Icons für Ausgabe, Gesamtzeit,
|
||||||
|
Abschnitt, Menü und Ablauf; Pause/Fortsetzen liegt zusätzlich als direkter,
|
||||||
|
beschrifteter Iconbutton neben der Abschnittszeit
|
||||||
|
- Sendungskarten ordnen Fakten auf kleinen Bildschirmen wieder horizontal an und
|
||||||
|
vermeiden dadurch die zuvor unnötig hohe Kartenfläche
|
||||||
|
- Reparaturmigration `0014_live_show_timer_repair.sql` ergänzt fehlende
|
||||||
|
Gesamttimertabellen auch dann, wenn `0013` bereits in einem früheren
|
||||||
|
Zwischenstand protokolliert wurde; der 500-Fehler beim Laden von `/live` ist
|
||||||
|
damit behoben
|
||||||
|
- gemeinsame persistente Pause für Piece-, Abschnitts- und Gesamttimer;
|
||||||
|
Fortsetzen zählt Pausenzeit weder nach Navigation noch nach Reload mit
|
||||||
|
- getrennte bestätigungspflichtige Resets für Abschnitts- und Gesamtzeit, ohne
|
||||||
|
Piece-Status, Ablaufposition, Restplanung oder Präsentationsstand zu verändern
|
||||||
|
- umschaltbare, lokal gemerkte Anzeige von gelaufener Gesamtzeit und geschätzter
|
||||||
|
Restzeit; Abschnitte ohne eigene Planung werden kenntlich gemacht und
|
||||||
|
Abschnitts- sowie Piece-Dauern nie doppelt addiert
|
||||||
|
- explizite Sendungssteuerung für Entwurf, Bereit, Start, Pause, Ende,
|
||||||
|
Wiederaufnahme eines beendeten Laufs und klar getrennten neuen Durchlauf
|
||||||
|
- optionales privates Markdownfeld „Interaktion“ zwischen Moderation und
|
||||||
|
Überleitung; Original, Vorlage, Sendungsfassung, Duplikation und bewusste
|
||||||
|
Fundus-Aktualisierung führen jeweils unabhängige Snapshots
|
||||||
|
- „Interaktion“, Timer- und Sendungssteuerung bleiben aus der öffentlichen
|
||||||
|
heinzlive-Projektion ausgeschlossen
|
||||||
|
- fokussierte private Liveansicht ohne normale App-Seitenleiste oder mobile
|
||||||
|
Bottomnavigation; Planung, Theme, Schriftgröße, Zeitdarstellung und
|
||||||
|
Ausgabekanal bleiben über das kompakte Regiemenü erreichbar
|
||||||
|
- zweizeilige feste Kopfzeile: Abschnitt und „Danach“ teilen sich die erste
|
||||||
|
Zeile mit dem Menü; Ausgabe, Abschnittscountdown und Ablaufschalter bilden die
|
||||||
|
zweite Zeile. Der Sendungsname steht platzsparend im Menü
|
||||||
|
- lange Abschnitts- und Folgetitel sind regulär einzeilig gekürzt und lassen
|
||||||
|
sich per Klick oder Tastatur vollständig aufklappen
|
||||||
|
- feste, auf vier Kernaktionen reduzierte Fußleiste mit Zurück, Präsentieren,
|
||||||
|
Überspringen und kontextgerechtem Erledigen; das Leeren der Präsentation liegt
|
||||||
|
direkt beim Ausgabezustand
|
||||||
|
- aktuelle Moderation und schmale Ablaufspalte bleiben auch bei 900 × 450 Pixeln
|
||||||
|
nebeneinander; beide Arbeitsflächen scrollen unabhängig, ohne die ganze Seite
|
||||||
|
oder einzelne Textblöcke zu verschachtelten Scrollcontainern zu machen
|
||||||
|
- bei niedrigen Desktopfenstern werden Abstände und Bedienleisten anhand der
|
||||||
|
verfügbaren Höhe verdichtet, ohne Text oder Gesamtoberfläche zu skalieren
|
||||||
|
- auf schmalen Ansichten bleibt das Piece die Hauptfläche und der Ablauf öffnet
|
||||||
|
als separates, safe-area-fähiges Sheet mit Fokusführung und Escape-Schließen
|
||||||
|
- kompakter aufklappbarer Ablauf mit ausgeschriebenen Zuständen, lokaler
|
||||||
|
Betrachtung jedes Pieces, automatisch sichtbarem aktuellen Eintrag und
|
||||||
|
zusammengeklappter Reserve; Sortierung bleibt in einer kompakten Aktion
|
||||||
|
erreichbar, dauerhafte Sortierpfeile entfallen
|
||||||
|
- die Leseposition wird je Piece lokal erhalten; ein erstmals gewähltes Piece
|
||||||
|
beginnt oben statt an der Position des vorherigen Textes
|
||||||
|
- Piece-Position, Titel und Zeit teilen sich einen kompakten Piece-Kopf, ohne die
|
||||||
|
einstellbare Moderationsschrift zu verkleinern
|
||||||
|
- das Regiemenü liegt als viewportgebundenes Popover sicher über den Glasflächen,
|
||||||
|
bleibt scrollbar erreichbar und schließt per Escape oder Außenklick mit
|
||||||
|
Fokus-Rückkehr zum Auslöser
|
||||||
|
- Recherche, Quellen, Werkzeuge und eigene Notizen erscheinen nur bei Bedarf;
|
||||||
|
leere Moderationsbereiche werden nicht dargestellt
|
||||||
|
- Zurück und direkte Ablaufwahl verändern weder tatsächliches Live-Piece noch
|
||||||
|
Status, Timer oder öffentliche Ausgabe; Überspringen und Erledigen bleiben bei
|
||||||
|
einem nur betrachteten früheren Piece gesperrt
|
||||||
|
- Liste und Kalender teilen dieselben Sendungsdaten; der Desktop zeigt ein
|
||||||
|
vollständiges Monatsraster, das Smartphone eine chronologische Monatsagenda
|
||||||
|
- Monatswechsel, heutiger Tag, mehrere Termine pro Tag und Sendungen ohne Termin
|
||||||
|
bleiben sichtbar; die bevorzugte Listen- oder Kalenderansicht wird je Konto
|
||||||
|
gespeichert
|
||||||
|
- die Wiederaufnahmeaktion erscheint nur bei tatsächlich unterbrochenem
|
||||||
|
Live-Stand statt während eines laufenden Pieces
|
||||||
|
|
||||||
|
Etappe 6 bleibt unverändert unbegonnen.
|
||||||
|
|
||||||
|
Die bestehende lokale Prüfmatrix umfasst 44 Verhaltenstests und 17
|
||||||
|
Chromium-Abläufe. Die nachträgliche Icon-, Pause- und Kartenkorrektur wurde
|
||||||
|
zusätzlich mit Typprüfung, sechs Live-Verhaltenstests, Produktionsbuild und
|
||||||
|
gezielten Chromium-Abläufen bei 670 × 450, 390 × 700 und 656 × 400 Pixeln
|
||||||
|
geprüft. Die vollständige Matrix wurde dafür nicht erneut ausgeführt.
|
||||||
|
Reales OBS, echte YouTube-/TikTok-Wiedergabe sowie Safari auf einem iOS-Gerät
|
||||||
|
bleiben mangels verfügbarer Umgebung offen.
|
||||||
|
|
||||||
|
## Verwaltungskorrektur 0.5.11
|
||||||
|
|
||||||
|
- der Werkzeugdialog bleibt beim Laden geschlossen und öffnet erst nach einer
|
||||||
|
bewussten Aktion
|
||||||
|
- die Themen-Iconauswahl bleibt bei mobilen und breiten Ansichten vollständig
|
||||||
|
innerhalb des Viewports
|
||||||
|
|
||||||
|
## Editor-Erlebnis 0.5.10
|
||||||
|
|
||||||
|
- direkte, tastaturbedienbare Titelbearbeitung in Fundus- und Sendungsfassungen
|
||||||
|
sowie gemeinsame kompakte Auswahlflächen für Themen und Werkzeuge
|
||||||
|
- vollständiger lokaler Classic-Regular-Katalog mit 3.671 Icons für Navigation,
|
||||||
|
Aktionen sowie frei wählbare Themen- und Werkzeugicons; durchsuchbare
|
||||||
|
Metadaten und SVG-Sprite vermeiden externe Laufzeitabhängigkeiten
|
||||||
|
- kompakter Werkzeugkatalog nach dem Muster der Themenverwaltung; Anlegen und
|
||||||
|
Bearbeiten teilen einen responsiven Dialog mit gestalteter Icon- und
|
||||||
|
Formatauswahl, bestehende Zuordnungen und Deaktivierung bleiben erhalten
|
||||||
|
- Präsentationsbilder werden bewusst aus aktiven Piece-Medien ausgewählt und als
|
||||||
|
unveränderlicher Usage-Snapshot eingeplant; private Bilder werden dadurch
|
||||||
|
weder implizit noch nach späterer Originaländerung öffentlich
|
||||||
|
- entfernte Medien bleiben für bestehende Sendungsfassungen erhalten, während
|
||||||
|
neue Einplanungen nur aktive Medien übernehmen
|
||||||
|
- wiederherstellbares Soft Delete für Pieces sowie geschütztes Entfernen offener,
|
||||||
|
nie live gelaufener Verwendungen und Abschnitte
|
||||||
|
- kompaktere Fundus-, Eingangs-, Archiv-, Vorlagen- und Sendungsansichten mit
|
||||||
|
mobilen, touch- und tastaturbedienbaren Alternativen
|
||||||
|
- transparente leere heinzlive-Ausgabe ohne sichtbaren Renderer oder
|
||||||
|
Verbindungsstatus; die Steuerung kann eine aktive Präsentation unabhängig vom
|
||||||
|
aktuellen Piece leeren
|
||||||
|
|
||||||
|
Etappe 6 bleibt unverändert unbegonnen. Die lokale Prüfmatrix umfasst 40
|
||||||
|
Verhaltenstests und 16 Chromium-Abläufe. Reales OBS, echte
|
||||||
|
YouTube-/TikTok-Wiedergabe sowie Safari auf einem iOS-Gerät bleiben mangels
|
||||||
|
verfügbarer Umgebung offen.
|
||||||
|
|
||||||
|
## Live-Werkbank 0.5.9
|
||||||
|
|
||||||
|
- globale Favoriten am Fundus-Original mit stabiler Vorsortierung; verwendete
|
||||||
|
Favoriten bleiben sichtbar, geplante Favoriten respektieren weiterhin den
|
||||||
|
Einplanungsfilter
|
||||||
|
- Barkeeper-Werkzeugkatalog mit Start-URL, Beschreibung, passenden Formaten und
|
||||||
|
verlustfreier Deaktivierung; Arbeitslink und Notiz bleiben für die gesamte
|
||||||
|
Thekencrew im gemeinsamen Piece- und Verwendungseditor bearbeitbar
|
||||||
|
- vollständige, voneinander unabhängige Usage-Snapshots für Quellen, Werkzeuge
|
||||||
|
und alphabetisch stabilisierte Themen; spätere Original- oder
|
||||||
|
Katalogänderungen wirken erst nach bewusster Aktualisierung aus dem Fundus
|
||||||
|
- kompakte private Materialliste in der Live-Steuerung mit Arbeitslink vor
|
||||||
|
Start-URL und verständlichem Zustand ohne URL
|
||||||
|
- responsive Themen-Dachzeile in der Publikumsausgabe; ohne Thema bleibt sie
|
||||||
|
vollständig aus, während Präsentations- und interner Titel getrennt bleiben
|
||||||
|
- persistenter Abschnitts-Countdown für positive explizite Abschnittsdauern mit
|
||||||
|
eingefrorenem Soll, sekündlicher Anzeige, Goldwarnung ab zwei Minuten und
|
||||||
|
roter Überziehung ohne Ton, Blinken oder automatische Weiterschaltung
|
||||||
|
|
||||||
|
Etappe 6 bleibt unverändert unbegonnen. Reales OBS und echte
|
||||||
|
YouTube-/TikTok-Wiedergabe bleiben mangels verfügbarer Umgebung offen.
|
||||||
|
|
||||||
|
Die lokale Prüfmatrix umfasst 37 Verhaltenstests und 16 Chromium-Abläufe.
|
||||||
|
Desktop, Smartphone, Light/Dark, Favoritenfilter, Werkzeug-Deaktivierung,
|
||||||
|
Snapshot-Stabilität, private Live-Materialien, öffentliche Themenprojektion und
|
||||||
|
persistente Abschnittszeit wurden geprüft.
|
||||||
|
|
||||||
|
## Redaktionelle Arbeitsabläufe 0.5.8
|
||||||
|
|
||||||
|
- persistente Piece-Vorlagen als unabhängige, revisionierte Snapshots; eigene
|
||||||
|
Vorlagen lassen sich aus Fundus-Pieces speichern, erneut verwenden und löschen
|
||||||
|
- atomare, idempotente Piece-Neuanlage direkt in einer Sendung einschließlich
|
||||||
|
Einplanung; fehlgeschlagene Schritte hinterlassen weder Piece noch Verwendung
|
||||||
|
- getrennte interne und öffentliche Titel an Piece und Verwendung; ein leerer
|
||||||
|
Präsentationstitel fällt kontrolliert auf den internen Titel zurück
|
||||||
|
- präzises Entfernen einzelner Verwendungen mit Bestätigung sowie zusätzlicher
|
||||||
|
Drag-and-drop-Rückgabe in den Fundusbereich
|
||||||
|
- Auswahl jedes konkreten Ablauf-Pieces in der Live-Steuerung, einschließlich
|
||||||
|
erstem und bereits erledigtem Eintrag
|
||||||
|
- stabiler OBS-Ausgabekanal `/heinzlive/default` mit geheimem URL-Fragment,
|
||||||
|
HttpOnly-Cookie-Austausch und ausdrücklicher Schlüsselrotation
|
||||||
|
- öffentliche Ausgabe als Positivliste: implizite Piece-Quellen und private
|
||||||
|
Uploads werden nicht mehr veröffentlicht; nur ausdrücklich gewählte
|
||||||
|
Link-/Videoquellen und deren erlaubte Vorschau bleiben sichtbar
|
||||||
|
|
||||||
|
Die vollständige lokale Prüfmatrix umfasst 33 Verhaltenstests und 15
|
||||||
|
Chromium-Abläufe. Desktop, Mobil, Light/Dark, Tastatur- und Touchalternativen,
|
||||||
|
Planung, Live-Ausgabe sowie anonyme Ausgabedaten wurden geprüft. Reales OBS und
|
||||||
|
echte YouTube-/TikTok-Wiedergabe bleiben mangels verfügbarer Umgebung offen.
|
||||||
|
|
||||||
|
## Materialkorrektur 0.5.7
|
||||||
|
|
||||||
|
- statische Gold-/Blauviolettlichter so positioniert und verstärkt, dass sie im
|
||||||
|
eigentlichen Arbeitsbereich statt fast ausschließlich hinter der Navigation
|
||||||
|
sichtbar werden
|
||||||
|
- gemeinsame Materialtokens auf Außenglas, dunkle Innenfläche und tiefe
|
||||||
|
Ablaufkarte reduziert; verschachtelte Abschnitte, Pieces und Aktionsgruppen
|
||||||
|
werden nach innen dunkler statt heller und blaugrau
|
||||||
|
- aktive Editortabs transparent goldgetönt und private Recherche sowie
|
||||||
|
Quellen-/Materialbereiche auf transparente Nachtblauflächen zurückgeführt
|
||||||
|
- Dialog-Backdrop von starker Abdunklung und Unschärfe auf eine leichtere
|
||||||
|
Trennung reduziert, sodass die Lichtstimmung bei geöffnetem Editor erhalten
|
||||||
|
bleibt
|
||||||
|
- deckende Schreibfelder, Goldfokus, Lightmode, Mobilansicht und lesbare
|
||||||
|
Komponentenfüllungen ohne verfügbaren Blur bleiben erhalten
|
||||||
|
|
||||||
|
Die berechneten Hintergründe, Transparenzen, Filter und Begrenzungseigenschaften
|
||||||
|
wurden vor und nach der Korrektur in Chromium kontrolliert. Sendungsplanung,
|
||||||
|
Sendungstext und Recherche wurden bei 1440 × 1000 und 390 × 844 Pixeln in beiden
|
||||||
|
Themes visuell geprüft.
|
||||||
|
|
||||||
|
## Oberflächenkorrektur 0.5.6
|
||||||
|
|
||||||
|
- sichtbare statische Gold- und Blauviolettlichter hinter den halbtransparenten
|
||||||
|
Navigations- und Editorflächen
|
||||||
|
- klarere Glaswirkung in Navigation sowie Kopf, Tabs und Fuß des Piece-Editors;
|
||||||
|
Schreibflächen bleiben deckend, ruhig und goldfokussiert
|
||||||
|
- Fundus mit dauerhaft sichtbarer Suche und Neuanlage, aufklappbaren Zusatzfiltern
|
||||||
|
und inhaltsorientierten, vollständig anklickbaren Zeilen
|
||||||
|
- vorhandene Vorschaubilder beziehungsweise kleine Symbolplatzhalter sowie
|
||||||
|
gebündelte Menüs für Duplizieren und Archivieren
|
||||||
|
- bestehende Server-Actions, Filterlogik und Editor-Bereiche unverändert
|
||||||
|
|
||||||
|
Geprüft wurden `npm run check` sowie Fundus und geöffneter Piece-Editor in
|
||||||
|
Chromium bei 1440 × 1000 und 390 × 844 Pixeln in Light/Dark. Die gezielte
|
||||||
|
Kontrolle umfasste Tastaturöffnung, Menüs, Fokus, Glaswerte, Seitenüberlauf und
|
||||||
|
visuell geprüfte Screenshots. Produktionsbuild und vollständige Testmatrix wurden
|
||||||
|
gemäß Auftrag nicht ausgeführt.
|
||||||
|
|
||||||
## Oberflächenkorrektur 0.5.5
|
## Oberflächenkorrektur 0.5.5
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
ALTER TABLE pieces ADD COLUMN output_title TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE piece_usages ADD COLUMN output_title_snapshot TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
CREATE TABLE piece_templates (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
snapshot_json TEXT NOT NULL CHECK (json_valid(snapshot_json)),
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
|
||||||
|
created_at_utc TEXT NOT NULL,
|
||||||
|
updated_at_utc TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO piece_templates (
|
||||||
|
id, name, description, snapshot_json, revision, created_at_utc, updated_at_utc
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
'talk', 'Gespräch',
|
||||||
|
'Leitfragen, Recherche und eine klare Überleitung.',
|
||||||
|
'{"title":"Unbenanntes Gespräch","formatId":"format-talk","moderationNotes":"## Leitfragen\n\n- ","categoryIds":["cat-jc"]}',
|
||||||
|
1, '2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'find', 'Reaction / Fundstück',
|
||||||
|
'Quelle, Einordnung und präsentierbare Kernaussage.',
|
||||||
|
'{"title":"Neues Fundstück","formatId":"format-find","presentationText":"## Kernaussage\n\n","categoryIds":["cat-reaction"]}',
|
||||||
|
1, '2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'interaction', 'Interaktion',
|
||||||
|
'Quiz, Publikumsfrage oder kleines Spiel.',
|
||||||
|
'{"title":"Neue Interaktion","formatId":"format-quiz","moderationNotes":"## Ablauf\n\n1. ","categoryIds":["cat-game"]}',
|
||||||
|
1, '2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE planning_commands (
|
||||||
|
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
|
||||||
|
command_id TEXT NOT NULL,
|
||||||
|
piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE CASCADE,
|
||||||
|
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
|
||||||
|
result_revision INTEGER NOT NULL CHECK (result_revision >= 1),
|
||||||
|
created_at_utc TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (show_id, command_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE presentation_channels (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
access_key TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
show_id TEXT REFERENCES shows(id) ON DELETE SET NULL,
|
||||||
|
revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0),
|
||||||
|
created_at_utc TEXT NOT NULL,
|
||||||
|
updated_at_utc TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE presentation_access_tokens
|
||||||
|
SET revoked_at_utc = COALESCE(revoked_at_utc, '2026-10-23T00:00:00.000Z');
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE planning_commands_durable (
|
||||||
|
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
|
||||||
|
command_id TEXT NOT NULL,
|
||||||
|
piece_id TEXT NOT NULL,
|
||||||
|
usage_id TEXT NOT NULL,
|
||||||
|
result_revision INTEGER NOT NULL CHECK (result_revision >= 1),
|
||||||
|
created_at_utc TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (show_id, command_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO planning_commands_durable (
|
||||||
|
show_id, command_id, piece_id, usage_id, result_revision, created_at_utc
|
||||||
|
)
|
||||||
|
SELECT show_id, command_id, piece_id, usage_id, result_revision, created_at_utc
|
||||||
|
FROM planning_commands;
|
||||||
|
|
||||||
|
DROP TABLE planning_commands;
|
||||||
|
ALTER TABLE planning_commands_durable RENAME TO planning_commands;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
ALTER TABLE pieces ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0
|
||||||
|
CHECK (is_favorite IN (0, 1));
|
||||||
|
|
||||||
|
CREATE TABLE tools (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
start_url TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||||
|
created_at_utc TEXT NOT NULL,
|
||||||
|
updated_at_utc TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tool_formats (
|
||||||
|
tool_id TEXT NOT NULL REFERENCES tools(id) ON DELETE CASCADE,
|
||||||
|
format_id TEXT NOT NULL REFERENCES formats(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (tool_id, format_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE piece_tools (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE CASCADE,
|
||||||
|
tool_id TEXT NOT NULL REFERENCES tools(id) ON DELETE RESTRICT,
|
||||||
|
work_url TEXT NOT NULL DEFAULT '',
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
position INTEGER NOT NULL CHECK (position >= 0),
|
||||||
|
UNIQUE (piece_id, tool_id),
|
||||||
|
UNIQUE (piece_id, position)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE usage_source_snapshots (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
timecode_seconds INTEGER CHECK (timecode_seconds IS NULL OR timecode_seconds >= 0),
|
||||||
|
position INTEGER NOT NULL CHECK (position >= 0),
|
||||||
|
UNIQUE (usage_id, position)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE usage_tool_snapshots (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
|
||||||
|
original_tool_id TEXT REFERENCES tools(id) ON DELETE SET NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
start_url TEXT NOT NULL DEFAULT '',
|
||||||
|
work_url TEXT NOT NULL DEFAULT '',
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
position INTEGER NOT NULL CHECK (position >= 0),
|
||||||
|
UNIQUE (usage_id, position)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE usage_topic_snapshots (
|
||||||
|
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL CHECK (position >= 0),
|
||||||
|
PRIMARY KEY (usage_id, position)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE live_section_timers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
|
||||||
|
section_id TEXT NOT NULL REFERENCES show_sections(id) ON DELETE CASCADE,
|
||||||
|
planned_duration_seconds INTEGER NOT NULL CHECK (planned_duration_seconds > 0),
|
||||||
|
started_at_utc TEXT NOT NULL,
|
||||||
|
ended_at_utc TEXT,
|
||||||
|
CHECK (ended_at_utc IS NULL OR ended_at_utc >= started_at_utc)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE live_section_timer_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
timer_id TEXT NOT NULL REFERENCES live_section_timers(id) ON DELETE CASCADE,
|
||||||
|
started_at_utc TEXT NOT NULL,
|
||||||
|
ended_at_utc TEXT,
|
||||||
|
CHECK (ended_at_utc IS NULL OR ended_at_utc >= started_at_utc)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_piece_tools_piece ON piece_tools(piece_id, position);
|
||||||
|
CREATE INDEX idx_tool_formats_format ON tool_formats(format_id, tool_id);
|
||||||
|
CREATE INDEX idx_usage_sources_usage ON usage_source_snapshots(usage_id, position);
|
||||||
|
CREATE INDEX idx_usage_tools_usage ON usage_tool_snapshots(usage_id, position);
|
||||||
|
CREATE UNIQUE INDEX idx_one_open_section_timer_per_show
|
||||||
|
ON live_section_timers(show_id) WHERE ended_at_utc IS NULL;
|
||||||
|
CREATE UNIQUE INDEX idx_one_open_run_per_section_timer
|
||||||
|
ON live_section_timer_runs(timer_id) WHERE ended_at_utc IS NULL;
|
||||||
|
|
||||||
|
INSERT INTO usage_source_snapshots (
|
||||||
|
id, usage_id, label, url, timecode_seconds, position
|
||||||
|
)
|
||||||
|
SELECT lower(hex(randomblob(16))), u.id, ps.label, ps.url, ps.timecode_seconds, ps.position
|
||||||
|
FROM piece_usages u
|
||||||
|
JOIN piece_sources ps ON ps.piece_id = u.original_piece_id;
|
||||||
|
|
||||||
|
INSERT INTO usage_topic_snapshots (usage_id, name, position)
|
||||||
|
SELECT usage_id, name, position
|
||||||
|
FROM (
|
||||||
|
SELECT u.id AS usage_id, t.name,
|
||||||
|
row_number() OVER (PARTITION BY u.id ORDER BY t.name, t.id) - 1 AS position
|
||||||
|
FROM piece_usages u
|
||||||
|
JOIN piece_topics pt ON pt.piece_id = u.original_piece_id
|
||||||
|
JOIN topics t ON t.id = pt.topic_id
|
||||||
|
);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
ALTER TABLE topics ADD COLUMN icon_name TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE pieces ADD COLUMN deleted_at_utc TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE piece_media ADD COLUMN removed_at_utc TEXT;
|
||||||
|
|
||||||
|
CREATE TABLE piece_presentation_media (
|
||||||
|
piece_id TEXT PRIMARY KEY REFERENCES pieces(id) ON DELETE CASCADE,
|
||||||
|
media_id TEXT NOT NULL UNIQUE REFERENCES piece_media(id) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER validate_piece_presentation_media_insert
|
||||||
|
BEFORE INSERT ON piece_presentation_media
|
||||||
|
WHEN NOT EXISTS (
|
||||||
|
SELECT 1 FROM piece_media
|
||||||
|
WHERE id = NEW.media_id AND piece_id = NEW.piece_id AND removed_at_utc IS NULL
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'presentation media does not belong to piece');
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER validate_piece_presentation_media_update
|
||||||
|
BEFORE UPDATE ON piece_presentation_media
|
||||||
|
WHEN NOT EXISTS (
|
||||||
|
SELECT 1 FROM piece_media
|
||||||
|
WHERE id = NEW.media_id AND piece_id = NEW.piece_id AND removed_at_utc IS NULL
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'presentation media does not belong to piece');
|
||||||
|
END;
|
||||||
|
|
||||||
|
ALTER TABLE usage_topic_snapshots ADD COLUMN icon_name TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
CREATE TABLE usage_media_snapshots (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
|
||||||
|
original_media_id TEXT NOT NULL REFERENCES piece_media(id) ON DELETE RESTRICT,
|
||||||
|
alt_text TEXT NOT NULL DEFAULT '',
|
||||||
|
position INTEGER NOT NULL CHECK (position >= 0),
|
||||||
|
is_presentation INTEGER NOT NULL DEFAULT 0 CHECK (is_presentation IN (0, 1)),
|
||||||
|
UNIQUE (usage_id, position)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_pieces_deleted_at ON pieces(deleted_at_utc);
|
||||||
|
CREATE INDEX idx_piece_media_active ON piece_media(piece_id, position)
|
||||||
|
WHERE removed_at_utc IS NULL;
|
||||||
|
CREATE INDEX idx_usage_media_usage ON usage_media_snapshots(usage_id, position);
|
||||||
|
CREATE UNIQUE INDEX idx_one_presentation_media_per_usage
|
||||||
|
ON usage_media_snapshots(usage_id) WHERE is_presentation = 1;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE tools ADD COLUMN icon_name TEXT NOT NULL DEFAULT 'wrench';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN show_overview_view TEXT NOT NULL DEFAULT 'list'
|
||||||
|
CHECK (show_overview_view IN ('list', 'calendar'));
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
ALTER TABLE pieces ADD COLUMN interaction_text TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE piece_usages ADD COLUMN interaction_snapshot TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
CREATE TABLE live_show_timer_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
|
||||||
|
started_at_utc TEXT NOT NULL,
|
||||||
|
ended_at_utc TEXT,
|
||||||
|
CHECK (ended_at_utc IS NULL OR ended_at_utc >= started_at_utc)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_one_open_show_timer_run
|
||||||
|
ON live_show_timer_runs(show_id)
|
||||||
|
WHERE ended_at_utc IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_live_show_timer_runs_show
|
||||||
|
ON live_show_timer_runs(show_id, started_at_utc);
|
||||||
|
|
||||||
|
INSERT INTO live_show_timer_runs (id, show_id, started_at_utc, ended_at_utc)
|
||||||
|
SELECT 'migrated-' || id, show_id, started_at_utc, ended_at_utc
|
||||||
|
FROM live_usage_runs;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS live_show_timer_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
|
||||||
|
started_at_utc TEXT NOT NULL,
|
||||||
|
ended_at_utc TEXT,
|
||||||
|
CHECK (ended_at_utc IS NULL OR ended_at_utc >= started_at_utc)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_show_timer_run
|
||||||
|
ON live_show_timer_runs(show_id)
|
||||||
|
WHERE ended_at_utc IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_live_show_timer_runs_show
|
||||||
|
ON live_show_timer_runs(show_id, started_at_utc);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO live_show_timer_runs (id, show_id, started_at_utc, ended_at_utc)
|
||||||
|
SELECT 'migrated-' || id, show_id, started_at_utc, ended_at_utc
|
||||||
|
FROM live_usage_runs;
|
||||||
@@ -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;
|
||||||
@@ -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 '';
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
);
|
||||||
Generated
+123
-2
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"version": "0.5.5",
|
"version": "0.7.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"version": "0.5.5",
|
"version": "0.7.2",
|
||||||
"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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.5",
|
"version": "0.7.2",
|
||||||
"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",
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const sourceRoot = process.argv[2];
|
||||||
|
if (!sourceRoot) {
|
||||||
|
throw new Error('Usage: node scripts/generate-classic-regular-icons.mjs <font-awesome-web-root>');
|
||||||
|
}
|
||||||
|
|
||||||
|
const javascript = readFileSync(path.join(sourceRoot, 'js', 'regular.js'), 'utf8');
|
||||||
|
const metadata = JSON.parse(readFileSync(path.join(sourceRoot, 'metadata', 'icons.json'), 'utf8'));
|
||||||
|
const start = javascript.indexOf(' var icons = ') + ' var icons = '.length;
|
||||||
|
const end = javascript.indexOf('\n };', start) + '\n }'.length;
|
||||||
|
if (start < ' var icons = '.length || end < start)
|
||||||
|
throw new Error('Classic Regular icons not found');
|
||||||
|
|
||||||
|
const definitions = JSON.parse(javascript.slice(start, end));
|
||||||
|
const icons = Object.keys(definitions)
|
||||||
|
.map((name) => {
|
||||||
|
const details = metadata[name];
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
label: details?.label ?? name.replaceAll('-', ' '),
|
||||||
|
terms: details?.search?.terms ?? []
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.label.localeCompare(right.label, 'en'));
|
||||||
|
|
||||||
|
const output = `// Generated from Font Awesome Pro Plus 7.0.0 Classic Regular metadata.
|
||||||
|
// Covered by the Font Awesome Pro commercial license; see THIRD_PARTY_NOTICES.md.
|
||||||
|
export type ClassicRegularIcon = {
|
||||||
|
\tname: string;
|
||||||
|
\tlabel: string;
|
||||||
|
\tterms: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const classicRegularIcons: readonly ClassicRegularIcon[] = ${JSON.stringify(icons)};
|
||||||
|
|
||||||
|
export type IconName = string;
|
||||||
|
|
||||||
|
export const classicRegularIconNames = new Set<string>(
|
||||||
|
classicRegularIcons.map((icon) => icon.name)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
writeFileSync('src/lib/icons/classic-regular.ts', output);
|
||||||
|
mkdirSync('static/icons', { recursive: true });
|
||||||
|
copyFileSync(
|
||||||
|
path.join(sourceRoot, 'sprites-full', 'regular.svg'),
|
||||||
|
'static/icons/classic-regular.svg'
|
||||||
|
);
|
||||||
|
console.log(`Generated ${icons.length} Classic Regular icons.`);
|
||||||
@@ -425,6 +425,28 @@ export function seed(databasePath = getPaths().database) {
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
database.prepare('DELETE FROM usage_source_snapshots').run();
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO usage_source_snapshots
|
||||||
|
(id, usage_id, label, url, timecode_seconds, position)
|
||||||
|
SELECT 'seed-source-' || u.id || '-' || ps.position, u.id, ps.label, ps.url,
|
||||||
|
ps.timecode_seconds, ps.position
|
||||||
|
FROM piece_usages u JOIN piece_sources ps ON ps.piece_id = u.original_piece_id`
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
database.prepare('DELETE FROM usage_topic_snapshots').run();
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO usage_topic_snapshots (usage_id, name, position)
|
||||||
|
SELECT usage_id, name, position FROM (
|
||||||
|
SELECT u.id AS usage_id, t.name,
|
||||||
|
row_number() OVER (PARTITION BY u.id ORDER BY t.name, t.id) - 1 AS position
|
||||||
|
FROM piece_usages u JOIN piece_topics pt ON pt.piece_id = u.original_piece_id
|
||||||
|
JOIN topics t ON t.id = pt.topic_id
|
||||||
|
)`
|
||||||
|
)
|
||||||
|
.run();
|
||||||
});
|
});
|
||||||
|
|
||||||
seedDatabase();
|
seedDatabase();
|
||||||
|
|||||||
+3496
-389
File diff suppressed because it is too large.
Load diff
+5
-1
@@ -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
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
berlinDateKey,
|
||||||
|
buildCalendarDays,
|
||||||
|
formatMonthLabel,
|
||||||
|
normalizeMonthKey,
|
||||||
|
shiftMonth
|
||||||
|
} from './calendar.js';
|
||||||
|
|
||||||
|
describe('show calendar', () => {
|
||||||
|
it('groups UTC instants by their Berlin civil date', () => {
|
||||||
|
expect(berlinDateKey('2026-09-30T22:30:00.000Z')).toBe('2026-10-01');
|
||||||
|
expect(berlinDateKey('2026-12-31T23:30:00.000Z')).toBe('2027-01-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a Monday-first six-week month without dropping boundary days', () => {
|
||||||
|
const days = buildCalendarDays('2026-10', '2026-10-24');
|
||||||
|
expect(days).toHaveLength(42);
|
||||||
|
expect(days[0]).toMatchObject({ date: '2026-09-28', inSelectedMonth: false });
|
||||||
|
expect(days.find((day) => day.date === '2026-10-24')?.isToday).toBe(true);
|
||||||
|
expect(days.at(-1)?.date).toBe('2026-11-08');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves across year boundaries and validates month keys', () => {
|
||||||
|
expect(shiftMonth('2026-12', 1)).toBe('2027-01');
|
||||||
|
expect(shiftMonth('2026-01', -1)).toBe('2025-12');
|
||||||
|
expect(normalizeMonthKey('2026-13', '2026-10')).toBe('2026-10');
|
||||||
|
expect(normalizeMonthKey('2026-02', '2026-10')).toBe('2026-02');
|
||||||
|
expect(formatMonthLabel('2026-10')).toBe('Oktober 2026');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
export const BERLIN_TIME_ZONE = 'Europe/Berlin';
|
||||||
|
|
||||||
|
export type CalendarDay = {
|
||||||
|
date: string;
|
||||||
|
dayNumber: number;
|
||||||
|
inSelectedMonth: boolean;
|
||||||
|
isToday: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const berlinDateFormatter = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: BERLIN_TIME_ZONE,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
function padded(value: number) {
|
||||||
|
return String(value).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateParts(value: string | Date) {
|
||||||
|
const parts = berlinDateFormatter.formatToParts(
|
||||||
|
typeof value === 'string' ? new Date(value) : value
|
||||||
|
);
|
||||||
|
const read = (type: Intl.DateTimeFormatPartTypes) =>
|
||||||
|
Number(parts.find((part) => part.type === type)?.value);
|
||||||
|
return { year: read('year'), month: read('month'), day: read('day') };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function berlinDateKey(value: string | Date) {
|
||||||
|
const parts = dateParts(value);
|
||||||
|
return `${parts.year}-${padded(parts.month)}-${padded(parts.day)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function berlinMonthKey(value: string | Date) {
|
||||||
|
return berlinDateKey(value).slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMonthKey(value: string | null | undefined, fallback: string) {
|
||||||
|
if (!value || !/^\d{4}-(0[1-9]|1[0-2])$/.test(value)) return fallback;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shiftMonth(monthKey: string, delta: number) {
|
||||||
|
const [year, month] = monthKey.split('-').map(Number);
|
||||||
|
const shifted = new Date(Date.UTC(year, month - 1 + delta, 1));
|
||||||
|
return `${shifted.getUTCFullYear()}-${padded(shifted.getUTCMonth() + 1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMonthLabel(monthKey: string) {
|
||||||
|
const [year, month] = monthKey.split('-').map(Number);
|
||||||
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
|
timeZone: 'UTC',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
}).format(new Date(Date.UTC(year, month - 1, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCalendarDays(monthKey: string, todayKey: string): CalendarDay[] {
|
||||||
|
const [year, month] = monthKey.split('-').map(Number);
|
||||||
|
const first = new Date(Date.UTC(year, month - 1, 1));
|
||||||
|
const mondayOffset = (first.getUTCDay() + 6) % 7;
|
||||||
|
const start = new Date(Date.UTC(year, month - 1, 1 - mondayOffset));
|
||||||
|
return Array.from({ length: 42 }, (_, index) => {
|
||||||
|
const date = new Date(start);
|
||||||
|
date.setUTCDate(start.getUTCDate() + index);
|
||||||
|
const dateKey = `${date.getUTCFullYear()}-${padded(date.getUTCMonth() + 1)}-${padded(date.getUTCDate())}`;
|
||||||
|
return {
|
||||||
|
date: dateKey,
|
||||||
|
dayNumber: date.getUTCDate(),
|
||||||
|
inSelectedMonth: dateKey.startsWith(monthKey),
|
||||||
|
isToday: dateKey === todayKey
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { name, label, size = '1em' }: { name: string; label?: string; size?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
role={label ? 'img' : undefined}
|
||||||
|
aria-label={label}
|
||||||
|
aria-hidden={label ? undefined : 'true'}
|
||||||
|
class="app-icon"
|
||||||
|
>
|
||||||
|
<use href={`/icons/classic-regular.svg#${name || 'circle'}`} />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app-icon {
|
||||||
|
display: inline-block;
|
||||||
|
flex: none;
|
||||||
|
fill: currentColor;
|
||||||
|
vertical-align: -0.125em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AppIcon from './AppIcon.svelte';
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
iconName?: string;
|
||||||
|
description?: string;
|
||||||
|
meta?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
selectedIds,
|
||||||
|
label,
|
||||||
|
fieldName,
|
||||||
|
onselectionchange
|
||||||
|
}: {
|
||||||
|
items: Item[];
|
||||||
|
selectedIds: string[];
|
||||||
|
label: string;
|
||||||
|
fieldName?: string;
|
||||||
|
onselectionchange: (ids: string[]) => void;
|
||||||
|
} = $props();
|
||||||
|
let query = $state('');
|
||||||
|
let menu: HTMLDetailsElement;
|
||||||
|
let filtered = $derived(
|
||||||
|
items.filter((item) =>
|
||||||
|
`${item.name} ${item.description ?? ''} ${item.meta ?? ''}`
|
||||||
|
.toLocaleLowerCase('de')
|
||||||
|
.includes(query.trim().toLocaleLowerCase('de'))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let selected = $derived(items.filter((item) => selectedIds.includes(item.id)));
|
||||||
|
|
||||||
|
function toggle(id: string) {
|
||||||
|
onselectionchange(
|
||||||
|
selectedIds.includes(id)
|
||||||
|
? selectedIds.filter((selectedId) => selectedId !== id)
|
||||||
|
: [...selectedIds, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="choice-picker">
|
||||||
|
{#if fieldName}
|
||||||
|
{#each selectedIds as id (id)}<input type="hidden" name={fieldName} value={id} />{/each}
|
||||||
|
{/if}
|
||||||
|
{#if selected.length}
|
||||||
|
<div class="choice-picker-selected" aria-label={`${label}: ausgewählt`}>
|
||||||
|
{#each selected as item (item.id)}
|
||||||
|
<span
|
||||||
|
>{#if item.iconName}<AppIcon name={item.iconName} />{/if}{item.name}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<details bind:this={menu} class="choice-picker-menu">
|
||||||
|
<summary>{selected.length ? `${label} ändern` : `${label} auswählen`}</summary>
|
||||||
|
<div class="choice-picker-popover">
|
||||||
|
<label class="choice-picker-search">
|
||||||
|
<span class="sr-only">{label} durchsuchen</span>
|
||||||
|
<AppIcon name="magnifying-glass" />
|
||||||
|
<input type="search" placeholder={`${label} durchsuchen`} bind:value={query} />
|
||||||
|
</label>
|
||||||
|
<div class="choice-picker-options">
|
||||||
|
{#each filtered as item (item.id)}
|
||||||
|
<label class:active={selectedIds.includes(item.id)}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.includes(item.id)}
|
||||||
|
onchange={() => toggle(item.id)}
|
||||||
|
/>
|
||||||
|
{#if item.iconName}<AppIcon name={item.iconName} size="1.2em" />{/if}
|
||||||
|
<span
|
||||||
|
><strong>{item.name}</strong>{#if item.description}<small>{item.description}</small
|
||||||
|
>{/if}</span
|
||||||
|
>
|
||||||
|
{#if item.meta}<small>{item.meta}</small>{/if}
|
||||||
|
</label>
|
||||||
|
{:else}<p>Keine Treffer.</p>{/each}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="choice-picker-close text-button"
|
||||||
|
type="button"
|
||||||
|
onclick={() => (menu.open = false)}>Auswahl schließen</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import AppIcon from './AppIcon.svelte';
|
||||||
|
|
||||||
|
type Feed = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
isEnabled: boolean;
|
||||||
|
refreshIntervalMinutes: number;
|
||||||
|
defaultCategoryId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { categories }: { categories: { id: string; name: string }[] } = $props();
|
||||||
|
let dialog: HTMLDialogElement;
|
||||||
|
let nameInput: HTMLInputElement;
|
||||||
|
let feed = $state<Feed | null>(null);
|
||||||
|
let name = $state('');
|
||||||
|
let url = $state('');
|
||||||
|
let refreshIntervalMinutes = $state(60);
|
||||||
|
let defaultCategoryId = $state('');
|
||||||
|
let isEnabled = $state(true);
|
||||||
|
|
||||||
|
export async function showModal(selectedFeed: Feed | null = null) {
|
||||||
|
feed = selectedFeed;
|
||||||
|
name = selectedFeed?.name ?? '';
|
||||||
|
url = selectedFeed?.url ?? '';
|
||||||
|
refreshIntervalMinutes = selectedFeed?.refreshIntervalMinutes ?? 60;
|
||||||
|
defaultCategoryId = selectedFeed?.defaultCategoryId ?? '';
|
||||||
|
isEnabled = selectedFeed?.isEnabled ?? true;
|
||||||
|
dialog.showModal();
|
||||||
|
await tick();
|
||||||
|
nameInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
bind:this={dialog}
|
||||||
|
class="management-dialog feed-editor-dialog"
|
||||||
|
aria-labelledby="feed-dialog-title"
|
||||||
|
onclick={(event) => event.target === dialog && close()}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<div class="management-dialog-title">
|
||||||
|
<span class="management-dialog-icon"><AppIcon name="rss" /></span>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">RSS-Quelle</p>
|
||||||
|
<h2 id="feed-dialog-title">{feed?.name || 'Feed hinzufügen'}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-close" type="button" aria-label="Feeddialog schließen" onclick={close}
|
||||||
|
><AppIcon name="xmark" /></button
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
<form class="management-dialog-form" method="POST" action="?/saveFeed">
|
||||||
|
{#if feed}<input type="hidden" name="feedId" value={feed.id} />{/if}
|
||||||
|
<div class="management-dialog-fields">
|
||||||
|
<label
|
||||||
|
>Name<input
|
||||||
|
bind:this={nameInput}
|
||||||
|
bind:value={name}
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxlength="120"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Feed-URL<input bind:value={url} name="url" type="url" required maxlength="2000" /></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Intervall in Minuten<input
|
||||||
|
bind:value={refreshIntervalMinutes}
|
||||||
|
name="refreshIntervalMinutes"
|
||||||
|
type="number"
|
||||||
|
min="15"
|
||||||
|
max="10080"
|
||||||
|
required
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Standardkategorie<select bind:value={defaultCategoryId} name="defaultCategoryId"
|
||||||
|
><option value="">Keine</option>{#each categories as category (category.id)}<option
|
||||||
|
value={category.id}>{category.name}</option
|
||||||
|
>{/each}</select
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="management-dialog-selections">
|
||||||
|
<label class="toggle-row"
|
||||||
|
><input type="checkbox" name="isEnabled" value="1" bind:checked={isEnabled} /><span
|
||||||
|
><strong>Automatischer Abruf</strong><small
|
||||||
|
>Der Feed wird im festgelegten Intervall sicher abgerufen.</small
|
||||||
|
></span
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<button class="secondary-button" type="button" onclick={close}>Abbrechen</button>
|
||||||
|
<button class="primary-button" type="submit"
|
||||||
|
>{feed ? 'Änderungen speichern' : 'Feed anlegen'}</button
|
||||||
|
>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { classicRegularIcons } from '#lib/icons/classic-regular.js';
|
||||||
|
import AppIcon from './AppIcon.svelte';
|
||||||
|
|
||||||
|
let { name = $bindable(), inputName = 'iconName' }: { name: string; inputName?: string } =
|
||||||
|
$props();
|
||||||
|
let query = $state('');
|
||||||
|
let menu: HTMLDetailsElement;
|
||||||
|
let searchInput: HTMLInputElement;
|
||||||
|
let filtered = $derived(
|
||||||
|
classicRegularIcons.filter((icon) =>
|
||||||
|
`${icon.name} ${icon.label} ${icon.terms.join(' ')}`
|
||||||
|
.toLocaleLowerCase('de')
|
||||||
|
.includes(query.trim().toLocaleLowerCase('de'))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div class="icon-picker">
|
||||||
|
<input type="hidden" name={inputName} value={name} />
|
||||||
|
<details
|
||||||
|
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">
|
||||||
|
<label
|
||||||
|
><span class="sr-only">Icons durchsuchen</span><input
|
||||||
|
type="search"
|
||||||
|
placeholder="Icon suchen"
|
||||||
|
bind:value={query}
|
||||||
|
bind:this={searchInput}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<div class="icon-picker-grid">
|
||||||
|
{#each visible as icon (icon.name)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={name === icon.name}
|
||||||
|
title={icon.label}
|
||||||
|
onclick={() => {
|
||||||
|
name = icon.name;
|
||||||
|
menu.open = false;
|
||||||
|
}}><AppIcon name={icon.name} /><span>{icon.label}</span></button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if filtered.length > visible.length}<p class="icon-picker-hint">
|
||||||
|
{visible.length} von {filtered.length} Icons sichtbar. Suche verwenden, um weitere Icons zu
|
||||||
|
finden.
|
||||||
|
</p>{/if}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import AppIcon from './AppIcon.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(),
|
||||||
|
form,
|
||||||
|
name = 'title',
|
||||||
|
label = 'Titel',
|
||||||
|
oncommit
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
form: string;
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
oncommit?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let editing = $state(false);
|
||||||
|
let original = $state('');
|
||||||
|
let input = $state<HTMLInputElement>();
|
||||||
|
let trigger = $state<HTMLButtonElement>();
|
||||||
|
|
||||||
|
async function edit() {
|
||||||
|
original = value;
|
||||||
|
editing = true;
|
||||||
|
await tick();
|
||||||
|
input?.focus();
|
||||||
|
input?.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finish(commit: boolean) {
|
||||||
|
if (!editing) return;
|
||||||
|
if (!commit) value = original;
|
||||||
|
editing = false;
|
||||||
|
if (commit && value !== original) oncommit?.();
|
||||||
|
await tick();
|
||||||
|
trigger?.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="inline-title-editor">
|
||||||
|
<input type="hidden" {form} {name} {value} />
|
||||||
|
{#if editing}
|
||||||
|
<label>
|
||||||
|
<span class="sr-only">{label}</span>
|
||||||
|
<input
|
||||||
|
bind:this={input}
|
||||||
|
bind:value
|
||||||
|
maxlength="200"
|
||||||
|
required
|
||||||
|
onkeydown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
void finish(true);
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
void finish(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onblur={() => void finish(true)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
|
<button bind:this={trigger} type="button" class="inline-title-trigger" onclick={edit}>
|
||||||
|
<span>{value}</span><AppIcon name="pen" size="0.9em" />
|
||||||
|
<span class="sr-only">{label} bearbeiten</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
|
import AppIcon from './AppIcon.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
name,
|
name,
|
||||||
@@ -60,12 +61,15 @@
|
|||||||
<button type="button" aria-label="Kursiv" title="Kursiv" onclick={() => replaceSelection('_')}
|
<button type="button" aria-label="Kursiv" title="Kursiv" onclick={() => replaceSelection('_')}
|
||||||
><em>I</em></button
|
><em>I</em></button
|
||||||
>
|
>
|
||||||
<button type="button" aria-label="Liste" title="Liste" onclick={addList}>• Liste</button>
|
<button type="button" aria-label="Liste" title="Liste" onclick={addList}
|
||||||
|
><AppIcon name="list-check" /> Liste</button
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Link"
|
aria-label="Link"
|
||||||
title="Link"
|
title="Link"
|
||||||
onclick={() => replaceSelection('[', '](https://)', 'Linktext')}>↗ Link</button
|
onclick={() => replaceSelection('[', '](https://)', 'Linktext')}
|
||||||
|
><AppIcon name="link" /> Link</button
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import AppIcon from './AppIcon.svelte';
|
||||||
|
|
||||||
let { name }: { name: string } = $props();
|
let { name }: { name: string } = $props();
|
||||||
|
const aliases: Record<string, string> = {
|
||||||
|
library: 'folder-open',
|
||||||
|
shows: 'calendar-days',
|
||||||
|
archive: 'box-archive',
|
||||||
|
settings: 'gear',
|
||||||
|
edit: 'pen'
|
||||||
|
};
|
||||||
|
let iconName = $derived(aliases[name] ?? name);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<AppIcon name={iconName} size="1.25rem" />
|
||||||
{#if name === 'inbox'}
|
|
||||||
<path d="M4 5.5h16v13H4zM4 14h4l1.5 2h5l1.5-2h4" />
|
|
||||||
{:else if name === 'library'}
|
|
||||||
<path d="M5 4.5h4v15H5zM10.5 4.5h4v15h-4zM16 5.5l3-1 3.5 14-3 1z" />
|
|
||||||
{:else if name === 'shows'}
|
|
||||||
<path d="M4 6.5h16v13H4zM8 3.5v6M16 3.5v6M4 10h16" />
|
|
||||||
{:else if name === 'archive'}
|
|
||||||
<path d="M4 7h16v13H4zM3 4h18v4H3zM9 12h6" />
|
|
||||||
{:else if name === 'settings'}
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
<path
|
|
||||||
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4"
|
|
||||||
/>
|
|
||||||
{:else if name === 'edit'}
|
|
||||||
<path d="M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4" />
|
|
||||||
{/if}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
svg {
|
:global(.app-icon) {
|
||||||
width: 1.25rem;
|
display: block;
|
||||||
height: 1.25rem;
|
|
||||||
fill: none;
|
|
||||||
stroke: currentColor;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
stroke-width: 1.7;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick, type Snippet } from 'svelte';
|
import { tick, type Snippet } from 'svelte';
|
||||||
|
import AppIcon from './AppIcon.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
labelledby,
|
labelledby,
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
class="dialog-close"
|
class="dialog-close"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Editor schließen"
|
aria-label="Editor schließen"
|
||||||
onclick={onrequestclose}>×</button
|
onclick={onrequestclose}><AppIcon name="xmark" /></button
|
||||||
>
|
>
|
||||||
</header>
|
</header>
|
||||||
<nav class="piece-dialog-tabs" aria-label="Editorbereiche">
|
<nav class="piece-dialog-tabs" aria-label="Editorbereiche">
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
{#if content.mode === 'text'}
|
{#if content.mode === 'text'}
|
||||||
<article class="output-content output-text">
|
<article class="output-content output-text">
|
||||||
<p class="output-kicker">heinzlive</p>
|
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
|
||||||
<h1>{content.title}</h1>
|
<h1>{content.title}</h1>
|
||||||
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
|
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
@@ -60,12 +60,17 @@
|
|||||||
{/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">
|
||||||
<img src={imageSrc} alt="" onerror={() => (imageUnavailable = true)} />
|
<img src={imageSrc} alt="" onerror={() => (imageUnavailable = true)} />
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
<div class="output-link-copy">
|
<div class="output-link-copy">
|
||||||
|
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
|
||||||
<p class="output-kicker">{content.siteName || content.source?.label || 'Webseite'}</p>
|
<p class="output-kicker">{content.siteName || content.source?.label || 'Webseite'}</p>
|
||||||
<h1>{content.previewTitle || content.title}</h1>
|
<h1>{content.previewTitle || content.title}</h1>
|
||||||
{#if content.description}<p>{content.description}</p>{/if}
|
{#if content.description}<p>{content.description}</p>{/if}
|
||||||
@@ -79,6 +84,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<article class:portrait={content.provider === 'tiktok'} class="output-content output-video">
|
<article class:portrait={content.provider === 'tiktok'} class="output-content output-video">
|
||||||
<header>
|
<header>
|
||||||
|
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
|
||||||
<p class="output-kicker">{content.siteName || content.source?.label || 'Video'}</p>
|
<p class="output-kicker">{content.siteName || content.source?.label || 'Video'}</p>
|
||||||
<h1>{content.previewTitle || content.title}</h1>
|
<h1>{content.previewTitle || content.title}</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/* eslint-disable svelte/no-navigation-without-resolve -- RSS links are validated external URLs. */
|
||||||
|
import { onMount, tick } from 'svelte';
|
||||||
|
import AppIcon from './AppIcon.svelte';
|
||||||
|
import ChoicePicker from './ChoicePicker.svelte';
|
||||||
|
|
||||||
|
type FeedEntry = {
|
||||||
|
id: string;
|
||||||
|
feedId: string;
|
||||||
|
feedName: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
summary: string;
|
||||||
|
publishedAtUtc: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
entries,
|
||||||
|
categories,
|
||||||
|
form = null
|
||||||
|
}: {
|
||||||
|
entries: FeedEntry[];
|
||||||
|
categories: { id: string; name: string }[];
|
||||||
|
form?: { intent?: string; feedError?: string } | null;
|
||||||
|
} = $props();
|
||||||
|
let dialog: HTMLDialogElement;
|
||||||
|
let closeButton: HTMLButtonElement;
|
||||||
|
let selectedIds = $state<string[]>([]);
|
||||||
|
let selectedFeedIds = $state<string[]>([]);
|
||||||
|
let categoryIds = $state<string[]>([]);
|
||||||
|
let page = $state(1);
|
||||||
|
const pageSize = 20;
|
||||||
|
let feeds = $derived(
|
||||||
|
Array.from(new Map(entries.map((entry) => [entry.feedId, entry.feedName])).entries()).map(
|
||||||
|
([id, name]) => ({ id, name })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let filteredEntries = $derived(entries.filter((entry) => selectedFeedIds.includes(entry.feedId)));
|
||||||
|
let pageCount = $derived(Math.max(1, Math.ceil(filteredEntries.length / pageSize)));
|
||||||
|
let visibleEntries = $derived(filteredEntries.slice((page - 1) * pageSize, page * pageSize));
|
||||||
|
let allSourcesSelected = $derived(feeds.length > 0 && selectedFeedIds.length === feeds.length);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
selectedFeedIds = feeds.map((feed) => feed.id);
|
||||||
|
if (form?.intent === 'feedImport' && form.feedError) void showModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function showModal() {
|
||||||
|
if (!selectedFeedIds.length) selectedFeedIds = feeds.map((feed) => feed.id);
|
||||||
|
if (!dialog.open) dialog.showModal();
|
||||||
|
await tick();
|
||||||
|
closeButton.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllSources() {
|
||||||
|
selectedFeedIds = feeds.map((feed) => feed.id);
|
||||||
|
page = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSource(id: string) {
|
||||||
|
selectedFeedIds = selectedFeedIds.includes(id)
|
||||||
|
? selectedFeedIds.filter((feedId) => feedId !== id)
|
||||||
|
: [...selectedFeedIds, id];
|
||||||
|
page = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEntry(id: string) {
|
||||||
|
selectedIds = selectedIds.includes(id)
|
||||||
|
? selectedIds.filter((entryId) => entryId !== id)
|
||||||
|
: [...selectedIds, id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEntryKeydown(event: KeyboardEvent, id: string) {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
|
event.preventDefault();
|
||||||
|
toggleEntry(id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
bind:this={dialog}
|
||||||
|
class="management-dialog rss-selection-dialog"
|
||||||
|
aria-labelledby="rss-selection-title"
|
||||||
|
onclick={(event) => event.target === dialog && close()}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<div class="management-dialog-title">
|
||||||
|
<span class="management-dialog-icon"><AppIcon name="rss" /></span>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">RSS-Importeingang</p>
|
||||||
|
<h2 id="rss-selection-title">RSS-Beiträge auswählen</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
bind:this={closeButton}
|
||||||
|
class="dialog-close"
|
||||||
|
type="button"
|
||||||
|
aria-label="RSS-Auswahl schließen"
|
||||||
|
onclick={close}><AppIcon name="xmark" /></button
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
<form class="rss-selection-form" method="POST" action="?/importFeeds">
|
||||||
|
{#each selectedIds as id (id)}<input type="hidden" name="entryIds" value={id} />{/each}
|
||||||
|
<div class="rss-selection-content">
|
||||||
|
{#if form?.intent === 'feedImport' && form.feedError}<div
|
||||||
|
class="form-message error"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{form.feedError}
|
||||||
|
</div>{/if}
|
||||||
|
<nav class="rss-source-filters" aria-label="RSS-Quellen filtern">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={allSourcesSelected}
|
||||||
|
aria-pressed={allSourcesSelected}
|
||||||
|
onclick={selectAllSources}>Alle Quellen</button
|
||||||
|
>
|
||||||
|
{#each feeds as feed (feed.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={selectedFeedIds.includes(feed.id)}
|
||||||
|
aria-pressed={selectedFeedIds.includes(feed.id)}
|
||||||
|
onclick={() => toggleSource(feed.id)}>{feed.name}</button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="rss-result-summary" aria-live="polite">
|
||||||
|
<strong>{filteredEntries.length} verfügbare Beiträge</strong>
|
||||||
|
<span>Seite {page} von {pageCount}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rss-entry-list"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Verfügbare RSS-Beiträge"
|
||||||
|
aria-multiselectable="true"
|
||||||
|
>
|
||||||
|
{#each visibleEntries as entry (entry.id)}
|
||||||
|
<div
|
||||||
|
class:selected={selectedIds.includes(entry.id)}
|
||||||
|
class="rss-entry"
|
||||||
|
role="option"
|
||||||
|
tabindex="0"
|
||||||
|
aria-selected={selectedIds.includes(entry.id)}
|
||||||
|
onclick={() => toggleEntry(entry.id)}
|
||||||
|
onkeydown={(event) => handleEntryKeydown(event, entry.id)}
|
||||||
|
>
|
||||||
|
<div class="rss-entry-state" aria-hidden="true">
|
||||||
|
<AppIcon name={selectedIds.includes(entry.id) ? 'check' : 'plus'} />
|
||||||
|
</div>
|
||||||
|
<div class="rss-entry-copy">
|
||||||
|
<small
|
||||||
|
>{entry.feedName}{entry.publishedAtUtc
|
||||||
|
? ` · ${new Date(entry.publishedAtUtc).toLocaleDateString('de-DE')}`
|
||||||
|
: ''}</small
|
||||||
|
>
|
||||||
|
<strong>{entry.title}</strong>
|
||||||
|
{#if entry.summary}<p>{entry.summary}</p>{/if}
|
||||||
|
<a
|
||||||
|
href={entry.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onclick={(event) => event.stopPropagation()}
|
||||||
|
onkeydown={(event) => event.stopPropagation()}>Original öffnen</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if selectedIds.includes(entry.id)}<span class="rss-selected-label">Ausgewählt</span
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
{:else}<p class="empty-copy">
|
||||||
|
Für diese Quellenauswahl sind keine Beiträge verfügbar.
|
||||||
|
</p>{/each}
|
||||||
|
</div>
|
||||||
|
{#if pageCount > 1}<nav class="rss-pagination" aria-label="RSS-Beitragsseiten">
|
||||||
|
<button
|
||||||
|
class="secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={page === 1}
|
||||||
|
onclick={() => (page -= 1)}>Zurück</button
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
>{(page - 1) * pageSize + 1}–{Math.min(page * pageSize, filteredEntries.length)} von {filteredEntries.length}</span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={page === pageCount}
|
||||||
|
onclick={() => (page += 1)}>Weiter</button
|
||||||
|
>
|
||||||
|
</nav>{/if}
|
||||||
|
<div class="rss-category-selection">
|
||||||
|
<span class="field-label">Kategorien für die Auswahl</span>
|
||||||
|
<small>Ohne Auswahl gilt die Standardkategorie der jeweiligen Quelle.</small>
|
||||||
|
<ChoicePicker
|
||||||
|
items={categories}
|
||||||
|
selectedIds={categoryIds}
|
||||||
|
label="Kategorien"
|
||||||
|
fieldName="categoryIds"
|
||||||
|
onselectionchange={(ids) => (categoryIds = ids)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="rss-selection-actions">
|
||||||
|
<button
|
||||||
|
class="text-button danger-text"
|
||||||
|
type="submit"
|
||||||
|
formaction="?/discardFeeds"
|
||||||
|
disabled={!selectedIds.length}>Auswahl verwerfen</button
|
||||||
|
>
|
||||||
|
<button class="primary-button" type="submit" disabled={!selectedIds.length}>
|
||||||
|
Auswahl in den Eingang übernehmen ({selectedIds.length})
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, tick } from 'svelte';
|
||||||
|
import AppIcon from './AppIcon.svelte';
|
||||||
|
type TechnicalAccess = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
permissions: 'read' | 'read_write';
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
form = null
|
||||||
|
}: {
|
||||||
|
form?: {
|
||||||
|
intent?: string;
|
||||||
|
error?: string;
|
||||||
|
success?: string | boolean;
|
||||||
|
technicalToken?: string;
|
||||||
|
} | null;
|
||||||
|
} = $props();
|
||||||
|
let dialog: HTMLDialogElement;
|
||||||
|
let nameInput = $state<HTMLInputElement>();
|
||||||
|
let copyMessage = $state('');
|
||||||
|
let access = $state<TechnicalAccess | null>(null);
|
||||||
|
let name = $state('');
|
||||||
|
let permissions = $state<'read' | 'read_write'>('read');
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (form?.intent === 'technicalAccess' && (form.error || form.technicalToken)) void showModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function showModal(selectedAccess: TechnicalAccess | null = null) {
|
||||||
|
access = selectedAccess;
|
||||||
|
name = selectedAccess?.name ?? '';
|
||||||
|
permissions = selectedAccess?.permissions ?? 'read';
|
||||||
|
if (!dialog.open) dialog.showModal();
|
||||||
|
await tick();
|
||||||
|
(form?.technicalToken && !access
|
||||||
|
? dialog.querySelector<HTMLButtonElement>('.token-copy')
|
||||||
|
: nameInput
|
||||||
|
)?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToken() {
|
||||||
|
if (!form?.technicalToken) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(form.technicalToken);
|
||||||
|
copyMessage = 'Token kopiert.';
|
||||||
|
} catch {
|
||||||
|
copyMessage = 'Kopieren wurde vom Browser blockiert.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
bind:this={dialog}
|
||||||
|
class="management-dialog technical-access-dialog"
|
||||||
|
aria-labelledby="technical-access-dialog-title"
|
||||||
|
onclick={(event) => event.target === dialog && close()}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<div class="management-dialog-title">
|
||||||
|
<span class="management-dialog-icon"><AppIcon name="key" /></span>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Externe Assistenz</p>
|
||||||
|
<h2 id="technical-access-dialog-title">{access?.name || 'API-Zugang hinzufügen'}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-close" type="button" aria-label="Zugangsdialog schließen" onclick={close}
|
||||||
|
><AppIcon name="xmark" /></button
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
{#if form?.technicalToken && !access}
|
||||||
|
<div class="technical-token-result">
|
||||||
|
<div class="form-message success" role="status">
|
||||||
|
<strong>Token, einmalig sichtbar</strong>
|
||||||
|
<p>Jetzt kopieren und sicher hinterlegen. Sendewerk speichert weiterhin nur den Hash.</p>
|
||||||
|
</div>
|
||||||
|
<code>{form.technicalToken}</code>
|
||||||
|
<button class="primary-button token-copy" type="button" onclick={copyToken}
|
||||||
|
><AppIcon name="copy" /> Token kopieren</button
|
||||||
|
>
|
||||||
|
{#if copyMessage}<small role="status">{copyMessage}</small>{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form
|
||||||
|
class="management-dialog-form"
|
||||||
|
method="POST"
|
||||||
|
action={access ? '?/updateTechnicalAccess' : '?/createTechnicalAccess'}
|
||||||
|
>
|
||||||
|
{#if access}<input type="hidden" name="accessId" value={access.id} />{/if}
|
||||||
|
{#if form?.intent === 'technicalAccess' && form.error}<div
|
||||||
|
class="form-message error dialog-form-message"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{form.error}
|
||||||
|
</div>{/if}
|
||||||
|
<div class="management-dialog-fields">
|
||||||
|
<label
|
||||||
|
>Name<input
|
||||||
|
bind:this={nameInput}
|
||||||
|
bind:value={name}
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxlength="120"
|
||||||
|
placeholder="Redaktionsassistenz"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Berechtigung<select bind:value={permissions} name="permissions"
|
||||||
|
><option value="read">Nur lesen</option><option value="read_write"
|
||||||
|
>Lesen und schreiben</option
|
||||||
|
></select
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<button class="secondary-button" type="button" onclick={close}>Abbrechen</button>
|
||||||
|
<button class="primary-button" type="submit"
|
||||||
|
>{access ? 'Änderungen speichern' : 'Zugang anlegen'}</button
|
||||||
|
>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</dialog>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import AppIcon from './AppIcon.svelte';
|
||||||
|
|
||||||
type Preference = 'system' | 'light' | 'dark';
|
type Preference = 'system' | 'light' | 'dark';
|
||||||
const preferences: Preference[] = ['system', 'light', 'dark'];
|
const preferences: Preference[] = ['system', 'light', 'dark'];
|
||||||
@@ -42,8 +43,10 @@
|
|||||||
onclick={cycle}
|
onclick={cycle}
|
||||||
aria-label={`Darstellung: ${labels[preference]}`}
|
aria-label={`Darstellung: ${labels[preference]}`}
|
||||||
>
|
>
|
||||||
<span class="theme-symbol" aria-hidden="true">
|
<span class="theme-symbol" aria-hidden="true"
|
||||||
{preference === 'system' ? '◐' : preference === 'light' ? '○' : '●'}
|
><AppIcon
|
||||||
</span>
|
name={preference === 'system' ? 'display' : preference === 'light' ? 'sun' : 'moon'}
|
||||||
|
/></span
|
||||||
|
>
|
||||||
<span>{labels[preference]}</span>
|
<span>{labels[preference]}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import AppIcon from './AppIcon.svelte';
|
||||||
|
import ChoicePicker from './ChoicePicker.svelte';
|
||||||
|
import IconPicker from './IconPicker.svelte';
|
||||||
|
|
||||||
|
type Format = { id: string; name: string };
|
||||||
|
type Tool = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
startUrl: string;
|
||||||
|
description: string;
|
||||||
|
iconName: string;
|
||||||
|
isActive: boolean;
|
||||||
|
formatIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let { formats }: { formats: Format[] } = $props();
|
||||||
|
let dialog: HTMLDialogElement;
|
||||||
|
let nameInput: HTMLInputElement;
|
||||||
|
let tool = $state<Tool | null>(null);
|
||||||
|
let name = $state('');
|
||||||
|
let startUrl = $state('');
|
||||||
|
let description = $state('');
|
||||||
|
let iconName = $state('wrench');
|
||||||
|
let isActive = $state(true);
|
||||||
|
let formatIds = $state<string[]>([]);
|
||||||
|
|
||||||
|
export async function showModal(selectedTool: Tool | null = null) {
|
||||||
|
tool = selectedTool;
|
||||||
|
name = selectedTool?.name ?? '';
|
||||||
|
startUrl = selectedTool?.startUrl ?? '';
|
||||||
|
description = selectedTool?.description ?? '';
|
||||||
|
iconName = selectedTool?.iconName || 'wrench';
|
||||||
|
isActive = selectedTool?.isActive ?? true;
|
||||||
|
formatIds = [...(selectedTool?.formatIds ?? [])];
|
||||||
|
dialog.showModal();
|
||||||
|
await tick();
|
||||||
|
nameInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
bind:this={dialog}
|
||||||
|
class="management-dialog tool-editor-dialog"
|
||||||
|
aria-labelledby="tool-dialog-title"
|
||||||
|
onclick={(event) => event.target === dialog && close()}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<div class="management-dialog-title">
|
||||||
|
<span class="management-dialog-icon"><AppIcon name={iconName || 'wrench'} /></span>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">{tool ? 'Werkzeug bearbeiten' : 'Werkzeug hinzufügen'}</p>
|
||||||
|
<h2 id="tool-dialog-title">{tool?.name || 'Neues Werkzeug'}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-close" type="button" aria-label="Werkzeugdialog schließen" onclick={close}
|
||||||
|
><AppIcon name="xmark" /></button
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
<form
|
||||||
|
class="management-dialog-form"
|
||||||
|
method="POST"
|
||||||
|
action={tool ? '?/updateTool' : '?/createTool'}
|
||||||
|
>
|
||||||
|
{#if tool}<input type="hidden" name="toolId" value={tool.id} />{/if}
|
||||||
|
<div class="management-dialog-fields">
|
||||||
|
<label
|
||||||
|
>Name<input
|
||||||
|
bind:this={nameInput}
|
||||||
|
bind:value={name}
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxlength="120"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Start-URL<input
|
||||||
|
bind:value={startUrl}
|
||||||
|
name="startUrl"
|
||||||
|
type="url"
|
||||||
|
maxlength="2000"
|
||||||
|
placeholder="https://…"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="wide-field"
|
||||||
|
>Beschreibung<textarea bind:value={description} name="description" rows="3" maxlength="2000"
|
||||||
|
></textarea></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="management-dialog-selections">
|
||||||
|
<div>
|
||||||
|
<span class="field-label">Icon</span>
|
||||||
|
<IconPicker bind:name={iconName} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="field-label">Passende Formate</span>
|
||||||
|
<ChoicePicker
|
||||||
|
items={formats}
|
||||||
|
selectedIds={formatIds}
|
||||||
|
label="Formate"
|
||||||
|
fieldName="formatIds"
|
||||||
|
onselectionchange={(ids) => (formatIds = ids)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-row"
|
||||||
|
><input type="checkbox" name="isActive" value="1" bind:checked={isActive} /><span
|
||||||
|
><strong>Aktiv</strong><small
|
||||||
|
>Deaktivierte Werkzeuge bleiben in bestehenden Pieces erhalten.</small
|
||||||
|
></span
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<button class="secondary-button" type="button" onclick={close}>Abbrechen</button>
|
||||||
|
<button class="primary-button" type="submit"
|
||||||
|
>{tool ? 'Änderungen speichern' : 'Werkzeug anlegen'}</button
|
||||||
|
>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
@@ -1,9 +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 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 {
|
||||||
@@ -12,7 +14,8 @@
|
|||||||
sectionId,
|
sectionId,
|
||||||
sections,
|
sections,
|
||||||
error,
|
error,
|
||||||
enhanceCommand
|
enhanceCommand,
|
||||||
|
open = false
|
||||||
}: {
|
}: {
|
||||||
usage: PieceUsage;
|
usage: PieceUsage;
|
||||||
revision: number;
|
revision: number;
|
||||||
@@ -20,24 +23,89 @@
|
|||||||
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}`);
|
||||||
let editor: { showModal: () => void; close: () => void };
|
let editor: { showModal: () => void; close: () => void };
|
||||||
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
|
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
|
||||||
let draft = $state(untrack(() => ({ ...usage })));
|
let draft = $state(
|
||||||
|
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
|
||||||
|
);
|
||||||
let dirty = $state(false);
|
let dirty = $state(false);
|
||||||
|
let selectedSourceId = $state(
|
||||||
|
untrack(
|
||||||
|
() => usage.sources.find((source) => source.url === usage.presentationSourceUrl)?.id ?? ''
|
||||||
|
)
|
||||||
|
);
|
||||||
|
onMount(() => {
|
||||||
|
if (open) editor.showModal();
|
||||||
|
});
|
||||||
|
let selectedMedia = $derived(
|
||||||
|
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
|
||||||
|
);
|
||||||
|
|
||||||
function durationInMinutes(seconds: number) {
|
function durationInMinutes(seconds: number) {
|
||||||
return Number((seconds / 60).toFixed(2));
|
return Number((seconds / 60).toFixed(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function previewHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''')
|
||||||
|
.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) => {
|
||||||
@@ -65,18 +133,7 @@
|
|||||||
>
|
>
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
<p class="eyebrow">Sendungsfassung</p>
|
<p class="eyebrow">Sendungsfassung</p>
|
||||||
<label class="piece-dialog-title">
|
<InlineTitleEditor form={formId} bind:value={draft.title} oncommit={() => (dirty = true)} />
|
||||||
<span class="sr-only">Titel</span>
|
|
||||||
<textarea
|
|
||||||
form={formId}
|
|
||||||
name="title"
|
|
||||||
required
|
|
||||||
rows="1"
|
|
||||||
maxlength="200"
|
|
||||||
aria-label="Titel"
|
|
||||||
bind:value={draft.title}
|
|
||||||
oninput={() => (dirty = true)}></textarea>
|
|
||||||
</label>
|
|
||||||
<h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
|
<h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
@@ -113,6 +170,13 @@
|
|||||||
rows={10}
|
rows={10}
|
||||||
bind:value={draft.moderation}
|
bind:value={draft.moderation}
|
||||||
/>
|
/>
|
||||||
|
<MarkdownEditorField
|
||||||
|
name="interaction"
|
||||||
|
label="Interaktion"
|
||||||
|
placeholder="Welche Frage möchtest du dem Chat stellen?"
|
||||||
|
privateField
|
||||||
|
bind:value={draft.interaction}
|
||||||
|
/>
|
||||||
<MarkdownEditorField
|
<MarkdownEditorField
|
||||||
name="transition"
|
name="transition"
|
||||||
label="Überleitung"
|
label="Überleitung"
|
||||||
@@ -123,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"
|
||||||
@@ -163,13 +247,40 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<section class="piece-source-summary">
|
<section class="piece-source-summary">
|
||||||
<p class="eyebrow">Snapshot-Quelle</p>
|
<p class="eyebrow">Snapshot-Quellen</p>
|
||||||
{#if draft.presentationSourceUrl}
|
{#if draft.sources.length}
|
||||||
<strong>{draft.presentationSourceLabel || draft.presentationSourceUrl}</strong>
|
{#each draft.sources as source (source.id)}<div>
|
||||||
<small>{draft.presentationSourceUrl}</small>
|
<strong>{source.label}</strong><small>{source.url}</small>
|
||||||
{:else}
|
</div>{/each}
|
||||||
<p>Für diese Verwendung wurde keine Präsentationsquelle kopiert.</p>
|
{:else}<p>Für diese Verwendung wurden keine Quellen kopiert.</p>{/if}
|
||||||
{/if}
|
</section>
|
||||||
|
<section class="piece-source-editor">
|
||||||
|
<div class="piece-subsection-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Snapshot</p>
|
||||||
|
<h3>Werkzeuge</h3>
|
||||||
|
</div>
|
||||||
|
<small>Name und Start-URL bleiben Teil der Sendungsfassung.</small>
|
||||||
|
</div>
|
||||||
|
{#if draft.tools.length}<div class="source-list">
|
||||||
|
{#each draft.tools as tool (tool.id)}<div class="source-row tool-assignment-row">
|
||||||
|
<input type="hidden" name="usageToolId" value={tool.id} />
|
||||||
|
<div class="tool-assignment-name">
|
||||||
|
<strong>{tool.name}</strong>{#if tool.startUrl}<small>{tool.startUrl}</small>{/if}
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
>Arbeitslink<input
|
||||||
|
name="toolWorkUrl"
|
||||||
|
type="url"
|
||||||
|
maxlength="2000"
|
||||||
|
placeholder={tool.startUrl || 'https://…'}
|
||||||
|
bind:value={tool.workUrl}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label>Notiz<input name="toolNote" maxlength="2000" bind:value={tool.note} /></label
|
||||||
|
>
|
||||||
|
</div>{/each}
|
||||||
|
</div>{:else}<p class="context-empty">Keine Werkzeuge in dieser Sendungsfassung.</p>{/if}
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -183,14 +294,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="piece-presentation-layout">
|
<div class="piece-presentation-layout">
|
||||||
<div class="piece-presentation-controls">
|
<div class="piece-presentation-controls">
|
||||||
<MarkdownEditorField
|
<p class="field-label">Präsentationsart</p>
|
||||||
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"
|
||||||
@@ -201,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"
|
||||||
@@ -213,34 +338,45 @@
|
|||||||
value={draft.presentationSourceTimecodeSeconds ?? ''}
|
value={draft.presentationSourceTimecodeSeconds ?? ''}
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
|
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
|
||||||
{#if draft.presentationMode !== 'text'}
|
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
|
||||||
|
{#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
|
||||||
>Titel<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"
|
||||||
@@ -260,21 +396,37 @@
|
|||||||
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
|
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if draft.media.length && ['text', 'image'].includes(draft.presentationMode)}<fieldset
|
||||||
|
class="usage-media-picker"
|
||||||
|
>
|
||||||
|
<legend>Hochgeladenes Präsentationsbild</legend>
|
||||||
|
{#if draft.presentationMode === 'text'}<label
|
||||||
|
><input
|
||||||
|
type="radio"
|
||||||
|
checked={!draft.presentationMediaId}
|
||||||
|
onchange={() => {
|
||||||
|
draft.presentationMediaId = null;
|
||||||
|
dirty = true;
|
||||||
|
}}
|
||||||
|
/> Kein Bild</label
|
||||||
|
>{/if}
|
||||||
|
<div class="media-grid">
|
||||||
|
{#each draft.media as media (media.id)}<label class="media-presentation-choice"
|
||||||
|
><input
|
||||||
|
type="radio"
|
||||||
|
checked={draft.presentationMediaId === media.id}
|
||||||
|
onchange={() => {
|
||||||
|
draft.presentationMediaId = media.id;
|
||||||
|
dirty = true;
|
||||||
|
}}
|
||||||
|
/><img src={`/media/${media.originalMediaId}`} alt={media.altText} /><span
|
||||||
|
>Auswählen</span
|
||||||
|
></label
|
||||||
|
>{/each}
|
||||||
|
</div>
|
||||||
|
</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.presentationTitle || draft.title}</h4>
|
|
||||||
{#if draft.presentationMode === 'text'}
|
|
||||||
<p class="preview-markdown-source">
|
|
||||||
{draft.presentation || 'Noch kein öffentlicher Text.'}
|
|
||||||
</p>
|
|
||||||
{: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>
|
||||||
@@ -318,14 +470,16 @@
|
|||||||
method="POST"
|
method="POST"
|
||||||
action="?/removeUsage"
|
action="?/removeUsage"
|
||||||
onsubmit={(event) =>
|
onsubmit={(event) =>
|
||||||
!confirm('Verwendung aus der Sendung entfernen?') && event.preventDefault()}
|
!confirm('Piece aus dieser Sendung entfernen?') && event.preventDefault()}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="revision" value={revision} /><input
|
<input type="hidden" name="revision" value={revision} /><input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="usageId"
|
name="usageId"
|
||||||
value={usage.id}
|
value={usage.id}
|
||||||
/>
|
/>
|
||||||
<button class="text-button danger-text" type="submit" disabled={dirty}>Entfernen</button>
|
<button class="text-button danger-text" type="submit" disabled={dirty}
|
||||||
|
>Aus Sendung entfernen</button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="piece-dialog-primary-action">
|
<div class="piece-dialog-primary-action">
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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 };
|
||||||
|
}
|
||||||
@@ -70,6 +70,21 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('authentication', () => {
|
describe('authentication', () => {
|
||||||
|
it('stores the show overview preference per user', async () => {
|
||||||
|
const { createFirstAdmin, getShowOverviewView, setShowOverviewView } =
|
||||||
|
await import('./auth.js');
|
||||||
|
const user = await createFirstAdmin({
|
||||||
|
setupToken: 'a'.repeat(32),
|
||||||
|
email: 'calendar@example.test',
|
||||||
|
displayName: 'Kalender Test',
|
||||||
|
password: 'a-valid-password-123'
|
||||||
|
});
|
||||||
|
expect(getShowOverviewView(user.id)).toBe('list');
|
||||||
|
expect(setShowOverviewView(user.id, 'calendar')).toBe('calendar');
|
||||||
|
expect(getShowOverviewView(user.id)).toBe('calendar');
|
||||||
|
expect(() => setShowOverviewView(user.id, 'invalid')).toThrow('INVALID_VIEW');
|
||||||
|
});
|
||||||
|
|
||||||
it('creates the first Barkeeper once and stores no plaintext secret', async () => {
|
it('creates the first Barkeeper once and stores no plaintext secret', async () => {
|
||||||
const { createFirstAdmin } = await import('./auth.js');
|
const { createFirstAdmin } = await import('./auth.js');
|
||||||
const password = 'Ein-sehr-langes-Testpasswort';
|
const password = 'Ein-sehr-langes-Testpasswort';
|
||||||
|
|||||||
+20
-2
@@ -26,6 +26,7 @@ const PASSWORD_OPTIONS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Role = 'barkeeper' | 'thekencrew';
|
export type Role = 'barkeeper' | 'thekencrew';
|
||||||
|
export type ShowOverviewView = 'list' | 'calendar';
|
||||||
export type AuthUser = {
|
export type AuthUser = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -66,6 +67,23 @@ export function normalizeEmail(email: string): string {
|
|||||||
return email.trim().toLowerCase();
|
return email.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getShowOverviewView(userId: string): ShowOverviewView {
|
||||||
|
const row = getDatabase()
|
||||||
|
.prepare('SELECT show_overview_view FROM users WHERE id = ?')
|
||||||
|
.get(userId) as { show_overview_view: ShowOverviewView } | undefined;
|
||||||
|
if (!row) throw new Error('NOT_FOUND');
|
||||||
|
return row.show_overview_view;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setShowOverviewView(userId: string, view: string): ShowOverviewView {
|
||||||
|
if (view !== 'list' && view !== 'calendar') throw new Error('INVALID_VIEW');
|
||||||
|
const result = getDatabase()
|
||||||
|
.prepare('UPDATE users SET show_overview_view = ?, updated_at_utc = ? WHERE id = ?')
|
||||||
|
.run(view, nowUtc(), userId);
|
||||||
|
if (!result.changes) throw new Error('NOT_FOUND');
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
export function validEmail(email: string): boolean {
|
export function validEmail(email: string): boolean {
|
||||||
return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
}
|
}
|
||||||
@@ -92,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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -12,14 +12,19 @@ import {
|
|||||||
getMediaFile,
|
getMediaFile,
|
||||||
getPiece,
|
getPiece,
|
||||||
getTaxonomy,
|
getTaxonomy,
|
||||||
|
listPieceTemplates,
|
||||||
listPieces,
|
listPieces,
|
||||||
loadPiecePresentationMetadata,
|
loadPiecePresentationMetadata,
|
||||||
savePiece,
|
savePiece,
|
||||||
|
savePieceAsTemplate,
|
||||||
setPieceArchived,
|
setPieceArchived,
|
||||||
|
setPieceDeleted,
|
||||||
|
setPieceFavorite,
|
||||||
uploadPieceImage,
|
uploadPieceImage,
|
||||||
|
updateTaxonomy,
|
||||||
type PieceInput
|
type PieceInput
|
||||||
} from './content.js';
|
} from './content.js';
|
||||||
import { closeDatabase } from './database.js';
|
import { closeDatabase, getDatabase } from './database.js';
|
||||||
import { renderMarkdown } from './markdown.js';
|
import { renderMarkdown } from './markdown.js';
|
||||||
|
|
||||||
let directory: string;
|
let directory: string;
|
||||||
@@ -42,6 +47,7 @@ afterEach(() => {
|
|||||||
function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
|
function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
|
||||||
return {
|
return {
|
||||||
title: 'Getestetes Piece',
|
title: 'Getestetes Piece',
|
||||||
|
outputTitle: '',
|
||||||
summary: 'Eine persistente Kurzbeschreibung.',
|
summary: 'Eine persistente Kurzbeschreibung.',
|
||||||
formatId: 'format-talk',
|
formatId: 'format-talk',
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
@@ -68,11 +74,63 @@ 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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('content management', () => {
|
describe('content management', () => {
|
||||||
|
it('sorts global favorites first without changing their revision', () => {
|
||||||
|
const before = getPiece('piece-console')!;
|
||||||
|
setPieceFavorite('piece-console', true);
|
||||||
|
const pieces = listPieces({ statuses: ['ready'] });
|
||||||
|
expect(pieces[0]).toMatchObject({ id: 'piece-console', isFavorite: true });
|
||||||
|
expect(getPiece('piece-console')).toMatchObject({
|
||||||
|
isFavorite: true,
|
||||||
|
revision: before.revision,
|
||||||
|
status: before.status
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists independently versioned templates and copies their complete snapshot', () => {
|
||||||
|
const sourceId = createPiece('talk');
|
||||||
|
const source = getPiece(sourceId)!;
|
||||||
|
savePiece(
|
||||||
|
sourceId,
|
||||||
|
source.revision,
|
||||||
|
completeInput({
|
||||||
|
title: 'Interner Vorlagentitel',
|
||||||
|
outputTitle: 'Titel für die Ausgabe',
|
||||||
|
presentationText: 'Unveränderlicher Vorlagentext'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const created = savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Redaktioneller Stand');
|
||||||
|
const template = listPieceTemplates().find((entry) => entry.id === created.id)!;
|
||||||
|
expect(template).toMatchObject({ name: 'Eigene Vorlage', revision: 1 });
|
||||||
|
|
||||||
|
const changed = getPiece(sourceId)!;
|
||||||
|
savePiece(sourceId, changed.revision, {
|
||||||
|
...changed,
|
||||||
|
status: 'ready',
|
||||||
|
presentationText: 'Spätere Änderung'
|
||||||
|
});
|
||||||
|
const copyId = createPiece(created.id);
|
||||||
|
expect(getPiece(copyId)).toMatchObject({
|
||||||
|
title: 'Interner Vorlagentitel',
|
||||||
|
outputTitle: 'Titel für die Ausgabe',
|
||||||
|
presentationText: 'Unveränderlicher Vorlagentext',
|
||||||
|
sources: [
|
||||||
|
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Neuer Stand', created.id, 1);
|
||||||
|
expect(updated.revision).toBe(2);
|
||||||
|
expect(() =>
|
||||||
|
savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Veraltet', created.id, 1)
|
||||||
|
).toThrow('CONFLICT');
|
||||||
|
});
|
||||||
|
|
||||||
it('persists complete pieces and rejects stale revisions atomically', () => {
|
it('persists complete pieces and rejects stale revisions atomically', () => {
|
||||||
const id = createPiece('talk');
|
const id = createPiece('talk');
|
||||||
const initial = getPiece(id)!;
|
const initial = getPiece(id)!;
|
||||||
@@ -143,8 +201,17 @@ describe('content management', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('searches independent taxonomies and archives, restores and duplicates pieces', async () => {
|
it('searches independent taxonomies and archives, restores and duplicates pieces', async () => {
|
||||||
createTaxonomy('topic', 'Technikgeschichte');
|
createTaxonomy('topic', 'Technikgeschichte', 'microchip');
|
||||||
const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!;
|
const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!;
|
||||||
|
expect(topic.iconName).toBe('microchip');
|
||||||
|
updateTaxonomy('topic', topic.id, 'Computergeschichte', 'microchip');
|
||||||
|
expect(getTaxonomy().topics.find((entry) => entry.id === topic.id)).toMatchObject({
|
||||||
|
name: 'Computergeschichte',
|
||||||
|
iconName: 'microchip'
|
||||||
|
});
|
||||||
|
expect(() => updateTaxonomy('topic', topic.id, 'Computergeschichte', '../unsafe')).toThrow(
|
||||||
|
'INVALID_ICON'
|
||||||
|
);
|
||||||
const sourceId = createPiece('find');
|
const sourceId = createPiece('find');
|
||||||
const source = getPiece(sourceId)!;
|
const source = getPiece(sourceId)!;
|
||||||
savePiece(
|
savePiece(
|
||||||
@@ -198,15 +265,17 @@ describe('content management', () => {
|
|||||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||||
'base64'
|
'base64'
|
||||||
);
|
);
|
||||||
await uploadPieceImage(
|
const uploaded = await uploadPieceImage(
|
||||||
id,
|
id,
|
||||||
1,
|
1,
|
||||||
new File([png], '../bild.png', { type: 'text/html' }),
|
new File([png], '../bild.png', { type: 'text/html' }),
|
||||||
'Ein heller Pixel'
|
'Ein heller Pixel',
|
||||||
|
true
|
||||||
);
|
);
|
||||||
const piece = getPiece(id)!;
|
const piece = getPiece(id)!;
|
||||||
expect(piece.media).toHaveLength(1);
|
expect(piece.media).toHaveLength(1);
|
||||||
expect(piece.media[0]).toMatchObject({ mimeType: 'image/png', altText: 'Ein heller Pixel' });
|
expect(piece.media[0]).toMatchObject({ mimeType: 'image/png', altText: 'Ein heller Pixel' });
|
||||||
|
expect(piece.presentationMediaId).toBe(uploaded.id);
|
||||||
const media = await getMediaFile(piece.media[0].id);
|
const media = await getMediaFile(piece.media[0].id);
|
||||||
expect(media?.data.equals(png)).toBe(true);
|
expect(media?.data.equals(png)).toBe(true);
|
||||||
expect(existsSync(path.join(directory, 'uploads', '../bild.png'))).toBe(false);
|
expect(existsSync(path.join(directory, 'uploads', '../bild.png'))).toBe(false);
|
||||||
@@ -219,7 +288,93 @@ describe('content management', () => {
|
|||||||
''
|
''
|
||||||
)
|
)
|
||||||
).rejects.toThrow('INVALID_IMAGE_TYPE');
|
).rejects.toThrow('INVALID_IMAGE_TYPE');
|
||||||
|
const stored = getDatabase()
|
||||||
|
.prepare('SELECT storage_name FROM piece_media WHERE id = ?')
|
||||||
|
.get(piece.media[0].id) as { storage_name: string };
|
||||||
await deletePieceImage(id, piece.media[0].id, piece.revision);
|
await deletePieceImage(id, piece.media[0].id, piece.revision);
|
||||||
expect(getPiece(id)?.media).toHaveLength(0);
|
expect(getPiece(id)?.media).toHaveLength(0);
|
||||||
|
expect(getPiece(id)?.presentationMediaId).toBeNull();
|
||||||
|
expect(await getMediaFile(piece.media[0].id)).toBeNull();
|
||||||
|
expect(existsSync(path.join(directory, 'uploads', stored.storage_name))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects presentation media atomically, copies active media and omits media from templates', async () => {
|
||||||
|
const id = createPiece();
|
||||||
|
const png = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||||
|
'base64'
|
||||||
|
);
|
||||||
|
const selected = await uploadPieceImage(
|
||||||
|
id,
|
||||||
|
1,
|
||||||
|
new File([png], 'auswahl.png', { type: 'image/png' }),
|
||||||
|
'Auswahl',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const second = await uploadPieceImage(
|
||||||
|
id,
|
||||||
|
selected.revision,
|
||||||
|
new File([png], 'entfernt.png', { type: 'image/png' }),
|
||||||
|
'Entfernt'
|
||||||
|
);
|
||||||
|
await deletePieceImage(id, second.id, second.revision);
|
||||||
|
const source = getPiece(id)!;
|
||||||
|
expect(() =>
|
||||||
|
savePiece(id, source.revision, {
|
||||||
|
...source,
|
||||||
|
title: 'Darf nicht gespeichert werden',
|
||||||
|
status: 'preparing',
|
||||||
|
presentationMediaId: 'fremdes-medium'
|
||||||
|
})
|
||||||
|
).toThrow('presentation media does not belong to piece');
|
||||||
|
expect(getPiece(id)).toMatchObject({
|
||||||
|
title: 'Neues Piece',
|
||||||
|
revision: source.revision,
|
||||||
|
presentationMediaId: selected.id
|
||||||
|
});
|
||||||
|
|
||||||
|
savePiece(id, source.revision, {
|
||||||
|
...source,
|
||||||
|
status: 'preparing',
|
||||||
|
presentationMode: 'image'
|
||||||
|
});
|
||||||
|
const duplicateId = await duplicatePiece(id);
|
||||||
|
const duplicate = getPiece(duplicateId)!;
|
||||||
|
expect(duplicate.media).toHaveLength(1);
|
||||||
|
expect(duplicate.presentationMediaId).toBe(duplicate.media[0].id);
|
||||||
|
expect(duplicate.presentationMediaId).not.toBe(selected.id);
|
||||||
|
expect(duplicate.presentationMode).toBe('image');
|
||||||
|
|
||||||
|
const template = savePieceAsTemplate(id, 'Ohne Medienreferenz', 'Nur Inhaltsdaten');
|
||||||
|
const snapshot = getDatabase()
|
||||||
|
.prepare('SELECT snapshot_json FROM piece_templates WHERE id = ?')
|
||||||
|
.get(template.id) as { snapshot_json: string };
|
||||||
|
expect(JSON.parse(snapshot.snapshot_json)).not.toHaveProperty('presentationMediaId');
|
||||||
|
expect(getPiece(createPiece(template.id))?.presentationMediaId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('soft-deletes pieces behind revisions and keeps their original media private', async () => {
|
||||||
|
const id = createPiece();
|
||||||
|
const png = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||||
|
'base64'
|
||||||
|
);
|
||||||
|
const uploaded = await uploadPieceImage(
|
||||||
|
id,
|
||||||
|
1,
|
||||||
|
new File([png], 'privat.png', { type: 'image/png' }),
|
||||||
|
'Privat'
|
||||||
|
);
|
||||||
|
const deleted = setPieceDeleted(id, uploaded.revision, true);
|
||||||
|
expect(getPiece(id)).toBeNull();
|
||||||
|
expect(listPieces({ statuses: ['preparing'] }).some((piece) => piece.id === id)).toBe(false);
|
||||||
|
expect(await getMediaFile(uploaded.id)).toBeNull();
|
||||||
|
expect(() => setPieceFavorite(id, true)).toThrow('NOT_FOUND');
|
||||||
|
expect(() => savePiece(id, deleted.revision, completeInput())).toThrow('NOT_FOUND');
|
||||||
|
expect(() => setPieceDeleted(id, uploaded.revision, false)).toThrow('CONFLICT');
|
||||||
|
|
||||||
|
const restored = setPieceDeleted(id, deleted.revision, false);
|
||||||
|
expect(getPiece(id)).toMatchObject({ id, revision: restored.revision });
|
||||||
|
expect((await getMediaFile(uploaded.id))?.data.equals(png)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+455
-147
File diff suppressed because it is too large.
Load diff
@@ -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: 6,
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
+202
-3
@@ -6,13 +6,20 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||||||
import { closeDatabase, getDatabase } from './database.js';
|
import { closeDatabase, getDatabase } from './database.js';
|
||||||
import {
|
import {
|
||||||
completeCurrent,
|
completeCurrent,
|
||||||
|
clearPresentationChannel,
|
||||||
getLiveView,
|
getLiveView,
|
||||||
|
getPresentationChannelStatus,
|
||||||
|
getPresentationChannelView,
|
||||||
getPresentationMedia,
|
getPresentationMedia,
|
||||||
getPresentationView,
|
getPresentationView,
|
||||||
goBack,
|
goBack,
|
||||||
issuePresentationAccess,
|
issuePresentationAccess,
|
||||||
moveOpenUsage,
|
moveOpenUsage,
|
||||||
|
presentationChannelAccess,
|
||||||
presentUsage,
|
presentUsage,
|
||||||
|
resumeUsage,
|
||||||
|
rotatePresentationChannelKey,
|
||||||
|
selectUsage,
|
||||||
skipCurrent,
|
skipCurrent,
|
||||||
startOrResumeLive
|
startOrResumeLive
|
||||||
} from './live.js';
|
} from './live.js';
|
||||||
@@ -35,6 +42,148 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('live mode', () => {
|
describe('live mode', () => {
|
||||||
|
it('keeps elapsed section time while applying an updated section plan', () => {
|
||||||
|
getDatabase()
|
||||||
|
.prepare(
|
||||||
|
"UPDATE show_sections SET explicit_duration_seconds = 300 WHERE id = 'section-stories'"
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
const start = new Date('2026-10-23T18:00:00.000Z');
|
||||||
|
startOrResumeLive('show-october', 'timer-start', 0, start);
|
||||||
|
let live = getLiveView('show-october', new Date('2026-10-23T18:01:40.000Z'));
|
||||||
|
expect(live.sectionTimer).toMatchObject({
|
||||||
|
sectionId: 'section-stories',
|
||||||
|
plannedDurationSeconds: 300,
|
||||||
|
elapsedSeconds: 100
|
||||||
|
});
|
||||||
|
getDatabase()
|
||||||
|
.prepare(
|
||||||
|
"UPDATE show_sections SET explicit_duration_seconds = 600 WHERE id = 'section-stories'"
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
live = getLiveView('show-october', new Date('2026-10-23T18:01:40.000Z'));
|
||||||
|
expect(live.sectionTimer).toMatchObject({
|
||||||
|
plannedDurationSeconds: 600,
|
||||||
|
elapsedSeconds: 100
|
||||||
|
});
|
||||||
|
completeCurrent(
|
||||||
|
'show-october',
|
||||||
|
'timer-next-piece',
|
||||||
|
live.liveRevision,
|
||||||
|
new Date('2026-10-23T18:02:00.000Z')
|
||||||
|
);
|
||||||
|
live = getLiveView('show-october', new Date('2026-10-23T18:04:00.000Z'));
|
||||||
|
expect(live.current?.id).toBe('usage-effects');
|
||||||
|
expect(live.sectionTimer).toMatchObject({
|
||||||
|
plannedDurationSeconds: 600,
|
||||||
|
elapsedSeconds: 240
|
||||||
|
});
|
||||||
|
completeCurrent(
|
||||||
|
'show-october',
|
||||||
|
'timer-finish',
|
||||||
|
live.liveRevision,
|
||||||
|
new Date('2026-10-23T18:06:00.000Z')
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
getLiveView('show-october', new Date('2026-10-23T18:07:00.000Z')).sectionTimer
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects any rundown usage by stable id, including the first and completed entries', () => {
|
||||||
|
let live = getLiveView('show-october');
|
||||||
|
expect(() =>
|
||||||
|
selectUsage('show-october', 'usage-effects', 'select-before-start', live.liveRevision)
|
||||||
|
).toThrow('NOT_STARTED');
|
||||||
|
startOrResumeLive('show-october', 'start-selection', live.liveRevision);
|
||||||
|
live = getLiveView('show-october');
|
||||||
|
selectUsage('show-october', 'usage-effects', 'select-second', live.liveRevision);
|
||||||
|
live = getLiveView('show-october');
|
||||||
|
expect(live.current?.id).toBe('usage-effects');
|
||||||
|
|
||||||
|
selectUsage('show-october', 'usage-console', 'select-first', live.liveRevision);
|
||||||
|
live = getLiveView('show-october');
|
||||||
|
expect(live.current?.id).toBe('usage-console');
|
||||||
|
completeCurrent('show-october', 'complete-first', live.liveRevision);
|
||||||
|
live = getLiveView('show-october');
|
||||||
|
expect(
|
||||||
|
live.sections
|
||||||
|
.flatMap((section) => section.usages)
|
||||||
|
.find((usage) => usage.id === 'usage-console')?.status
|
||||||
|
).toBe('done');
|
||||||
|
|
||||||
|
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({
|
||||||
|
id: 'usage-console',
|
||||||
|
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', () => {
|
||||||
|
expect(getPresentationChannelStatus('show-october')).toBe('disconnected');
|
||||||
|
const key = issuePresentationAccess('show-october');
|
||||||
|
expect(getPresentationChannelStatus('show-october')).toBe('connected');
|
||||||
|
expect(issuePresentationAccess('show-october')).toBe(key);
|
||||||
|
expect(presentationChannelAccess('default', key)).toBe(true);
|
||||||
|
expect(getPresentationChannelView('default')).toEqual({
|
||||||
|
revision: 1_000_000_000,
|
||||||
|
content: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const replacement = rotatePresentationChannelKey('show-october');
|
||||||
|
expect(replacement).not.toBe(key);
|
||||||
|
expect(presentationChannelAccess('default', key)).toBe(false);
|
||||||
|
expect(presentationChannelAccess('default', replacement)).toBe(true);
|
||||||
|
startOrResumeLive('show-october', 'start-channel', 0);
|
||||||
|
presentUsage('show-october', 'usage-console', 'present-channel', 1);
|
||||||
|
expect(getPresentationChannelView('default')?.content).not.toBeNull();
|
||||||
|
clearPresentationChannel();
|
||||||
|
expect(getPresentationChannelStatus('show-october')).toBe('disconnected');
|
||||||
|
expect(getPresentationChannelView('default')?.content).toBeNull();
|
||||||
|
issuePresentationAccess('show-october');
|
||||||
|
expect(getPresentationChannelView('default')?.content).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('restores live state, records actual intervals and applies commands only once', () => {
|
it('restores live state, records actual intervals and applies commands only once', () => {
|
||||||
const started = new Date('2026-10-22T18:00:00.000Z');
|
const started = new Date('2026-10-22T18:00:00.000Z');
|
||||||
const initial = getLiveView('show-october', started);
|
const initial = getLiveView('show-october', started);
|
||||||
@@ -105,6 +254,14 @@ describe('live mode', () => {
|
|||||||
0,
|
0,
|
||||||
'2026-10-22T18:00:00.000Z'
|
'2026-10-22T18:00:00.000Z'
|
||||||
);
|
);
|
||||||
|
getDatabase()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO usage_tool_snapshots
|
||||||
|
(id, usage_id, original_tool_id, name, start_url, work_url, note, position)
|
||||||
|
VALUES ('private-live-tool', 'usage-console', NULL, 'Privates Werkzeug', '',
|
||||||
|
'https://private.example.test/board', 'PRIVATE_TOOL_NOTE', 0)`
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
const token = issuePresentationAccess('show-october');
|
const token = issuePresentationAccess('show-october');
|
||||||
expect(getPresentationView(token)).toEqual({ revision: 0, content: null });
|
expect(getPresentationView(token)).toEqual({ revision: 0, content: null });
|
||||||
@@ -115,7 +272,9 @@ describe('live mode', () => {
|
|||||||
const output = getPresentationView(token)!;
|
const output = getPresentationView(token)!;
|
||||||
expect(output.content).toMatchObject({
|
expect(output.content).toMatchObject({
|
||||||
title: 'Die kleine Konsole mit dem großen Dreh',
|
title: 'Die kleine Konsole mit dem großen Dreh',
|
||||||
images: [{ id: 'presentation-image', altText: 'Konsole auf dem Tisch' }]
|
topics: ['Gaming'],
|
||||||
|
images: [],
|
||||||
|
sources: []
|
||||||
});
|
});
|
||||||
const serialized = JSON.stringify(output);
|
const serialized = JSON.stringify(output);
|
||||||
expect(serialized).toContain('Weniger Leistung, mehr Idee.');
|
expect(serialized).toContain('Weniger Leistung, mehr Idee.');
|
||||||
@@ -123,13 +282,53 @@ describe('live mode', () => {
|
|||||||
expect(serialized).not.toContain('Moderationsstichpunkte');
|
expect(serialized).not.toContain('Moderationsstichpunkte');
|
||||||
expect(serialized).not.toContain('private-original-name');
|
expect(serialized).not.toContain('private-original-name');
|
||||||
expect(serialized).not.toContain('presentation.png');
|
expect(serialized).not.toContain('presentation.png');
|
||||||
|
expect(serialized).not.toContain('https://example.com/console');
|
||||||
|
expect(serialized).not.toContain('https://private.example.test/board');
|
||||||
|
expect(serialized).not.toContain('PRIVATE_TOOL_NOTE');
|
||||||
|
|
||||||
const media = await getPresentationMedia(token, 'presentation-image');
|
const media = await getPresentationMedia(token, 'presentation-image');
|
||||||
expect(media?.data.toString()).toBe('test-image');
|
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(getPresentationView(token)).toBeNull();
|
expect(replacementToken).toBe(token);
|
||||||
|
expect(getPresentationView(token)?.content?.title).toBe(
|
||||||
|
'Die kleine Konsole mit dem großen Dreh'
|
||||||
|
);
|
||||||
expect(getPresentationView(replacementToken)?.content?.title).toBe(
|
expect(getPresentationView(replacementToken)?.content?.title).toBe(
|
||||||
'Die kleine Konsole mit dem großen Dreh'
|
'Die kleine Konsole mit dem großen Dreh'
|
||||||
);
|
);
|
||||||
|
|||||||
+757
-58
File diff suppressed because it is too large.
Load diff
@@ -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 = {
|
||||||
@@ -28,6 +28,7 @@ export type PresentationSource = {
|
|||||||
|
|
||||||
export type PresentationContent = {
|
export type PresentationContent = {
|
||||||
mode: PresentationMode;
|
mode: PresentationMode;
|
||||||
|
topics: string[];
|
||||||
title: string;
|
title: string;
|
||||||
html: string;
|
html: string;
|
||||||
source: PresentationSource | null;
|
source: PresentationSource | null;
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function getPieces(): PreviewPiece[] {
|
|||||||
(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
|
||||||
FROM pieces p
|
FROM pieces p
|
||||||
LEFT JOIN formats f ON f.id = p.format_id
|
LEFT JOIN formats f ON f.id = p.format_id
|
||||||
WHERE p.status IN ('preparing', 'ready')
|
WHERE p.status IN ('preparing', 'ready') AND p.deleted_at_utc IS NULL
|
||||||
ORDER BY CASE p.status WHEN 'ready' THEN 0 ELSE 1 END, p.title`
|
ORDER BY CASE p.status WHEN 'ready' THEN 0 ELSE 1 END, p.title`
|
||||||
)
|
)
|
||||||
.all() as PieceRow[];
|
.all() as PieceRow[];
|
||||||
@@ -175,9 +175,12 @@ export function getTaxonomy() {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}[],
|
}[],
|
||||||
topics: database.prepare('SELECT id, name FROM topics ORDER BY name').all() as {
|
topics: database
|
||||||
|
.prepare('SELECT id, name, icon_name AS iconName FROM topics ORDER BY name')
|
||||||
|
.all() as {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
iconName: string;
|
||||||
}[]
|
}[]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
addPieceUsage,
|
addPieceUsage,
|
||||||
addShowSection,
|
addShowSection,
|
||||||
createShow,
|
createShow,
|
||||||
|
createAndAddPieceUsage,
|
||||||
duplicatePieceUsage,
|
duplicatePieceUsage,
|
||||||
getPieceUsageContexts,
|
getPieceUsageContexts,
|
||||||
getShow,
|
getShow,
|
||||||
|
listPreparationUsages,
|
||||||
listShowTemplates,
|
listShowTemplates,
|
||||||
movePieceUsage,
|
movePieceUsage,
|
||||||
moveShowSection,
|
moveShowSection,
|
||||||
@@ -44,6 +46,7 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
|
|||||||
const edited = { ...usage, ...overrides };
|
const edited = { ...usage, ...overrides };
|
||||||
return {
|
return {
|
||||||
title: edited.title,
|
title: edited.title,
|
||||||
|
outputTitle: edited.outputTitle,
|
||||||
intro: edited.intro,
|
intro: edited.intro,
|
||||||
research: edited.research,
|
research: edited.research,
|
||||||
moderation: edited.moderation,
|
moderation: edited.moderation,
|
||||||
@@ -57,12 +60,116 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
|
|||||||
presentationDescription: edited.presentationDescription,
|
presentationDescription: edited.presentationDescription,
|
||||||
presentationImageUrl: edited.presentationImageUrl,
|
presentationImageUrl: edited.presentationImageUrl,
|
||||||
presentationSiteName: edited.presentationSiteName,
|
presentationSiteName: edited.presentationSiteName,
|
||||||
|
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', () => {
|
||||||
|
const created = createShow({ title: 'Direkte Planung', startsAtUtc: null, templateId: null });
|
||||||
|
const section = addShowSection(created.id, created.revision, {
|
||||||
|
name: 'Ablauf',
|
||||||
|
categoryId: null,
|
||||||
|
explicitDurationSeconds: null
|
||||||
|
});
|
||||||
|
const before = (
|
||||||
|
getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number }
|
||||||
|
).count;
|
||||||
|
const first = createAndAddPieceUsage(created.id, section.revision, 'new-piece-command', {
|
||||||
|
title: 'Direkt geplantes Piece',
|
||||||
|
templateId: 'talk',
|
||||||
|
sectionId: section.id
|
||||||
|
});
|
||||||
|
const repeated = createAndAddPieceUsage(created.id, section.revision, 'new-piece-command', {
|
||||||
|
title: 'Darf nicht doppelt entstehen',
|
||||||
|
templateId: 'blank',
|
||||||
|
sectionId: section.id
|
||||||
|
});
|
||||||
|
expect(repeated).toEqual({ ...first, duplicate: true });
|
||||||
|
expect(getShow(created.id)?.sections[0].usages).toHaveLength(1);
|
||||||
|
expect(getPiece(first.pieceId)).toMatchObject({
|
||||||
|
title: 'Direkt geplantes Piece',
|
||||||
|
status: 'ready',
|
||||||
|
formatId: 'format-talk'
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
|
||||||
|
.count
|
||||||
|
).toBe(before + 1);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
createAndAddPieceUsage(created.id, first.revision, 'failing-command', {
|
||||||
|
title: 'Rollback',
|
||||||
|
templateId: 'blank',
|
||||||
|
sectionId: 'missing'
|
||||||
|
})
|
||||||
|
).toThrow('NOT_FOUND');
|
||||||
|
expect(
|
||||||
|
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
|
||||||
|
.count
|
||||||
|
).toBe(before + 1);
|
||||||
|
|
||||||
|
const removed = removePieceUsage(created.id, first.usageId, first.revision);
|
||||||
|
const afterRemoval = createAndAddPieceUsage(created.id, removed.revision, 'new-piece-command', {
|
||||||
|
title: 'Darf auch nach dem Entfernen nicht neu entstehen',
|
||||||
|
templateId: 'blank',
|
||||||
|
sectionId: section.id
|
||||||
|
});
|
||||||
|
expect(afterRemoval).toEqual({ ...first, duplicate: true });
|
||||||
|
expect(getShow(created.id)?.sections[0].usages).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
|
||||||
|
.count
|
||||||
|
).toBe(before + 1);
|
||||||
|
});
|
||||||
|
|
||||||
it('creates blank and templated shows with independent sections and reserve banks', () => {
|
it('creates blank and templated shows with independent sections and reserve banks', () => {
|
||||||
const templatesBefore = listShowTemplates();
|
const templatesBefore = listShowTemplates();
|
||||||
expect(
|
expect(
|
||||||
@@ -192,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, {
|
||||||
@@ -360,7 +489,8 @@ describe('show builder data layer', () => {
|
|||||||
.run('https://youtu.be/dQw4w9WgXcQ');
|
.run('https://youtu.be/dQw4w9WgXcQ');
|
||||||
getDatabase()
|
getDatabase()
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE pieces SET presentation_mode = 'video', presentation_source_position = 0,
|
`UPDATE pieces SET output_title = 'Öffentlicher Ausgabetitel',
|
||||||
|
presentation_mode = 'video', presentation_source_position = 0,
|
||||||
presentation_title = 'Gespeicherte Vorschau', presentation_description = 'Erster Stand',
|
presentation_title = 'Gespeicherte Vorschau', presentation_description = 'Erster Stand',
|
||||||
presentation_site_name = 'YouTube', presentation_provider = 'youtube',
|
presentation_site_name = 'YouTube', presentation_provider = 'youtube',
|
||||||
presentation_provider_id = 'dQw4w9WgXcQ' WHERE id = 'piece-console'`
|
presentation_provider_id = 'dQw4w9WgXcQ' WHERE id = 'piece-console'`
|
||||||
@@ -378,6 +508,7 @@ describe('show builder data layer', () => {
|
|||||||
});
|
});
|
||||||
let usage = getShow(show.id)!.sections[0].usages[0];
|
let usage = getShow(show.id)!.sections[0].usages[0];
|
||||||
expect(usage).toMatchObject({
|
expect(usage).toMatchObject({
|
||||||
|
outputTitle: 'Öffentlicher Ausgabetitel',
|
||||||
presentationMode: 'video',
|
presentationMode: 'video',
|
||||||
presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ',
|
presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ',
|
||||||
presentationSourceTimecodeSeconds: 75,
|
presentationSourceTimecodeSeconds: 75,
|
||||||
@@ -402,6 +533,180 @@ describe('show builder data layer', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps private source, tool and topic snapshots stable until an explicit refresh', () => {
|
||||||
|
const database = getDatabase();
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO tools (id, name, start_url, description, is_active, created_at_utc, updated_at_utc)
|
||||||
|
VALUES ('tool-tierlist', 'Tierlist', 'https://example.com/start', '', 1, ?, ?)`
|
||||||
|
)
|
||||||
|
.run('2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z');
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO piece_tools (id, piece_id, tool_id, work_url, note, position)
|
||||||
|
VALUES ('piece-tool-console', 'piece-console', 'tool-tierlist', 'https://example.com/board', 'Erster Stand', 0)`
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
const show = createShow({ title: 'Relationen', startsAtUtc: null, templateId: null });
|
||||||
|
const section = addShowSection(show.id, show.revision, {
|
||||||
|
name: 'Block',
|
||||||
|
categoryId: null,
|
||||||
|
explicitDurationSeconds: null
|
||||||
|
});
|
||||||
|
const added = addPieceUsage(show.id, section.revision, {
|
||||||
|
pieceId: 'piece-console',
|
||||||
|
sectionId: section.id
|
||||||
|
});
|
||||||
|
let usage = getShow(show.id)!.sections[0].usages[0];
|
||||||
|
expect(usage).toMatchObject({
|
||||||
|
sources: [{ label: 'Herstellerseite', url: 'https://example.com/console' }],
|
||||||
|
tools: [{ name: 'Tierlist', workUrl: 'https://example.com/board', note: 'Erster Stand' }],
|
||||||
|
topics: [{ name: 'Gaming', iconName: '' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
database
|
||||||
|
.prepare("UPDATE piece_sources SET label = 'Neue Quelle' WHERE piece_id = 'piece-console'")
|
||||||
|
.run();
|
||||||
|
database
|
||||||
|
.prepare("UPDATE tools SET name = 'Neuer Katalogname' WHERE id = 'tool-tierlist'")
|
||||||
|
.run();
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
"UPDATE piece_tools SET work_url = 'https://example.com/new' WHERE id = 'piece-tool-console'"
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
database.prepare("DELETE FROM piece_topics WHERE piece_id = 'piece-console'").run();
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO piece_topics (piece_id, topic_id) VALUES ('piece-console', 'topic-internet')"
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
usage = getShow(show.id)!.sections[0].usages[0];
|
||||||
|
expect(usage.sources[0].label).toBe('Herstellerseite');
|
||||||
|
expect(usage.tools[0]).toMatchObject({
|
||||||
|
name: 'Tierlist',
|
||||||
|
workUrl: 'https://example.com/board'
|
||||||
|
});
|
||||||
|
expect(usage.topics).toEqual([{ name: 'Gaming', iconName: '' }]);
|
||||||
|
|
||||||
|
refreshPieceUsageFromOriginal(show.id, usage.id, added.revision);
|
||||||
|
usage = getShow(show.id)!.sections[0].usages[0];
|
||||||
|
expect(usage.sources[0].label).toBe('Neue Quelle');
|
||||||
|
expect(usage.tools[0]).toMatchObject({
|
||||||
|
name: 'Neuer Katalogname',
|
||||||
|
workUrl: 'https://example.com/new'
|
||||||
|
});
|
||||||
|
expect(usage.topics).toEqual([{ name: 'Netzkultur', iconName: '' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('snapshots active media, their presentation choice and topic icons independently', () => {
|
||||||
|
const database = getDatabase();
|
||||||
|
database.prepare("UPDATE topics SET icon_name = 'gamepad' WHERE id = 'topic-gaming'").run();
|
||||||
|
const insertMedia = database.prepare(
|
||||||
|
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
|
||||||
|
byte_size, alt_text, position, created_at_utc, removed_at_utc)
|
||||||
|
VALUES (?, 'piece-console', ?, ?, 'image/png', 10, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
insertMedia.run(
|
||||||
|
'media-first',
|
||||||
|
'first.png',
|
||||||
|
'first-original.png',
|
||||||
|
'Erstes Bild',
|
||||||
|
0,
|
||||||
|
'2026-10-23T00:00:00.000Z',
|
||||||
|
null
|
||||||
|
);
|
||||||
|
insertMedia.run(
|
||||||
|
'media-second',
|
||||||
|
'second.png',
|
||||||
|
'second-original.png',
|
||||||
|
'Zweites Bild',
|
||||||
|
1,
|
||||||
|
'2026-10-23T00:00:00.000Z',
|
||||||
|
null
|
||||||
|
);
|
||||||
|
insertMedia.run(
|
||||||
|
'media-removed',
|
||||||
|
'removed.png',
|
||||||
|
'removed-original.png',
|
||||||
|
'Entferntes Bild',
|
||||||
|
2,
|
||||||
|
'2026-10-23T00:00:00.000Z',
|
||||||
|
'2026-10-23T01:00:00.000Z'
|
||||||
|
);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO piece_presentation_media (piece_id, media_id) VALUES ('piece-console', 'media-first')"
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
const show = createShow({ title: 'Medien', startsAtUtc: null, templateId: null });
|
||||||
|
const section = addShowSection(show.id, show.revision, {
|
||||||
|
name: 'Bilder',
|
||||||
|
categoryId: null,
|
||||||
|
explicitDurationSeconds: null
|
||||||
|
});
|
||||||
|
const added = addPieceUsage(show.id, section.revision, {
|
||||||
|
pieceId: 'piece-console',
|
||||||
|
sectionId: section.id
|
||||||
|
});
|
||||||
|
let usage = getShow(show.id)!.sections[0].usages[0];
|
||||||
|
expect(usage.topics).toEqual([{ name: 'Gaming', iconName: 'gamepad' }]);
|
||||||
|
expect(usage.media).toMatchObject([
|
||||||
|
{ originalMediaId: 'media-first', altText: 'Erstes Bild', position: 0, isPresentation: true },
|
||||||
|
{
|
||||||
|
originalMediaId: 'media-second',
|
||||||
|
altText: 'Zweites Bild',
|
||||||
|
position: 1,
|
||||||
|
isPresentation: false
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
expect(usage.presentationMediaId).toBe(usage.media[0].id);
|
||||||
|
|
||||||
|
const selected = updatePieceUsage(show.id, usage.id, added.revision, {
|
||||||
|
...editedUsage(usage),
|
||||||
|
presentationMediaId: usage.media[1].id
|
||||||
|
});
|
||||||
|
usage = getShow(show.id)!.sections[0].usages[0];
|
||||||
|
expect(usage.media.map((item) => item.isPresentation)).toEqual([false, true]);
|
||||||
|
expect(() =>
|
||||||
|
updatePieceUsage(show.id, usage.id, selected.revision, {
|
||||||
|
...editedUsage(usage),
|
||||||
|
presentationMediaId: 'media-first'
|
||||||
|
})
|
||||||
|
).toThrow('INVALID_PRESENTATION_MEDIA');
|
||||||
|
expect(getShow(show.id)?.revision).toBe(selected.revision);
|
||||||
|
|
||||||
|
const duplicate = duplicatePieceUsage(show.id, usage.id, selected.revision);
|
||||||
|
const duplicateUsage = getShow(show.id)!.sections[0].usages[1];
|
||||||
|
expect(duplicateUsage.media.map((item) => item.originalMediaId)).toEqual([
|
||||||
|
'media-first',
|
||||||
|
'media-second'
|
||||||
|
]);
|
||||||
|
expect(duplicateUsage.presentationMediaId).toBe(duplicateUsage.media[1].id);
|
||||||
|
expect(duplicateUsage.media.map((item) => item.id)).not.toEqual(
|
||||||
|
usage.media.map((item) => item.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
database
|
||||||
|
.prepare("UPDATE piece_media SET removed_at_utc = ? WHERE id = 'media-first'")
|
||||||
|
.run('2026-10-23T02:00:00.000Z');
|
||||||
|
database
|
||||||
|
.prepare("UPDATE piece_media SET alt_text = 'Neuer Alternativtext' WHERE id = 'media-second'")
|
||||||
|
.run();
|
||||||
|
database.prepare("DELETE FROM piece_presentation_media WHERE piece_id = 'piece-console'").run();
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO piece_presentation_media (piece_id, media_id) VALUES ('piece-console', 'media-second')"
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
refreshPieceUsageFromOriginal(show.id, usage.id, duplicate.revision);
|
||||||
|
usage = getShow(show.id)!.sections[0].usages[0];
|
||||||
|
expect(usage.media).toMatchObject([
|
||||||
|
{ originalMediaId: 'media-second', altText: 'Neuer Alternativtext', isPresentation: true }
|
||||||
|
]);
|
||||||
|
expect(duplicateUsage.media).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('persists normalized section and usage reordering, moving and removal', () => {
|
it('persists normalized section and usage reordering, moving and removal', () => {
|
||||||
const created = createShow({ title: 'Sortierung', startsAtUtc: null, templateId: null });
|
const created = createShow({ title: 'Sortierung', startsAtUtc: null, templateId: null });
|
||||||
const firstSection = addShowSection(created.id, created.revision, {
|
const firstSection = addShowSection(created.id, created.revision, {
|
||||||
|
|||||||
+849
-67
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,103 @@
|
|||||||
|
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,
|
||||||
|
updateTechnicalAccess
|
||||||
|
} 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 });
|
||||||
|
updateTechnicalAccess(actor, created.id, 'Leser bearbeitet', 'read_write');
|
||||||
|
expect(authenticateTechnical(request(created.token), true)).toMatchObject({
|
||||||
|
name: 'Leser bearbeitet',
|
||||||
|
permissions: 'read_write'
|
||||||
|
});
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
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 updateTechnicalAccess(
|
||||||
|
actor: AuthUser,
|
||||||
|
id: string,
|
||||||
|
nameValue: string,
|
||||||
|
permissions: string
|
||||||
|
) {
|
||||||
|
const name = nameValue.trim();
|
||||||
|
if (!name || name.length > 120 || !['read', 'read_write'].includes(permissions))
|
||||||
|
throw new Error('INVALID_ACCESS');
|
||||||
|
const result = getDatabase()
|
||||||
|
.prepare(
|
||||||
|
`UPDATE technical_accesses SET name = ?, permissions = ?
|
||||||
|
WHERE id = ? AND revoked_at_utc IS NULL`
|
||||||
|
)
|
||||||
|
.run(name, permissions, id);
|
||||||
|
if (!result.changes) throw new Error('NOT_FOUND');
|
||||||
|
auditTechnical(
|
||||||
|
{ id: actor.id, name: actor.displayName, permissions: 'read_write' },
|
||||||
|
'technical_access.update',
|
||||||
|
'technical_access',
|
||||||
|
id,
|
||||||
|
'user'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { closeDatabase } from './database.js';
|
||||||
|
import { createTool, listTools, updateTool } from './tools.js';
|
||||||
|
|
||||||
|
let directory: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-tools-test-'));
|
||||||
|
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
|
||||||
|
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
|
||||||
|
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
|
||||||
|
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
closeDatabase();
|
||||||
|
delete process.env.SENDEWERK_DB_PATH;
|
||||||
|
delete process.env.SENDEWERK_UPLOAD_DIR;
|
||||||
|
rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tool catalog', () => {
|
||||||
|
it('validates, classifies and deactivates tools without deleting them', () => {
|
||||||
|
const id = createTool({
|
||||||
|
name: 'Tierlist',
|
||||||
|
startUrl: 'https://example.com/tierlist',
|
||||||
|
description: 'Gemeinsames Ranking',
|
||||||
|
iconName: 'ranking-star',
|
||||||
|
isActive: true,
|
||||||
|
formatIds: ['format-quiz']
|
||||||
|
});
|
||||||
|
expect(listTools()).toEqual([
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
name: 'Tierlist',
|
||||||
|
startUrl: 'https://example.com/tierlist',
|
||||||
|
description: 'Gemeinsames Ranking',
|
||||||
|
iconName: 'ranking-star',
|
||||||
|
isActive: true,
|
||||||
|
formatIds: ['format-quiz']
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
updateTool(id, {
|
||||||
|
name: 'Tierlist',
|
||||||
|
startUrl: '',
|
||||||
|
description: '',
|
||||||
|
iconName: 'list-ol',
|
||||||
|
isActive: false,
|
||||||
|
formatIds: []
|
||||||
|
});
|
||||||
|
expect(listTools()).toEqual([]);
|
||||||
|
expect(listTools(true)[0]).toMatchObject({ id, iconName: 'list-ol', isActive: false });
|
||||||
|
expect(() =>
|
||||||
|
createTool({
|
||||||
|
name: 'Unsicher',
|
||||||
|
startUrl: 'javascript:alert(1)',
|
||||||
|
description: '',
|
||||||
|
iconName: 'wrench',
|
||||||
|
isActive: true,
|
||||||
|
formatIds: []
|
||||||
|
})
|
||||||
|
).toThrow('INVALID_TOOL_URL');
|
||||||
|
expect(() =>
|
||||||
|
createTool({
|
||||||
|
name: 'Unbekanntes Icon',
|
||||||
|
startUrl: '',
|
||||||
|
description: '',
|
||||||
|
iconName: 'kein-font-awesome-icon',
|
||||||
|
isActive: true,
|
||||||
|
formatIds: []
|
||||||
|
})
|
||||||
|
).toThrow('INVALID_ICON');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { classicRegularIconNames } from '#lib/icons/classic-regular.js';
|
||||||
|
import { getDatabase } from './database.js';
|
||||||
|
|
||||||
|
export type Tool = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
startUrl: string;
|
||||||
|
description: string;
|
||||||
|
iconName: string;
|
||||||
|
isActive: boolean;
|
||||||
|
formatIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolInput = Omit<Tool, 'id'>;
|
||||||
|
|
||||||
|
function clean(value: unknown, maximum: number, required = false) {
|
||||||
|
const text = String(value ?? '').trim();
|
||||||
|
if (text.length > maximum || (required && !text)) throw new Error('INVALID_TOOL');
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validUrl(value: string) {
|
||||||
|
if (!value) return '';
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
|
||||||
|
throw new Error();
|
||||||
|
return url.href;
|
||||||
|
} catch {
|
||||||
|
throw new Error('INVALID_TOOL_URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validated(input: ToolInput): ToolInput {
|
||||||
|
const formatIds = [...new Set(input.formatIds.filter(Boolean))];
|
||||||
|
if (formatIds.length > 30) throw new Error('INVALID_TOOL');
|
||||||
|
if (formatIds.length) {
|
||||||
|
const placeholders = formatIds.map(() => '?').join(', ');
|
||||||
|
const row = getDatabase()
|
||||||
|
.prepare(`SELECT count(*) AS count FROM formats WHERE id IN (${placeholders})`)
|
||||||
|
.get(...formatIds) as { count: number };
|
||||||
|
if (row.count !== formatIds.length) throw new Error('INVALID_TOOL_FORMAT');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: clean(input.name, 120, true),
|
||||||
|
startUrl: validUrl(clean(input.startUrl, 2_000)),
|
||||||
|
description: clean(input.description, 2_000),
|
||||||
|
iconName: (() => {
|
||||||
|
const iconName = clean(input.iconName, 120, true);
|
||||||
|
if (!classicRegularIconNames.has(iconName)) throw new Error('INVALID_ICON');
|
||||||
|
return iconName;
|
||||||
|
})(),
|
||||||
|
isActive: Boolean(input.isActive),
|
||||||
|
formatIds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTools(includeInactive = false): Tool[] {
|
||||||
|
const database = getDatabase();
|
||||||
|
const rows = database
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, name, start_url, description, icon_name, is_active FROM tools
|
||||||
|
${includeInactive ? '' : 'WHERE is_active = 1'} ORDER BY is_active DESC, name`
|
||||||
|
)
|
||||||
|
.all() as {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
start_url: string;
|
||||||
|
description: string;
|
||||||
|
icon_name: string;
|
||||||
|
is_active: number;
|
||||||
|
}[];
|
||||||
|
const formats = database.prepare(
|
||||||
|
'SELECT format_id FROM tool_formats WHERE tool_id = ? ORDER BY format_id'
|
||||||
|
);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
startUrl: row.start_url,
|
||||||
|
description: row.description,
|
||||||
|
iconName: row.icon_name,
|
||||||
|
isActive: row.is_active === 1,
|
||||||
|
formatIds: (formats.all(row.id) as { format_id: string }[]).map((item) => item.format_id)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceFormats(toolId: string, formatIds: string[]) {
|
||||||
|
const database = getDatabase();
|
||||||
|
database.prepare('DELETE FROM tool_formats WHERE tool_id = ?').run(toolId);
|
||||||
|
const insert = database.prepare('INSERT INTO tool_formats (tool_id, format_id) VALUES (?, ?)');
|
||||||
|
for (const formatId of formatIds) insert.run(toolId, formatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTool(rawInput: ToolInput) {
|
||||||
|
const input = validated(rawInput);
|
||||||
|
const database = getDatabase();
|
||||||
|
const id = randomUUID();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
database.transaction(() => {
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO tools (id, name, start_url, description, icon_name, is_active, created_at_utc, updated_at_utc)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
id,
|
||||||
|
input.name,
|
||||||
|
input.startUrl,
|
||||||
|
input.description,
|
||||||
|
input.iconName,
|
||||||
|
input.isActive ? 1 : 0,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
replaceFormats(id, input.formatIds);
|
||||||
|
})();
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof Error && cause.message.includes('UNIQUE')) {
|
||||||
|
throw new Error('TOOL_EXISTS', { cause });
|
||||||
|
}
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTool(id: string, rawInput: ToolInput) {
|
||||||
|
const input = validated(rawInput);
|
||||||
|
const database = getDatabase();
|
||||||
|
try {
|
||||||
|
database.transaction(() => {
|
||||||
|
const result = database
|
||||||
|
.prepare(
|
||||||
|
`UPDATE tools SET name = ?, start_url = ?, description = ?, icon_name = ?, is_active = ?, updated_at_utc = ?
|
||||||
|
WHERE id = ?`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
input.name,
|
||||||
|
input.startUrl,
|
||||||
|
input.description,
|
||||||
|
input.iconName,
|
||||||
|
input.isActive ? 1 : 0,
|
||||||
|
new Date().toISOString(),
|
||||||
|
id
|
||||||
|
);
|
||||||
|
if (result.changes !== 1) throw new Error('NOT_FOUND');
|
||||||
|
replaceFormats(id, input.formatIds);
|
||||||
|
})();
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof Error && cause.message.includes('UNIQUE')) {
|
||||||
|
throw new Error('TOOL_EXISTS', { cause });
|
||||||
|
}
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +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:
|
||||||
presentationMode: url.pathname.startsWith('/heinzlive/')
|
url.pathname.startsWith('/heinzlive/') || url.pathname.startsWith('/freigabe/')
|
||||||
|
? null
|
||||||
|
: locals.user,
|
||||||
|
presentationMode: url.pathname.startsWith('/heinzlive/'),
|
||||||
|
guestMode: url.pathname.startsWith('/freigabe/'),
|
||||||
|
liveControllerMode: url.pathname === '/live'
|
||||||
});
|
});
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import NavIcon from '#lib/components/NavIcon.svelte';
|
import NavIcon from '#lib/components/NavIcon.svelte';
|
||||||
|
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||||
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
|
||||||
|
|
||||||
let { children, data } = $props();
|
let { children, data } = $props();
|
||||||
@@ -33,6 +34,19 @@
|
|||||||
|
|
||||||
{#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}
|
||||||
|
<main id="main-content" class="live-controller-shell">{@render children()}</main>
|
||||||
{:else if data.user}
|
{:else if data.user}
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
@@ -84,7 +98,9 @@
|
|||||||
<span>Sendewerk</span>
|
<span>Sendewerk</span>
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/logout">
|
<form method="POST" action="/logout">
|
||||||
<button class="mobile-logout" type="submit" aria-label="Abmelden">↗</button>
|
<button class="mobile-logout" type="submit" aria-label="Abmelden"
|
||||||
|
><AppIcon name="right-from-bracket" /></button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,11 +1,21 @@
|
|||||||
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 } 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 { pieces: listPieces({ statuses: ['archived'] }) };
|
return {
|
||||||
|
completedShows: listShows(['completed']),
|
||||||
|
duplicateCommandId: randomUUID(),
|
||||||
|
pieces: listPieces({ statuses: ['archived'] }),
|
||||||
|
deletedPieces: listPieces({
|
||||||
|
statuses: ['inbox', 'preparing', 'ready', 'archived'],
|
||||||
|
deleted: true
|
||||||
|
})
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@@ -18,5 +28,29 @@ 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.' });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
restoreDeleted: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
try {
|
||||||
|
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), false);
|
||||||
|
return { success: 'Gelöschtes Piece wurde wiederhergestellt.' };
|
||||||
|
} catch {
|
||||||
|
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;
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
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>
|
||||||
@@ -8,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>
|
||||||
@@ -28,8 +50,29 @@
|
|||||||
</article>{/each}
|
</article>{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}<section class="quiet-panel planned-panel">
|
{:else}<section class="quiet-panel planned-panel">
|
||||||
<div class="planned-icon" aria-hidden="true">□</div>
|
<div class="planned-icon" aria-hidden="true"><AppIcon name="box-archive" /></div>
|
||||||
<h2>Keine Pieces archiviert</h2>
|
<h2>Keine Pieces archiviert</h2>
|
||||||
<p>Archivierte Inhalte lassen sich hier später wiederherstellen.</p>
|
<p>Archivierte Inhalte lassen sich hier später wiederherstellen.</p>
|
||||||
</section>{/if}
|
</section>{/if}
|
||||||
|
<section class="deleted-pieces" aria-labelledby="deleted-pieces-title">
|
||||||
|
<h2 id="deleted-pieces-title">Gelöschte Pieces</h2>
|
||||||
|
<p class="quiet-copy">
|
||||||
|
Diese Einträge bleiben wiederherstellbar; bestehende Sendungsfassungen werden nicht verändert.
|
||||||
|
</p>
|
||||||
|
{#if data.deletedPieces.length}<div class="archive-list">
|
||||||
|
{#each data.deletedPieces as piece (piece.id)}<article>
|
||||||
|
<div>
|
||||||
|
<h2>{piece.title}</h2>
|
||||||
|
{#if piece.summary}<p>{piece.summary}</p>{/if}
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="?/restoreDeleted">
|
||||||
|
<input type="hidden" name="pieceId" value={piece.id} /><input
|
||||||
|
type="hidden"
|
||||||
|
name="revision"
|
||||||
|
value={piece.revision}
|
||||||
|
/><button class="secondary-button" type="submit">Wiederherstellen</button>
|
||||||
|
</form>
|
||||||
|
</article>{/each}
|
||||||
|
</div>{:else}<p class="context-empty">Keine gelöschten Pieces.</p>{/if}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 } 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 = {
|
||||||
@@ -26,5 +31,49 @@ export const actions = {
|
|||||||
: 'Ein Titel ist erforderlich.'
|
: 'Ein Titel ist erforderlich.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
delete: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
try {
|
||||||
|
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), true);
|
||||||
|
return { deleted: true };
|
||||||
|
} catch {
|
||||||
|
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, {
|
||||||
|
intent: 'feedImport',
|
||||||
|
feedError: 'Mindestens einen Beitrag auswählen.'
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const pieceIds = importFeedEntries(entryIds, data.getAll('categoryIds').map(String));
|
||||||
|
return {
|
||||||
|
intent: 'feedImport',
|
||||||
|
feedSuccess: `${pieceIds.length} ${pieceIds.length === 1 ? 'Beitrag' : 'Beiträge'} in den Eingang übernommen.`
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return fail(400, {
|
||||||
|
intent: 'feedImport',
|
||||||
|
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, { intent: 'feedDiscard', feedError: 'Mindestens einen Beitrag auswählen.' });
|
||||||
|
const count = discardFeedEntries(entryIds);
|
||||||
|
return {
|
||||||
|
intent: 'feedDiscard',
|
||||||
|
feedSuccess: `${count} ${count === 1 ? 'Beitrag' : 'Beiträge'} verworfen.`
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||||
|
import RssSelectionDialog from '#lib/components/RssSelectionDialog.svelte';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
|
let rssDialog: { showModal: () => void };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Eingang | Sendewerk</title></svelte:head>
|
<svelte:head><title>Eingang | Sendewerk</title></svelte:head>
|
||||||
@@ -42,22 +45,89 @@
|
|||||||
<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 rss-inbox-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">RSS-Importeingang</p>
|
||||||
|
<h2>RSS-Beiträge</h2>
|
||||||
|
</div>
|
||||||
|
<span class="catalog-entry-icon"><AppIcon name="rss" /></span>
|
||||||
|
</div>
|
||||||
|
<p class="catalog-note">
|
||||||
|
{data.feedEntries.length}
|
||||||
|
{data.feedEntries.length === 1 ? 'Beitrag ist' : 'Beiträge sind'}
|
||||||
|
verfügbar. Erst deine ausdrückliche Auswahl übernimmt sie in den redaktionellen Eingang.
|
||||||
|
</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}
|
||||||
|
<button
|
||||||
|
class="compact-add-button"
|
||||||
|
type="button"
|
||||||
|
disabled={!data.feedEntries.length}
|
||||||
|
onclick={() => rssDialog.showModal()}
|
||||||
|
>
|
||||||
|
<AppIcon name="list" />
|
||||||
|
{data.feedEntries.length ? 'RSS-Beiträge auswählen' : 'Keine neuen RSS-Beiträge'}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
<RssSelectionDialog
|
||||||
|
bind:this={rssDialog}
|
||||||
|
entries={data.feedEntries}
|
||||||
|
categories={data.taxonomy.categories}
|
||||||
|
{form}
|
||||||
|
/>
|
||||||
{#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>
|
||||||
{#each data.pieces as piece (piece.id)}<a
|
{#each data.pieces as piece (piece.id)}<article class="inbox-item">
|
||||||
class="inbox-item"
|
<a href={resolve('/fundus/[id]', { id: piece.id })}
|
||||||
href={resolve('/fundus/[id]', { id: piece.id })}
|
|
||||||
><span
|
><span
|
||||||
><strong>{piece.title}</strong><small
|
><strong>{piece.title}</strong><small
|
||||||
>{piece.sourceCount ? `${piece.sourceCount} Quelle` : 'Gedanke ohne Link'}</small
|
>{piece.sourceCount ? `${piece.sourceCount} Quelle` : 'Gedanke ohne Link'}</small
|
||||||
></span
|
></span
|
||||||
><span>Ausarbeiten →</span></a
|
><span>Ausarbeiten <AppIcon name="arrow-right" /></span></a
|
||||||
>{/each}
|
>
|
||||||
|
<details class="piece-actions">
|
||||||
|
<summary aria-label={`Aktionen für ${piece.title}`}><AppIcon name="ellipsis" /></summary
|
||||||
|
>
|
||||||
|
<div class="piece-actions-popover">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/delete"
|
||||||
|
onsubmit={(event) =>
|
||||||
|
!confirm(`„${piece.title}“ löschen?`) && event.preventDefault()}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="pieceId" value={piece.id} /><input
|
||||||
|
type="hidden"
|
||||||
|
name="revision"
|
||||||
|
value={piece.revision}
|
||||||
|
/>
|
||||||
|
<button class="text-button danger-text" type="submit">Piece löschen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</article>{/each}
|
||||||
</section>
|
</section>
|
||||||
{:else}<section class="quiet-panel planned-panel">
|
{:else}<section class="quiet-panel planned-panel">
|
||||||
<div class="planned-icon" aria-hidden="true">+</div>
|
<div class="planned-icon" aria-hidden="true"><AppIcon name="plus" /></div>
|
||||||
<h2>Eingang ist leer</h2>
|
<h2>Eingang ist leer</h2>
|
||||||
<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>
|
||||||
|
.rss-inbox-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-block: 1.5rem;
|
||||||
|
padding: clamp(0.85rem, 2vw, 1.2rem);
|
||||||
|
}
|
||||||
|
.rss-inbox-panel .panel-heading {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.rss-inbox-panel .catalog-note {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</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;
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -3,12 +3,14 @@ import type { Actions, PageServerLoad } from './$types';
|
|||||||
import { requireUser } from '#lib/server/auth.js';
|
import { requireUser } from '#lib/server/auth.js';
|
||||||
import {
|
import {
|
||||||
createPiece,
|
createPiece,
|
||||||
|
deletePieceTemplate,
|
||||||
duplicatePiece,
|
duplicatePiece,
|
||||||
getTaxonomy,
|
getTaxonomy,
|
||||||
listPieces,
|
listPieces,
|
||||||
pieceTemplates,
|
listPieceTemplates,
|
||||||
setPieceArchived,
|
setPieceArchived,
|
||||||
type PieceTemplateId
|
setPieceDeleted,
|
||||||
|
setPieceFavorite
|
||||||
} from '#lib/server/content.js';
|
} from '#lib/server/content.js';
|
||||||
import { getPieceUsageContexts } from '#lib/server/shows.js';
|
import { getPieceUsageContexts } from '#lib/server/shows.js';
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ export const load: PageServerLoad = ({ locals, url }) => {
|
|||||||
return {
|
return {
|
||||||
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
|
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
|
||||||
taxonomy: getTaxonomy(),
|
taxonomy: getTaxonomy(),
|
||||||
templates: pieceTemplates,
|
templates: listPieceTemplates(),
|
||||||
filters
|
filters
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -57,12 +59,22 @@ export const actions = {
|
|||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
let id: string;
|
let id: string;
|
||||||
try {
|
try {
|
||||||
id = createPiece(String(data.get('template') || 'blank') as PieceTemplateId);
|
id = createPiece(String(data.get('template') || 'blank'));
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
return fail(400, { intent: 'create', error: actionError(cause) });
|
return fail(400, { intent: 'create', error: actionError(cause) });
|
||||||
}
|
}
|
||||||
redirect(303, `/fundus/${id}`);
|
redirect(303, `/fundus/${id}`);
|
||||||
},
|
},
|
||||||
|
deleteTemplate: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
try {
|
||||||
|
deletePieceTemplate(String(data.get('templateId') || ''), Number(data.get('revision')));
|
||||||
|
return { intent: 'deleteTemplate', success: 'Vorlage gelöscht.' };
|
||||||
|
} catch (cause) {
|
||||||
|
return fail(409, { intent: 'deleteTemplate', error: actionError(cause) });
|
||||||
|
}
|
||||||
|
},
|
||||||
duplicate: async ({ locals, request }) => {
|
duplicate: async ({ locals, request }) => {
|
||||||
requireUser(locals.user);
|
requireUser(locals.user);
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
@@ -83,5 +95,28 @@ export const actions = {
|
|||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
return fail(409, { intent: 'archive', error: actionError(cause) });
|
return fail(409, { intent: 'archive', error: actionError(cause) });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
favorite: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
try {
|
||||||
|
setPieceFavorite(String(data.get('pieceId') || ''), data.get('favorite') === '1');
|
||||||
|
return { intent: 'favorite', success: 'Favorit aktualisiert.' };
|
||||||
|
} catch (cause) {
|
||||||
|
return fail(404, { intent: 'favorite', error: actionError(cause) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
delete: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
try {
|
||||||
|
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), true);
|
||||||
|
return {
|
||||||
|
intent: 'delete',
|
||||||
|
success: 'Piece gelöscht. Bestehende Sendungsfassungen bleiben erhalten.'
|
||||||
|
};
|
||||||
|
} catch (cause) {
|
||||||
|
return fail(409, { intent: 'delete', error: actionError(cause) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
import { goto, replaceState } from '$app/navigation';
|
import { goto, replaceState } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
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 { formatClock } from '#lib/format.js';
|
import { formatClock } from '#lib/format.js';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@
|
|||||||
let visiblePieces = $derived(
|
let visiblePieces = $derived(
|
||||||
data.pieces.filter(
|
data.pieces.filter(
|
||||||
(piece) =>
|
(piece) =>
|
||||||
(showUsedPieces || piece.sentHistory.length === 0) &&
|
(showUsedPieces || piece.sentHistory.length === 0 || piece.isFavorite) &&
|
||||||
(showPlannedPieces || !piece.isPlannedInActiveShow)
|
(showPlannedPieces || !piece.isPlannedInActiveShow)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -59,26 +61,49 @@
|
|||||||
<h1>Fundus</h1>
|
<h1>Fundus</h1>
|
||||||
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
|
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fundus-heading-actions">
|
||||||
<div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}>
|
<div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}>
|
||||||
<strong>{visiblePieces.length}</strong><span>Inhalte</span>
|
<strong>{visiblePieces.length}</strong><span>Inhalte</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
{#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}
|
|
||||||
|
|
||||||
<details class="template-picker">
|
<details class="template-picker">
|
||||||
<summary>Neues Piece anlegen</summary>
|
<summary><AppIcon name="plus" /> Neues Piece</summary>
|
||||||
|
<div class="template-popover">
|
||||||
<div class="template-grid">
|
<div class="template-grid">
|
||||||
{#each data.templates as template (template.id)}
|
{#each data.templates as template (template.id)}
|
||||||
<form method="POST" action="?/create">
|
<form method="POST" action="?/create">
|
||||||
<input type="hidden" name="template" value={template.id} />
|
<input type="hidden" name="template" value={template.id} />
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
><strong>{template.name}</strong><span>{template.description}</span></button
|
><AppIcon name={template.iconName} size="1.35rem" /><strong
|
||||||
|
>{template.name}</strong
|
||||||
|
><span>{template.description}</span></button
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{#if data.templates.some((template) => template.id !== 'blank')}<details
|
||||||
|
class="template-management"
|
||||||
|
>
|
||||||
|
<summary>Vorlagen verwalten</summary>
|
||||||
|
{#each data.templates.filter((template) => template.id !== 'blank') as template (template.id)}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteTemplate"
|
||||||
|
onsubmit={(event) =>
|
||||||
|
!confirm(`Vorlage „${template.name}“ löschen?`) && event.preventDefault()}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="templateId" value={template.id} />
|
||||||
|
<input type="hidden" name="revision" value={template.revision} />
|
||||||
|
<span>{template.name}</span>
|
||||||
|
<button class="text-button danger-text" type="submit">Löschen</button>
|
||||||
|
</form>
|
||||||
|
{/each}
|
||||||
|
</details>{/if}
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{#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}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
id="library-filters"
|
id="library-filters"
|
||||||
@@ -86,20 +111,26 @@
|
|||||||
method="GET"
|
method="GET"
|
||||||
aria-label="Fundus durchsuchen und filtern"
|
aria-label="Fundus durchsuchen und filtern"
|
||||||
>
|
>
|
||||||
|
<div class="fundus-search-row">
|
||||||
<label class="search-field"
|
<label class="search-field"
|
||||||
><span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span><input
|
><AppIcon name="magnifying-glass" /><span class="sr-only">Fundus durchsuchen</span><input
|
||||||
name="q"
|
name="q"
|
||||||
type="search"
|
type="search"
|
||||||
value={data.filters.query}
|
value={data.filters.query}
|
||||||
placeholder="Titel, Quelle oder Thema suchen"
|
placeholder="Titel, Quelle oder Thema suchen"
|
||||||
/></label
|
/></label
|
||||||
>
|
>
|
||||||
|
<button class="secondary-button" type="submit">Suchen</button>
|
||||||
|
<details class="filter-picker">
|
||||||
|
<summary><AppIcon name="list-check" /> Weitere Filter</summary>
|
||||||
|
<div class="filter-panel">
|
||||||
<label
|
<label
|
||||||
><span>Status</span><select name="status"
|
><span>Status</span><select name="status"
|
||||||
><option value="">Alle</option><option
|
><option value="">Alle</option><option
|
||||||
value="ready"
|
value="ready"
|
||||||
selected={data.filters.status === 'ready'}>Sendefertig</option
|
selected={data.filters.status === 'ready'}>Sendefertig</option
|
||||||
><option value="preparing" selected={data.filters.status === 'preparing'}>In Arbeit</option
|
><option value="preparing" selected={data.filters.status === 'preparing'}
|
||||||
|
>In Arbeit</option
|
||||||
></select
|
></select
|
||||||
></label
|
></label
|
||||||
>
|
>
|
||||||
@@ -121,22 +152,19 @@
|
|||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
><span>Abschnitt</span><select name="category"
|
><span>Abschnitt</span><select name="category"
|
||||||
><option value="">Alle</option>{#each data.taxonomy.categories as item (item.id)}<option
|
><option value="">Alle</option
|
||||||
|
>{#each data.taxonomy.categories as item (item.id)}<option
|
||||||
value={item.id}
|
value={item.id}
|
||||||
selected={data.filters.categoryId === item.id}>{item.name}</option
|
selected={data.filters.categoryId === item.id}>{item.name}</option
|
||||||
>{/each}</select
|
>{/each}</select
|
||||||
></label
|
></label
|
||||||
>
|
>
|
||||||
<button class="secondary-button" type="submit">Anwenden</button>
|
|
||||||
<button class="text-button" type="button" onclick={resetFilters}>Zurücksetzen</button>
|
|
||||||
</form>
|
|
||||||
<div class="library-filter-options" aria-label="Fundusauswahl erweitern">
|
<div class="library-filter-options" aria-label="Fundusauswahl erweitern">
|
||||||
<label class="filter-checkbox">
|
<label class="filter-checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="includeUsed"
|
name="includeUsed"
|
||||||
value="1"
|
value="1"
|
||||||
form="library-filters"
|
|
||||||
checked={showUsedPieces}
|
checked={showUsedPieces}
|
||||||
onchange={(event) => setSourceFilter('includeUsed', event.currentTarget.checked)}
|
onchange={(event) => setSourceFilter('includeUsed', event.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
@@ -147,13 +175,20 @@
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="includePlanned"
|
name="includePlanned"
|
||||||
value="1"
|
value="1"
|
||||||
form="library-filters"
|
|
||||||
checked={showPlannedPieces}
|
checked={showPlannedPieces}
|
||||||
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)}
|
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
<span>Auch eingeplante Inhalte zeigen</span>
|
<span>Auch eingeplante Inhalte zeigen</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button class="secondary-button" type="submit">Filter anwenden</button>
|
||||||
|
<button class="text-button" type="button" onclick={resetFilters}>Zurücksetzen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
{#if visiblePieces.length}
|
{#if visiblePieces.length}
|
||||||
<div class="piece-grid">
|
<div class="piece-grid">
|
||||||
@@ -162,16 +197,29 @@
|
|||||||
class="piece-card"
|
class="piece-card"
|
||||||
style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`}
|
style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`}
|
||||||
>
|
>
|
||||||
|
<form method="POST" action="?/favorite" class="piece-favorite-form" use:enhance>
|
||||||
|
<input type="hidden" name="pieceId" value={piece.id} />
|
||||||
|
<input type="hidden" name="favorite" value={piece.isFavorite ? '0' : '1'} />
|
||||||
|
<button
|
||||||
|
class="piece-favorite"
|
||||||
|
class:active={piece.isFavorite}
|
||||||
|
type="submit"
|
||||||
|
aria-label={piece.isFavorite
|
||||||
|
? `${piece.title} aus Favoriten entfernen`
|
||||||
|
: `${piece.title} als Favorit markieren`}
|
||||||
|
aria-pressed={piece.isFavorite}><AppIcon name="star" /></button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
{#if piece.mediaId}<img
|
{#if piece.mediaId}<img
|
||||||
class="piece-thumbnail"
|
class="piece-thumbnail"
|
||||||
src={`/media/${piece.mediaId}`}
|
src={`/media/${piece.mediaId}`}
|
||||||
alt=""
|
alt=""
|
||||||
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true"
|
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true">
|
||||||
>S</span
|
<AppIcon name={piece.topics[0]?.iconName || 'newspaper'} size="2rem" />
|
||||||
>{/if}
|
</span>{/if}
|
||||||
<div class="piece-primary">
|
<div class="piece-primary">
|
||||||
<h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2>
|
<h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2>
|
||||||
<p>{piece.summary || 'Noch keine Kurzbeschreibung.'}</p>
|
{#if piece.summary}<p>{piece.summary}</p>{/if}
|
||||||
<PieceUsageHistory history={piece.sentHistory} />
|
<PieceUsageHistory history={piece.sentHistory} />
|
||||||
</div>
|
</div>
|
||||||
<div class="piece-classification">
|
<div class="piece-classification">
|
||||||
@@ -179,22 +227,26 @@
|
|||||||
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
|
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
|
||||||
>
|
>
|
||||||
<div class="piece-tags">
|
<div class="piece-tags">
|
||||||
<span>{piece.format}</span>{#each piece.topics as topic (topic.id)}<span
|
{#if piece.formatId}<span>{piece.format}</span
|
||||||
>{topic.name}</span
|
>{/if}{#each piece.topics as topic (topic.id)}<span>{topic.name}</span>{/each}
|
||||||
>{/each}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="piece-state">
|
<div class="piece-state">
|
||||||
<span class:ready={piece.status === 'ready'} class="status-label"
|
<span class:ready={piece.status === 'ready'} class="status-label"
|
||||||
>{statusLabel[piece.status]}</span
|
>{statusLabel[piece.status]}</span
|
||||||
><span class="piece-facts"
|
>{#if piece.durationSeconds || piece.sourceCount}<span class="piece-facts"
|
||||||
><span><b>{formatClock(piece.durationSeconds)}</b> geplant</span><span
|
>{#if piece.durationSeconds}<span
|
||||||
|
><b>{formatClock(piece.durationSeconds)}</b> geplant</span
|
||||||
|
>{/if}{#if piece.sourceCount}<span
|
||||||
>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span
|
>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span
|
||||||
></span
|
>{/if}</span
|
||||||
|
>{/if}
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="piece-actions">
|
<details class="piece-actions">
|
||||||
<a class="text-link" href={resolve('/fundus/[id]', { id: piece.id })}>Bearbeiten</a>
|
<summary aria-label={`Aktionen für ${piece.title}`}><AppIcon name="ellipsis" /></summary
|
||||||
|
>
|
||||||
|
<div class="piece-actions-popover">
|
||||||
<form method="POST" action="?/duplicate">
|
<form method="POST" action="?/duplicate">
|
||||||
<input type="hidden" name="pieceId" value={piece.id} /><button
|
<input type="hidden" name="pieceId" value={piece.id} /><button
|
||||||
class="text-button"
|
class="text-button"
|
||||||
@@ -208,13 +260,29 @@
|
|||||||
value={piece.revision}
|
value={piece.revision}
|
||||||
/><button class="text-button danger-text" type="submit">Archivieren</button>
|
/><button class="text-button danger-text" type="submit">Archivieren</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/delete"
|
||||||
|
onsubmit={(event) =>
|
||||||
|
!confirm(
|
||||||
|
`Piece „${piece.title}“ löschen? Bestehende Sendungsfassungen bleiben erhalten.`
|
||||||
|
) && event.preventDefault()}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="pieceId" value={piece.id} /><input
|
||||||
|
type="hidden"
|
||||||
|
name="revision"
|
||||||
|
value={piece.revision}
|
||||||
|
/>
|
||||||
|
<button class="text-button danger-text" type="submit">Piece löschen</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<span aria-hidden="true">⌕</span>
|
<span aria-hidden="true"><AppIcon name="magnifying-glass" /></span>
|
||||||
<h2>Kein passendes Piece</h2>
|
<h2>Kein passendes Piece</h2>
|
||||||
<p>Andere Filter wählen oder ein neues Piece anlegen.</p>
|
<p>Andere Filter wählen oder ein neues Piece anlegen.</p>
|
||||||
<a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a>
|
<a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a>
|
||||||
|
|||||||
@@ -6,26 +6,37 @@ import {
|
|||||||
duplicatePiece,
|
duplicatePiece,
|
||||||
getPiece,
|
getPiece,
|
||||||
getTaxonomy,
|
getTaxonomy,
|
||||||
|
listPieceTemplates,
|
||||||
loadPiecePresentationMetadata,
|
loadPiecePresentationMetadata,
|
||||||
savePiece,
|
savePiece,
|
||||||
|
savePieceAsTemplate,
|
||||||
setPieceArchived,
|
setPieceArchived,
|
||||||
|
setPieceDeleted,
|
||||||
|
setPieceFavorite,
|
||||||
uploadPieceImage,
|
uploadPieceImage,
|
||||||
type EditablePieceStatus,
|
type EditablePieceStatus,
|
||||||
type PieceInput,
|
type PieceInput,
|
||||||
type PieceSourceInput
|
type PieceSourceInput
|
||||||
} from '#lib/server/content.js';
|
} from '#lib/server/content.js';
|
||||||
import { renderMarkdown } from '#lib/server/markdown.js';
|
import { renderMarkdown } from '#lib/server/markdown.js';
|
||||||
|
import { listTools } from '#lib/server/tools.js';
|
||||||
|
|
||||||
function renderedFields(
|
function renderedFields(
|
||||||
piece: Pick<
|
piece: Pick<
|
||||||
PieceInput,
|
PieceInput,
|
||||||
'intro' | 'researchText' | 'moderationNotes' | 'transitionText' | 'presentationText'
|
| 'intro'
|
||||||
|
| 'researchText'
|
||||||
|
| 'moderationNotes'
|
||||||
|
| 'interactionText'
|
||||||
|
| 'transitionText'
|
||||||
|
| 'presentationText'
|
||||||
>
|
>
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
intro: renderMarkdown(piece.intro),
|
intro: renderMarkdown(piece.intro),
|
||||||
research: renderMarkdown(piece.researchText),
|
research: renderMarkdown(piece.researchText),
|
||||||
moderation: renderMarkdown(piece.moderationNotes),
|
moderation: renderMarkdown(piece.moderationNotes),
|
||||||
|
interaction: renderMarkdown(piece.interactionText ?? ''),
|
||||||
transition: renderMarkdown(piece.transitionText),
|
transition: renderMarkdown(piece.transitionText),
|
||||||
presentation: renderMarkdown(piece.presentationText)
|
presentation: renderMarkdown(piece.presentationText)
|
||||||
};
|
};
|
||||||
@@ -55,15 +66,21 @@ function pieceInput(data: FormData): PieceInput {
|
|||||||
timecodeSeconds: timecode ? Number(timecode) : null
|
timecodeSeconds: timecode ? Number(timecode) : null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const toolIds = data.getAll('toolId').map(String);
|
||||||
|
const toolWorkUrls = data.getAll('toolWorkUrl').map(String);
|
||||||
|
const toolNotes = data.getAll('toolNote').map(String);
|
||||||
return {
|
return {
|
||||||
title: text(data, 'title'),
|
title: text(data, 'title'),
|
||||||
|
outputTitle: text(data, 'outputTitle'),
|
||||||
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'),
|
||||||
moderationNotes: text(data, 'moderationNotes'),
|
moderationNotes: text(data, 'moderationNotes'),
|
||||||
|
interactionText: text(data, 'interactionText'),
|
||||||
transitionText: text(data, 'transitionText'),
|
transitionText: text(data, 'transitionText'),
|
||||||
presentationText: text(data, 'presentationText'),
|
presentationText: text(data, 'presentationText'),
|
||||||
presentationMode: text(data, 'presentationMode') as PieceInput['presentationMode'],
|
presentationMode: text(data, 'presentationMode') as PieceInput['presentationMode'],
|
||||||
@@ -80,9 +97,15 @@ function pieceInput(data: FormData): PieceInput {
|
|||||||
presentationDescriptionManual: text(data, 'presentationDescriptionManual') === '1',
|
presentationDescriptionManual: text(data, 'presentationDescriptionManual') === '1',
|
||||||
presentationImageManual: text(data, 'presentationImageManual') === '1',
|
presentationImageManual: text(data, 'presentationImageManual') === '1',
|
||||||
presentationMetadataFetchedAtUtc: text(data, 'presentationMetadataFetchedAtUtc') || null,
|
presentationMetadataFetchedAtUtc: text(data, 'presentationMetadataFetchedAtUtc') || null,
|
||||||
|
presentationMediaId: text(data, 'presentationMediaId') || null,
|
||||||
topicIds: data.getAll('topicIds').map(String),
|
topicIds: data.getAll('topicIds').map(String),
|
||||||
categoryIds: data.getAll('categoryIds').map(String),
|
categoryIds: data.getAll('categoryIds').map(String),
|
||||||
sources
|
sources,
|
||||||
|
tools: toolIds.map((toolId, index) => ({
|
||||||
|
toolId,
|
||||||
|
workUrl: toolWorkUrls[index] ?? '',
|
||||||
|
note: toolNotes[index] ?? ''
|
||||||
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +143,13 @@ export const load: PageServerLoad = ({ locals, params }) => {
|
|||||||
const piece = getPiece(params.id);
|
const piece = getPiece(params.id);
|
||||||
if (!piece) error(404, 'Piece nicht gefunden');
|
if (!piece) error(404, 'Piece nicht gefunden');
|
||||||
if (piece.status === 'archived') redirect(303, '/archiv');
|
if (piece.status === 'archived') redirect(303, '/archiv');
|
||||||
return { piece, taxonomy: getTaxonomy(), rendered: renderedFields(piece) };
|
return {
|
||||||
|
piece,
|
||||||
|
taxonomy: getTaxonomy(),
|
||||||
|
tools: listTools(true),
|
||||||
|
templates: listPieceTemplates().filter((template) => template.id !== 'blank'),
|
||||||
|
rendered: renderedFields(piece)
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@@ -161,6 +190,25 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
redirect(303, `/fundus/${params.id}`);
|
redirect(303, `/fundus/${params.id}`);
|
||||||
},
|
},
|
||||||
|
saveTemplate: async ({ locals, params, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
const reference = text(data, 'templateRef');
|
||||||
|
const [templateId, revision] = reference.split('|');
|
||||||
|
try {
|
||||||
|
savePieceAsTemplate(
|
||||||
|
params.id,
|
||||||
|
text(data, 'templateName'),
|
||||||
|
text(data, 'templateDescription'),
|
||||||
|
templateId || undefined,
|
||||||
|
revision ? Number(revision) : undefined
|
||||||
|
);
|
||||||
|
return { intent: 'saveTemplate', success: 'Vorlage gespeichert.' };
|
||||||
|
} catch (cause) {
|
||||||
|
const problem = saveError(cause);
|
||||||
|
return fail(problem.status, { intent: 'saveTemplate', ...problem });
|
||||||
|
}
|
||||||
|
},
|
||||||
upload: async ({ locals, params, request }) => {
|
upload: async ({ locals, params, request }) => {
|
||||||
requireUser(locals.user);
|
requireUser(locals.user);
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
@@ -168,7 +216,13 @@ export const actions = {
|
|||||||
if (!(image instanceof File))
|
if (!(image instanceof File))
|
||||||
return fail(400, { intent: 'upload', error: 'Bitte ein Bild auswählen.' });
|
return fail(400, { intent: 'upload', error: 'Bitte ein Bild auswählen.' });
|
||||||
try {
|
try {
|
||||||
await uploadPieceImage(params.id, Number(data.get('revision')), image, text(data, 'altText'));
|
await uploadPieceImage(
|
||||||
|
params.id,
|
||||||
|
Number(data.get('revision')),
|
||||||
|
image,
|
||||||
|
text(data, 'altText'),
|
||||||
|
data.get('selectForPresentation') === '1'
|
||||||
|
);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
const code = cause instanceof Error ? cause.message : '';
|
const code = cause instanceof Error ? cause.message : '';
|
||||||
if (code === 'CONFLICT')
|
if (code === 'CONFLICT')
|
||||||
@@ -216,5 +270,25 @@ export const actions = {
|
|||||||
return fail(409, { intent: 'archive', error: saveError(cause).error });
|
return fail(409, { intent: 'archive', error: saveError(cause).error });
|
||||||
}
|
}
|
||||||
redirect(303, '/archiv');
|
redirect(303, '/archiv');
|
||||||
|
},
|
||||||
|
favorite: async ({ locals, params, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
try {
|
||||||
|
setPieceFavorite(params.id, data.get('favorite') === '1');
|
||||||
|
return { intent: 'favorite', success: true };
|
||||||
|
} catch (cause) {
|
||||||
|
return fail(404, { intent: 'favorite', error: saveError(cause).error });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
delete: async ({ locals, params, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
try {
|
||||||
|
setPieceDeleted(params.id, Number(data.get('revision')), true);
|
||||||
|
} catch (cause) {
|
||||||
|
return fail(409, { intent: 'delete', error: saveError(cause).error });
|
||||||
|
}
|
||||||
|
redirect(303, '/fundus');
|
||||||
}
|
}
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { onDestroy, onMount, untrack } from 'svelte';
|
import { onDestroy, onMount, untrack } from 'svelte';
|
||||||
import MarkdownEditorField from '#lib/components/MarkdownEditorField.svelte';
|
import MarkdownEditorField from '#lib/components/MarkdownEditorField.svelte';
|
||||||
|
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||||
|
import ChoicePicker from '#lib/components/ChoicePicker.svelte';
|
||||||
|
import InlineTitleEditor from '#lib/components/InlineTitleEditor.svelte';
|
||||||
import PieceEditorDialog from '#lib/components/PieceEditorDialog.svelte';
|
import PieceEditorDialog from '#lib/components/PieceEditorDialog.svelte';
|
||||||
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
|
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
|
||||||
import type { PresentationContent } from '#lib/server/presentation.js';
|
import type { PresentationContent } from '#lib/server/presentation.js';
|
||||||
@@ -14,6 +17,7 @@
|
|||||||
let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2))));
|
let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2))));
|
||||||
let revision = $state(untrack(() => data.piece.revision));
|
let revision = $state(untrack(() => data.piece.revision));
|
||||||
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
|
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
|
||||||
|
let pieceTools = $state(untrack(() => data.piece.tools.map((tool) => ({ ...tool }))));
|
||||||
let rendered = $state(untrack(() => ({ ...data.rendered })));
|
let rendered = $state(untrack(() => ({ ...data.rendered })));
|
||||||
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
|
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
|
||||||
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
|
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
|
||||||
@@ -42,9 +46,17 @@
|
|||||||
? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id })
|
? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id })
|
||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
let previewContent = $derived<PresentationContent>({
|
let selectedMedia = $derived(
|
||||||
|
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
|
||||||
|
);
|
||||||
|
let previewContent = $derived<
|
||||||
|
PresentationContent & { images: { id: string; altText: string }[] }
|
||||||
|
>({
|
||||||
mode: draft.presentationMode,
|
mode: draft.presentationMode,
|
||||||
title: draft.title,
|
topics: data.taxonomy.topics
|
||||||
|
.filter((topic) => draft.topicIds.includes(topic.id))
|
||||||
|
.map((topic) => topic.name),
|
||||||
|
title: draft.outputTitle || draft.title,
|
||||||
html: rendered.presentation,
|
html: rendered.presentation,
|
||||||
source: featuredSource
|
source: featuredSource
|
||||||
? {
|
? {
|
||||||
@@ -64,8 +76,21 @@
|
|||||||
? `https://www.youtube-nocookie.com/embed/${draft.presentationProviderId}${featuredSource?.timecodeSeconds ? `?start=${featuredSource.timecodeSeconds}` : ''}`
|
? `https://www.youtube-nocookie.com/embed/${draft.presentationProviderId}${featuredSource?.timecodeSeconds ? `?start=${featuredSource.timecodeSeconds}` : ''}`
|
||||||
: 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 && ['text', 'image'].includes(draft.presentationMode)
|
||||||
|
? [{ id: selectedMedia.id, altText: selectedMedia.altText }]
|
||||||
|
: []
|
||||||
});
|
});
|
||||||
|
let presentationPreviewImage = $derived(
|
||||||
|
selectedMedia && ['text', 'image'].includes(draft.presentationMode)
|
||||||
|
? draft.presentationMode === 'text'
|
||||||
|
? '/media'
|
||||||
|
: `/media/${selectedMedia.id}`
|
||||||
|
: draft.presentationMode === 'link'
|
||||||
|
? sourcePreviewImage
|
||||||
|
: ''
|
||||||
|
);
|
||||||
let pendingHref: string | null = null;
|
let pendingHref: string | null = null;
|
||||||
let allowNavigation = false;
|
let allowNavigation = false;
|
||||||
|
|
||||||
@@ -96,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;
|
||||||
}
|
}
|
||||||
@@ -147,6 +172,31 @@
|
|||||||
scheduleSave();
|
scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeTool(index: number) {
|
||||||
|
pieceTools.splice(index, 1);
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTools(ids: string[]) {
|
||||||
|
for (const id of ids) {
|
||||||
|
if (pieceTools.some((tool) => tool.toolId === id)) continue;
|
||||||
|
const tool = data.tools.find((item) => item.id === id);
|
||||||
|
if (tool)
|
||||||
|
pieceTools.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
toolId: tool.id,
|
||||||
|
name: tool.name,
|
||||||
|
startUrl: tool.startUrl,
|
||||||
|
iconName: tool.iconName,
|
||||||
|
isActive: tool.isActive,
|
||||||
|
workUrl: '',
|
||||||
|
note: ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pieceTools = pieceTools.filter((tool) => ids.includes(tool.toolId));
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
function presentationSourceChanged() {
|
function presentationSourceChanged() {
|
||||||
draft.presentationMetadataFetchedAtUtc = null;
|
draft.presentationMetadataFetchedAtUtc = null;
|
||||||
draft.presentationProvider = '';
|
draft.presentationProvider = '';
|
||||||
@@ -209,19 +259,24 @@
|
|||||||
onrequestclose={requestClose}
|
onrequestclose={requestClose}
|
||||||
>
|
>
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
|
<div class="piece-editor-kicker">
|
||||||
<p class="eyebrow">Fundusfassung</p>
|
<p class="eyebrow">Fundusfassung</p>
|
||||||
<label class="piece-dialog-title">
|
<form method="POST" action="?/favorite" use:enhance>
|
||||||
<span class="sr-only">Titel</span>
|
<input type="hidden" name="favorite" value={data.piece.isFavorite ? '0' : '1'} />
|
||||||
<textarea
|
<button
|
||||||
|
class="piece-favorite"
|
||||||
|
class:active={data.piece.isFavorite}
|
||||||
|
type="submit"
|
||||||
|
aria-label={data.piece.isFavorite ? 'Aus Favoriten entfernen' : 'Als Favorit markieren'}
|
||||||
|
aria-pressed={data.piece.isFavorite}><AppIcon name="star" /></button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<InlineTitleEditor
|
||||||
form="piece-editor-form"
|
form="piece-editor-form"
|
||||||
name="title"
|
|
||||||
required
|
|
||||||
rows="1"
|
|
||||||
maxlength="200"
|
|
||||||
aria-label="Titel"
|
|
||||||
bind:value={draft.title}
|
bind:value={draft.title}
|
||||||
oninput={scheduleSave}></textarea>
|
oncommit={scheduleSave}
|
||||||
</label>
|
/>
|
||||||
<h1 class="sr-only" id="piece-editor-title">{draft.title}</h1>
|
<h1 class="sr-only" id="piece-editor-title">{draft.title}</h1>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
@@ -258,6 +313,13 @@
|
|||||||
rows={10}
|
rows={10}
|
||||||
bind:value={draft.moderationNotes}
|
bind:value={draft.moderationNotes}
|
||||||
/>
|
/>
|
||||||
|
<MarkdownEditorField
|
||||||
|
name="interactionText"
|
||||||
|
label="Interaktion"
|
||||||
|
placeholder="Welche Frage möchtest du dem Chat stellen?"
|
||||||
|
privateField
|
||||||
|
bind:value={draft.interactionText}
|
||||||
|
/>
|
||||||
<MarkdownEditorField
|
<MarkdownEditorField
|
||||||
name="transitionText"
|
name="transitionText"
|
||||||
label="Überleitung"
|
label="Überleitung"
|
||||||
@@ -290,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
|
||||||
@@ -307,17 +377,16 @@
|
|||||||
bind:value={draft.summary}></textarea></label
|
bind:value={draft.summary}></textarea></label
|
||||||
>
|
>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Themen</legend>
|
<legend>Themen</legend><ChoicePicker
|
||||||
<div class="choice-grid">
|
items={data.taxonomy.topics}
|
||||||
{#each data.taxonomy.topics as item (item.id)}<label
|
selectedIds={draft.topicIds}
|
||||||
><input
|
label="Themen"
|
||||||
type="checkbox"
|
fieldName="topicIds"
|
||||||
name="topicIds"
|
onselectionchange={(ids) => {
|
||||||
value={item.id}
|
draft.topicIds = ids;
|
||||||
bind:group={draft.topicIds}
|
scheduleSave();
|
||||||
/>{item.name}</label
|
}}
|
||||||
>{/each}
|
/>
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Vorgeschlagene Abschnittskategorien</legend>
|
<legend>Vorgeschlagene Abschnittskategorien</legend>
|
||||||
@@ -396,6 +465,63 @@
|
|||||||
</div>{/each}
|
</div>{/each}
|
||||||
</div>{:else}<p class="context-empty">Noch keine Quelle hinterlegt.</p>{/if}
|
</div>{:else}<p class="context-empty">Noch keine Quelle hinterlegt.</p>{/if}
|
||||||
</section>
|
</section>
|
||||||
|
<section class="piece-source-editor">
|
||||||
|
<div class="piece-subsection-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Arbeitsmittel</p>
|
||||||
|
<h3>Werkzeuge</h3>
|
||||||
|
</div>
|
||||||
|
<ChoicePicker
|
||||||
|
items={data.tools
|
||||||
|
.filter((tool) => tool.isActive)
|
||||||
|
.map((tool) => ({
|
||||||
|
id: tool.id,
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
iconName: tool.iconName,
|
||||||
|
meta: tool.formatIds.includes(draft.formatId ?? '') ? 'Passt zum Format' : ''
|
||||||
|
}))}
|
||||||
|
selectedIds={pieceTools.map((tool) => tool.toolId)}
|
||||||
|
label="Werkzeuge"
|
||||||
|
onselectionchange={selectTools}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if pieceTools.length}<div class="source-list">
|
||||||
|
{#each pieceTools as tool, index (tool.id)}<div
|
||||||
|
class="source-row tool-assignment-row"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="toolId" value={tool.toolId} />
|
||||||
|
<div class="tool-assignment-name">
|
||||||
|
<AppIcon name={tool.iconName || 'wrench'} /><span
|
||||||
|
><strong>{tool.name}</strong>{#if !tool.isActive}<small>Deaktiviert</small
|
||||||
|
>{/if}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
>Arbeitslink<input
|
||||||
|
name="toolWorkUrl"
|
||||||
|
type="url"
|
||||||
|
maxlength="2000"
|
||||||
|
placeholder={tool.startUrl || 'https://…'}
|
||||||
|
bind:value={tool.workUrl}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Notiz<input
|
||||||
|
name="toolNote"
|
||||||
|
maxlength="2000"
|
||||||
|
placeholder="Optional"
|
||||||
|
bind:value={tool.note}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="text-button danger-text"
|
||||||
|
type="button"
|
||||||
|
onclick={() => removeTool(index)}>Entfernen</button
|
||||||
|
>
|
||||||
|
</div>{/each}
|
||||||
|
</div>{:else}<p class="context-empty">Noch kein Werkzeug zugeordnet.</p>{/if}
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
|
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
|
||||||
@@ -413,15 +539,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="piece-presentation-layout">
|
<div class="piece-presentation-layout">
|
||||||
<div class="piece-presentation-controls">
|
<div class="piece-presentation-controls">
|
||||||
<MarkdownEditorField
|
<p class="field-label">Präsentationsart</p>
|
||||||
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"
|
||||||
@@ -430,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}
|
||||||
@@ -443,7 +584,7 @@
|
|||||||
></label
|
></label
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
>Titel<input
|
>{draft.presentationMode === 'video' ? 'Videotitel' : 'Kartentitel'}<input
|
||||||
name="presentationTitle"
|
name="presentationTitle"
|
||||||
maxlength="300"
|
maxlength="300"
|
||||||
placeholder="Optionaler Anzeigetitel"
|
placeholder="Optionaler Anzeigetitel"
|
||||||
@@ -451,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"
|
||||||
@@ -484,11 +633,25 @@
|
|||||||
>
|
>
|
||||||
</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
|
||||||
content={previewContent}
|
content={previewContent}
|
||||||
imageSrc={sourceImageUnavailable ? '' : sourcePreviewImage}
|
imageSrc={sourceImageUnavailable ? '' : presentationPreviewImage}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -510,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"
|
||||||
@@ -518,17 +682,44 @@
|
|||||||
</section>
|
</section>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'research'}>
|
<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">Private Dateien</p>
|
<p class="eyebrow">Hochgeladene Bilder</p>
|
||||||
<h3>Material</h3>
|
<h3>Präsentationsbild</h3>
|
||||||
</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}<div class="media-grid">
|
{#if draft.media.length && draft.presentationMode === 'text'}<label class="media-none"
|
||||||
{#each data.piece.media as media (media.id)}<figure>
|
><input
|
||||||
<img src={`/media/${media.id}`} alt={media.altText} />
|
type="radio"
|
||||||
|
name="presentationMediaChoice"
|
||||||
|
checked={!draft.presentationMediaId}
|
||||||
|
onchange={() => {
|
||||||
|
draft.presentationMediaId = null;
|
||||||
|
scheduleSave();
|
||||||
|
}}
|
||||||
|
/> Kein hochgeladenes Bild</label
|
||||||
|
>
|
||||||
|
<div class="media-grid">
|
||||||
|
{#each draft.media as media (media.id)}<figure>
|
||||||
|
<label class="media-presentation-choice"
|
||||||
|
><input
|
||||||
|
type="radio"
|
||||||
|
name="presentationMediaChoice"
|
||||||
|
checked={draft.presentationMediaId === media.id}
|
||||||
|
onchange={() => {
|
||||||
|
draft.presentationMediaId = media.id;
|
||||||
|
scheduleSave();
|
||||||
|
}}
|
||||||
|
/><img src={`/media/${media.id}`} alt={media.altText} /><span
|
||||||
|
>Als Präsentationsbild</span
|
||||||
|
></label
|
||||||
|
>
|
||||||
<figcaption>
|
<figcaption>
|
||||||
<span>{media.altText || media.originalName}</span>
|
<span>{media.altText || media.originalName}</span>
|
||||||
<form method="POST" action="?/deleteImage">
|
<form method="POST" action="?/deleteImage">
|
||||||
@@ -560,11 +751,33 @@
|
|||||||
><button class="secondary-button" type="submit" disabled={actionsBlocked}
|
><button class="secondary-button" type="submit" disabled={actionsBlocked}
|
||||||
>Bild hochladen</button
|
>Bild hochladen</button
|
||||||
>
|
>
|
||||||
|
<label class="upload-select"
|
||||||
|
><input type="checkbox" name="selectForPresentation" value="1" checked /> Direkt als Präsentationsbild
|
||||||
|
verwenden</label
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#snippet footer()}
|
{#snippet footer()}
|
||||||
<div class="piece-dialog-secondary-actions">
|
<div class="piece-dialog-secondary-actions">
|
||||||
|
<details class="template-save-menu">
|
||||||
|
<summary>Als Vorlage speichern</summary>
|
||||||
|
<form method="POST" action="?/saveTemplate">
|
||||||
|
<label
|
||||||
|
>Vorlage<select name="templateRef">
|
||||||
|
<option value="">Neue Vorlage</option>
|
||||||
|
{#each data.templates as template (template.id)}<option
|
||||||
|
value={`${template.id}|${template.revision}`}>{template.name}</option
|
||||||
|
>{/each}
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<label>Name<input name="templateName" required maxlength="120" /></label>
|
||||||
|
<label>Beschreibung<input name="templateDescription" maxlength="500" /></label>
|
||||||
|
<button class="text-button" type="submit" disabled={actionsBlocked}
|
||||||
|
>Vorlage speichern</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
<form method="POST" action="?/duplicate">
|
<form method="POST" action="?/duplicate">
|
||||||
<button class="text-button" type="submit" disabled={actionsBlocked}>Duplizieren</button>
|
<button class="text-button" type="submit" disabled={actionsBlocked}>Duplizieren</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -576,6 +789,19 @@
|
|||||||
disabled={actionsBlocked}>Archivieren</button
|
disabled={actionsBlocked}>Archivieren</button
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/delete"
|
||||||
|
onsubmit={(event) =>
|
||||||
|
!confirm('Piece löschen? Bestehende Sendungsfassungen bleiben erhalten.') &&
|
||||||
|
event.preventDefault()}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="revision" value={revision} /><button
|
||||||
|
class="text-button danger-text"
|
||||||
|
type="submit"
|
||||||
|
disabled={actionsBlocked}>Piece löschen</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="piece-dialog-primary-action">
|
<div class="piece-dialog-primary-action">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { requireUser } from '#lib/server/auth.js';
|
import { requireUser } from '#lib/server/auth.js';
|
||||||
import { getPiece } from '#lib/server/content.js';
|
import { getPiece, getTaxonomy } from '#lib/server/content.js';
|
||||||
import { renderMarkdown } from '#lib/server/markdown.js';
|
import { renderMarkdown } from '#lib/server/markdown.js';
|
||||||
import { buildEmbedUrl, type PresentationContent } from '#lib/server/presentation.js';
|
import { buildEmbedUrl, type PresentationContent } from '#lib/server/presentation.js';
|
||||||
|
|
||||||
@@ -18,7 +18,10 @@ export const load: PageServerLoad = ({ locals, params }) => {
|
|||||||
images: { id: string; altText: string }[];
|
images: { id: string; altText: string }[];
|
||||||
} = {
|
} = {
|
||||||
mode: piece.presentationMode,
|
mode: piece.presentationMode,
|
||||||
title: piece.title,
|
topics: getTaxonomy()
|
||||||
|
.topics.filter((topic) => piece.topicIds.includes(topic.id))
|
||||||
|
.map((topic) => topic.name),
|
||||||
|
title: piece.outputTitle || piece.title,
|
||||||
html: renderMarkdown(piece.presentationText),
|
html: renderMarkdown(piece.presentationText),
|
||||||
source,
|
source,
|
||||||
previewTitle: piece.presentationTitle,
|
previewTitle: piece.presentationTitle,
|
||||||
@@ -37,8 +40,10 @@ export const load: PageServerLoad = ({ locals, params }) => {
|
|||||||
piece.presentationProviderId,
|
piece.presentationProviderId,
|
||||||
source?.timecodeSeconds ?? null
|
source?.timecodeSeconds ?? null
|
||||||
),
|
),
|
||||||
sources: piece.sources,
|
sources: [],
|
||||||
images: piece.media.map((item) => ({ id: item.id, altText: item.altText }))
|
images: piece.media
|
||||||
|
.filter((item) => item.id === piece.presentationMediaId)
|
||||||
|
.map((item) => ({ id: item.id, altText: item.altText }))
|
||||||
};
|
};
|
||||||
return { pieceId: piece.id, content };
|
return { pieceId: piece.id, content };
|
||||||
};
|
};
|
||||||
@@ -14,8 +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.mode === 'text'
|
imageSrc={data.content.mode === 'text' && data.content.images.length
|
||||||
? '/media'
|
? '/media'
|
||||||
: `/fundus/${data.pieceId}/praesentation/preview-image`}
|
: data.content.mode === 'image' && data.content.images.length
|
||||||
|
? `/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), {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { getPresentationView } from '#lib/server/live.js';
|
import { getPresentationChannelView, presentationChannelAccess } from '#lib/server/live.js';
|
||||||
|
|
||||||
export const load: PageServerLoad = ({ params }) => {
|
export const load: PageServerLoad = ({ params, cookies }) => {
|
||||||
const view = getPresentationView(params.token);
|
const accessKey = cookies.get(`sendewerk-output-${params.token}`) ?? '';
|
||||||
if (!view) error(404, 'Präsentationsausgabe nicht gefunden');
|
const view = presentationChannelAccess(params.token, accessKey)
|
||||||
return { view };
|
? getPresentationChannelView(params.token)
|
||||||
|
: null;
|
||||||
|
return { view, channelId: params.token };
|
||||||
};
|
};
|
||||||
@@ -5,8 +5,10 @@
|
|||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
let view = $state(untrack(() => data.view));
|
let view = $state(untrack(() => data.view ?? { revision: -1, content: null }));
|
||||||
let connection = $state<'connected' | 'waiting' | 'interrupted'>('connected');
|
let connection = $state<'connected' | 'waiting' | 'interrupted'>(
|
||||||
|
untrack(() => (data.view ? 'connected' : 'waiting'))
|
||||||
|
);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
@@ -26,8 +28,22 @@
|
|||||||
}
|
}
|
||||||
if (!stopped) timer = setTimeout(refresh, connection === 'interrupted' ? 3000 : 1000);
|
if (!stopped) timer = setTimeout(refresh, connection === 'interrupted' ? 3000 : 1000);
|
||||||
}
|
}
|
||||||
connection = 'waiting';
|
async function connect() {
|
||||||
timer = setTimeout(refresh, 500);
|
const accessKey = decodeURIComponent(location.hash.slice(1));
|
||||||
|
if (accessKey) {
|
||||||
|
const response = await fetch(`/heinzlive/${data.channelId}/access`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key: accessKey })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
connection = 'interrupted';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer = setTimeout(refresh, 0);
|
||||||
|
}
|
||||||
|
void connect();
|
||||||
return () => {
|
return () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
@@ -40,7 +56,7 @@
|
|||||||
<meta name="robots" content="noindex, nofollow, noarchive" />
|
<meta name="robots" content="noindex, nofollow, noarchive" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="heinzlive-output">
|
<main class:empty={!view.content} class="heinzlive-output">
|
||||||
<div class="output-connection sr-only" class:problem={connection === 'interrupted'} role="status">
|
<div class="output-connection sr-only" class:problem={connection === 'interrupted'} role="status">
|
||||||
<span></span>{connection === 'interrupted' ? 'Verbindung unterbrochen' : 'Verbunden'}
|
<span></span>{connection === 'interrupted' ? 'Verbindung unterbrochen' : 'Verbunden'}
|
||||||
</div>
|
</div>
|
||||||
@@ -48,16 +64,12 @@
|
|||||||
{#key view.revision}
|
{#key view.revision}
|
||||||
<PresentationDisplay
|
<PresentationDisplay
|
||||||
content={view.content}
|
content={view.content}
|
||||||
imageSrc={view.content.mode === 'text'
|
imageSrc={view.content.images.length
|
||||||
|
? view.content.mode === 'text'
|
||||||
? `/heinzlive/${page.params.token}/media`
|
? `/heinzlive/${page.params.token}/media`
|
||||||
|
: `/heinzlive/${page.params.token}/media/${view.content.images[0].id}`
|
||||||
: `/heinzlive/${page.params.token}/preview-image`}
|
: `/heinzlive/${page.params.token}/preview-image`}
|
||||||
/>
|
/>
|
||||||
{/key}
|
{/key}
|
||||||
{:else}
|
|
||||||
<section class="output-idle">
|
|
||||||
<div class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></div>
|
|
||||||
<h1>Bereit für den nächsten Inhalt</h1>
|
|
||||||
<p>Die Moderation wählt ausdrücklich aus, was hier erscheint.</p>
|
|
||||||
</section>
|
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { presentationChannelAccess } from '#lib/server/live.js';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request, cookies, url }) => {
|
||||||
|
const body = (await request.json().catch(() => null)) as { key?: unknown } | null;
|
||||||
|
const accessKey = typeof body?.key === 'string' ? body.key : '';
|
||||||
|
if (!presentationChannelAccess(params.token, accessKey)) {
|
||||||
|
error(404, 'Präsentationsausgabe nicht gefunden');
|
||||||
|
}
|
||||||
|
cookies.set(`sendewerk-output-${params.token}`, accessKey, {
|
||||||
|
path: `/heinzlive/${params.token}`,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
secure: url.protocol === 'https:'
|
||||||
|
});
|
||||||
|
return json({ success: true }, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
};
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { getPresentationMedia } from '#lib/server/live.js';
|
import { getPresentationMedia, presentationChannelAccess } from '#lib/server/live.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params }) => {
|
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||||
const media = await getPresentationMedia(params.token, params.id);
|
const accessKey = cookies.get(`sendewerk-output-${params.token}`) ?? '';
|
||||||
|
if (!presentationChannelAccess(params.token, accessKey)) error(404, 'Bild nicht gefunden');
|
||||||
|
const media = await getPresentationMedia(accessKey, params.id);
|
||||||
if (!media) error(404, 'Bild nicht gefunden');
|
if (!media) error(404, 'Bild nicht gefunden');
|
||||||
return new Response(media.data, {
|
return new Response(new Uint8Array(media.data), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': media.mimeType,
|
'Content-Type': media.mimeType,
|
||||||
'Content-Length': String(media.byteSize),
|
'Content-Length': String(media.byteSize),
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { getPresentationPreviewImage } from '#lib/server/live.js';
|
import { getPresentationPreviewImage, presentationChannelAccess } from '#lib/server/live.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params }) => {
|
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||||
const image = await getPresentationPreviewImage(params.token);
|
const accessKey = cookies.get(`sendewerk-output-${params.token}`) ?? '';
|
||||||
|
if (!presentationChannelAccess(params.token, accessKey))
|
||||||
|
error(404, 'Vorschaubild nicht verfügbar');
|
||||||
|
const image = await getPresentationPreviewImage(accessKey);
|
||||||
if (!image) error(404, 'Vorschaubild nicht verfügbar');
|
if (!image) error(404, 'Vorschaubild nicht verfügbar');
|
||||||
return new Response(new Uint8Array(image.data), {
|
return new Response(new Uint8Array(image.data), {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { error, json } from '@sveltejs/kit';
|
import { error, json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { getPresentationView } from '#lib/server/live.js';
|
import { getPresentationChannelView, presentationChannelAccess } from '#lib/server/live.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = ({ params }) => {
|
export const GET: RequestHandler = ({ params, cookies }) => {
|
||||||
const view = getPresentationView(params.token);
|
const accessKey = cookies.get(`sendewerk-output-${params.token}`) ?? '';
|
||||||
|
if (!presentationChannelAccess(params.token, accessKey))
|
||||||
|
error(404, 'Präsentationsausgabe nicht gefunden');
|
||||||
|
const view = getPresentationChannelView(params.token);
|
||||||
if (!view) error(404, 'Präsentationsausgabe nicht gefunden');
|
if (!view) error(404, 'Präsentationsausgabe nicht gefunden');
|
||||||
return json(view, { headers: { 'Cache-Control': 'no-store' } });
|
return json(view, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
};
|
};
|
||||||
@@ -1,18 +1,40 @@
|
|||||||
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,
|
||||||
|
endLive,
|
||||||
getLiveView,
|
getLiveView,
|
||||||
|
getPresentationChannelStatus,
|
||||||
goBack,
|
goBack,
|
||||||
issuePresentationAccess,
|
issuePresentationAccess,
|
||||||
moveOpenUsage,
|
moveOpenUsage,
|
||||||
|
pauseLive,
|
||||||
presentUsage,
|
presentUsage,
|
||||||
|
rotatePresentationChannelKey,
|
||||||
|
resetSectionTimer,
|
||||||
|
resetTotalTimer,
|
||||||
|
resumeUsage,
|
||||||
|
resumeEndedLive,
|
||||||
|
resumeLive,
|
||||||
|
selectUsage,
|
||||||
|
setShowStatus,
|
||||||
skipCurrent,
|
skipCurrent,
|
||||||
|
startNewLiveRun,
|
||||||
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) || '');
|
||||||
@@ -26,6 +48,8 @@ function actionError(cause: unknown) {
|
|||||||
error: 'Der Live-Stand wurde bereits geändert. Die Ansicht wurde erneuert.'
|
error: 'Der Live-Stand wurde bereits geändert. Die Ansicht wurde erneuert.'
|
||||||
};
|
};
|
||||||
if (code === 'NO_CURRENT') return { status: 409, error: 'Es ist kein aktuelles Piece aktiv.' };
|
if (code === 'NO_CURRENT') return { status: 409, error: 'Es ist kein aktuelles Piece aktiv.' };
|
||||||
|
if (code === 'NOT_STARTED')
|
||||||
|
return { status: 409, error: 'Die Sendung muss vor der Auswahl gestartet werden.' };
|
||||||
if (code === 'NO_PREVIOUS') return { status: 409, error: 'Es gibt kein vorheriges Piece.' };
|
if (code === 'NO_PREVIOUS') return { status: 409, error: 'Es gibt kein vorheriges Piece.' };
|
||||||
if (code === 'LIVE_ORDER_LOCKED')
|
if (code === 'LIVE_ORDER_LOCKED')
|
||||||
return {
|
return {
|
||||||
@@ -36,6 +60,13 @@ function actionError(cause: unknown) {
|
|||||||
return { status: 409, error: 'Abgeschlossene Sendungen können nicht live gesteuert werden.' };
|
return { status: 409, error: 'Abgeschlossene Sendungen können nicht live gesteuert werden.' };
|
||||||
if (code === 'NOT_FOUND')
|
if (code === 'NOT_FOUND')
|
||||||
return { status: 404, error: 'Sendung oder Piece wurde nicht gefunden.' };
|
return { status: 404, error: 'Sendung oder Piece wurde nicht gefunden.' };
|
||||||
|
if (code === 'TIMER_PAUSED') return { status: 409, error: 'Die Sendung ist pausiert.' };
|
||||||
|
if (code === 'ALREADY_PAUSED' || code === 'ALREADY_RUNNING')
|
||||||
|
return { status: 409, error: 'Der Timerzustand wurde bereits geändert.' };
|
||||||
|
if (code === 'NO_SECTION_TIMER')
|
||||||
|
return { status: 409, error: 'Es ist kein Abschnittstimer aktiv.' };
|
||||||
|
if (code === 'NOT_LIVE' || code === 'NOT_ENDED' || code === 'ALREADY_STARTED')
|
||||||
|
return { status: 409, error: 'Die Sendung befindet sich nicht im passenden Zustand.' };
|
||||||
if (code.startsWith('INVALID_')) return { status: 400, error: 'Die Live-Aktion ist ungültig.' };
|
if (code.startsWith('INVALID_')) return { status: 400, error: 'Die Live-Aktion ist ungültig.' };
|
||||||
return { status: 500, error: 'Die Live-Aktion konnte nicht gespeichert werden.' };
|
return { status: 500, error: 'Die Live-Aktion konnte nicht gespeichert werden.' };
|
||||||
}
|
}
|
||||||
@@ -50,11 +81,20 @@ 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,
|
||||||
|
outputStatus: getPresentationChannelStatus(showId),
|
||||||
|
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');
|
||||||
@@ -63,6 +103,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();
|
||||||
@@ -70,6 +177,86 @@ export const actions = {
|
|||||||
startOrResumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
startOrResumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
pause: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
return attempt('pause', () =>
|
||||||
|
pauseLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
resume: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
return attempt('resume', () =>
|
||||||
|
resumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
resetSectionTimer: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
return attempt('resetSectionTimer', () =>
|
||||||
|
resetSectionTimer(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
resetTotalTimer: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
return attempt('resetTotalTimer', () =>
|
||||||
|
resetTotalTimer(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
setStatus: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
const status = text(data, 'status');
|
||||||
|
if (status !== 'draft' && status !== 'ready') {
|
||||||
|
return fail(400, { intent: 'setStatus', error: 'Der Sendungsstatus ist ungültig.' });
|
||||||
|
}
|
||||||
|
return attempt('setStatus', () =>
|
||||||
|
setShowStatus(
|
||||||
|
text(data, 'showId'),
|
||||||
|
text(data, 'commandId'),
|
||||||
|
Number(data.get('revision')),
|
||||||
|
Number(data.get('showRevision')),
|
||||||
|
status
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
end: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
return attempt('end', () =>
|
||||||
|
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 }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
return attempt('resumeEnded', () =>
|
||||||
|
resumeEndedLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
startNewRun: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
return attempt('startNewRun', () =>
|
||||||
|
startNewLiveRun(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
||||||
|
);
|
||||||
|
},
|
||||||
complete: async ({ locals, request }) => {
|
complete: async ({ locals, request }) => {
|
||||||
requireUser(locals.user);
|
requireUser(locals.user);
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
@@ -91,6 +278,30 @@ export const actions = {
|
|||||||
goBack(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
goBack(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
select: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
return attempt('select', () =>
|
||||||
|
selectUsage(
|
||||||
|
text(data, 'showId'),
|
||||||
|
text(data, 'usageId'),
|
||||||
|
text(data, 'commandId'),
|
||||||
|
Number(data.get('revision'))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
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();
|
||||||
@@ -118,10 +329,25 @@ export const actions = {
|
|||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
try {
|
try {
|
||||||
const token = issuePresentationAccess(text(data, 'showId'));
|
const token = issuePresentationAccess(text(data, 'showId'));
|
||||||
return { intent: 'createOutput', success: true, outputUrl: `/heinzlive/${token}` };
|
return { intent: 'createOutput', success: true, outputUrl: `/heinzlive/default#${token}` };
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
const problem = actionError(cause);
|
const problem = actionError(cause);
|
||||||
return fail(problem.status, { intent: 'createOutput', ...problem });
|
return fail(problem.status, { intent: 'createOutput', ...problem });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
rotateOutput: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
try {
|
||||||
|
const token = rotatePresentationChannelKey(text(data, 'showId'));
|
||||||
|
return { intent: 'rotateOutput', success: true, outputUrl: `/heinzlive/default#${token}` };
|
||||||
|
} catch (cause) {
|
||||||
|
const problem = actionError(cause);
|
||||||
|
return fail(problem.status, { intent: 'rotateOutput', ...problem });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearOutput: async ({ locals }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
return attempt('clearOutput', () => clearPresentationChannel());
|
||||||
}
|
}
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
+1083
-170
File diff suppressed because it is too large.
Load diff
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user