Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6c8606f3c | ||
|
|
abc31d920f | ||
|
|
07f2c4607b | ||
|
|
21c0de7dd9 | ||
|
|
d5b1c58ba2 | ||
|
|
64f55a8b1f | ||
|
|
a08b02c34b | ||
|
|
6d96900d81 | ||
|
|
d586fbd8ce | ||
|
|
b80dded55d | ||
|
|
fd47910d09 | ||
|
|
01b5606a3b | ||
|
|
fa17187af4 | ||
|
|
b1474bc33d |
No files matched your search
@@ -6,6 +6,7 @@ BODY_SIZE_LIMIT=6M
|
||||
|
||||
# Generate this locally and insert it here as described in README.md. Never commit .env.
|
||||
SENDEWERK_SETUP_TOKEN=
|
||||
# Public production origins must use HTTPS; loopback HTTP remains available locally.
|
||||
SENDEWERK_APP_ORIGIN=http://localhost:5173
|
||||
|
||||
# "local" writes messages to the outbox and never contacts a mail server.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"title": "Sendewerk",
|
||||
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
|
||||
"tagline": "Vom Fundstück bis zur Livesendung",
|
||||
"version": "0.5.12",
|
||||
"version": "0.9.0",
|
||||
"healthCheckPath": "/healthz",
|
||||
"httpPort": 3000,
|
||||
"addons": {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten
|
||||
Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst
|
||||
Etappe 1 bis 5: technisches Grundgerüst, Anmeldung und Rollen, einen
|
||||
persistenten Fundus, den Sendungsbaukasten sowie Live-Steuerung und eine
|
||||
abgesicherte Präsentationsausgabe. Abschluss und Archivierung folgen in Etappe 6.
|
||||
Etappe 1 bis 7: technisches Grundgerüst, Anmeldung und Rollen, Fundus,
|
||||
Sendungsbau, Live-Steuerung, Präsentationsausgabe, Abschluss, Archiv,
|
||||
Freigaben, RSS sowie technische REST- und MCP-Zugänge.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
@@ -62,6 +62,9 @@ enthält bewusst kein Konto und kein Standardpasswort.
|
||||
Relative Sendewerk-Pfade werden vom Projektverzeichnis aus aufgelöst. In
|
||||
Cloudron liegen Datenbank und Uploads unter `/app/data`.
|
||||
|
||||
Eine öffentliche Produktions-Origin muss HTTPS verwenden. Nur für lokale
|
||||
Loopback-Adressen bleibt HTTP zulässig.
|
||||
|
||||
Der Vorgabemodus `local` baut keine Netzwerkverbindung auf. Einladungen und
|
||||
Passwort-Resets landen als JSON-Dateien in `.data/mail-outbox`; die enthaltenen
|
||||
Links sind Geheimnisse und dürfen nicht veröffentlicht werden. Echter Versand
|
||||
@@ -151,6 +154,53 @@ DNS-, Redirect-, Zeit- und Größenlimits abgerufen. Private und lokale
|
||||
Netzwerkziele sind gesperrt. Eingebettetes HTML oder Skripte einer Quellseite
|
||||
werden nicht übernommen.
|
||||
|
||||
## MCP und ChatGPT
|
||||
|
||||
Der lesende Streamable-HTTP-Endpunkt liegt unter `/mcp`. Seine konkrete
|
||||
Adresse ist die konfigurierte Anwendungs-Origin mit angehängtem Pfad:
|
||||
|
||||
```text
|
||||
https://<Cloudron-App-Domain>/mcp
|
||||
```
|
||||
|
||||
Beispiel: Bei `SENDEWERK_APP_ORIGIN=https://sendewerk.example.org` lautet die
|
||||
MCP-URL `https://sendewerk.example.org/mcp`. Niemals einen technischen
|
||||
REST-Token an die URL hängen oder in ChatGPT eintragen. MCP verwendet einen
|
||||
eigenen OAuth-2.1-Flow mit Authorization Code, PKCE und kurzlebigen Tokens.
|
||||
|
||||
Die Verbindung stellt ausschließlich lesende Werkzeuge bereit:
|
||||
|
||||
- `get_profile` identifiziert das verbundene Sendewerk-Konto ohne E-Mail-Adresse.
|
||||
- `list_pieces` und `get_piece` lesen Fundus-Pieces.
|
||||
- `list_shows` und `get_show_rundown` lesen Sendungen und deren Abläufe.
|
||||
|
||||
Die Detailwerkzeuge können private Recherche, Moderationsnotizen, Quellen,
|
||||
eigene Notizen und Reserveinhalte liefern. Der angemeldete Nutzer muss diesem
|
||||
Umfang ausdrücklich zustimmen. Sperrung oder Passwortwechsel widerrufen
|
||||
bestehende OAuth-Tokens; jede MCP-Anfrage prüft Konto, Scope, Ablauf und
|
||||
Zielressource erneut. MCP kann keine Inhalte schreiben.
|
||||
|
||||
### In ChatGPT einrichten
|
||||
|
||||
1. Zuerst Version `0.8.1` unter der endgültigen öffentlichen HTTPS-Origin
|
||||
bereitstellen. Lokalhost ohne öffentliches HTTPS ist für eine direkte
|
||||
ChatGPT-Verbindung nicht erreichbar.
|
||||
2. In [ChatGPT Plugins](https://chatgpt.com/plugins) das Plus wählen und
|
||||
**Add custom MCP server** öffnen.
|
||||
3. Einen Namen und eine Beschreibung vergeben und unter **Connection** die
|
||||
vollständige URL `https://<Cloudron-App-Domain>/mcp` eintragen.
|
||||
4. OAuth als Authentifizierung verwenden, den Risikohinweis prüfen und
|
||||
**Create as a plugin** wählen. ChatGPT entdeckt die OAuth-Endpunkte und
|
||||
registriert einen öffentlichen PKCE-Client dynamisch.
|
||||
5. Im geöffneten Sendewerk-Fenster mit dem eigenen Konto anmelden, Clientname
|
||||
und Rücksprung-Domain prüfen und den lesenden Zugriff ausdrücklich erlauben.
|
||||
6. Die gefundenen fünf Werkzeuge kontrollieren, das Plugin installieren und in
|
||||
einer neuen Unterhaltung über `@` auswählen.
|
||||
|
||||
Eine erfolgreich lokale OAuth- und MCP-Prüfung beweist noch keine reale
|
||||
ChatGPT-Verbindung. Diese muss nach dem Cloudron-Update separat in der
|
||||
jeweiligen ChatGPT-Workspace-Konfiguration durchgeführt werden.
|
||||
|
||||
## Befehle
|
||||
|
||||
| Befehl | Zweck |
|
||||
@@ -210,6 +260,12 @@ Umgebungsvariable gesetzt werden. `CLOUDRON_APP_ORIGIN` liefert dort die feste
|
||||
HTTPS-Origin. SMTP-Zugangsdaten werden ebenfalls ausschließlich als
|
||||
Laufzeitvariablen gesetzt, nicht in Manifest oder Image geschrieben.
|
||||
|
||||
Für MCP sind keine zusätzlichen Geheimnisse erforderlich. Seit Version `0.7.3`
|
||||
führt der bestehende Startprozess Migration `0020_oauth_mcp.sql`
|
||||
aus. Anschließend muss die von Cloudron gelieferte HTTPS-Origin mit der in
|
||||
ChatGPT eingetragenen MCP-Domain übereinstimmen. Reverse Proxy und öffentliche
|
||||
Erreichbarkeit wurden ohne Cloudron-Zugang nicht geprüft.
|
||||
|
||||
Das ersetzt keinen Test auf einem echten Cloudron. Insbesondere Addon-Backup,
|
||||
Restore, Proxy-Origin und das read-only Laufzeitdateisystem können erst dort
|
||||
abschließend geprüft werden.
|
||||
@@ -226,7 +282,7 @@ Medien und redaktionellen Inhalten werden dadurch nicht verändert.
|
||||
|
||||
Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder
|
||||
Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und
|
||||
`better-sqlite3`, `@node-rs/argon2`, Marked, sanitize-html, file-type und
|
||||
`better-sqlite3`, `@node-rs/argon2`, Tiptap, Marked, sanitize-html, file-type und
|
||||
Metascraper sind MIT-lizenziert; Nodemailer verwendet MIT-0, TypeScript und
|
||||
Playwright stehen unter Apache-2.0. Die Lockdatei fixiert den tatsächlich
|
||||
installierten Baum.
|
||||
+385
-19
@@ -1,5 +1,284 @@
|
||||
# Abnahme
|
||||
|
||||
## Einheitliche Inhaltswerkbänke 0.9.0
|
||||
|
||||
| Bereich | Nachweis | Stand |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| Gemeinsame Übersichtshierarchie | Pieces, Eingang und Post zeigen Navigation/Quellen, mittlere Auswahl und rechte Schnellbearbeitung aus denselben Layoutgrundlagen | erfüllt am 29.10.2026 |
|
||||
| Piece-Schnellbearbeitung | Titel, Kurzbeschreibung, Einleitung, Moderation, Status, Dauer, Format und Bild-Upload speichern revisionsgeschützt; Voll-Editor öffnet separat | erfüllt am 29.10.2026 |
|
||||
| Getrennte RSS-Sichtung | RSS-Vorschau, Originallink, Übernehmen und Verwerfen bleiben vor der bewussten Piece-Erzeugung getrennt | erfüllt am 29.10.2026 |
|
||||
| Post ohne zweite Sendungsverwaltung | Übersicht beginnt bei abgeschlossenen Streams; Vollbearbeitung verwendet vorhandene Post- und Streamdienste | erfüllt am 29.10.2026 |
|
||||
| Eigenständige Vollbearbeitung | Piece bleibt modal; Post besitzt eine normale eigene Seite im Streams-Layout mit Streamkopf, Zurücklink und Schnellnavigation | erfüllt am 30.10.2026 |
|
||||
| Einheitliche Auswahlflächen | Aktive Karten, Checkboxen und Radios folgen Streams: abgerundete ruhige Fläche mit dünner goldener Kontur, ohne Cyan-Füllung oder Seitenbalken | erfüllt am 30.10.2026 |
|
||||
| Responsive Gestaltung | Chromium prüft Übersichten bei 1440 × 1000, 960 × 900 und 390 × 844 sowie Piece-Overlay und Post-Detailseite ohne horizontalen Überlauf | erfüllt am 30.10.2026 |
|
||||
| Bestehende Fachgrenzen | Autosave, Revisionen, Medien, RSS-Import, Stream-/Live-Snapshots, Post-Exporte, Rechte und private Dateien bleiben erhalten | erfüllt am 29.10.2026 |
|
||||
| Live als Nur-Lesen-Projektion | Normale Navigation, Streamkopf und drei Pane-Flächen; Auswahl und Darstellung ändern nachweislich weder Show-/Usage-Status noch Reihenfolge | erfüllt am 30.10.2026 |
|
||||
| Verbindliches Seitenraster | Linke Spalte beginnt oben; freier Seitenkopf und darunter liegende Inhalts-/Inspectorflächen nutzen in allen Werkbänken dieselben Maße | erfüllt am 30.10.2026 |
|
||||
| Reduzierte App-Bewegung | Piece-Overlay schließt nach echtem Animationsende; Dialog-/Pane-Wechsel bleiben unter 200 ms und respektieren reduzierte Bewegung | erfüllt am 30.10.2026 |
|
||||
|
||||
- Gezielte Chromium-Abläufe für Piece-Auswahl, RSS, Schnellaufnahme,
|
||||
Piece-Autosave/Präsentationsmodus, Streamplanung und Postproduktion: erfolgreich.
|
||||
- Desktop-, Zwischenbreiten- und Mobilaufnahmen der drei Übersichten sowie der
|
||||
Piece- und Post-Vollbearbeitung wurden erzeugt und visuell kontrolliert.
|
||||
- Korrekturstand 30.10.2026: Post-Navigation, Piece-/Post-Schnellinspektoren und
|
||||
Post-Vollbearbeitung verwenden nachweislich die Stream-Basisklassen. Gezielte
|
||||
Chromium-Abläufe für Fundus und Post liefen erfolgreich; die Aufnahmen bei
|
||||
1440, 960 und 390 Pixeln wurden erneut visuell kontrolliert.
|
||||
- Die vollständige Chromium-Matrix bedient die vorhandenen Checkboxen und Radios
|
||||
weiterhin über ihre native Semantik. Post-Detailübersicht ohne Auswahl und
|
||||
mobile Schnellnavigation wurden separat aufgenommen und visuell kontrolliert.
|
||||
- `npm run lint`: erfolgreich.
|
||||
- `npm run check`: 0 Fehler und 0 Warnungen.
|
||||
- `npm test`: 69 von 69 Verhaltenstests erfolgreich.
|
||||
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
|
||||
- `npm run test:e2e`: 22 von 22 Chromium-Abläufen erfolgreich.
|
||||
- Live-Nur-Lesen-Test: Desktop-, Kompakt- und Mobilansicht erfolgreich; lokale
|
||||
Auswahl, Vor/Zurück, Präsentieren, Darstellungswechsel und ein abgewiesener
|
||||
Status-POST ließen Show-/Usage-Status und Reihenfolge unverändert.
|
||||
- Safari und echte Mobilgeräte: nicht verfügbar und deshalb offen.
|
||||
|
||||
## Postproduktion 0.9.0
|
||||
|
||||
| Bereich | Nachweis | Stand |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| Bestehende abgeschlossene Streams | `/post` zeigt ausschließlich vorhandene Streams mit Status `completed`; Suche, Sortierung und Fortschrittsfilter erzeugen keine zweite Sendung | erfüllt am 29.10.2026 |
|
||||
| Responsive Arbeitsfläche | Chromium prüft die normale Detailseite mit drei Desktopspalten und mobiler Umschaltung zwischen Inhalte, Bearbeiten und Details ohne Überlauf | erfüllt am 30.10.2026 |
|
||||
| Stream-artige Gestaltung | Detailkopf, Datumszeile, Formfelder, Radien, Abstände und aktive Auswahl entsprechen unmittelbar der Streams-Referenz | erfüllt am 30.10.2026 |
|
||||
| Direkte Streamwerkbank | Klick auf Streamzeile oder Öffnen-Aktion führt direkt zur Werkbank; ohne Einzelwahl erscheinen Inhaltsübersicht und Streammetadaten | erfüllt am 30.10.2026 |
|
||||
| Schnelle Inhaltsnavigation | Links stehen YouTube, Social-Clips und Twitch-Highlights mit Sofortanlage; aktive Inhalte wechseln farblich markiert in den Editor | erfüllt am 30.10.2026 |
|
||||
| Aufzeichnungen und Kontext | Mehrere Teile und Markdown-Dateien sind persistent; SHA-256-Dublettenschutz, unveränderter Inhalt und konservative Zeitmarkenextraktion sind im Servicetest geprüft | erfüllt am 29.10.2026 |
|
||||
| YouTube-Uncuts | Beschreibung, Titelvarianten, Thumbnailkonzept, private echte Bilddatei, Auswahl, Download, Status und Veröffentlichungsdaten sind verfügbar | erfüllt am 29.10.2026 |
|
||||
| Social-Clips | Beliebig viele Clips führen Shorts-Titel, gemeinsame TikTok-/Instagram-Caption samt Hashtags, Hooks, Endcards, Ziele, Untertitel und Schnittdaten | erfüllt am 30.10.2026 |
|
||||
| Kontextbezogene Zuordnung | Eine einzige Kontextdatei wird automatisch gesetzt; Piece-Optionen stammen ausschließlich aus tatsächlichen Verwendungen des geöffneten Streams | erfüllt am 30.10.2026 |
|
||||
| Twitch-Highlights | Eigenständiger Typ mit Beschreibung, Originaltranskript, Zeitbereich, Schnitt- und Veröffentlichungsdaten | erfüllt am 29.10.2026 |
|
||||
| Status ohne Aufgaben/Analytics | Revisionierter Workflow Entwurf bis Veröffentlicht; keine Aufgaben-, Kennzahlen- oder Publikationsautomatik | erfüllt am 29.10.2026 |
|
||||
| Private Dateien | Kontextdateien und Thumbnails liegen im konfigurierten Uploadpfad und werden ausschließlich über authentifizierte, private Routen ausgeliefert | erfüllt am 29.10.2026 |
|
||||
| Valide Exporte | Servicetests blockieren unvollständige Resolve-/SRT-Daten; Chromium lädt XML und SRT erst mit allen technisch erforderlichen Werten | erfüllt am 29.10.2026 |
|
||||
| Manueller KI-unabhängiger Ablauf | Kein Netzwerkaufruf und keine Scheinaktion; providerneutraler Adaptervertrag bleibt unkonfiguriert und der gesamte Ablauf manuell bedienbar | erfüllt am 29.10.2026 |
|
||||
| Bestehende Abläufe | Vollständige Verhaltenstest- und Chromium-Matrix für Pieces, Streams, Live, Abschluss, Freigaben, RSS, Rechte und Präsentation bleibt erfolgreich | erfüllt am 29.10.2026 |
|
||||
|
||||
- `npm run lint`: erfolgreich.
|
||||
- `npm run check`: 0 Fehler und 0 Warnungen.
|
||||
- `npm test`: 69 von 69 Verhaltenstests erfolgreich.
|
||||
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
|
||||
- `npm run test:e2e`: 22 von 22 Chromium-Abläufen erfolgreich.
|
||||
- Gezielter Post-Lauf nach der CSS-Korrektur: 1 von 1 erfolgreich; Registerwechsel
|
||||
explizit per Mausklick geprüft. Desktop- und Mobilaufnahmen einschließlich
|
||||
inhaltsloser Übersicht und mobiler Schnellnavigation visuell kontrolliert.
|
||||
- `git diff --check`: erfolgreich.
|
||||
- Safari, echte Mobilgeräte, Import realer heinzlive-Kontextdateien, Öffnen in
|
||||
DaVinci Resolve und eine reale KI-Schreibschnittstelle: mangels Umgebung offen.
|
||||
|
||||
## Piece-Schreibraum 0.8.2
|
||||
|
||||
| Bereich | Nachweis | Stand |
|
||||
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| Zusammenhängendes Dokument | Vier getrennt gespeicherte Bereiche ohne Einzelkarten; nur Fokus zeigt Werkzeuge, Auswahl bleibt beim Formatieren erhalten | erfüllt am 28.10.2026 |
|
||||
| Recherche und Markdown | Bereichswechsel erhält Eingaben; Recherche, Quellen, Zeitmarken, Werkzeuge und Markdown-Quellansicht bleiben vollständig | erfüllt am 28.10.2026 |
|
||||
| Kontextbezogene Piece-Liste | Editor zeigt gefilterte Liste, aktuelle Markierung, Suche/Filter und Rücksprung; schmutziger Wechsel speichert vor Navigation | erfüllt am 28.10.2026 |
|
||||
| Kompakte Details und Ausgabe | Häufige Werte sind direkt editierbar, seltene eingeklappt; Moduswahl und editierbare Publikumsvorschau liegen sichtbar darunter | erfüllt am 28.10.2026 |
|
||||
| Kopf und Aktionen | Speicherstatus/-aktion und Favorit sind kompakt; Vorlagen, Duplikation und bestätigte destruktive Aktionen liegen im Menü | erfüllt am 28.10.2026 |
|
||||
| Kopf unter schmaler Breite | Titel, Favorit, Speicher-/Aktionssteuerung und Bereichsauswahl belegen getrennte Zeilen ohne Abschneiden | erfüllt am 28.10.2026 |
|
||||
| Responsive Gestaltung | Chromium prüft 1440 × 1000 offen/eingeklappt, 960 × 900 sowie mobilen Streamtext, Piece-Drawer und Details ohne Überlauf | erfüllt am 28.10.2026 |
|
||||
| Eingeklappte Zwischenbreite | Bei 960 × 900 wächst die Editorroute mit eingeklappter Navigation über 820 Pixel; Icons sind mindestens 16 Pixel groß, Version sichtbar | erfüllt am 28.10.2026 |
|
||||
| Piece-Liste und Streamplan | Scrollgutter hält die Piece-Markierung bündig; der Streamplan zeigt rechts den Inspector in derselben kompakten Breitenklasse | erfüllt am 28.10.2026 |
|
||||
| Kontextbezogener Inspector | Ohne Auswahl erscheinen Stream-/Twitch-Daten; Abschnitt und Piece wechseln per Klick zu ihren Metadaten und festen Speicheraktionen | erfüllt am 28.10.2026 |
|
||||
| Sichere Usage-Schnelländerung | Titel, Publikumstitel, Dauer, Vorbereitung und Notizen speichern revisionsgeschützt, ohne Langtexte oder Präsentationswerte zu leeren | erfüllt am 28.10.2026 |
|
||||
| Responsive Streamdetails | Desktop hält Ablauf und Inspector nebeneinander; schmalere Ansichten bieten Details als dritten tastatur- und touchfähigen Arbeitsbereich | erfüllt am 28.10.2026 |
|
||||
| Live-Regie | Chromium prüft Dokument, flachen Ablauf ohne Aktionsüberlagerung, feste Steuerung, 900/670 × 450 und mobiles Ablauf-Sheet | erfüllt am 28.10.2026 |
|
||||
| Bestehende Fachgrenzen | Autosave, Revisionen, Medien, Stream-Snapshots, globale Navigation und benachbarte Arbeitsbereiche bleiben unverändert | erfüllt am 28.10.2026 |
|
||||
|
||||
- Gezielte Chromium-Abläufe für Schreibraum, Auswahl erhaltende Formatierung,
|
||||
Autosave-Piece-Wechsel, Konflikte, öffentliche Überschrift, Upload,
|
||||
Link-/Videopräsentation und mobile Ansichtswechsel: erfolgreich.
|
||||
- Sichtkontrolle der korrigierten Geometrie bei 1440 × 1000 mit ausgeklappter
|
||||
und eingeklappter Navigation, bei 960 × 900, im Streamplan-Inspector sowie in
|
||||
der Live-Regie bei 1440 × 900, 900/670 × 450 und 390 × 700: erfolgreich;
|
||||
Desktop-, Streamtext-, Drawer-, Inspector- und Live-Screenshots wurden
|
||||
erzeugt und kontrolliert.
|
||||
- `npm run lint`: erfolgreich.
|
||||
- `npm run check`: 0 Fehler und 0 Warnungen.
|
||||
- `npm test`: 64 von 64 Verhaltenstests erfolgreich.
|
||||
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
|
||||
- `npm run test:e2e`: 22 von 22 Chromium-Abläufen erfolgreich.
|
||||
- `git diff --check`: erfolgreich.
|
||||
- Safari und echte Mobilgeräte: nicht verfügbar und deshalb offen.
|
||||
|
||||
## Redaktionelle Werkbänke 0.8.1
|
||||
|
||||
| Bereich | Nachweis | Stand |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| Piece-Editor | Streamtext beginnt direkt unter dem Titel; Chromium prüft Autosave, Konflikt, Quellen, Werkzeuge, Medien und Präsentationsmodi | erfüllt am 27.10.2026 |
|
||||
| Streamplanung | Ablauf ist Hauptfläche, Piece-Bibliothek öffnet bei Bedarf; Drag-and-drop, Klick-/Tastaturalternativen, Reserve und Usage-Editor laufen | erfüllt am 27.10.2026 |
|
||||
| Live-Regie | Aktuelles Piece, Abschnitt und Moderation sind priorisiert; Timer, Ablauf-Sheet, Ausgabe und OBS-Aktionen bleiben erreichbar | erfüllt am 27.10.2026 |
|
||||
| Globale Suche | Servertest prüft Typ-, Status- und Taxonomiefilter; Chromium prüft Filterspalte, Treffergruppen und mobilen Filterzugang | erfüllt am 27.10.2026 |
|
||||
| Responsive Gestaltung | Gezielte Chromium-Sichtkontrolle bei 1440 × 1000 und 390 × 844 in Piece, Planung, Live und Suche ohne horizontalen Überlauf | erfüllt am 27.10.2026 |
|
||||
| Verträge und Sicherheit | Navigation, URLs, Actions, Revisionen, Snapshots, Rechte und öffentliche Positivprojektionen bleiben unverändert | erfüllt am 27.10.2026 |
|
||||
|
||||
- `npm run lint`: erfolgreich.
|
||||
- `npm run check`: 0 Fehler und 0 Warnungen.
|
||||
- `npm test`: 63 von 63 Verhaltenstests erfolgreich.
|
||||
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
|
||||
- `npm run test:e2e`: 21 von 21 Chromium-Abläufen erfolgreich.
|
||||
- `git diff --check`: erfolgreich.
|
||||
- Safari und echte Mobilgeräte: nicht verfügbar und deshalb offen.
|
||||
|
||||
## Redaktioneller Arbeitsbereich 0.8.0
|
||||
|
||||
| Bereich | Nachweis | Stand |
|
||||
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| Gemeinsame Navigation | Chromium prüft Start, Eingang, Pieces, Streams und Live im Desktopkopf sowie als mobile Bottom-Navigation | erfüllt am 27.10.2026 |
|
||||
| Dashboard ohne erfundene Daten | Root-Route zeigt nächsten Ablauf, aktuelle Pieces, Schnellaktionen und weitere Streams aus bestehenden Diensten | erfüllt am 27.10.2026 |
|
||||
| Berechtigungssichere Suche | Servertests finden private Piece-Texte, archivierte Pieces und Stream-Snapshots; Browser prüft gruppierte Trefferseite | erfüllt am 27.10.2026 |
|
||||
| Pieces und Abschluss | Chromium prüft automatisches Ausblenden, Abgeschlossen-Ansicht, verlinkte Historie und erneute Einplanung | erfüllt am 27.10.2026 |
|
||||
| Streams und Historie | Filter leiten Angelegt, Vorbereitung, Sendefertig und Abgeschlossen ab; historische Abläufe bleiben unveränderlich | erfüllt am 27.10.2026 |
|
||||
| Kontextnavigationen | Pieces und Streams besitzen lokale Suche und einklappbare Desktopnavigation; mobile Flächen bleiben überlauffrei | erfüllt am 27.10.2026 |
|
||||
| Optionale rechte Arbeitsleiste | Usage-Auswahl öffnet bestehenden Editor nicht-modal rechts; Schließen, Speichern und mobile Vollbildansicht bleiben | erfüllt am 27.10.2026 |
|
||||
| Daten- und Sicherheitsgrenzen | 62 Verhaltenstests sowie Browserabläufe für Rollen, Revisionen, Uploads, Live, Freigaben und Präsentation bleiben grün | erfüllt am 27.10.2026 |
|
||||
|
||||
- `npm run lint`: erfolgreich.
|
||||
- `npm run check`: 0 Fehler und 0 Warnungen.
|
||||
- `npm test`: 62 von 62 Verhaltenstests erfolgreich.
|
||||
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
|
||||
- `npm run test:e2e`: 21 von 21 Chromium-Abläufen erfolgreich.
|
||||
- `git diff --check`: erfolgreich.
|
||||
- Safari und echte Mobilgeräte: nicht verfügbar und deshalb offen.
|
||||
|
||||
## Horizontale Fundus-Werkbank 0.7.4
|
||||
|
||||
| Bereich | Nachweis | Stand |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| Horizontale Hauptnavigation | Chromium prüft Desktopkopf und getrennte mobile Navigation bei den vorhandenen Matrixbreiten | erfüllt am 27.10.2026 |
|
||||
| Exakt drei Fundusspalten | Bounding-Box-Prüfung ordnet Navigation, Liste und Kontext bei 1440 Pixeln nebeneinander an | erfüllt am 27.10.2026 |
|
||||
| Zustanderhaltende Navigation | Chromium behält `includePlanned` beim Wechsel Liste → Piece → Liste; Scrollzustand ist querygebunden implementiert | erfüllt am 27.10.2026 |
|
||||
| Eingebetteter Piece-Editor | Schreiben liegt mittig, Metadaten rechts; Anlage, Autosave, Reload, Konflikt und Upload laufen ohne Desktopdialog | erfüllt am 27.10.2026 |
|
||||
| Markdown und Rich Text | Gemeinsame Tiptap-Komponente in Original und Usage; Quellansicht speichert Markdown und sichere Vorschau bleibt bereinigt | erfüllt am 27.10.2026 |
|
||||
| Bestehende Fachgrenzen | 60 Verhaltenstests sowie Browserabläufe für Snapshots, Werkzeuge, Live und Präsentation bleiben erfolgreich | erfüllt am 27.10.2026 |
|
||||
| Responsive und Themes | Chromium-Matrix prüft 390 bis 1440 Pixel, Light/Dark und horizontalen Überlauf | erfüllt am 27.10.2026 |
|
||||
|
||||
- `npm run check`: 0 Fehler und 0 Warnungen.
|
||||
- `npm run lint`: erfolgreich.
|
||||
- `npm test`: 60 von 60 Verhaltenstests erfolgreich.
|
||||
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
|
||||
- `npm run test:e2e`: 20 von 20 Chromium-Abläufen erfolgreich.
|
||||
- Safari und echte Mobilgeräte: nicht verfügbar und deshalb offen.
|
||||
|
||||
## Lesender MCP-Zugang 0.7.3
|
||||
|
||||
| Bereich | Nachweis | Stand |
|
||||
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| OAuth-Discovery und anonymer Schutz | Reale lokale HTTP-Anfrage erhält 401 mit `resource_metadata`; Discovery-Routen sind im Produktionsbuild enthalten | erfüllt am 27.10.2026 |
|
||||
| DCR, Zustimmung und PKCE | Isolierter HTTP-Ablauf registriert Client, lädt Zustimmung, erhält Code mit `state`/`iss` und tauscht per S256 | erfüllt am 27.10.2026 |
|
||||
| Nutzer-, Scope- und Ressourcenbindung | Ausgestellter Token ist gehasht persistiert und wird bei jedem Aufruf gegen Nutzer, Scope, Ablauf und `/mcp` geprüft | erfüllt am 27.10.2026 |
|
||||
| MCP-Kompatibilität | Legacy-Initialisierung `2025-11-25` sowie `server/discover` und `tools/list` mit `2026-07-28` über HTTP | erfüllt am 27.10.2026 |
|
||||
| Tatsächliche Leseanfrage | `tools/call` auf `list_pieces` liefert aus einer temporären Seed-Datenbank reale Pieces | erfüllt am 27.10.2026 |
|
||||
| Schreibschutz und Datenminimierung | Nur fünf Lese-Tools; Positivprojektionen ohne E-Mail, Uploadnamen, Dateipfade und Werkzeug-Arbeitsdaten | erfüllt am 27.10.2026 |
|
||||
| ChatGPT- und Cloudron-Verbindung | Kein Deployment und keine externe Clientumgebung vorhanden | offen |
|
||||
|
||||
- `npm run check`: 0 Fehler und 0 Warnungen.
|
||||
- Gezielter isolierter HTTP-Ablauf: OAuth Code Flow, PKCE, anonymer Challenge,
|
||||
Legacy-Initialisierung, aktuelle Discovery, Toolliste und echte
|
||||
`list_pieces`-Anfrage erfolgreich.
|
||||
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
|
||||
- Keine weitere Verhaltenstest- oder Browsermatrix gemäß Auftrag ausgeführt;
|
||||
die Zustimmungsseite wurde im HTTP-Ablauf geladen, aber nicht visuell geprüft.
|
||||
- Echte ChatGPT-Verbindung, öffentliche HTTPS-Erreichbarkeit und Cloudron-
|
||||
Reverse-Proxy-Verhalten bleiben bis zu einem ausdrücklich freigegebenen
|
||||
Deployment offen.
|
||||
|
||||
## 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
|
||||
|
||||
| Kriterium | Nachweis | Stand |
|
||||
@@ -424,32 +703,119 @@
|
||||
|
||||
## 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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`: 40 Tests in 7 Dateien erfolgreich
|
||||
- gezielter Live-Regie-Lauf: 1 von 1 Chromium-Test erfolgreich
|
||||
- `npm run test:e2e`: 16 von 16 Chromium-Browsertests erfolgreich
|
||||
- Screenshots der Live-Regie bei 1440 × 900, 900 × 450 und 390 × 700 Pixeln
|
||||
einschließlich mobilem Ablauf-Sheet visuell kontrolliert
|
||||
- `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
|
||||
+401
-56
@@ -3,7 +3,7 @@
|
||||
## Systemgrenze
|
||||
|
||||
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
|
||||
persistenten Laufzeitabhängigkeiten. Externe Mail-, RSS- oder KI-Dienste sind
|
||||
nicht Voraussetzung für Kernfunktionen.
|
||||
@@ -11,7 +11,7 @@ nicht Voraussetzung für Kernfunktionen.
|
||||
```text
|
||||
Browser -> SvelteKit-Routen -> Anwendungsdienste -> SQLite
|
||||
| -> 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
|
||||
@@ -19,6 +19,70 @@ gelangen. Routen liefern nur die für ihre Ansicht erlaubten Daten. Fachlogik
|
||||
wird nicht an Svelte-Komponenten gebunden, damit Browseraktionen und die
|
||||
spätere API dieselben Regeln verwenden.
|
||||
|
||||
## Navigation, Suche und Arbeitsflächen
|
||||
|
||||
`+layout.svelte` stellt für angemeldete Nutzer die globalen Bereiche Start,
|
||||
Eingang, Pieces, Streams, Live und Post bereit. Desktop verwendet eine horizontale
|
||||
Kopfzeile, Mobilgeräte eine obere Such-/Kontoleiste und eine Safe-Area-fähige
|
||||
Bottom-Navigation. `UserMenu.svelte` bündelt Verwaltung, Abmeldung und die weiter
|
||||
in `localStorage` gespeicherte explizite Auswahl Hell, Dunkel oder System.
|
||||
|
||||
Die Root-Route komponiert das Dashboard ausschließlich aus `listShows()`,
|
||||
`getShow()`, `listPieces()` und den vorhandenen Verwendungskontexten. Sie besitzt
|
||||
keinen eigenen persistenten Zustand. Pieces und Streams verwenden getrennte,
|
||||
lokal gespeicherte Ein-/Ausklappzustände ihrer Kontextnavigation; URL-Parameter
|
||||
bleiben die fachliche Quelle für lokale Suche und Filter.
|
||||
|
||||
`global-search.ts` ist eine serverseitige, ausschließlich hinter `requireUser`
|
||||
verwendete Projektion. Sie durchsucht aktive und archivierte Pieces einschließlich
|
||||
redaktioneller Langtexte sowie Metadaten und Usage-Snapshots aller Streams. Die
|
||||
Route `/suche` gibt gruppierte Positivprojektionen mit ID, Titel, Status, internem
|
||||
Ziel, Feldbezeichnung und begrenztem Ausschnitt aus. Gelöschte Pieces werden nicht
|
||||
global angeboten. Es gibt keinen externen Index und keine neue Persistenz.
|
||||
|
||||
Die normale Piece-Arbeitsmenge schließt Originale mit materialisierter
|
||||
Verwendungshistorie aus. Dieselben Originale werden unter `view=completed` mit
|
||||
ihren verlinkten Streams angezeigt und bleiben über die bestehenden
|
||||
Planungsfilter erneut einplanbar. Abgeschlossene Streams werden nicht mehr als
|
||||
eigener globaler Archivbereich behandelt, sondern von `listShows()` in der
|
||||
Streamübersicht gefiltert. Der technische `/archiv`-Pfad bleibt für archivierte
|
||||
und gelöschte Pieces bestehen.
|
||||
|
||||
`StreamInspector.svelte` ist die dauerhafte rechte Projektion des Streamplans.
|
||||
Seine flüchtige Auswahl referenziert Show, Abschnitt oder Usage nur über stabile
|
||||
IDs. Der Standardzustand bearbeitet Show- und Twitch-Metadaten; Abschnitt und
|
||||
Usage verwenden ihre bestehenden revisionsgeschützten Actions. Die zusätzliche
|
||||
Action `updateUsageMetadata` liest die aktuelle Usage serverseitig und führt
|
||||
nicht dargestellte Langtexte, Präsentationswerte, Medien und Werkzeuge in
|
||||
`updatePieceUsage()` unverändert mit. Ein schmales Clientformular kann private
|
||||
Felder daher weder versehentlich leeren noch die Geschäftsvalidierung umgehen.
|
||||
|
||||
Seit 0.8.1 liest `/suche` die optionalen URL-Filter `type`, `status`, `topic`
|
||||
und `format` serverseitig. Themen und Formate stammen direkt aus den bestehenden
|
||||
Taxonomietabellen; Piece-Filter wirken nicht auf Streams. Die Trefferprojektion
|
||||
und ihre begrenzten Ausschnitte bleiben unverändert, es entsteht weder ein
|
||||
Suchindex noch persistenter Filterzustand.
|
||||
|
||||
Die Piece-, Eingangs-, Post-, Planungs- und Live-Arbeitsflächen verwenden eine
|
||||
gemeinsame Oberflächensprache. Pieces, Eingang und Post teilen die Klasse `unified-workspace`:
|
||||
links Navigation oder Quellen, mittig die auswählbare Inhaltsprojektion und
|
||||
rechts `content-quick-inspector` für häufige Felder und Aktionen. Die Auswahl ist
|
||||
flüchtiger URL- beziehungsweise Browserzustand und erzeugt kein Fachobjekt. Im Streamplan ist der Ablauf die Hauptfläche, der Inspector die
|
||||
dauerhafte rechte Metadatenspalte und die Piece-Auswahl eine bei Bedarf geöffnete
|
||||
Nebenspalte. Eine Auswahl schließt diese temporäre Bibliothek. Unterhalb der
|
||||
Desktopbreite sind Pieces, Ablauf und Details gleichwertige Pane-Zustände.
|
||||
Revisionen, Drag-Daten und Fokusgrenzen bleiben die Interaktionsgrenze. Live ist
|
||||
eine reguläre Seite der App-Shell und projiziert Ablauf, Moderationsdokument und
|
||||
Details ausschließlich lesend. Die lokale Piece-Auswahl und Darstellungsoptionen
|
||||
ändern weder den tatsächlichen Live-Stand noch Usage-Status, Reihenfolge, Timer
|
||||
oder öffentliche Ausgabe.
|
||||
|
||||
Die gemeinsamen Klassen sind bewusst keine bloße Rasterabstraktion. Pieces und
|
||||
Post verwenden für linke Kataloge dieselbe `fundus-navigation`-Basis wie Streams
|
||||
und für rechte Projektionen `stream-inspector` zusammen mit `builder-pane`.
|
||||
Dadurch bleiben Kopf, Formularrhythmus, Scrollgrenze und Aktionsfuß identisch.
|
||||
Post ergänzt nur fachliche Felder und eine kompakte Inhaltswerkzeugzeile.
|
||||
|
||||
## Laufzeit und Speicher
|
||||
|
||||
- Lokal: `.data/sendewerk.sqlite` und `.data/uploads`, jeweils konfigurierbar.
|
||||
@@ -33,22 +97,23 @@ spätere API dieselben Regeln verwenden.
|
||||
|
||||
## Gesamtdatenmodell
|
||||
|
||||
Die ersten fünf Etappen materialisieren Identität, Inhaltsverwaltung,
|
||||
Sendungsplanung und Live-Durchführung. Folgende Grenzen gelten für umgesetzte
|
||||
und spätere Bereiche.
|
||||
Die ersten sieben Etappen materialisieren Identität, Inhaltsverwaltung,
|
||||
Sendungsplanung, Live-Durchführung, Historie, Lesefreigaben, RSS-Import und eine
|
||||
technische API. Folgende Grenzen gelten für umgesetzte und spätere Bereiche.
|
||||
|
||||
| Bereich | Geplante Entitäten und Beziehungen |
|
||||
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später |
|
||||
| Taxonomie | getrennte `section_categories`, `formats`, `topics` samt optionalem Themenicon; Pieces und Vorschläge mehrfach verknüpft |
|
||||
| Inhalt | `pieces` mit Status, Soft Delete, Favorit, Revision, Texten, Quellen, Werkzeugen und privaten Medien; Vorlagen speichern Snapshots |
|
||||
| Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themen-/Mediensnapshots, Reservebank und Vorlagen |
|
||||
| Live | expliziter Live-Stand, aktuelles Element, tatsächliche Piece-Zeiten, persistente Abschnittstimer und getrennte Präsentationsausgabe |
|
||||
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
|
||||
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
|
||||
| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen |
|
||||
| API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel |
|
||||
| Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie |
|
||||
| Bereich | Geplante Entitäten und Beziehungen |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sowie getrennte technische Zugänge |
|
||||
| Taxonomie | getrennte `section_categories`, `formats`, `topics` samt optionalem Themenicon; Pieces und Vorschläge mehrfach verknüpft |
|
||||
| Inhalt | `pieces` mit Status, Soft Delete, Favorit, Revision, Texten, Quellen, Werkzeugen und privaten Medien; Vorlagen speichern Snapshots |
|
||||
| Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themen-/Mediensnapshots, Reservebank und Vorlagen |
|
||||
| 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 |
|
||||
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
|
||||
| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen |
|
||||
| API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel |
|
||||
| Post | Aufzeichnungsteile, private Markdown-Quellen, revisionierte YouTube-/Clip-/Highlight-Elemente, Thumbnails und valide Exporte |
|
||||
| Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie |
|
||||
|
||||
### Historien- und Kopierregel
|
||||
|
||||
@@ -68,6 +133,10 @@ Ein Live-Abschnittstimer entsteht ausschließlich beim tatsächlichen Eintritt i
|
||||
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
|
||||
|
||||
@@ -95,6 +164,43 @@ konfigurierten Outbox-Pfad, `smtp` nutzt Nodemailer nur nach expliziter Auswahl.
|
||||
Die feste Anwendungs-Origin erzeugt Links und bestimmt den `Secure`-Status der
|
||||
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
|
||||
|
||||
Etappe 3 führt die Inhaltslogik in `src/lib/server/content.ts` zusammen. Listen
|
||||
@@ -156,7 +262,7 @@ erhöht sie atomar. Abgeschlossene Sendungen sind bereits an dieser Grenze gegen
|
||||
weitere Bearbeitung geschützt.
|
||||
|
||||
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,
|
||||
Metadaten und validierte Anbieter-ID. Zusätzlich speichern eigene geordnete
|
||||
Tabellen alle Quellen, Werkzeugnamen samt Start-/Arbeitslink und Notiz, die
|
||||
@@ -192,6 +298,13 @@ Sendungsvorlagen und ihre Abschnitte sind eigene Tabellen. Beim Anlegen werden
|
||||
die Abschnitte mit neuen IDs kopiert; spätere Änderungen an der Sendung können
|
||||
die Vorlage deshalb nicht verändern.
|
||||
|
||||
Die 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
|
||||
@@ -226,6 +339,14 @@ liegen als mehrere Start-/Endintervalle je Verwendung vor. Damit bleiben
|
||||
Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von der
|
||||
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
|
||||
Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt
|
||||
den stabilen Kanal `/heinzlive/default` in einem reduzierten Layout ohne
|
||||
@@ -236,15 +357,20 @@ rohen Schlüssel, weil die angemeldete Steuerung dieselbe stabile OBS-Adresse
|
||||
erneut ausgeben können muss. Datenbank und Sicherungen sind deshalb Teil der
|
||||
Geheimnisgrenze. Eine Rotation widerruft den bisherigen Schlüssel, normales
|
||||
erneutes Öffnen behält ihn bei.
|
||||
Page, Polling-JSON und Proxyrouten leiten ihre Daten aus einer eigenen
|
||||
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.
|
||||
Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den
|
||||
Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den
|
||||
gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen
|
||||
erst nach einer ausdrücklichen Snapshot-Aktualisierung.
|
||||
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
|
||||
`src/lib/server/presentation.ts` geladen. Jeder HTTP(S)-Abruf löst den Zielhost
|
||||
@@ -270,31 +396,136 @@ sichtbar; die Oberfläche kennzeichnet den Abbruch und versucht die Verbindung
|
||||
mit verlangsamtem Intervall erneut. Private Live-Daten werden nicht Teil dieses
|
||||
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.
|
||||
|
||||
## Postproduktion
|
||||
|
||||
`src/lib/server/post.ts` ist die gemeinsame Geschäftsgrenze für den Hauptbereich
|
||||
`/post`. Jede Übersicht beginnt bei `shows.status = 'completed'`; Post besitzt
|
||||
keine eigene Sendungsidentität und kann weder Entwürfe noch laufende Streams
|
||||
nachträglich als abgeschlossen markieren. `post_items` hält gemeinsame Identität,
|
||||
Typ, Workflowstatus, Herkunft, Position und Revision. Typbezogene 1:1-Tabellen
|
||||
speichern YouTube-, Social-Clip- oder Twitch-Highlight-Felder.
|
||||
|
||||
Migration `0022_post_quick_workbench.sql` ergänzt `social_caption` an
|
||||
`post_social_clips` und führt vorhandene TikTok-/Instagram-Captions und Hashtags
|
||||
verlustarm in das gemeinsame Feld über. YouTube Shorts verwendet im aktuellen
|
||||
Formvertrag ausschließlich `shorts_title`; TikTok und Instagram teilen die
|
||||
Caption inklusive Hashtags. Die älteren Spalten bleiben für die additive
|
||||
Migration bestehen, werden von der neuen Oberfläche aber nicht mehr getrennt
|
||||
bearbeitet.
|
||||
|
||||
`post_recording_parts` beschreibt mehrere Teile einer Aufzeichnung. Medienpfade
|
||||
sind redaktionelle Verweise und müssen für Resolve-Export absolut oder als
|
||||
`file://` angegeben sein; Framerate und Timebase werden separat validiert.
|
||||
`post_source_files` speichert Originalinhalt, privaten zufälligen Speichernamen,
|
||||
SHA-256, Größe sowie ausschließlich aus tatsächlich gefundenen Zeilen abgeleitete
|
||||
Transkript- und Zeitmarkenprojektionen. `(show_id, sha256)` verhindert Dubletten
|
||||
innerhalb einer Sendung, ohne Dateien sendungsübergreifend zusammenzuführen.
|
||||
|
||||
Kontextdateien und `post_thumbnails` liegen im konfigurierten Uploadverzeichnis.
|
||||
Die Routen unter `/post/[id]/source` und `/post/[id]/thumbnail` verlangen eine
|
||||
Redaktionssitzung, prüfen die Zuordnung zur abgeschlossenen Sendung und liefern
|
||||
private Sicherheitsheader. Originalnamen sind Metadaten und niemals Pfade. Pro
|
||||
YouTube-Element kann ein Thumbnail ausdrücklich ausgewählt werden.
|
||||
|
||||
Exporte unter `/post/[id]/export` lesen dieselbe positiv validierte Projektion.
|
||||
Resolve-XML benötigt Aufzeichnungspfad, Framerate, Timebase und einen positiven
|
||||
Start-/Endbereich. SRT wird cueweise vollständig geprüft; unvollständige Daten
|
||||
führen zu `EXPORT_NOT_READY` statt zu einer scheinbar gültigen Datei. Text- und
|
||||
JSON-Exporte erzeugen keine externe Veröffentlichung.
|
||||
|
||||
`src/lib/server/post-ai.ts` enthält nur Typen für einen später konfigurierbaren
|
||||
Provideradapter. Es gibt weder Netzwerkaufruf noch automatisch erzeugten Inhalt.
|
||||
Importierte Quellen und manuelle Bearbeitung bleiben die vollständige
|
||||
Produktfunktion.
|
||||
|
||||
## Oberflächenstruktur
|
||||
|
||||
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
|
||||
Auf dem Desktop bleibt eine feste Seitenleiste sichtbar; Verwaltung liegt an
|
||||
ihrem unteren Ende. Bis einschließlich `52rem` ersetzen eine obere Leiste und
|
||||
die untere Hauptnavigation die Seitenleiste vollständig. Auf breiten Ansichten
|
||||
stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie
|
||||
über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale
|
||||
Desktopfenster ohne horizontalen Überlauf bedienbar bleiben.
|
||||
Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
|
||||
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
|
||||
ohne interne Anwendungsnavigation.
|
||||
Die Hauptnavigation umfasst Start, Eingang, Pieces, Streams, Live und Post;
|
||||
Verwaltung bleibt im Benutzermenü.
|
||||
Auf dem Desktop liegt sie als feste horizontale Glasleiste oberhalb der
|
||||
Arbeitsfläche. Bis einschließlich `52rem` ersetzen eine kompakte obere Leiste
|
||||
und die untere Hauptnavigation diesen Desktopkopf vollständig. Auf breiten
|
||||
Ansichten stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem`
|
||||
werden sie über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch
|
||||
schmale 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.
|
||||
Die private Route `/live` läuft in derselben authentifizierten App-Shell wie
|
||||
Streams. Ein Streamkopf zeigt Titel, Termin, Umfang und den expliziten
|
||||
Nur-Lesen-Zustand. Darunter folgen auf breiten Ansichten Ablauf, Lesedokument und
|
||||
Details als drei `builder-pane`-/`stream-inspector`-artige Flächen. Bis
|
||||
einschließlich `72rem` werden diese Bereiche über eine gleichwertige Umschaltung
|
||||
einzeln gezeigt; bis `52rem` bleiben der kompakte App-Header und die mobile
|
||||
Bottomnavigation sichtbar.
|
||||
|
||||
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. Dieser Layoutmodus ändert weder die Live-Actions noch die getrennte
|
||||
öffentliche `/heinzlive`-Projektion. Lokale Lesepositionen sind an die jeweilige
|
||||
Verwendung gebunden und werden nicht zwischen Pieces übertragen.
|
||||
`src/routes/live/+page.server.ts` bietet neben `load` ausschließlich die getrennte
|
||||
Präsentationsaktion an. Das Auswählen einer Usage, Vor/Zurück, Schriftgröße,
|
||||
sichtbare Zeiten und das Kopieren von Twitch-Tags bleiben lokaler
|
||||
Browserzustand. Status-, Fortschritts-, Sortier- und Abschlussänderungen sind
|
||||
nicht Teil dieser Route. Präsentieren ändert nur `presentation_usage_id` und
|
||||
damit weder Show-/Usage-Status noch Reihenfolge. Die öffentliche `/heinzlive`-
|
||||
Projektion und die serverseitigen historischen Live-Dienste bleiben erhalten.
|
||||
|
||||
Das gemeinsame Arbeitsraster besteht aus einer linken, über Kopf und Inhalt
|
||||
reichenden Navigation sowie einem rechten Hauptbereich. Dessen freie Kopfzeile
|
||||
besitzt bewusst keine Pane-Fläche; darunter beginnen Inhalt und optionaler
|
||||
Inspector. `unified-workspace`, Post-Detail und Live verwenden dieselben
|
||||
Spaltenwerte `13–16rem / minmax(0, 1fr) / 19–22rem` und `0.85rem` Abstand. Bei
|
||||
mittleren Breiten folgt der Inspector unter dem Inhalt, mobil übernimmt die
|
||||
bereichsspezifische Pane-Umschaltung.
|
||||
|
||||
Dialog- und Pane-Bewegungen sind reine Oberflächenzustände mit 140–170 ms Dauer.
|
||||
Der Piece-Dialog animiert Öffnen, explizites Schließen und Backdrop; Disclosure-
|
||||
Inhalte und mobile Pane-Wechsel blenden kurz ein. Unter
|
||||
`prefers-reduced-motion: reduce` werden Animationen und Übergänge entfernt.
|
||||
|
||||
Icons werden aus dem lokalen Font-Awesome-Classic-Regular-Sprite unter
|
||||
`static/icons` gerendert. Die Verwaltung lädt zusätzlich die durchsuchbaren
|
||||
@@ -302,17 +533,58 @@ 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
|
||||
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung,
|
||||
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche.
|
||||
Quellen, Einordnung, Präsentation und eine Bildzusammenfassung liegen auf breiten
|
||||
Ansichten in einer kontextuellen Seitenleiste; auf kleinen Ansichten öffnen
|
||||
dieselben DOM-Formfelder als fokussierbare Werkzeugflächen und werden dabei nicht
|
||||
neu erzeugt oder ausgehängt. `EditorialMarkdownField` hält Lese- und
|
||||
Bearbeitungszustand je Textfeld lokal, während alle Werte weiterhin gemeinsam
|
||||
über die bestehende `save`-Action und Piece-Revision gespeichert werden. Die
|
||||
separate Bild-Action und alle Präsentationsactions bleiben eigene serverseitige
|
||||
Mutationen.
|
||||
Der Fundus ist auf breiten Ansichten ein Raster aus Bibliotheksnavigation,
|
||||
mittlerer Liste und rechter Schnellbearbeitung. Kleinere Desktopfenster ordnen
|
||||
den Inspector unter der Liste an; mobil werden Navigation, Liste und Inspector
|
||||
gestapelt. URL-Parameter tragen Suche, Filter und ausgewählte Piece-ID über den
|
||||
Routenwechsel, `sessionStorage` ausschließlich die zugehörige flüchtige
|
||||
Listenposition. Reduzierte Schnellformulare lesen nicht dargestellte Werte
|
||||
serverseitig aus dem aktuellen Piece und führen sie unverändert mit.
|
||||
|
||||
Der eigenständige Piece-Editor ordnet denselben Formvertrag als modales
|
||||
redaktionelles Arbeitsblatt mit eigener URL an. Titel, Einleitung, Moderationsstichpunkte,
|
||||
Interaktion, Überleitung, private Recherche, Quellen und Werkzeuge bilden die
|
||||
mittlere Schreibfläche. Status, Dauer, Einordnung, Präsentationsmodi und Medien
|
||||
liegen auf breiten Ansichten rechts. Alle Werte werden weiterhin gemeinsam über
|
||||
die bestehende `save`-Action und Piece-Revision gespeichert; separate Bild- und
|
||||
Präsentationsactions bleiben eigene serverseitige Mutationen.
|
||||
|
||||
Der Eingang verwendet dieselbe Darstellung, behält aber zwei Datenarten: neue
|
||||
`feed_entries` und bereits erzeugte Inbox-Pieces. Die rechte Projektion ruft für
|
||||
RSS weiterhin ausschließlich Import-/Verwerfactions auf. Post beginnt ebenfalls
|
||||
in einer dreiteiligen Übersicht; die vollständige Streamzeile und die rechte
|
||||
Öffnen-Aktion verlinken direkt auf `/post/[id]`. Die Route ist eine normale
|
||||
Anwendungsseite und übernimmt Seitenkopf, Datumsdarstellung, Feldgeometrie und
|
||||
responsive Pane-Umschaltung der Streambearbeitung. Darunter stehen
|
||||
Schnellnavigation, Editor/Übersicht und Inspector. Die linke Navigation
|
||||
gruppiert YouTube, Social-Clips und Twitch-Highlights und ruft deren Sofortanlage
|
||||
über dieselbe Post-Action auf. Ohne `item`-Queryparameter bleibt die Auswahl
|
||||
leer: Die Mitte projiziert alle vorhandenen Post-Inhalte mit Kopieraktionen,
|
||||
rechts stehen Streamzähler. Mit `item` zeigt dieselbe Mitte den Editor und rechts
|
||||
dessen Eigenschaften. Mutations-URLs führen `item` mit, damit Upload oder
|
||||
Speichern die aktuelle Auswahl nicht verlieren.
|
||||
|
||||
`createPostItem()` setzt `source_file_id` bereits beim Anlegen, wenn genau eine
|
||||
Kontextdatei zum Stream existiert. `listPostPieceOptions(showId)` verbindet
|
||||
`pieces`, `piece_usages` und `show_sections`; dadurch sind nur Original-Pieces
|
||||
des geöffneten Streams zuordenbar. Editor und Inspector nutzen dieselben Pane-,
|
||||
Feld- und Aktionsgrundlagen wie der Streamplan. Mobil werden Navigation,
|
||||
Bearbeitung und Details als drei explizite Pane-Zustände angeboten.
|
||||
|
||||
Checkboxen und Radios bleiben im DOM native Formularsteuerungen. Das globale
|
||||
Stylesheet blendet nur ihre sichtbaren Kästchen aus und projiziert `:checked` und
|
||||
`:focus-visible` auf das umgebende Label. Damit sind ganze Flächen per Maus,
|
||||
Touch und Tastatur wählbar, ohne Semantik oder Formübermittlung zu ersetzen. Der
|
||||
aktive Zustand verwendet wie Post-Navigation und Post-Register den cyanfarbenen
|
||||
Oberflächenakzent; Gold bleibt Primäraktionen vorbehalten.
|
||||
|
||||
`RichMarkdownField` erstellt je sichtbarem Langtext genau eine Tiptap-Instanz mit
|
||||
StarterKit und offizieller Markdown-Erweiterung. Der Formularwert bleibt rohes
|
||||
Markdown. Eine Bearbeitung aktualisiert ihn aus `getMarkdown()`, während das
|
||||
bloße Initialisieren keinen Input auslöst. Weicht die erste Serialisierung vom
|
||||
gespeicherten Text ab, startet das Feld in der Markdown-Quellansicht. Der Nutzer
|
||||
kann diese Ansicht jederzeit erreichen; Autosave erzeugt die Editorinstanz nicht
|
||||
neu.
|
||||
|
||||
## Sicherheitsgrenzen
|
||||
|
||||
@@ -321,3 +593,76 @@ betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor
|
||||
Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Netzwerkabrufe für
|
||||
Präsentationsmetadaten und Vorschaubilder schützen interne Ziele auch nach
|
||||
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.
|
||||
|
||||
## Lesender MCP-Zugang 0.7.3
|
||||
|
||||
`/mcp` ist ein stateless Streamable-HTTP-Adapter über `content.ts` und
|
||||
`shows.ts`. Er unterstützt die aktuelle per Request deklarierte MCP-Fassung
|
||||
`2026-07-28` einschließlich `server/discover`, Header-/Body-Abgleich und
|
||||
`resultType`. Für derzeitige OpenAI-Clients bleiben die Initialisierungsfassungen
|
||||
`2025-11-25`, `2025-06-18` und `2025-03-26` verfügbar. Es gibt keine
|
||||
MCP-Sitzung und keinen unabhängigen SSE-Strom; jede Anfrage trägt erneut ihre
|
||||
Authentifizierung.
|
||||
|
||||
Die fünf Werkzeuge sind ausschließlich lesend. Eigene Positivprojektionen
|
||||
lassen Mediennamen, interne Speicherpfade und Werkzeug-Arbeitsdaten aus. Die
|
||||
Detailprojektionen dürfen nach ausdrücklicher Zustimmung redaktionell private
|
||||
Recherche, Moderation, eigene Notizen, Quellen und Reserveinhalte enthalten.
|
||||
Keine MCP-Methode ruft Mutationsdienste auf.
|
||||
|
||||
Sendewerk ist zugleich OAuth-Autorisierungs- und Ressourcenserver. Geschützte
|
||||
Ressourcen- und Authorization-Server-Metadaten verweisen ausschließlich auf die
|
||||
konfigurierte Origin. Öffentliche PKCE-Clients werden per begrenztem DCR
|
||||
registriert; die Autorisierung verlangt exakte Redirect-URI, `S256`, den Scope
|
||||
`sendewerk:read` und den exakten Ressourcenindikator `<Origin>/mcp`. Codes sind
|
||||
fünf Minuten, Zugriffstokens eine Stunde gültig. SQLite speichert nur SHA-256-
|
||||
Hashes. Tokens sind direkt an Nutzer, Client, Scope und Ressource gebunden;
|
||||
Kontosperre und Passwortwechsel widerrufen sie, und jeder MCP-Aufruf verbindet
|
||||
erneut den aktuellen Nutzerzustand.
|
||||
|
||||
DCR ist für die von OpenAI weiterhin unterstützte Kompatibilität vorhanden,
|
||||
aber pro Quelladresse und insgesamt begrenzt. Clientnamen sind nicht verifiziert;
|
||||
der Zustimmungsdialog kennzeichnet dies und zeigt die genaue Rücksprung-Domain.
|
||||
Öffentliche Produktion verlangt HTTPS, während HTTP nur an Loopback-Adressen
|
||||
zulässig bleibt. Der bestehende technische REST-Token wird weder gelesen noch
|
||||
weitergereicht.
|
||||
+394
-5
@@ -371,8 +371,397 @@ 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.
|
||||
|
||||
Diese Darstellung ist keine neue Live-Zustandsgrenze. Start, direkte Auswahl,
|
||||
Erledigen, Überspringen, Reserveeinfügung und Präsentation bleiben unveränderte
|
||||
serverseitige Actions mit ihren bisherigen Revisions- und Idempotenzregeln. Ein
|
||||
Piece-Wechsel veröffentlicht daher weiterhin nichts; nur die ausdrückliche
|
||||
Präsentationsaction verändert die getrennte öffentliche Ausgabe.
|
||||
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.
|
||||
|
||||
## D-034: Nutzergebundener OAuth-Zugang statt Token-Passthrough
|
||||
|
||||
Der bestehende technische REST-Zugang kann und darf nicht für ChatGPT
|
||||
weitergereicht werden: Sein Klartexttoken ist nach der Erstellung nicht mehr
|
||||
verfügbar, und MCP verbietet Token-Passthrough. Sendewerk stellt deshalb einen
|
||||
eigenen OAuth-2.1-Authorization-Code-Flow für öffentliche Clients bereit. PKCE
|
||||
`S256`, exakte Redirect-URIs und der auf `<Origin>/mcp` gebundene
|
||||
Ressourcenindikator sind verpflichtend. Codes und einstündige Zugriffstokens
|
||||
liegen ausschließlich gehasht vor und sind direkt an ein aktives Nutzerkonto
|
||||
gebunden.
|
||||
|
||||
Die Autorisierungsserver-Metadaten bieten das von OpenAI unterstützte Dynamic
|
||||
Client Registration als Kompatibilitätsweg an. Die aktuelle MCP-Fassung stuft
|
||||
DCR zugunsten von Client ID Metadata Documents als veraltet ein. Ein allgemeiner
|
||||
CIMD-Abruf würde jedoch eine neue SSRF- und Cache-Sicherheitsgrenze eröffnen;
|
||||
für diese private Anwendung bleibt DCR deshalb eng mengen- und ratenbegrenzt.
|
||||
Der Zustimmungsdialog behandelt den selbst gemeldeten Clientnamen ausdrücklich
|
||||
als unverifiziert und zeigt dessen Rücksprung-Domain.
|
||||
|
||||
Der MCP-Adapter unterstützt sowohl die aktuelle stateless Fassung `2026-07-28`
|
||||
als auch die von OpenAI weiterhin dokumentierten Initialisierungsfassungen bis
|
||||
`2025-03-26`. Tooldaten werden aus bestehenden Diensten positiv projiziert.
|
||||
Der einzige Scope `sendewerk:read` ist im Dialog ausdrücklich einschließlich
|
||||
privater redaktioneller Recherche beschrieben; E-Mail, Uploadnamen,
|
||||
Speicherpfade und Werkzeug-Arbeitsdaten werden nicht ausgegeben. Alle Werkzeuge
|
||||
sind als lesend markiert und haben keinen Pfad zu Mutationsdiensten.
|
||||
|
||||
Quellen, geprüft am 27. Oktober 2026:
|
||||
|
||||
- <https://developers.openai.com/apps-sdk/build/auth>
|
||||
- <https://developers.openai.com/apps-sdk/deploy/connect-chatgpt>
|
||||
- <https://platform.openai.com/docs/mcp>
|
||||
- <https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization>
|
||||
- <https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http>
|
||||
- <https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning>
|
||||
- <https://modelcontextprotocol.io/specification/2026-07-28/server/discover>
|
||||
- <https://modelcontextprotocol.io/specification/2026-07-28/server/tools>
|
||||
|
||||
## D-035: Horizontale Fundus-Werkbank und Markdown als Editorgrenze
|
||||
|
||||
Die Desktopnavigation wird horizontal, damit der Fundus die gesamte Breite als
|
||||
drei fachlich eindeutige Spalten nutzen kann: Navigation, Liste oder Schreiben,
|
||||
sowie Metadaten und Präsentation. Das Öffnen eines Pieces wechselt nur den
|
||||
mittleren Arbeitskontext; Filter bleiben URL-Zustand, die Listenposition bleibt
|
||||
flüchtiger Browserzustand. Bei weniger Platz verschwindet zuerst die rechte
|
||||
Kontextspalte. Mobil wird nicht das Desktopgitter gestapelt, sondern zwischen
|
||||
kompakten, einzeln bedienbaren Ansichten gewechselt.
|
||||
|
||||
Tiptap ist ausschließlich eine Browser-Bearbeitungsschicht. Markdown bleibt
|
||||
persistentes und öffentliches API-Format, die vorhandene serverseitige
|
||||
Marked-/sanitize-html-Grenze bleibt unverändert. `@tiptap/core`, `@tiptap/pm`,
|
||||
`@tiptap/starter-kit` und `@tiptap/markdown` werden versionsgleich in 3.32.0
|
||||
verwendet und sind MIT-lizenziert. Weil die Markdown-Erweiterung noch als frühe
|
||||
Veröffentlichung dokumentiert ist, löst Initialisierung keine Speicherung aus;
|
||||
abweichende Roundtrips öffnen die jederzeit erreichbare Quellansicht statt den
|
||||
gespeicherten Text still umzuschreiben.
|
||||
|
||||
Quellen, geprüft am 27. Oktober 2026:
|
||||
|
||||
- <https://tiptap.dev/docs/editor/getting-started/install/svelte>
|
||||
- <https://tiptap.dev/docs/editor/markdown/getting-started/basic-usage>
|
||||
- npm-Metadaten der vier Tiptap-Pakete 3.32.0
|
||||
|
||||
## D-036: Begriffe und Navigation sind Projektionen, keine neuen Fachmodelle
|
||||
|
||||
Die sichtbaren Begriffe Pieces und Streams ersetzen Fundus und Sendungen in der
|
||||
Bedienoberfläche. Persistierte Tabellen, Statuswerte, API-Pfade und bestehende
|
||||
Browserrouten werden nicht umbenannt: Eine rein visuelle Neuordnung rechtfertigt
|
||||
weder eine riskante Datenmigration noch einen Bruch externer Verträge.
|
||||
|
||||
Start ist eine aus vorhandenen Diensten abgeleitete Projektion. Die globale
|
||||
Suche bleibt ein synchroner serverseitiger SQLite-Leseweg ohne eigenen Index.
|
||||
Nur angemeldete Nutzer erreichen sie; Treffer enthalten kurze Ausschnitte statt
|
||||
vollständiger Dokumente. Lokale Bereichssuchen und URL-Filter bleiben davon
|
||||
unabhängig.
|
||||
|
||||
Bereits gesendete Pieces bilden keinen neuen persistenten Status. Wie bisher ist
|
||||
die materialisierte Verwendungshistorie die Quelle; sie bestimmt nun zusätzlich
|
||||
die sichtbare Gruppe Abgeschlossen. Ebenso werden die sichtbaren Streamzustände
|
||||
Angelegt und In Vorbereitung aus `draft` plus vorhandener Abschnittszahl
|
||||
abgeleitet. Die bestehenden Status- und Snapshotregeln bleiben damit alleinige
|
||||
Fachquelle.
|
||||
|
||||
Der rechte Usage-Editor ist auf Desktop ein nicht-modales Dialogelement. Dadurch
|
||||
bleiben dieselbe Komponente, Validierung und Form-Action erhalten, während der
|
||||
Ablauf daneben bedienbar bleibt. Mobil verwendet dieselbe Komponente weiterhin
|
||||
einen modalen Vollbildkontext.
|
||||
|
||||
## D-037: Arbeitsflächenhierarchie ohne neue Zustandsgrenzen
|
||||
|
||||
Das 0.8.1-Redesign verändert die visuelle Gewichtung, nicht die fachlichen
|
||||
Aggregate. Piece-Editor, Streamplan und Live-Regie behalten ihre vorhandenen
|
||||
Form-Actions, Revisionen, Befehls-IDs, Snapshots und getrennten Scroll- sowie
|
||||
Fokuszustände. Die Piece-Bibliothek im Streamplan wird nur flüchtig ein- und
|
||||
ausgeblendet; URL, Filter und Planungsdaten bleiben davon unberührt.
|
||||
|
||||
Suchfilter sind dagegen fachliche Leseparameter und werden deshalb serverseitig
|
||||
aus der URL angewendet. Status gilt für beide Treffergruppen; Thema und Format
|
||||
gelten ausschließlich für Pieces und schließen Streamtreffer aus. Taxonomie und
|
||||
Trefferzahlen werden aus SQLite abgeleitet. Es gibt keinen neuen Index, keine
|
||||
Migration und keinen konto- oder browsergebundenen Filterzustand.
|
||||
|
||||
## D-038: Stream-Inspector statt Usage-Overlay
|
||||
|
||||
Die nicht-modale Dialoglösung aus D-036 wird für die Streamplanung durch eine
|
||||
dauerhafte rechte Inspector-Spalte ersetzt. Der Ablauf bleibt dadurch räumlich
|
||||
stabil und die Auswahl von Stream, Abschnitt oder Usage ändert nur den Inhalt
|
||||
derselben Metadatenfläche. Unterhalb der Desktopbreite ist der Inspector ein
|
||||
eigener `Details`-Pane statt eines überlagernden Dialogs.
|
||||
|
||||
Show und Abschnitt verwenden die vorhandenen Actions. Für Usage-Schnellfelder
|
||||
führt eine schmale Route-Action die nicht dargestellten Snapshotfelder aus der
|
||||
aktuellen serverseitigen Projektion mit und ruft anschließend unverändert
|
||||
`updatePieceUsage()` auf. Damit bleibt die Revision die gemeinsame
|
||||
Schreibgrenze; ein reduziertes Formular darf keine Moderations-, Recherche- oder
|
||||
Präsentationsdaten löschen. Der Link `Piece bearbeiten` führt bewusst zum
|
||||
Original-Piece und vermischt dessen Autosave nicht mit der Streamrevision.
|
||||
|
||||
## D-039: Postproduktion bleibt an abgeschlossene Streams gebunden
|
||||
|
||||
Post erhält kein zweites Sendungsmodell. Jeder Aufzeichnungsteil, jede Quelle und
|
||||
jeder Post-Inhalt verweist auf eine vorhandene Sendung; der Dienst akzeptiert
|
||||
ausschließlich `shows.status = 'completed'`. Gemeinsame Identität, Status,
|
||||
Revision, Herkunft und Reihenfolge liegen in `post_items`, während YouTube,
|
||||
Social-Clips und Twitch-Highlights ihre unterschiedlichen Felder in typbezogenen
|
||||
Tabellen führen. Migration `0021` ist damit rein additiv und verändert keine
|
||||
bestehenden Stream-Snapshots.
|
||||
|
||||
Markdown-Kontext wird unverändert privat gespeichert. SHA-256 schützt pro
|
||||
Sendung vor Dubletten; Zeitmarken und Transkriptfragmente werden nur aus
|
||||
tatsächlich vorhandenen Zeilen extrahiert. Quellen und Thumbnails verwenden
|
||||
zufällige Speichernamen im konfigurierten Uploadverzeichnis und authentifizierte
|
||||
Auslieferungsrouten. Ein Originalname oder Medienpfad wird nie zu einer
|
||||
ungeschützten statischen URL.
|
||||
|
||||
Ein Export darf seine technische Verwendbarkeit nicht vortäuschen. Resolve-XML
|
||||
benötigt daher einen absoluten Pfad oder `file://`, Framerate, Timebase sowie
|
||||
positive Start- und Endpunkte. SRT wird vollständig blockweise validiert. Fehlt
|
||||
eine Voraussetzung, antwortet die Exportroute mit `EXPORT_NOT_READY`, und die
|
||||
Oberfläche bietet den Export nicht an.
|
||||
|
||||
Eine KI-Schreibfunktion wird erst mit einem real konfigurierten Provider
|
||||
eingeführt. `post-ai.ts` definiert nur den providerneutralen Ein-/Ausgabevertrag;
|
||||
es gibt keine Scheinaktion, keinen Netzwerkaufruf und keine Abhängigkeit des
|
||||
manuellen Workflows von einem KI-Dienst.
|
||||
|
||||
## D-040: Gemeinsame Inhaltswerkbank und URL-gebundene Voll-Editoren
|
||||
|
||||
Pieces, Eingang und Post folgen derselben visuellen Hierarchie: Navigation oder
|
||||
Quellen links, auswählbare Inhalte mittig und häufige Bearbeitung rechts. Der
|
||||
Inspector ist ausdrücklich kein reines Metadatenpanel; er enthält die im Alltag
|
||||
benötigten Texte, kontextbezogene Uploads und Primäraktionen. Bei mittleren
|
||||
Breiten folgt er unter dem Inhalt, mobil werden die Bereiche geordnet gestapelt.
|
||||
Diese Projektion führt keine neue persistente Auswahl oder Fachlogik ein.
|
||||
„Gemeinsam“ bedeutet dabei auch gemeinsame Oberflächenbausteine: Katalognavigation,
|
||||
`builder-pane` und `stream-inspector` werden von Streams, Pieces und Post direkt
|
||||
geteilt. Bereichsspezifische Parallelimplementierungen mit nur ähnlicher Optik
|
||||
werden vermieden.
|
||||
|
||||
Die vollständige Piece- und Postbearbeitung behält ihre bestehende eigene URL.
|
||||
Der Piece-Editor bleibt ein nativer modaler Dialog ohne zweite
|
||||
Inhaltsnavigation. Die Post-Werkbank ist dagegen eine normale Seite im
|
||||
Streams-Layout, damit globale Navigation, Streamkontext und der Wechsel zwischen
|
||||
mehreren YouTube-, Social- und Highlight-Inhalten räumlich stabil bleiben. Sie
|
||||
ergänzt dafür bewusst eine linke Schnellnavigation. Piece-Autosave, Revisionen,
|
||||
Post-Revisionen und sämtliche Snapshotgrenzen bleiben die einzigen
|
||||
Schreibgrenzen.
|
||||
|
||||
RSS bleibt vor der Übernahme eine eigene Entität im Eingang. Eine einheitliche
|
||||
Darstellung darf diese Grenze nicht verwischen: Vorschau, Verwerfen und
|
||||
Übernehmen arbeiten auf `feed_entries`; erst der ausdrückliche Import erzeugt ein
|
||||
Piece. Ebenso projiziert Post ausschließlich vorhandene abgeschlossene Streams
|
||||
und erhält keine zweite Sendungsverwaltung.
|
||||
|
||||
## D-041: Stream-orientierte Post-Schnellwerkbank und gemeinsame Auswahlflächen
|
||||
|
||||
Die Post-Detailroute startet ohne ausgewählten Einzelinhalt. In diesem Zustand
|
||||
zeigt die Mitte eine kopierbare Übersicht und der Inspector ausschließlich
|
||||
Streammetadaten; erst eine Auswahl in der linken Navigation öffnet einen Editor.
|
||||
Die URL trägt die Auswahl als `item`, und jede Mutation übernimmt diesen
|
||||
Parameter. Dadurch bleiben Reload, Uploads und Speichern adressierbar, ohne einen
|
||||
neuen persistenten Auswahlzustand einzuführen.
|
||||
|
||||
TikTok und Instagram teilen eine Caption inklusive Hashtags. YouTube Shorts
|
||||
führt nur den Titel. Migration `0022` ergänzt dafür additiv `social_caption` und
|
||||
übernimmt vorhandene Texte. Eine einzige Kontextdatei wird bei der Anlage
|
||||
automatisch verknüpft. Piece-Optionen werden anhand der tatsächlichen Usages des
|
||||
geöffneten Streams begrenzt; die Postproduktion kann damit keine fachlich
|
||||
fremden Fundus-Pieces zuordnen.
|
||||
|
||||
Checkboxen und Radios werden nicht durch eigene JavaScript-Steuerungen ersetzt.
|
||||
Sie bleiben native, fokussierbare Eingaben, während das Label als farblich
|
||||
markierte Auswahlfläche erscheint und das Kästchen selbst unsichtbar bleibt.
|
||||
Diese Regel gilt sendewerkweit und erhält Formsemantik, Tastaturbedienung sowie
|
||||
serverseitige Formverträge.
|
||||
|
||||
Post verwendet für aktive Inhaltszeilen, Register, Zielplattformen und Filter
|
||||
dieselbe Auswahl wie Streams: abgerundete ruhige Fläche und dünne goldene
|
||||
Kontur, ohne Cyan-Füllung oder seitlichen Balken. Felder, Radien und Abstände
|
||||
werden aus der Streambearbeitung übernommen, statt einen eigenen
|
||||
Post-Formularstil zu bilden.
|
||||
|
||||
## D-042: Live als vollständig lesende Streamprojektion
|
||||
|
||||
Live ist keine eigene Steuerungsshell mehr, sondern eine reguläre
|
||||
Sendewerk-Seite. Sie verwendet globale Desktop- und Mobilnavigation, den
|
||||
Streamkopf sowie drei Pane-Flächen für Ablauf, Moderationsdokument und Details.
|
||||
Die responsive Umschaltung folgt den übrigen Werkbänken statt eines modalen
|
||||
Ablauf-Sheets.
|
||||
|
||||
Die Route zeigt keine Status-, Fortschritts-, Sortier- oder Abschlusssteuerung.
|
||||
Das Auswählen eines Pieces, Vor/Zurück und die Darstellungsoptionen sind
|
||||
ausschließlich lokaler Browserzustand. Präsentieren bleibt als ausdrücklich
|
||||
getrennte Ausgabeaktion erhalten und schreibt nur den Präsentationsstand. Dadurch
|
||||
kann Navigation innerhalb von Live weder den Stream- oder Usage-Status noch die
|
||||
Reihenfolge verändern. Bestehende Live-Daten werden als gespeicherter Stand
|
||||
dargestellt.
|
||||
|
||||
## D-043: Verbindliches Seitenraster und reduzierte App-Bewegung
|
||||
|
||||
Streams definiert nicht nur Farben und Pane-Radien, sondern das Seitenraster für
|
||||
alle Bereiche. Die linke Bereichsspalte beginnt unmittelbar unter der globalen
|
||||
Navigation und überspannt freien Seitenkopf sowie Inhaltszeile. Der Seitenkopf
|
||||
liegt ohne eigene Hintergrundfläche im Hauptbereich. Erst darunter beginnen die
|
||||
mittlere Inhaltsfläche und, falls vorhanden, der rechte Inspector. Gemeinsame
|
||||
Spaltenbreiten und Abstände dürfen nicht bereichsspezifisch abweichen.
|
||||
|
||||
Der Piece-Voll-Editor bleibt ein erkennbarer modaler Dialog und erhält deshalb
|
||||
auf Desktop Abstand zum Viewportrand, Kontur, Schatten und sichtbaren Backdrop.
|
||||
Öffnen, Schließen, Vergrößern beziehungsweise Pane-Wechsel verwenden kurze,
|
||||
ruhige Bewegungen unter 200 ms. Sie verändern keine Fachzustände und werden bei
|
||||
reduzierter Bewegung vollständig deaktiviert.
|
||||
+473
-22
@@ -1,29 +1,425 @@
|
||||
# Release Notes
|
||||
|
||||
## 0.5.12 - Kompakte Live-Regie
|
||||
## 0.9.0 - Postproduktion
|
||||
|
||||
Veröffentlicht am 29. Oktober 2026, Korrekturstand 30. Oktober 2026
|
||||
|
||||
- **Post** ergänzt die Hauptnavigation und zeigt alle bestehenden abgeschlossenen
|
||||
Streams mit Suche, Sortierung, Fortschritt und Statusfilter. Die Streamdaten
|
||||
bleiben die einzige fachliche Quelle.
|
||||
- Pieces, Eingang und Post verwenden eine gemeinsame Inhaltswerkbank mit
|
||||
Navigation beziehungsweise Quellen links, auswählbaren Inhalten in der Mitte
|
||||
und echter Schnellbearbeitung rechts. Wichtige Texte, Einordnung, passende
|
||||
Uploads und häufige Aktionen sind ohne Öffnen des Voll-Editors erreichbar.
|
||||
- Die Korrektur vom 30. Oktober vereinheitlicht auch die konkreten Bauteile:
|
||||
Post besitzt nun dieselbe einklappbare Katalognavigation wie Streams und
|
||||
Pieces; Piece-Schnellbearbeitung, Post-Schnellübersicht und Post-Eigenschaften
|
||||
verwenden den Stream-Inspector samt identischem Formular- und Aktionsaufbau.
|
||||
- Vollständige Piece- und Postbearbeitung behält die bestehende eigene URL und
|
||||
folgt zwei passenden Darstellungen: Piece bleibt ein großes gesperrtes
|
||||
Arbeitsblatt, Post ist eine normale Seite im Streams-Layout. Post besitzt
|
||||
Streamkopf, Datum und Zurücklink sowie links eine Schnellnavigation für
|
||||
YouTube, Social-Clips und Twitch-Highlights samt Sofortanlage. Ohne Einzelwahl
|
||||
zeigt die Mitte eine kopierbare Inhaltsübersicht und rechts stehen
|
||||
Streammetadaten.
|
||||
- Streamzeilen und die rechte Öffnen-Aktion führen direkt in die zugehörige
|
||||
Post-Werkbank. Eine farblich aktive Inhaltszeile öffnet den jeweiligen Editor.
|
||||
- Checkboxen und Radios erscheinen sendewerkweit als vollständig anklickbare,
|
||||
wie Streams markierte Auswahlflächen ohne sichtbare Kästchen. Post-Inhaltszeilen,
|
||||
Register, Filter und RSS-Auswahl verwenden eine abgerundete ruhige Fläche mit
|
||||
dünner goldener Kontur statt Cyan-Füllung oder Seitenbalken. Native Semantik,
|
||||
Fokus und Formübermittlung bleiben erhalten.
|
||||
- Live ist nun eine vollständig lesende Streamprojektion in der normalen
|
||||
Sendewerk-Shell. Streamkopf, Ablauf, Moderationsdokument und Details folgen der
|
||||
Streams-Geometrie; Desktop- und Mobilnavigation bleiben sichtbar. Vor/Zurück,
|
||||
Uhr und Präsentieren bleiben erhalten; Status-, Fortschritts-, Sortier- und
|
||||
Abschlussaktionen entfallen. Lokale Auswahl und Darstellungsoptionen schreiben
|
||||
keine Stream- oder Piece-Daten, Präsentieren nur den getrennten Ausgabestand.
|
||||
- Pieces, Eingang, Post und Live übernehmen auch das Seitenraster von Streams:
|
||||
linke Navigation ab Oberkante, freier Seitenkopf und erst darunter Inhalts-
|
||||
und Inspectorflächen mit festen gemeinsamen Spaltenbreiten und Abständen.
|
||||
- Der Piece-Editor ist wieder klar als Overlay erkennbar und hält auf Desktop
|
||||
Abstand zum Viewportrand. Sein Öffnen kombiniert einen schnellen räumlichen
|
||||
Lift mit sanft gestaffeltem Inhalt und Backdrop; Schließen führt dieselbe
|
||||
Bewegung kompakt zurück und navigiert erst nach ihrem tatsächlichen Ende.
|
||||
Weitere native Dialoge verwenden dieselbe Bewegungsrichtung. Alle Übergänge
|
||||
bleiben unter 200 ms; reduzierte Bewegung deaktiviert sie ohne Wartezeit.
|
||||
- Post-Formfelder, Rundungen und Abstände entsprechen der Streambearbeitung. Die
|
||||
Überschrift der Übersicht und der linken Detailnavigation lautet vollständig
|
||||
„Postproduktion“.
|
||||
- Der Eingang bleibt Sichtungs- und Importbereich. RSS-Vorschau, Original,
|
||||
Übernehmen und Verwerfen stehen im Inspector; erst die bewusste Übernahme
|
||||
erzeugt ein normales Piece.
|
||||
- Aufzeichnungsteile speichern optionale Medienparameter. Mehrere private
|
||||
Markdown-Kontextdateien behalten ihren Originalinhalt, werden innerhalb einer
|
||||
Sendung per SHA-256 dedupliziert und nur konservativ nach vorhandenen
|
||||
Zeitmarken ausgewertet.
|
||||
- Mehrere YouTube-Uncuts führen Beschreibung, Titelvarianten, Thumbnailideen,
|
||||
echte auswählbare Thumbnail-Dateien und Veröffentlichungsdaten.
|
||||
- Social-Clips besitzen Originaltranskripte, einen YouTube-Shorts-Titel, eine
|
||||
gemeinsame TikTok-/Instagram-Caption inklusive Hashtags, Hooks, Endcards,
|
||||
Schnitt-, Format- und Untertiteldaten. Twitch-Highlights bilden einen
|
||||
eigenständigen redaktionellen Typ.
|
||||
- Bei genau einer Kontextdatei wird sie neuen Post-Inhalten automatisch
|
||||
zugeordnet. Die Piece-Auswahl ist auf Originale begrenzt, die im geöffneten
|
||||
Stream tatsächlich verwendet wurden.
|
||||
- Authentifizierte Datei- und Exportrouten schützen Quellen und Thumbnails.
|
||||
Resolve-XML und SRT werden nur angeboten und ausgeliefert, wenn die jeweils
|
||||
erforderlichen Daten vollständig valide sind.
|
||||
- Es gibt keine simulierte KI-Funktion. Ein providerneutraler Adaptervertrag ist
|
||||
für eine spätere echte Integration vorbereitet; der manuelle Ablauf ist ohne
|
||||
externe Dienste vollständig.
|
||||
- Migration `0021_post_production.sql` ist additiv. Migration
|
||||
`0022_post_quick_workbench.sql` ergänzt die gemeinsame Social-Caption und
|
||||
übernimmt vorhandene Plattformtexte. Bestehende Stream-, Piece-, Live- und
|
||||
Freigabedaten werden nicht verändert. Paket, Lockdatei, Cloudron-Manifest und
|
||||
Oberfläche tragen Version `0.9.0`.
|
||||
- Lint, Typprüfung, 69 Verhaltenstests, Produktionsbuild und 22 Chromium-Abläufe
|
||||
sind erfolgreich. Safari, echte Mobilgeräte, reale Schnittsoftware und eine
|
||||
echte KI-Anbindung bleiben mangels Umgebung offen.
|
||||
|
||||
## 0.8.2 - Piece-Schreibraum
|
||||
|
||||
Veröffentlicht am 27. Oktober 2026, Korrekturstand 28. Oktober 2026
|
||||
|
||||
- Einleitung, Moderationsstichpunkte, Interaktion und Überleitung bilden ein
|
||||
zusammenhängendes, inhaltswachsendes Schreibdokument. Nur der fokussierte
|
||||
Abschnitt zeigt Formatierungswerkzeuge; Tiptap, Auswahl erhaltende Befehle und
|
||||
Markdown-Quellansicht bleiben erhalten.
|
||||
- Im geöffneten Editor wird die linke Bereichsnavigation zur gefilterten
|
||||
Piece-Liste mit Suche, denselben Filtern, markiertem aktuellem Piece und
|
||||
zustandserhaltendem Rücksprung. Ein Wechsel läuft über bestehenden Autosave und
|
||||
Revisionsschutz. Übersichtsseiten behalten ihre bisherige Navigation.
|
||||
- Status, Dauer, Format und Themen sind rechts kompakt direkt bearbeitbar;
|
||||
Kurzbeschreibung, Aufwand und Kategorien liegen unter „Weitere Details“.
|
||||
Text, Nur Bild, Linkvorschau und Video sowie die direkt editierbare
|
||||
Publikumsvorschau bleiben darunter sichtbar.
|
||||
- Der reduzierte Kopf bündelt Favorit, Speicherzustand, manuelles Speichern und
|
||||
ein abgesichertes Aktionsmenü. Speicher- und Aktionssteuerung stehen unter dem
|
||||
Titel, sodass lange Überschriften auch bei schmalem Desktop Platz behalten. Die
|
||||
dauerhafte Editor-Fußleiste und doppelte Metadaten entfallen.
|
||||
- Mobil öffnen Streamtext, Recherche, Piece-Liste und Details/Ausgabe als
|
||||
getrennte Ansichten. Der Piece-Drawer ist schließbar; jeder Textbereich besitzt
|
||||
einen touchfähigen Zugang zu seinen Werkzeugen. Die globale Hauptnavigation
|
||||
bleibt unten.
|
||||
- Autosave, Revisionsschutz, Quellen, Uploads, Testfenster, Vorlagen, Favorit,
|
||||
Duplikation, Archivierung und Löschung behalten ihre bisherigen Actions.
|
||||
Streamfassungen und Snapshots, Live-Regie und Suche bleiben fachlich
|
||||
unverändert. Es gibt keine Datenbankmigration.
|
||||
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.8.2`.
|
||||
- Die Desktopgeometrie füllt die mittlere Spalte und wächst beim Einklappen der
|
||||
Piece-Liste. Der Textsatz bleibt innerhalb der vollbreiten Dokumentfläche
|
||||
lesbar; die rechte Spalte zeigt die Publikumsvorschau statt einer langen
|
||||
Formularmatrix.
|
||||
- Eine Korrektur für mittlere Desktopbreiten entfernt die beim Einklappen
|
||||
verbliebene leere Rasterspalte. Größere Navigations- und Piece-Icons, eine
|
||||
überlappungsfreie kompakte Kopfnavigation und das sichtbare Versionslabel
|
||||
`0.8.2` verbessern die Orientierung.
|
||||
- Die Piece-Markierung bleibt mit eigenem Scrollgutter bündig.
|
||||
- Der Streamplan zeigt rechts dauerhaft einen Inspector in der Breite der
|
||||
Piece-Metadaten. Standardmäßig enthält er Streamdaten und Twitch-Texte; ein
|
||||
Klick auf Abschnitt oder Ablauf-Piece zeigt die passenden Schnellfelder.
|
||||
Piece-Auswahlen enden mit `Piece bearbeiten` und `Speichern`, weitere
|
||||
bestehende Usage-Aktionen bleiben eingeklappt erreichbar.
|
||||
- Die Piece-Bibliothek schließt bei einer Inhaltsauswahl automatisch, damit
|
||||
Ablauf und Inspector nebeneinander lesbar bleiben. Schmalere Ansichten
|
||||
verwenden dafür den zusätzlichen Arbeitsbereich `Details`.
|
||||
- Das neue Usage-Metadatenformular führt nicht dargestellte Langtexte,
|
||||
Präsentationswerte, Medien und Werkzeuge serverseitig unverändert mit und
|
||||
bleibt revisionsgeschützt.
|
||||
- Die Live-Regie verwendet die neue ruhige Dokument- und Ablaufsprache: aktuelle
|
||||
Moderationstexte liegen in einem zusammenhängenden Lesedokument, der Ablauf ist
|
||||
flacher und Aktionszeilen überlagern sich nicht. Vollbildshell, Timer,
|
||||
Präsentationsgrenze, mobile Ablaufansicht und sämtliche Live-Actions bleiben
|
||||
unverändert.
|
||||
- Lint, Typprüfung, 64 Verhaltenstests, Produktionsbuild und 22
|
||||
Chromium-Abläufe sind erfolgreich. Safari und echte Mobilgeräte bleiben
|
||||
mangels Umgebung offen.
|
||||
|
||||
## 0.8.1 - Redaktionelle Werkbänke
|
||||
|
||||
Veröffentlicht am 27. Oktober 2026
|
||||
|
||||
- Der Piece-Editor beginnt direkt unter dem Titel mit Einleitung,
|
||||
Moderationsstichpunkten, Interaktion und Überleitung. Die ruhig begrenzte
|
||||
Schreibspalte steht der dauerhaft rechten Metadaten- und Präsentationsfläche
|
||||
gegenüber; Recherche, Quellen, Werkzeuge, Uploads und Testansicht bleiben
|
||||
vollständig erhalten.
|
||||
- Die Streamplanung priorisiert den breiten Ablauf. Abschnitte sind klar
|
||||
abgegrenzte Blöcke, Pieces kompakte Zeilen und die durchsuchbare Piece-Auswahl
|
||||
öffnet sich erst auf Anforderung neben dem Plan. Direktanlage, Reservebank,
|
||||
Verwendungsfilter sowie Drag-and-drop und seine Klick-/Tastaturalternativen
|
||||
bleiben unverändert funktionsfähig.
|
||||
- Die Live-Regie hebt aktuelles Piece, Abschnitt und Moderationsinhalte stärker
|
||||
hervor. Ausgabe, Gesamt- und Abschnittszeit sowie Ablauf bleiben schnell
|
||||
erfassbar; mobile Ablaufleiste, Timer, Fortschrittsaktionen, Recherche,
|
||||
Präsentation und OBS-Ausgabekanal behalten ihre bisherigen Zustandsgrenzen.
|
||||
- Die globale Suche besitzt eine einklappbare Filterspalte für Ergebnisart,
|
||||
Status, Piece-Thema und Piece-Format. Trefferzahlen, aktive Filter und
|
||||
Zurücksetzen sind sichtbar; archivierte Pieces und abgeschlossene Streams
|
||||
bleiben auffindbar. Filter werden serverseitig aus URL-Parametern angewendet.
|
||||
- Die globale Desktop- und Mobilnavigation, Routen, Form-Actions, Revisionen,
|
||||
Snapshotgrenzen und öffentlichen Datenprojektionen sind unverändert. Es gibt
|
||||
keine Datenbankmigration.
|
||||
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.8.1`.
|
||||
- Lint, Typprüfung, 63 Verhaltenstests, Produktionsbuild und 21
|
||||
Chromium-Abläufe sind erfolgreich. Safari und echte Mobilgeräte bleiben
|
||||
mangels Umgebung offen.
|
||||
|
||||
## 0.8.0 - Redaktioneller Arbeitsbereich
|
||||
|
||||
Veröffentlicht am 27. Oktober 2026
|
||||
|
||||
- Die globale Navigation besteht aus Start, Eingang, Pieces, Streams und Live.
|
||||
Globale Suche und ein gemeinsames Benutzermenü ergänzen Desktopkopf und mobile
|
||||
Suchleiste; Theme, Verwaltung und Abmeldung sind dort explizit erreichbar.
|
||||
- Die neue Startseite zeigt ausschließlich vorhandene redaktionelle Daten: den
|
||||
nächsten Stream mit Ablauf, aktuelle Pieces, Schnellaktionen und weitere
|
||||
geplante Streams.
|
||||
- Die angemeldeten Nutzern vorbehaltene globale Suche gruppiert Pieces und
|
||||
Streams, berücksichtigt archivierte beziehungsweise abgeschlossene Inhalte
|
||||
sowie private Redaktionstexte und zeigt kurze Trefferkontexte.
|
||||
- Pieces und Streams besitzen einklappbare Kontextnavigationen und unabhängige
|
||||
lokale Suchen. Gesendete Pieces liegen automatisch unter Abgeschlossen;
|
||||
abgeschlossene Streams bleiben samt gespeichertem Ablauf unter Streams.
|
||||
- Die Piece-Übersicht nutzt kompakte vollständig anklickbare Zeilen ohne
|
||||
Bildplatzhalter oder leere rechte Kontextspalte. Der Usage-Editor öffnet auf
|
||||
Desktop als nicht-modale rechte Arbeitsleiste und mobil als Vollbilddialog.
|
||||
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.8.0`. Es gibt keine
|
||||
Datenbankmigration und keine Änderung persistierter Status- oder API-Verträge.
|
||||
- Lint, Typprüfung, 62 Verhaltenstests, Produktionsbuild und 21
|
||||
Chromium-Abläufe sind erfolgreich. Safari und echte Mobilgeräte bleiben
|
||||
mangels Umgebung offen.
|
||||
|
||||
## 0.7.4 - Horizontale Fundus-Werkbank
|
||||
|
||||
Veröffentlicht am 27. Oktober 2026
|
||||
|
||||
- Die globale Desktopnavigation verläuft horizontal. Der Fundus verwendet die
|
||||
freie Breite als dreispaltige Werkbank aus Navigation, Liste beziehungsweise
|
||||
Editor sowie Metadaten und öffentlicher Präsentation.
|
||||
- Der Piece-Editor ersetzt beim Öffnen die mittlere Liste und ist kein
|
||||
Desktopdialog mehr. Filterparameter und Listenposition bleiben bei der
|
||||
Rückkehr erhalten; schmalere und mobile Ansichten reduzieren die Spalten
|
||||
kontrolliert statt sie vollständig untereinander zu stapeln.
|
||||
- Fundus- und Sendungsfassungen teilen einen Tiptap-Editor für Langtexte.
|
||||
Markdown bleibt Speicher- und API-Format. Eine jederzeit erreichbare
|
||||
Quellansicht schützt Inhalte, die sich nicht verlustfrei visuell abbilden
|
||||
lassen; das bloße Öffnen erzeugt keine Speicherung.
|
||||
- Bestehende Revisions-, Autosave-, Quellen-, Werkzeug-, Medien-, Snapshot- und
|
||||
Präsentationsgrenzen bleiben unverändert. Die rechte Präsentationsfläche nutzt
|
||||
weiterhin den gemeinsamen sicheren Renderer und verändert keinen OBS-Kanal.
|
||||
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.4`; es gibt keine
|
||||
Datenbankmigration. Die vier offiziellen Tiptap-Pakete sind auf Version 3.32.0
|
||||
gepinnt und MIT-lizenziert.
|
||||
- Lint, Typprüfung, 60 Verhaltenstests, Produktionsbuild und 20
|
||||
Chromium-Abläufe sind erfolgreich. Safari und echte Mobilgeräte bleiben
|
||||
mangels Umgebung offen.
|
||||
|
||||
## 0.7.3 - Lesender MCP-Zugang mit OAuth 2.1
|
||||
|
||||
Veröffentlicht am 27. Oktober 2026
|
||||
|
||||
- Der öffentliche Streamable-HTTP-Endpunkt `/mcp` bietet fünf ausschließlich
|
||||
lesende Werkzeuge für Kontoidentität, Pieces, Sendungen und vollständige
|
||||
Abläufe. Er verwendet die bestehenden Inhalts- und Sendungsdienste und keine
|
||||
parallele Datenhaltung.
|
||||
- OAuth-2.1-Discovery, begrenzte dynamische Clientregistrierung,
|
||||
Authorization Code, PKCE `S256`, exakte Redirect-URI und Ressourcenbindung
|
||||
ermöglichen die von OpenAI dokumentierte Kontoverknüpfung. Codes und
|
||||
einstündige Tokens werden nur gehasht gespeichert.
|
||||
- Nutzerzustand, Sperren und Rollen werden bei jedem MCP-Aufruf erneut gelesen.
|
||||
Passwortwechsel und Kontosperre widerrufen bestehende OAuth-Tokens. Der
|
||||
vorhandene technische REST-Token wird weder offengelegt noch weitergereicht.
|
||||
- MCP `2026-07-28` und die weiterhin von OpenAI verwendeten älteren
|
||||
Initialisierungsfassungen werden am selben stateless Endpunkt unterstützt.
|
||||
Private redaktionelle Details sind erst nach ausdrücklicher Zustimmung
|
||||
lesbar; E-Mail, Uploadnamen, interne Pfade und Werkzeug-Arbeitsdaten bleiben
|
||||
ausgeschlossen.
|
||||
- Die Produktions-URL lautet `https://<Cloudron-App-Domain>/mcp`. README und
|
||||
Betriebsdokumentation beschreiben die Einrichtung über **Add custom MCP
|
||||
server**. Das Release wurde nicht auf Cloudron ausgerollt und eine reale
|
||||
ChatGPT-Verbindung wird nicht als geprüft ausgegeben.
|
||||
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.3`. Migration
|
||||
`0020_oauth_mcp.sql` ergänzt additive OAuth- und Ratenbegrenzungstabellen.
|
||||
- Typprüfung und Produktionsbuild sind erfolgreich. Ein isolierter lokaler
|
||||
HTTP-Rundlauf hat OAuth, beide MCP-Protokollgenerationen und eine echte
|
||||
`list_pieces`-Anfrage geprüft; eine reale ChatGPT-/Cloudron-Verbindung bleibt
|
||||
ausdrücklich offen.
|
||||
|
||||
## 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. Das
|
||||
aktuelle Piece, die Position im Ablauf, der Abschnittscountdown und die
|
||||
wichtigsten Aktionen bleiben gleichzeitig erreichbar. Die öffentliche
|
||||
heinzlive-Ausgabe und die gespeicherten Livezustände bleiben technisch getrennt.
|
||||
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.
|
||||
- Eine kompakte Kopfzeile zeigt aktuellen Abschnitt, Abschnittscountdown und den
|
||||
tatsächlich präsentierten Ausgabezustand.
|
||||
- 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 bündelt nächstes Piece, „Präsentieren“, „Überspringen“ und
|
||||
„Erledigen & weiter“. Beim letzten offenen Piece wird das Sendungsende
|
||||
eindeutig benannt.
|
||||
- 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.
|
||||
@@ -36,21 +432,36 @@ heinzlive-Ausgabe und die gespeicherten Livezustände bleiben technisch getrennt
|
||||
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 „Aktuell“, „Erledigt“, „Übersprungen“ und „Kommend“ auch
|
||||
textlich und lassen sich direkt auswählen, einschließlich des ersten Eintrags.
|
||||
- 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.
|
||||
- Direkte Auswahl verändert keinen Erledigungsstatus und veröffentlicht nichts.
|
||||
Nur „Präsentieren“ oder „Präsentation leeren“ verändert die öffentliche
|
||||
Ausgabe.
|
||||
- „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
|
||||
@@ -60,21 +471,61 @@ heinzlive-Ausgabe und die gespeicherten Livezustände bleiben technisch getrennt
|
||||
|
||||
- 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
|
||||
|
||||
- Dieses Release benötigt keine neue Datenbankmigration und keine Änderung der
|
||||
Konfiguration.
|
||||
- Bestehende Sendungen, Livezustände, Timer, Ausgabekanäle und
|
||||
- `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
|
||||
|
||||
- 40 Unit- und Verhaltenstests sowie 16 Chromium-E2E-Abläufe sind erfolgreich.
|
||||
- Die Live-Regie wurde bei 1440 × 900, 900 × 450 und 390 × 700 Pixeln mit
|
||||
langen Inhalten, unabhängigen Scrollflächen und mobilem Ablauf-Sheet geprüft;
|
||||
die erzeugten Screenshots wurden visuell kontrolliert.
|
||||
- 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.
|
||||
|
||||
|
||||
+475
-12
@@ -2,19 +2,467 @@
|
||||
|
||||
## 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,
|
||||
Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert.
|
||||
Etappe 6 wurde nicht begonnen.
|
||||
Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie,
|
||||
Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind
|
||||
dokumentiert.
|
||||
|
||||
## Kompakte Live-Regie 0.5.12
|
||||
## Einheitliche Inhaltswerkbänke 0.9.0
|
||||
|
||||
- Pieces, Eingang und Post verwenden dieselbe Werkbankhierarchie aus linker
|
||||
Navigation beziehungsweise Quellenwahl, mittlerer Inhaltsliste und rechter
|
||||
Schnellbearbeitung. Bei mittleren Breiten folgt der Inspector unter dem Inhalt;
|
||||
mobil werden die drei Bereiche in derselben fachlichen Reihenfolge gestapelt.
|
||||
- Pieces und Post verwenden dabei nicht nur dasselbe Raster, sondern direkt die
|
||||
Stream-Komponentensprache: einklappbare Katalognavigation, `builder-pane`,
|
||||
`stream-inspector`, identische Feldabstände sowie dieselben festen Aktionsleisten.
|
||||
Der Post-Voll-Editor kombiniert entsprechend Schnellnavigation, Schreibpane
|
||||
und Stream-artigen Eigenschaften-Inspector ohne abweichende Parallelbausteine.
|
||||
- Die Piece-Übersicht wählt Inhalte zunächst in der Liste aus. Der rechte
|
||||
Inspector bearbeitet Titel, Kurzbeschreibung, Einleitung, Moderation, Status,
|
||||
Dauer und Format revisionsgeschützt und bietet den kontextbezogenen Bild-Upload.
|
||||
- Der Eingang bleibt eine getrennte Sichtungsgrenze. RSS-Beiträge erhalten rechts
|
||||
Vorschau, Originallink sowie Übernehmen oder Verwerfen; erst die Übernahme
|
||||
erzeugt ein normales Piece. Bereits redaktionelle Eingangspieces lassen sich
|
||||
dort schnell benennen, beschreiben, löschen oder vollständig öffnen.
|
||||
- Die Post-Übersicht wählt ausschließlich abgeschlossene Streams und zeigt deren
|
||||
vorhandenen Arbeitsstand im Inspector. Ein Klick auf die Streamzeile oder die
|
||||
Öffnen-Aktion führt direkt in die zugehörige Werkbank. Es entsteht weder ein
|
||||
zweites Sendungsmodell noch eine zweite Abschlusslogik.
|
||||
- Die vollständige Piece-Bearbeitung bleibt unter ihrer eigenen URL ein nativer
|
||||
großer Modaldialog. Die Post-Bearbeitung ist dagegen eine normale Seite im
|
||||
Streams-Layout mit globaler Navigation, Streamkopf und Zurücklink. Links steht
|
||||
die dauerhafte Schnellnavigation für YouTube, Social-Clips und
|
||||
Twitch-Highlights mit Sofortanlage. Ohne konkrete Inhaltsauswahl zeigt die
|
||||
Mitte alle vorhandenen Post-Inhalte mit Kopieraktionen und rechts die
|
||||
Streamzusammenfassung.
|
||||
- Der Piece-Autosave synchronisiert separate Medienmutationen in den lokalen
|
||||
Entwurf. Radio- und Checkboxänderungen lösen den revisionsgeschützten Save aus;
|
||||
der Bildmodus wird erst nach bewusst ausgewähltem Präsentationsmedium gespeichert.
|
||||
- Checkboxen und Radios bleiben semantische, fokussierbare Formelemente, werden
|
||||
sendewerkweit aber als vollständig anklickbare Auswahlflächen ohne sichtbares
|
||||
Kästchen dargestellt. Aktive Auswahl übernimmt exakt die Streams-Sprache aus
|
||||
abgerundeter ruhiger Fläche und dünner goldener Kontur; Cyan-Füllung und
|
||||
seitlicher Auswahlbalken entfallen.
|
||||
- Live ist eine reguläre Sendewerk-Seite mit globaler Desktop- und
|
||||
Mobilnavigation, Streamkopf und drei Stream-artigen Pane-Flächen für Ablauf,
|
||||
Lesedokument und Details. Vor/Zurück, laufende Uhr und die fachlich getrennte
|
||||
Präsentationsausgabe bleiben verfügbar. Status-, Fortschritts-, Sortier- und
|
||||
Abschlussaktionen fehlen. Piece-Auswahl, Schriftgröße und Zeitanzeige sind
|
||||
lokale Darstellungszustände und verändern weder Streamstatus noch
|
||||
Piece-Verwendungen oder Reihenfolge.
|
||||
- Das gemeinsame Seitenraster folgt jetzt unmittelbar Streams: Die linke
|
||||
Bereichsspalte beginnt direkt unter der App-Navigation und reicht über die
|
||||
Seitenhöhe. Überschrift und Kontext stehen ohne umgebende Pane-Fläche rechts
|
||||
daneben; erst darunter beginnen mittlere Inhaltsfläche und optionaler rechter
|
||||
Inspector. Pieces, Eingang, Post und Live verwenden dieselben Spaltenbreiten,
|
||||
Abstände und responsiven Übergänge.
|
||||
- Der Piece-Editor bleibt auf Desktop und schmalem Desktop sichtbar ein Overlay
|
||||
mit Abstand zum Viewportrand, abgerundeter Kontur, Schatten und erkennbarem
|
||||
Hintergrund. Öffnen verwendet einen räumlichen Lift mit dezent gestaffeltem
|
||||
Inhalt, Schließen die schnelle Gegenbewegung. Die Navigation wartet auf das
|
||||
tatsächliche Animationsende statt auf eine feste Verzögerung. Dialog-, Pane-
|
||||
und Disclosure-Bewegungen bleiben unter 200 ms; `prefers-reduced-motion`
|
||||
schaltet sie ohne Wartezeit vollständig ab.
|
||||
|
||||
Geprüft wurden Lint, Typprüfung, Produktionsbuild, 69 von 69 Verhaltenstests und
|
||||
22 von 22 Chromium-Abläufe. Die responsive Sichtmatrix umfasst die drei
|
||||
Übersichten bei 1440 × 1000, 960 × 900 und 390 × 844 Pixeln sowie Piece- und
|
||||
Post-Detailseite. Die inhaltslose Post-Detailübersicht und ihre mobile
|
||||
Schnellnavigation wurden zusätzlich aufgenommen und visuell kontrolliert.
|
||||
Escape, sichtbares Schließen, Browser-Zurück, Autosave nach Medienauswahl,
|
||||
Auswahlflächen und horizontaler Überlauf wurden gezielt geprüft. Live wurde bei
|
||||
1440, 960 und 390 Pixeln visuell kontrolliert; ein abgewiesener POST-Versuch
|
||||
sowie Auswahl und Darstellungswechsel ließen Show-Status, Usage-Status und
|
||||
Reihenfolge unverändert. Safari und echte Mobilgeräte bleiben mangels Umgebung
|
||||
offen.
|
||||
|
||||
## Postproduktion 0.9.0
|
||||
|
||||
- Der neue Hauptbereich **Post** zeigt ausschließlich vorhandene abgeschlossene
|
||||
Streams. Suche, Sortierung, Fortschrittsfilter und Zähler werden aus denselben
|
||||
Sendungs- und Postdaten abgeleitet; es gibt keine zweite Sendungsverwaltung.
|
||||
- Die vollständige Detailansicht ist eine normale Seite im Layout der
|
||||
Streambearbeitung: Streamtitel und Datum stehen im Seitenkopf, darunter folgen
|
||||
linke Schnellnavigation, mittlere Schreib- beziehungsweise Übersichtsfläche
|
||||
und rechter Eigenschaften-Inspector. YouTube, Social-Clips und
|
||||
Twitch-Highlights lassen sich links sofort anlegen; mobil sind Inhalte,
|
||||
Bearbeiten und Details getrennte Pane-Zustände.
|
||||
- Formfelder, Rundungen und Abstände entsprechen den Streamformularen. Aktive
|
||||
Post-Inhalte, Register, Zielplattformen und Filter verwenden wie Streams eine
|
||||
ruhige abgerundete Fläche mit dünner goldener Kontur.
|
||||
- Pro Stream lassen sich mehrere Aufzeichnungsteile mit Medienpfad, Framerate und
|
||||
Timebase sowie mehrere unveränderte Markdown-Kontextdateien verwalten.
|
||||
SHA-256 verhindert Dubletten innerhalb einer Sendung; Zeitmarken und
|
||||
Transkriptfragmente werden ausschließlich konservativ aus vorhandenen Zeilen
|
||||
extrahiert.
|
||||
- YouTube-Uncuts, Social-Clips und Twitch-Highlights sind eigenständige,
|
||||
revisionierte Post-Elemente. YouTube führt Beschreibung, Titelvarianten,
|
||||
Thumbnailkonzept, echte private Thumbnail-Dateien und Veröffentlichungsdaten.
|
||||
Clips führen Originaltranskript, einen reinen YouTube-Shorts-Titel, eine
|
||||
gemeinsame TikTok-/Instagram-Caption inklusive Hashtags, Hooks, Endcards,
|
||||
Zielplattformen, Untertitel und Schnittdaten; Highlights bleiben unabhängig.
|
||||
- Bei genau einer Kontextdatei ordnet die Anlage sie automatisch zu. Die
|
||||
Piece-Auswahl enthält ausschließlich Original-Pieces, die im geöffneten Stream
|
||||
tatsächlich verwendet wurden.
|
||||
- Quellen und Thumbnails liegen im konfigurierten privaten Uploadverzeichnis und
|
||||
werden nur über authentifizierte Routen ausgeliefert. Originalnamen werden
|
||||
nicht zu Speicherpfaden.
|
||||
- Text- und JSON-Exporte stehen für vorhandene Inhalte bereit. DaVinci-Resolve-XML
|
||||
erscheint nur bei absolutem Medienpfad, Framerate, Timebase und gültigem
|
||||
Zeitbereich; SRT nur nach vollständiger blockweiser Validierung.
|
||||
- `post-ai.ts` beschreibt lediglich einen providerneutralen späteren Adapter.
|
||||
Ohne konfigurierte KI bleiben Import und sämtliche manuellen Arbeitsschritte
|
||||
vollständig nutzbar; die Oberfläche täuscht keine Generierung vor.
|
||||
- Migration `0021_post_production.sql` ergänzt ausschließlich Posttabellen;
|
||||
`0022_post_quick_workbench.sql` ergänzt die gemeinsame Social-Caption und
|
||||
übernimmt vorhandene Caption-/Hashtag-Texte. Der Seed enthält eine
|
||||
abgeschlossene Beispielsendung, und Paket, Lockdatei, Oberfläche sowie
|
||||
Cloudron-Manifest tragen Version `0.9.0`.
|
||||
|
||||
Geprüft wurden Lint, Typprüfung, Produktionsbuild, 69 von 69 Verhaltenstests und
|
||||
22 von 22 Chromium-Abläufe. Der vollständige Post-Rundlauf umfasst Material,
|
||||
Markdown, YouTube, Thumbnail, Social-Clip, echten Registerklick, Resolve-/SRT-
|
||||
Export, direkte Streamöffnung, inhaltslose Übersicht und mobile Schnellnavigation.
|
||||
Safari, echte Mobilgeräte, reale Schnittsoftware und eine KI-Anbindung bleiben
|
||||
mangels Umgebung offen.
|
||||
|
||||
## Piece-Schreibraum 0.8.2
|
||||
|
||||
- Einleitung, Moderationsstichpunkte, Interaktion und Überleitung bleiben getrennt
|
||||
gespeichert, erscheinen aber als ein zusammenhängendes Schreibdokument mit
|
||||
ruhigen Abschnittstrennern und inhaltswachsender Höhe. Nur der fokussierte
|
||||
Abschnitt zeigt seine Tiptap-Werkzeuge; Auswahl und Fokus bleiben beim
|
||||
Formatieren erhalten, die Markdown-Quellansicht bleibt erreichbar.
|
||||
- Die linke Spalte ist kontextsensitiv: Übersichtsseiten behalten ihre bisherige
|
||||
Bereichsnavigation, der geöffnete Editor zeigt Rücksprung, dieselben Filter,
|
||||
Suche und die aktuelle gefilterte Piece-Liste. Normale Links verwenden den
|
||||
vorhandenen Autosave-/Konfliktablauf; die aktuelle Auswahl ist markiert.
|
||||
- Rechts sind Status, Dauer, Format und Themen direkt kompakt bearbeitbar.
|
||||
Kurzbeschreibung, Vorbereitungsaufwand und Kategorien liegen unter „Weitere
|
||||
Details“. Darunter sind Moduswahl und die direkt editierbare
|
||||
Publikumsvorschau bereits in üblicher Fensterhöhe sichtbar.
|
||||
- Der Kopf enthält nur internen Titel, Favorit, kompakten Speicherzustand,
|
||||
manuelles Speichern und ein Aktionsmenü für Vorlagen, Duplizieren, Archivieren
|
||||
und Löschen. Speicher- und Aktionssteuerung stehen in einer eigenen Zeile unter
|
||||
dem Titel; die permanente Fußleiste und doppelte Metadaten entfallen.
|
||||
- Mobil bleiben Streamtext, Recherche, Piece-Liste und Details/Ausgabe getrennte
|
||||
Ansichten. Der Piece-Drawer ist opak, schließbar und Safe-Area-fähig;
|
||||
Formatierungswerkzeuge sind über „Formatieren“ ohne Hover erreichbar.
|
||||
- Bei mittleren Desktopbreiten gibt die eingeklappte Piece-Navigation ihren Platz
|
||||
vollständig an den Editor frei. Die globale Navigation wechselt dort ohne
|
||||
Überlagerungen auf größere, eindeutig beschriftete Icons; die sichtbare Marke
|
||||
weist den Stand `0.8.2` aus.
|
||||
- Die aktuelle Piece-Markierung hält rechts Abstand zum stabilen Scrollgutter.
|
||||
- Der Streamplan besitzt rechts dauerhaft einen kompakten Inspector in derselben
|
||||
Breitenklasse wie die Piece-Metadaten. Ohne Auswahl zeigt er Streamdaten und
|
||||
Twitch-Texte; ein Klick auf Abschnitt/Kategorie oder Ablauf-Piece wechselt zu
|
||||
dessen direkt speicherbaren Metadaten. Beim Piece bleiben `Piece bearbeiten`
|
||||
und `Speichern` als feste Abschlussaktionen sichtbar; seltene Usage-Aktionen
|
||||
liegen unter `Weitere Aktionen`.
|
||||
- Eine Inhaltsauswahl schließt die nur temporär benötigte Piece-Bibliothek und
|
||||
hält dadurch Ablauf und Inspector lesbar. Unterhalb der Desktopbreite ergänzt
|
||||
`Details` die bestehende Umschaltung zwischen Pieces und Ablauf; direkte
|
||||
Vorbereitungslinks öffnen sofort die richtige Streamfassung.
|
||||
- Die Live-Regie übernimmt die ruhigen Dokument-, Status- und Ablaufoberflächen
|
||||
der neuen Arbeitsbereiche. Timer, lokale Auswahl, tatsächliches Live-Piece,
|
||||
Präsentation, Ablauf-Sheet und feste Abschlussaktionen bleiben funktional
|
||||
unverändert.
|
||||
- Autosave, Revisionskonflikte, Medien, Quellen, Werkzeuge, Präsentationswerte,
|
||||
Favorit und getrennte Streamfassungen behalten ihre Persistenzgrenzen. Das
|
||||
Usage-Schnellformular führt unveränderte Langtexte serverseitig mit und kann
|
||||
sie deshalb nicht durch fehlende Formularfelder leeren. Es gibt keine
|
||||
Datenbankmigration und keine Änderung an Live-Regie, Suche, API oder Rechten.
|
||||
|
||||
Geprüft wurden Lint, Typprüfung, Produktionsbuild, 64 von 64 Verhaltenstests und
|
||||
22 von 22 Chromium-Abläufe. Die visuelle Matrix umfasst gefüllte und leere Pieces,
|
||||
Streamfassung und Live-Regie bei 1440/1280 × 900/1000, einen schmalen Desktop bei
|
||||
960 × 900, kompakte Live-Fenster bei 900/670 × 450 sowie mobile Piece- und
|
||||
Live-Ansichten. Safari und echte Mobilgeräte bleiben mangels Umgebung offen.
|
||||
|
||||
## Redaktionelle Werkbänke 0.8.1
|
||||
|
||||
- Der Piece-Editor führt ohne vorgeschaltete Leerfläche direkt vom internen
|
||||
Titel in die vier Streamtextbereiche. Die Textbreite bleibt ruhig begrenzt;
|
||||
Metadaten und alle öffentlichen Präsentationsarten liegen rechts, während
|
||||
Recherche, Quellen, Zeitmarken, Werkzeuge, Uploads und Testausgabe erhalten
|
||||
bleiben.
|
||||
- Die Streamplanung verwendet den Ablauf als breite Hauptfläche. Die
|
||||
durchsuchbare Piece-Bibliothek öffnet nur bei Bedarf daneben; auf schmalen
|
||||
Ansichten bleibt die bestehende Umschaltung erhalten. Abschnittsblöcke und
|
||||
kompakte Piece-Zeilen verwenden weiterhin dieselben Drag-, Klick-, Touch- und
|
||||
Tastaturaktionen sowie dieselben revisionsgeschützten Form-Actions.
|
||||
- Die Live-Regie priorisiert Piece, Abschnitt und Moderation stärker und zeigt
|
||||
Position und Abschnitt direkt am aktuellen Inhalt. Timer, Ausgabe, mobiler
|
||||
Ablauf, lokale Betrachtung, Präsentation und OBS-Kanal bleiben fachlich
|
||||
unverändert getrennt.
|
||||
- Die Suche verwendet eine linke, mobil ausfahrbare Filterspalte. Ergebnisart,
|
||||
Status, Piece-Thema und Piece-Format werden serverseitig über URL-Parameter
|
||||
gefiltert; Trefferzahlen und Zurücksetzen bleiben ohne neuen Suchindex oder
|
||||
zusätzliche Persistenz verfügbar.
|
||||
- Version `0.8.1` benötigt keine Migration und ändert weder globale Navigation
|
||||
noch URLs, API-Verträge, Rechte- oder Sicherheitsprojektionen.
|
||||
|
||||
Geprüft wurden Lint, Typprüfung, Produktionsbuild, 63 von 63 Verhaltenstests und
|
||||
21 von 21 Chromium-Abläufe. Die gezielte Sichtkontrolle umfasste Piece-Editor,
|
||||
Streamplanung, Live-Regie und Suche bei 1440 × 1000 sowie 390 × 844 Pixeln;
|
||||
Safari und echte Mobilgeräte bleiben mangels Umgebung offen.
|
||||
|
||||
## Redaktioneller Arbeitsbereich 0.8.0
|
||||
|
||||
- Die sichtbare Anwendungssprache verwendet durchgehend Pieces und Streams; die
|
||||
stabilen technischen Routen und Datenmodelle bleiben unverändert. Desktop und
|
||||
Mobilgerät teilen die fünf Hauptbereiche Start, Eingang, Pieces, Streams und
|
||||
Live. Suche, explizite Theme-Auswahl, Verwaltung und Abmeldung liegen in der
|
||||
gemeinsamen Kopfzeile beziehungsweise im Benutzermenü.
|
||||
- `/` ist ein echtes Dashboard aus vorhandenem nächsten Stream, dessen kompaktem
|
||||
Ablauf, aktuellen Pieces, Schnellaktionen und weiteren geplanten Streams. Es
|
||||
erzeugt weder Aufgaben noch andere redaktionelle Daten.
|
||||
- Die globale, angemeldeten Nutzern vorbehaltene Suche gruppiert Treffer nach
|
||||
Pieces und Streams. Sie berücksichtigt archivierte Pieces, abgeschlossene
|
||||
Streams, private redaktionelle Langtexte und eingefrorene Stream-Snapshots und
|
||||
liefert nur einen kurzen Trefferkontext mit Fundort.
|
||||
- Pieces und Streams besitzen einklappbare Kontextnavigationen mit lokaler Suche.
|
||||
Bereits gesendete Pieces verschwinden aus dem normalen Arbeitsbestand und
|
||||
liegen mit verlinkter Verwendungshistorie unter Abgeschlossen; archivierte und
|
||||
gelöschte Pieces bleiben unter Pieces erreichbar. Abgeschlossene Streams sind
|
||||
über den Streamfilter erreichbar und behalten den gespeicherten Ablauf.
|
||||
- Die Piece-Liste ist eine kompakte, vollständig anklickbare Zeilenansicht ohne
|
||||
erzwungene Bildplatzhalter. Ohne Auswahl existiert keine rechte Leerspalte. In
|
||||
der Streamplanung öffnet der bestehende vollständige Usage-Editor auf Desktop
|
||||
als nicht-modale rechte Arbeitsleiste und mobil weiterhin als Vollbilddialog.
|
||||
- Für diese Änderung war keine Datenbankmigration nötig. Rollen, Rechte,
|
||||
Revisionsschutz, Snapshotgrenzen, Markdown-Speicherung und bestehende Actions
|
||||
bleiben erhalten.
|
||||
|
||||
Geprüft wurden 62 von 62 Verhaltenstests, Lint, Typprüfung, Produktionsbuild und
|
||||
21 von 21 Chromium-Abläufe. Dashboard, globale Suche, abgeschlossene Pieces und
|
||||
Streams, Desktop-/Mobilnavigation, rechte Usage-Arbeitsleiste sowie die
|
||||
bestehenden Rollen-, Live-, Freigabe-, Autosave- und Präsentationsabläufe waren
|
||||
erfolgreich. Safari und echte Mobilgeräte bleiben mangels Umgebung offen.
|
||||
|
||||
## Horizontale Fundus-Werkbank 0.7.4
|
||||
|
||||
- Die allgemeine Desktopnavigation verläuft horizontal über der Anwendung. Der
|
||||
Fundus nutzt darunter eine dreispaltige Werkbank aus Bibliotheksnavigation,
|
||||
Liste beziehungsweise Schreibfläche sowie Kontext und Präsentation. Bei
|
||||
kleinerem Platz fällt zuerst die Kontextspalte weg; mobil werden Navigation
|
||||
und Editor gezielt einzeln statt als gestauchte Dreispaltenansicht gezeigt.
|
||||
- Fundusnavigation, Status, Favoriten, Archiv, Papierkorb und Themen zeigen aus
|
||||
den bestehenden Daten abgeleitete Zähler. Suche, Taxonomiefilter,
|
||||
Verwendungsfilter und Listenposition bleiben beim Wechsel in ein Piece und
|
||||
zurück erhalten.
|
||||
- Der Fundus-Editor ist auf Desktop kein Dialog mehr. Sendungstext und private
|
||||
Recherche stehen dauerhaft in der mittleren Schreibfläche; Status, Dauer,
|
||||
Einordnung und die öffentliche Präsentationsfläche liegen ausschließlich
|
||||
rechts. Bestehende Autosave-, Revisions-, Quellen-, Werkzeug-, Medien- und
|
||||
Präsentationsactions bleiben unverändert getrennt.
|
||||
- Alle formatierbaren Langtexte in Fundus- und Sendungsfassung verwenden eine
|
||||
gemeinsame Tiptap-Komponente. Gespeichert und an Form-Actions übermittelt wird
|
||||
weiterhin Markdown. Eine erreichbare Quellansicht übernimmt nicht verlustfrei
|
||||
abbildbares Markdown, ohne es bereits beim Öffnen umzuschreiben.
|
||||
|
||||
Geprüft wurden 60 von 60 Verhaltenstests, Lint, Typprüfung, Produktionsbuild und die
|
||||
gezielten Chromium-Abläufe für Fundus, Piece-Autosave, Sendungsfassung,
|
||||
Werkzeuge, Live-Snapshots sowie Link-/Videopräsentation. Die abschließende
|
||||
vollständige Browsermatrix ist im Abnahmenachweis festgehalten.
|
||||
|
||||
## Lesender MCP-Zugang 0.7.3
|
||||
|
||||
- Der öffentliche Streamable-HTTP-Endpunkt `/mcp` liest Pieces, Sendungen und
|
||||
Abläufe über dieselben Dienste wie Browser und REST-API. Fünf deklarierte
|
||||
Werkzeuge besitzen nur Leseannotationen und keine Mutationspfade.
|
||||
- OAuth 2.1 verwendet Discovery-Metadaten, begrenzte dynamische
|
||||
Clientregistrierung, Authorization Code, PKCE `S256`, exakte Redirect-URIs
|
||||
und Ressourcenbindung. Codes und einstündige Tokens liegen nur gehasht vor.
|
||||
- OAuth-Tokens sind an den zustimmenden Nutzer gebunden. Sperrung und
|
||||
Passwortwechsel widerrufen sie; jeder Zugriff prüft erneut Nutzerstatus,
|
||||
Scope, Ablauf und Zielressource.
|
||||
- Aktuelles MCP `2026-07-28` mit `server/discover` und per-Request-Metadaten
|
||||
läuft neben den OpenAI-kompatiblen Initialisierungsfassungen. Ausgaben sind
|
||||
Positivlisten ohne E-Mail, Uploadnamen, interne Pfade oder Werkzeug-Arbeitsdaten.
|
||||
- Die konkrete Produktionsadresse ist
|
||||
`https://<Cloudron-App-Domain>/mcp`. Ein Cloudron-Deployment und eine reale
|
||||
ChatGPT-Verbindung wurden nicht durchgeführt.
|
||||
|
||||
Geprüft wurden Typen, Produktionsbuild und ein isolierter tatsächlicher
|
||||
HTTP-Ablauf über eine temporäre Datenbank: anonymer OAuth-Challenge, DCR,
|
||||
Zustimmung, PKCE-Tokenaustausch, Legacy-Initialisierung, aktuelle
|
||||
`server/discover`-Abfrage, Toolliste und `list_pieces` mit Seed-Daten waren
|
||||
erfolgreich. Eine weitere Verhaltenstest- oder Browsermatrix wurde gemäß
|
||||
Auftrag nicht ausgeführt.
|
||||
|
||||
## 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
|
||||
- feste Kopfzeile mit aktuellem Abschnitt, persistentem Abschnittscountdown und
|
||||
öffentlichem Ausgabezustand sowie feste Fußleiste mit nächstem Piece,
|
||||
Präsentation, Überspringen und kontextgerechtem Erledigen
|
||||
- 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
|
||||
@@ -22,22 +470,37 @@ Etappe 6 wurde nicht begonnen.
|
||||
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, direkter
|
||||
Auswahl jedes Pieces, automatisch sichtbarem aktuellen Eintrag und
|
||||
- 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
|
||||
- Livefortschritt, direkte Auswahl, Abschnittstimer und öffentliche Präsentation
|
||||
verwenden unverändert ihre getrennten persistenten Zustände
|
||||
- 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 lokale Prüfmatrix umfasst 40 Verhaltenstests und 16 Chromium-Abläufe.
|
||||
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.
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
CREATE TABLE oauth_clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
redirect_uris_json TEXT NOT NULL CHECK (json_valid(redirect_uris_json)),
|
||||
created_at_utc TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE oauth_authorization_codes (
|
||||
code_hash TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
resource TEXT NOT NULL,
|
||||
code_challenge TEXT NOT NULL,
|
||||
created_at_utc TEXT NOT NULL,
|
||||
expires_at_utc TEXT NOT NULL,
|
||||
consumed_at_utc TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX oauth_authorization_codes_expiry_idx
|
||||
ON oauth_authorization_codes(expires_at_utc);
|
||||
|
||||
CREATE TABLE oauth_access_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
scope TEXT NOT NULL,
|
||||
resource TEXT NOT NULL,
|
||||
created_at_utc TEXT NOT NULL,
|
||||
expires_at_utc TEXT NOT NULL,
|
||||
last_used_at_utc TEXT,
|
||||
request_window_started_at_utc TEXT NOT NULL,
|
||||
request_count INTEGER NOT NULL DEFAULT 0 CHECK (request_count >= 0),
|
||||
revoked_at_utc TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX oauth_access_tokens_user_idx ON oauth_access_tokens(user_id);
|
||||
CREATE INDEX oauth_access_tokens_expiry_idx ON oauth_access_tokens(expires_at_utc);
|
||||
|
||||
CREATE TABLE oauth_rate_limit_attempts (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('registration', 'token')),
|
||||
address_hash TEXT NOT NULL,
|
||||
attempted_at_utc TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX oauth_rate_limit_attempts_lookup_idx
|
||||
ON oauth_rate_limit_attempts(kind, address_hash, attempted_at_utc);
|
||||
@@ -0,0 +1,127 @@
|
||||
CREATE TABLE post_recording_parts (
|
||||
id TEXT PRIMARY KEY,
|
||||
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL,
|
||||
position INTEGER NOT NULL CHECK (position >= 0),
|
||||
media_file_path TEXT NOT NULL DEFAULT '',
|
||||
frame_rate REAL CHECK (frame_rate IS NULL OR (frame_rate > 0 AND frame_rate <= 240)),
|
||||
timebase INTEGER CHECK (timebase IS NULL OR (timebase > 0 AND timebase <= 240)),
|
||||
created_at_utc TEXT NOT NULL,
|
||||
UNIQUE (show_id, position)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_post_recording_parts_show
|
||||
ON post_recording_parts(show_id, position);
|
||||
|
||||
CREATE TABLE post_source_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
|
||||
recording_part_id TEXT REFERENCES post_recording_parts(id) ON DELETE SET NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
storage_name TEXT NOT NULL UNIQUE,
|
||||
sha256 TEXT NOT NULL,
|
||||
byte_size INTEGER NOT NULL CHECK (byte_size > 0),
|
||||
content TEXT NOT NULL,
|
||||
transcript_text TEXT NOT NULL DEFAULT '',
|
||||
timecodes_json TEXT NOT NULL DEFAULT '[]',
|
||||
imported_at_utc TEXT NOT NULL,
|
||||
UNIQUE (show_id, sha256)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_post_source_files_show
|
||||
ON post_source_files(show_id, imported_at_utc);
|
||||
|
||||
CREATE TABLE post_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('youtube', 'clip', 'highlight')),
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('draft', 'review', 'approved', 'editing', 'done', 'published')),
|
||||
recording_part_id TEXT REFERENCES post_recording_parts(id) ON DELETE SET NULL,
|
||||
source_file_id TEXT REFERENCES post_source_files(id) ON DELETE SET NULL,
|
||||
piece_id TEXT REFERENCES pieces(id) ON DELETE SET NULL,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
|
||||
position INTEGER NOT NULL CHECK (position >= 0),
|
||||
created_at_utc TEXT NOT NULL,
|
||||
updated_at_utc TEXT NOT NULL,
|
||||
UNIQUE (show_id, kind, position)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_post_items_show_kind
|
||||
ON post_items(show_id, kind, position);
|
||||
|
||||
CREATE TABLE post_youtube_videos (
|
||||
item_id TEXT PRIMARY KEY REFERENCES post_items(id) ON DELETE CASCADE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
title_variants_json TEXT NOT NULL DEFAULT '[]',
|
||||
thumbnail_concept TEXT NOT NULL DEFAULT '',
|
||||
thumbnail_text TEXT NOT NULL DEFAULT '',
|
||||
publication_status TEXT NOT NULL DEFAULT 'unpublished'
|
||||
CHECK (publication_status IN ('unpublished', 'scheduled', 'published')),
|
||||
published_at_utc TEXT,
|
||||
youtube_url TEXT NOT NULL DEFAULT '',
|
||||
video_format TEXT NOT NULL DEFAULT '16:9'
|
||||
);
|
||||
|
||||
CREATE TABLE post_social_clips (
|
||||
item_id TEXT PRIMARY KEY REFERENCES post_items(id) ON DELETE CASCADE,
|
||||
clip_type TEXT NOT NULL DEFAULT 'other'
|
||||
CHECK (clip_type IN ('storytime', 'opinion', 'explanation', 'gaming', 'humor', 'other')),
|
||||
original_transcript TEXT NOT NULL DEFAULT '',
|
||||
start_seconds INTEGER CHECK (start_seconds IS NULL OR start_seconds >= 0),
|
||||
end_seconds INTEGER CHECK (end_seconds IS NULL OR end_seconds >= 0),
|
||||
dramaturgy_notes TEXT NOT NULL DEFAULT '',
|
||||
edit_instructions TEXT NOT NULL DEFAULT '',
|
||||
shorts_title TEXT NOT NULL DEFAULT '',
|
||||
shorts_description TEXT NOT NULL DEFAULT '',
|
||||
shorts_hashtags TEXT NOT NULL DEFAULT '',
|
||||
tiktok_caption TEXT NOT NULL DEFAULT '',
|
||||
instagram_caption TEXT NOT NULL DEFAULT '',
|
||||
social_hashtags TEXT NOT NULL DEFAULT '',
|
||||
primary_hook TEXT NOT NULL DEFAULT '',
|
||||
alternative_hooks TEXT NOT NULL DEFAULT '',
|
||||
endcard_text TEXT NOT NULL DEFAULT '',
|
||||
alternative_endcards TEXT NOT NULL DEFAULT '',
|
||||
framing_notes TEXT NOT NULL DEFAULT '',
|
||||
subtitle_notes TEXT NOT NULL DEFAULT '',
|
||||
subtitle_srt TEXT NOT NULL DEFAULT '',
|
||||
video_format TEXT NOT NULL DEFAULT '9:16',
|
||||
target_tiktok INTEGER NOT NULL DEFAULT 1 CHECK (target_tiktok IN (0, 1)),
|
||||
target_instagram INTEGER NOT NULL DEFAULT 1 CHECK (target_instagram IN (0, 1)),
|
||||
target_shorts INTEGER NOT NULL DEFAULT 1 CHECK (target_shorts IN (0, 1)),
|
||||
CHECK (end_seconds IS NULL OR start_seconds IS NULL OR end_seconds > start_seconds)
|
||||
);
|
||||
|
||||
CREATE TABLE post_twitch_highlights (
|
||||
item_id TEXT PRIMARY KEY REFERENCES post_items(id) ON DELETE CASCADE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
original_transcript TEXT NOT NULL DEFAULT '',
|
||||
start_seconds INTEGER CHECK (start_seconds IS NULL OR start_seconds >= 0),
|
||||
end_seconds INTEGER CHECK (end_seconds IS NULL OR end_seconds >= 0),
|
||||
edit_instructions TEXT NOT NULL DEFAULT '',
|
||||
publication_notes TEXT NOT NULL DEFAULT '',
|
||||
video_format TEXT NOT NULL DEFAULT '16:9',
|
||||
twitch_url TEXT NOT NULL DEFAULT '',
|
||||
CHECK (end_seconds IS NULL OR start_seconds IS NULL OR end_seconds > start_seconds)
|
||||
);
|
||||
|
||||
CREATE TABLE post_thumbnails (
|
||||
id TEXT PRIMARY KEY,
|
||||
youtube_item_id TEXT NOT NULL REFERENCES post_youtube_videos(item_id) ON DELETE CASCADE,
|
||||
storage_name TEXT NOT NULL UNIQUE,
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
byte_size INTEGER NOT NULL CHECK (byte_size > 0),
|
||||
alt_text TEXT NOT NULL DEFAULT '',
|
||||
position INTEGER NOT NULL CHECK (position >= 0),
|
||||
is_selected INTEGER NOT NULL DEFAULT 0 CHECK (is_selected IN (0, 1)),
|
||||
created_at_utc TEXT NOT NULL,
|
||||
UNIQUE (youtube_item_id, position)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_post_thumbnails_item
|
||||
ON post_thumbnails(youtube_item_id, position);
|
||||
|
||||
CREATE UNIQUE INDEX idx_post_one_selected_thumbnail
|
||||
ON post_thumbnails(youtube_item_id) WHERE is_selected = 1;
|
||||
@@ -0,0 +1,15 @@
|
||||
ALTER TABLE post_social_clips
|
||||
ADD COLUMN social_caption TEXT NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE post_social_clips
|
||||
SET social_caption = trim(
|
||||
CASE
|
||||
WHEN trim(tiktok_caption) != '' THEN trim(tiktok_caption)
|
||||
ELSE trim(instagram_caption)
|
||||
END ||
|
||||
CASE
|
||||
WHEN trim(social_hashtags) != '' THEN char(10) || char(10) || trim(social_hashtags)
|
||||
ELSE ''
|
||||
END
|
||||
)
|
||||
WHERE social_caption = '';
|
||||
Generated
+688
-2
@@ -1,15 +1,20 @@
|
||||
{
|
||||
"name": "sendewerk",
|
||||
"version": "0.5.12",
|
||||
"version": "0.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sendewerk",
|
||||
"version": "0.5.12",
|
||||
"version": "0.9.0",
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.2.2",
|
||||
"@tiptap/core": "^3.32.0",
|
||||
"@tiptap/markdown": "^3.32.0",
|
||||
"@tiptap/pm": "^3.32.0",
|
||||
"@tiptap/starter-kit": "^3.32.0",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"fast-xml-parser": "^5.11.2",
|
||||
"file-type": "^22.2.0",
|
||||
"marked": "^18.1.1",
|
||||
"metascraper": "^5.58.5",
|
||||
@@ -599,6 +604,18 @@
|
||||
"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": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.2.tgz",
|
||||
@@ -1221,6 +1238,404 @@
|
||||
"vite": "^8.0.0-beta.7 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.32.0.tgz",
|
||||
"integrity": "sha512-Ghh2h02GKSqzytQGcLV4espYOG4EHxjj0MsrUB2Vnr47xmlZYRf6r7k2J09H+JpBdaGrWwrTkyBEi5i3c3yy2Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.32.0.tgz",
|
||||
"integrity": "sha512-wmuthEdlYVArNoUbqFPH35giutBABFu7fqEB0lTD1k0UXJUs4dFvcbRyGWWgnHFZgP3TT4u8e+xiayYw0eQ0NQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.32.0",
|
||||
"@tiptap/pm": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.32.0.tgz",
|
||||
"integrity": "sha512-SQYewnYDoIBN+8oH53IDcD8p+Pz+Jnyq3hrxjy/FAo5ctc+96d3ZLdqc9gzLVSqFFo/SUINv2FVGoq9PvU7yqw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.32.0.tgz",
|
||||
"integrity": "sha512-Y5P/l06pVCUG46vXpBl3z0fW2b6JpSMX4Cvr+5sfQh8zB3TxwSiLQaaPwI3R5MFD1KfRk3AjiPRieEyXdWcNHw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.32.0.tgz",
|
||||
"integrity": "sha512-I+aEVl0dTmkfnU6HzBGEefb9U7ja2MkucwBBwrmoMwU+uIKpC4xw8NcHC9X6sjAcom3Pxs2HX+8WEo4Vp13R/Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.32.0.tgz",
|
||||
"integrity": "sha512-9NZW/yXP9j9XKr+hRS5m58xiyZI+tQo1PJ7eLHXjGNFWaF601NG/h6741mbKh5uCY51XsbyqG+7que+RavngPA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.32.0",
|
||||
"@tiptap/pm": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.32.0.tgz",
|
||||
"integrity": "sha512-GugeKE6AnZDluRXJUNBuX43TPcaJxy5wck6vT2J4a0/EnKStJ43suSZZf1IPfb1ij9EL9bYvAwg9DOcrvdEzTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.32.0.tgz",
|
||||
"integrity": "sha512-t4Ug8hFLt30dwwYvrh63BVUt6N1Yg5PVI3tMllhCmN3Q68oD47v4MB/i5nGfzIsQqMyXUOetwHJWcZ4MoLTx4Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.32.0.tgz",
|
||||
"integrity": "sha512-HUpKKWJLtthnpQY5LIjHlJJOmh562RusmOMIe/4iP/H7y+WUR9ZFiZjEs1NBkRjig9fwj8F3BfsrVPYBnM4nLA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.32.0.tgz",
|
||||
"integrity": "sha512-IKUYvK+bO2hGrDWhhTVg0CSh9C2belWg9GyLzvpyd3SZyQ9EgdccLfoytNjOBKbAE/TQ4Oo3kSP+XF6RsUIKPA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.32.0.tgz",
|
||||
"integrity": "sha512-XRiZxZuhfjvbgwCvoBHj04gOsPANEXxxRwrFCQVuFi5yeTHYIzMEioc81j6iAH9pV7aQeJCwLxuh6VHNz3dXEQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.32.0.tgz",
|
||||
"integrity": "sha512-Iry7ynsG4xgcxnH1cm+uXJnEtUzoop4sGA10NUN9I+08TOjhj/GrDiRKMQHKAiVI/R7t2MVav1MJlxFf2FdzGw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.32.0",
|
||||
"@tiptap/pm": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.32.0.tgz",
|
||||
"integrity": "sha512-KB/i1xoHilbBGcQ+UotDWhi60KpdCde3RzRIpl2na0Yol4z4Fe2IRWg8ljIrlsssDRwel/fsHJbG7a13EP8oKA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.32.0.tgz",
|
||||
"integrity": "sha512-DRdQSgWPSEjrDg5EDBB5yxRygHtUVLhceVGzAsTz8RziiFyvcwAvzBHCO9hd9jutjCXQ2DLIuZB8P+aHhFWnpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.32.0",
|
||||
"@tiptap/pm": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.32.0.tgz",
|
||||
"integrity": "sha512-QdzS5nFz9agAQhBgIuQluobWib39W2cMKx1XB6tYSue7bHMJdgPFP0EnGfFajaeVX42CG6RXtQWrNfyQGKhBvg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.32.0",
|
||||
"@tiptap/pm": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.32.0.tgz",
|
||||
"integrity": "sha512-FdJTCAeALixzyyGSSEmroXmkIEclbo/4t2Pl03aRERqpPOSPJcC3evDtIhLazTuP6v+FjCmJ+pXNql6ov7qitA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-keymap": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.32.0.tgz",
|
||||
"integrity": "sha512-vXFre18Dk2m5N/gjo408C02GSrolZ8ZIf0wz6/U8lNC4FcYOISgZFj3BND6uabVntGh2GCSPrVFuNpVPhFnn/A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.32.0.tgz",
|
||||
"integrity": "sha512-htZ5hoMLBkGiDP6M5mGemjOoaBmkrj911wiGoTmMMKzO7qJq9QxUBkLY7ILPaKlUWL8U3ccLdDV2TB/nysp4Jg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.32.0.tgz",
|
||||
"integrity": "sha512-afKLMc7fWMkkar9H5FilizStSbhFzUoCzdwwdZf6DDsn3LUt8tJlFHACK78cAyx/TzMJBo0N8/8T1u9bL+N67Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.32.0.tgz",
|
||||
"integrity": "sha512-J9Vw6wmh/wV6SxSsN9RhurvAGcXSY5J3p4EQrROD5PARBl0VOnDFi7/UVoz1RkSdqOKPgAiqDZpaLRgtdBO9Rg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.32.0.tgz",
|
||||
"integrity": "sha512-sF0jIK67IHdZMnzv2mz5vPTyK7R0ui6/YZgFg+/bLnFCNmweVAoYcHZLlC7ISRnwu0mqJr+epNtzJepY1ZDCjg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.32.0.tgz",
|
||||
"integrity": "sha512-rMLdd4vg6sed/JnaXnaupLbCtYiWikTnQ8qir9ItKOXwf1Ui9P1Y4G0bYU4+gcnMsFzaSGtrOhMqG4/c66AogQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extensions": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.32.0.tgz",
|
||||
"integrity": "sha512-qO+ndNvlOjzU2MWCtQHoRg1tIortCXLvqPjDx58F1pUYFNCOVZegXi+jqLGDODUA31er+TVz66umzRbbN1bO1Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.32.0",
|
||||
"@tiptap/pm": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/markdown": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.32.0.tgz",
|
||||
"integrity": "sha512-y+pGAu4QMkXBMxhBoLvc6FWucQ1EWIoPi+Ajf4rAh5OlZtSV3CjBwyK6/xTwO7mvQxtoUf7DD23CzSaj5nCTBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"marked": "^17.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.32.0",
|
||||
"@tiptap/pm": "3.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/markdown/node_modules/marked": {
|
||||
"version": "17.0.6",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz",
|
||||
"integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.32.0.tgz",
|
||||
"integrity": "sha512-H9eN+xHFboJybE7KPGVsu24S8aBlCIgYz38vCHRSnNv17nwmqm06owq/IQ5scza0n3Y7IfUfpgPOtm9SgeHmNQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.4.4",
|
||||
"prosemirror-commands": "^1.7.2",
|
||||
"prosemirror-dropcursor": "^1.8.4",
|
||||
"prosemirror-gapcursor": "^1.4.1",
|
||||
"prosemirror-history": "^1.5.1",
|
||||
"prosemirror-inputrules": "^1.5.1",
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.12",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-tables": "^1.8.5",
|
||||
"prosemirror-transform": "^1.12.2",
|
||||
"prosemirror-view": "^1.42.6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.32.0.tgz",
|
||||
"integrity": "sha512-aPYxaevci4PgMrTLsg+baiqI7M1ICd4V9H+zTTdrqs8OUZIbAvj/pNDuHU8uWZyKMu3Dh72t2D6ZdqRu/6aSWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "3.32.0",
|
||||
"@tiptap/extension-blockquote": "3.32.0",
|
||||
"@tiptap/extension-bold": "3.32.0",
|
||||
"@tiptap/extension-bullet-list": "3.32.0",
|
||||
"@tiptap/extension-code": "3.32.0",
|
||||
"@tiptap/extension-code-block": "3.32.0",
|
||||
"@tiptap/extension-document": "3.32.0",
|
||||
"@tiptap/extension-dropcursor": "3.32.0",
|
||||
"@tiptap/extension-gapcursor": "3.32.0",
|
||||
"@tiptap/extension-hard-break": "3.32.0",
|
||||
"@tiptap/extension-heading": "3.32.0",
|
||||
"@tiptap/extension-horizontal-rule": "3.32.0",
|
||||
"@tiptap/extension-italic": "3.32.0",
|
||||
"@tiptap/extension-link": "3.32.0",
|
||||
"@tiptap/extension-list": "3.32.0",
|
||||
"@tiptap/extension-list-item": "3.32.0",
|
||||
"@tiptap/extension-list-keymap": "3.32.0",
|
||||
"@tiptap/extension-ordered-list": "3.32.0",
|
||||
"@tiptap/extension-paragraph": "3.32.0",
|
||||
"@tiptap/extension-strike": "3.32.0",
|
||||
"@tiptap/extension-text": "3.32.0",
|
||||
"@tiptap/extension-underline": "3.32.0",
|
||||
"@tiptap/extensions": "3.32.0",
|
||||
"@tiptap/pm": "3.32.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenizer/inflate": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.5.1.tgz",
|
||||
@@ -1630,6 +2045,18 @@
|
||||
"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": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||
@@ -2689,6 +3116,45 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -3055,6 +3521,18 @@
|
||||
"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": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/is-uri/-/is-uri-1.2.14.tgz",
|
||||
@@ -3502,6 +3980,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.4.0.tgz",
|
||||
"integrity": "sha512-InTexvtbai+2c+dEVGcc6O6P2DMVIiwD9a/i4zFroazOIYLOS6FSVmAJ7kFeumnCMx6/gQqNAkbUvgMubV4+MA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-character": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
@@ -3910,6 +4394,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
@@ -4052,6 +4542,21 @@
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -4309,6 +4814,145 @@
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz",
|
||||
"integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
|
||||
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-dropcursor": {
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz",
|
||||
"integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0",
|
||||
"prosemirror-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-gapcursor": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-history": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz",
|
||||
"integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.31.0",
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-inputrules": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.12",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
|
||||
"integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-transform": "^1.10.5",
|
||||
"prosemirror-view": "^1.41.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.4.tgz",
|
||||
"integrity": "sha512-2/WuPum3pn+O/Vi2vzpi8210ICUe2kIPXdLYjQ09Pv5DtT5bcKWMJhAKPBwvpPjpp5iOYUVwVEr0gOM1BY1Mgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.42.8",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.8.tgz",
|
||||
"integrity": "sha512-ftDZjCs9AjAekXmCfJRn2U3IdlRCl0Yc4tZT1/jJWCsz4DiSifEBXtWmpJpw/4f1Rg328uAUQ+u2lCJ0+AVZkw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.25.12",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -4422,6 +5066,12 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.14"
|
||||
}
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sade": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||
@@ -4545,6 +5195,21 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "10.6.2",
|
||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.6.2.tgz",
|
||||
@@ -5170,6 +5835,12 @@
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
|
||||
@@ -5284,6 +5955,21 @@
|
||||
"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": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
|
||||
+6
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "sendewerk",
|
||||
"private": true,
|
||||
"version": "0.5.12",
|
||||
"version": "0.9.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.17 <25"
|
||||
@@ -25,7 +25,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.2.2",
|
||||
"@tiptap/core": "^3.32.0",
|
||||
"@tiptap/markdown": "^3.32.0",
|
||||
"@tiptap/pm": "^3.32.0",
|
||||
"@tiptap/starter-kit": "^3.32.0",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"fast-xml-parser": "^5.11.2",
|
||||
"file-type": "^22.2.0",
|
||||
"marked": "^18.1.1",
|
||||
"metascraper": "^5.58.5",
|
||||
|
||||
+28
-1
@@ -296,6 +296,17 @@ export function seed(databasePath = getPaths().database) {
|
||||
1,
|
||||
timestamp,
|
||||
timestamp
|
||||
],
|
||||
[
|
||||
'show-completed-demo',
|
||||
'Frühere Sendung: Linux, Spiele und Netzkultur',
|
||||
'2026-10-09T18:00:00.000Z',
|
||||
'Linux, Spiele und Netzkultur | heinzlive',
|
||||
'Eine abgeschlossene Beispielsendung für die Postproduktion.',
|
||||
'completed',
|
||||
1,
|
||||
timestamp,
|
||||
timestamp
|
||||
]
|
||||
]
|
||||
);
|
||||
@@ -320,7 +331,8 @@ export function seed(databasePath = getPaths().database) {
|
||||
['games-first', 'show-games-evening', 'Spiel 1', 'cat-game', 1, 2700, 0],
|
||||
['games-chat', 'show-games-evening', 'Pause & Chat', 'cat-jc', 2, 600, 0],
|
||||
['games-second', 'show-games-evening', 'Spiel 2', 'cat-game', 3, 2700, 0],
|
||||
['reserve-bank-show-games-evening', 'show-games-evening', 'Reservebank', null, 4, null, 1]
|
||||
['reserve-bank-show-games-evening', 'show-games-evening', 'Reservebank', null, 4, null, 1],
|
||||
['reserve-bank-show-completed-demo', 'show-completed-demo', 'Reservebank', null, 0, null, 1]
|
||||
]
|
||||
);
|
||||
|
||||
@@ -447,6 +459,21 @@ export function seed(databasePath = getPaths().database) {
|
||||
)`
|
||||
)
|
||||
.run();
|
||||
const hasCompletionSchema = database
|
||||
.prepare("SELECT 1 FROM pragma_table_info('shows') WHERE name = 'completed_at_utc'")
|
||||
.get();
|
||||
if (hasCompletionSchema) {
|
||||
database
|
||||
.prepare("UPDATE shows SET completed_at_utc = ? WHERE id = 'show-completed-demo'")
|
||||
.run('2026-10-10T00:00:00.000Z');
|
||||
database
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO show_finalizations
|
||||
(show_id, command_id, source_revision, finalized_by_user_id, finalized_at_utc)
|
||||
VALUES ('show-completed-demo', 'seed-completed-demo', 1, NULL, ?)`
|
||||
)
|
||||
.run('2026-10-10T00:00:00.000Z');
|
||||
}
|
||||
});
|
||||
|
||||
seedDatabase();
|
||||
|
||||
+7347
-125
File diff suppressed because it is too large.
Load diff
Vendored
+1
@@ -8,6 +8,7 @@ declare global {
|
||||
interface PageData {
|
||||
user?: AuthUser | null;
|
||||
presentationMode?: boolean;
|
||||
oauthMode?: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -2,8 +2,12 @@
|
||||
<html lang="de" data-theme="light" data-theme-preference="system">
|
||||
<head>
|
||||
<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="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>
|
||||
(function () {
|
||||
try {
|
||||
|
||||
+45
-3
@@ -1,6 +1,8 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { Handle } from '@sveltejs/kit/hooks';
|
||||
import { deleteSession, getSessionUser, hasUsers, SESSION_COOKIE } from '#lib/server/auth.js';
|
||||
import { startFeedScheduler } from '#lib/server/feeds.js';
|
||||
|
||||
const publicPaths = new Set([
|
||||
'/',
|
||||
@@ -13,17 +15,42 @@ const publicPaths = new Set([
|
||||
]);
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
startFeedScheduler();
|
||||
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 oauthPath =
|
||||
event.url.pathname.startsWith('/oauth/') ||
|
||||
event.url.pathname.startsWith('/.well-known/oauth-');
|
||||
const mcpPath = event.url.pathname === '/mcp';
|
||||
const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname);
|
||||
const token = event.cookies.get(SESSION_COOKIE);
|
||||
event.locals.user = getSessionUser(token);
|
||||
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 &&
|
||||
!oauthPath &&
|
||||
!mcpPath &&
|
||||
!event.locals.user
|
||||
) {
|
||||
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('X-Content-Type-Options', 'nosniff');
|
||||
response.headers.set('X-Frame-Options', 'DENY');
|
||||
@@ -35,7 +62,22 @@ 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'"
|
||||
);
|
||||
}
|
||||
if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') {
|
||||
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' ||
|
||||
oauthPath ||
|
||||
mcpPath
|
||||
) {
|
||||
response.headers.set('Cache-Control', 'no-store');
|
||||
}
|
||||
return response;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
let { name, label, size = '1em' }: { name: string; label?: string; size?: string } = $props();
|
||||
let { name, label, size = '1.1em' }: { name: string; label?: string; size?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
|
||||
@@ -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,320 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve -- all links are closed local routes assembled below */
|
||||
import { onMount, tick } from 'svelte';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
let {
|
||||
filters,
|
||||
counts,
|
||||
topics,
|
||||
formats = [],
|
||||
categories = [],
|
||||
editor,
|
||||
oneditorclose
|
||||
}: {
|
||||
filters: {
|
||||
query: string;
|
||||
status: string;
|
||||
formatId?: string;
|
||||
topicId: string;
|
||||
categoryId?: string;
|
||||
includeUsed?: boolean;
|
||||
includePlanned?: boolean;
|
||||
favorites: boolean;
|
||||
view: string;
|
||||
};
|
||||
counts: {
|
||||
all: number;
|
||||
inbox: number;
|
||||
preparing: number;
|
||||
ready: number;
|
||||
favorites: number;
|
||||
completed: number;
|
||||
archived: number;
|
||||
deleted: number;
|
||||
topics: Record<string, number>;
|
||||
};
|
||||
topics: { id: string; name: string; iconName: string }[];
|
||||
formats?: { id: string; name: string }[];
|
||||
categories?: { id: string; name: string }[];
|
||||
editor?: {
|
||||
current: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
durationSeconds: number;
|
||||
isFavorite: boolean;
|
||||
};
|
||||
pieces: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
durationSeconds: number;
|
||||
isFavorite: boolean;
|
||||
}[];
|
||||
search: string;
|
||||
};
|
||||
oneditorclose?: () => void;
|
||||
} = $props();
|
||||
let collapsed = $state(false);
|
||||
let searchInput = $state<HTMLInputElement>();
|
||||
|
||||
onMount(() => {
|
||||
collapsed = localStorage.getItem('sendewerk-pieces-navigation') === 'collapsed';
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
collapsed = !collapsed;
|
||||
localStorage.setItem('sendewerk-pieces-navigation', collapsed ? 'collapsed' : 'open');
|
||||
}
|
||||
|
||||
async function revealSearch() {
|
||||
if (collapsed) toggle();
|
||||
await tick();
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
const fundusLinks = $derived([
|
||||
{
|
||||
href: '/fundus',
|
||||
label: 'Alle Pieces',
|
||||
icon: 'library',
|
||||
count: counts.all,
|
||||
active: !filters.status && !filters.favorites && !filters.view
|
||||
},
|
||||
{ href: '/eingang', label: 'Eingang', icon: 'inbox', count: counts.inbox, active: false },
|
||||
{
|
||||
href: '/fundus?view=completed',
|
||||
label: 'Abgeschlossen',
|
||||
icon: 'circle-check',
|
||||
count: counts.completed,
|
||||
active: filters.view === 'completed'
|
||||
},
|
||||
{
|
||||
href: '/fundus?status=preparing',
|
||||
label: 'In Vorbereitung',
|
||||
icon: 'pen',
|
||||
count: counts.preparing,
|
||||
active: filters.status === 'preparing'
|
||||
},
|
||||
{
|
||||
href: '/fundus?status=ready',
|
||||
label: 'Sendefertig',
|
||||
icon: 'circle-check',
|
||||
count: counts.ready,
|
||||
active: filters.status === 'ready'
|
||||
},
|
||||
{
|
||||
href: '/fundus?favorites=1',
|
||||
label: 'Favoriten',
|
||||
icon: 'star',
|
||||
count: counts.favorites,
|
||||
active: filters.favorites
|
||||
},
|
||||
{
|
||||
href: '/archiv',
|
||||
label: 'Archiv',
|
||||
icon: 'box-archive',
|
||||
count: counts.archived,
|
||||
active: false
|
||||
},
|
||||
{
|
||||
href: '/archiv#deleted-pieces-title',
|
||||
label: 'Papierkorb',
|
||||
icon: 'trash',
|
||||
count: counts.deleted,
|
||||
active: false
|
||||
}
|
||||
]);
|
||||
let editorPieces = $derived(
|
||||
editor
|
||||
? editor.pieces.some((piece) => piece.id === editor.current.id)
|
||||
? editor.pieces
|
||||
: [editor.current, ...editor.pieces]
|
||||
: []
|
||||
);
|
||||
const statusLabels: Record<string, string> = {
|
||||
inbox: 'Eingang',
|
||||
preparing: 'In Vorbereitung',
|
||||
ready: 'Sendefertig',
|
||||
archived: 'Archiviert'
|
||||
};
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="fundus-navigation"
|
||||
class:collapsed
|
||||
class:editor-context={editor}
|
||||
aria-label={editor ? 'Piece wechseln' : 'Pieces-Navigation'}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">{editor ? 'Aktuelle Auswahl' : 'Bibliothek'}</p>
|
||||
<h1>{editor ? 'Piece wechseln' : 'Pieces'}</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
aria-label={collapsed ? 'Pieces-Navigation ausklappen' : 'Pieces-Navigation einklappen'}
|
||||
aria-expanded={!collapsed}
|
||||
><AppIcon name={collapsed ? 'chevron-right' : 'chevron-left'} /></button
|
||||
>
|
||||
{#if editor && oneditorclose}<button
|
||||
class="editor-navigation-close"
|
||||
type="button"
|
||||
onclick={oneditorclose}
|
||||
aria-label="Piece-Liste schließen"><AppIcon name="xmark" /></button
|
||||
>{/if}
|
||||
</header>
|
||||
{#if editor}
|
||||
<a class="editor-overview-link" href={`/fundus${editor.search}`}
|
||||
><AppIcon name="chevron-left" /><span>Zur Übersicht</span></a
|
||||
>
|
||||
{#if !collapsed}
|
||||
<form
|
||||
class="context-search editor-context-search"
|
||||
method="GET"
|
||||
action={`/fundus/${editor.current.id}`}
|
||||
role="search"
|
||||
>
|
||||
{#if filters.status}<input type="hidden" name="status" value={filters.status} />{/if}
|
||||
{#if filters.formatId}<input type="hidden" name="format" value={filters.formatId} />{/if}
|
||||
{#if filters.topicId}<input type="hidden" name="topic" value={filters.topicId} />{/if}
|
||||
{#if filters.categoryId}<input
|
||||
type="hidden"
|
||||
name="category"
|
||||
value={filters.categoryId}
|
||||
/>{/if}
|
||||
{#if filters.favorites}<input type="hidden" name="favorites" value="1" />{/if}
|
||||
{#if filters.view}<input type="hidden" name="view" value={filters.view} />{/if}
|
||||
{#if filters.includeUsed}<input type="hidden" name="includeUsed" value="1" />{/if}
|
||||
{#if filters.includePlanned}<input type="hidden" name="includePlanned" value="1" />{/if}
|
||||
<label
|
||||
><AppIcon name="magnifying-glass" /><span class="sr-only">Pieces durchsuchen</span><input
|
||||
bind:this={searchInput}
|
||||
name="q"
|
||||
type="search"
|
||||
value={filters.query}
|
||||
placeholder="Auswahl durchsuchen"
|
||||
/></label
|
||||
>
|
||||
</form>
|
||||
<details class="editor-piece-filters">
|
||||
<summary><AppIcon name="list-check" /> Filter</summary>
|
||||
<form method="GET" action={`/fundus/${editor.current.id}`}>
|
||||
{#if filters.query}<input type="hidden" name="q" value={filters.query} />{/if}
|
||||
{#if filters.view}<input type="hidden" name="view" value={filters.view} />{/if}
|
||||
{#if filters.includeUsed}<input type="hidden" name="includeUsed" value="1" />{/if}
|
||||
{#if filters.includePlanned}<input type="hidden" name="includePlanned" value="1" />{/if}
|
||||
<label
|
||||
>Status<select name="status" aria-label="Nach Bearbeitungsstand filtern"
|
||||
><option value="">Aktive Pieces</option><option
|
||||
value="preparing"
|
||||
selected={filters.status === 'preparing'}>In Vorbereitung</option
|
||||
><option value="ready" selected={filters.status === 'ready'}>Sendefertig</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Format<select name="format" aria-label="Nach Format filtern"
|
||||
><option value="">Alle Formate</option>{#each formats as format (format.id)}<option
|
||||
value={format.id}
|
||||
selected={filters.formatId === format.id}>{format.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Thema<select name="topic" aria-label="Nach Thema filtern"
|
||||
><option value="">Alle Themen</option>{#each topics as topic (topic.id)}<option
|
||||
value={topic.id}
|
||||
selected={filters.topicId === topic.id}>{topic.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Abschnitt<select name="category" aria-label="Nach Abschnitt filtern"
|
||||
><option value="">Alle Abschnitte</option
|
||||
>{#each categories as category (category.id)}<option
|
||||
value={category.id}
|
||||
selected={filters.categoryId === category.id}>{category.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label class="editor-filter-check"
|
||||
><input type="checkbox" name="favorites" value="1" checked={filters.favorites} /> Nur Favoriten</label
|
||||
>
|
||||
<button class="secondary-button" type="submit">Filter anwenden</button>
|
||||
</form>
|
||||
</details>
|
||||
{/if}
|
||||
<nav class="editor-piece-list" aria-label="Gefilterte Pieces">
|
||||
{#each editorPieces as piece (piece.id)}
|
||||
<a
|
||||
href={`/fundus/${piece.id}${editor.search}`}
|
||||
class:active={piece.id === editor.current.id}
|
||||
aria-current={piece.id === editor.current.id ? 'page' : undefined}
|
||||
>
|
||||
<AppIcon name={piece.isFavorite ? 'star' : 'newspaper'} />
|
||||
<span
|
||||
><strong>{piece.title}</strong><small
|
||||
>{statusLabels[piece.status] ?? piece.status} · {Math.round(
|
||||
piece.durationSeconds / 60
|
||||
)} Min.</small
|
||||
></span
|
||||
>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{:else if collapsed}
|
||||
<button
|
||||
class="collapsed-search-button"
|
||||
type="button"
|
||||
onclick={revealSearch}
|
||||
aria-label="Pieces durchsuchen"><AppIcon name="magnifying-glass" /></button
|
||||
>
|
||||
{:else}
|
||||
<form class="context-search" method="GET" action="/fundus" role="search">
|
||||
{#if filters.status}<input type="hidden" name="status" value={filters.status} />{/if}
|
||||
{#if filters.topicId}<input type="hidden" name="topic" value={filters.topicId} />{/if}
|
||||
{#if filters.favorites}<input type="hidden" name="favorites" value="1" />{/if}
|
||||
{#if filters.view}<input type="hidden" name="view" value={filters.view} />{/if}
|
||||
<label
|
||||
><AppIcon name="magnifying-glass" /><span class="sr-only">Pieces durchsuchen</span><input
|
||||
bind:this={searchInput}
|
||||
name="q"
|
||||
type="search"
|
||||
value={filters.query}
|
||||
placeholder="Pieces durchsuchen"
|
||||
/></label
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
{#if !editor}<nav>
|
||||
{#each fundusLinks as item (item.href)}
|
||||
<a
|
||||
href={item.href}
|
||||
class:active={item.active}
|
||||
aria-current={item.active ? 'page' : undefined}
|
||||
>
|
||||
<AppIcon name={item.icon} /><span>{item.label}</span><strong>{item.count}</strong>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>{/if}
|
||||
{#if !editor && topics.length}
|
||||
<section>
|
||||
<h2>Themen</h2>
|
||||
<nav aria-label="Themenfilter">
|
||||
{#each topics as topic (topic.id)}
|
||||
<a
|
||||
href={`/fundus?topic=${encodeURIComponent(topic.id)}`}
|
||||
class:active={filters.topicId === topic.id}
|
||||
>
|
||||
<AppIcon name={topic.iconName || 'tag'} /><span>{topic.name}</span><strong
|
||||
>{counts.topics[topic.id] ?? 0}</strong
|
||||
>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</section>
|
||||
{/if}
|
||||
</aside>
|
||||
@@ -6,6 +6,7 @@
|
||||
$props();
|
||||
let query = $state('');
|
||||
let menu: HTMLDetailsElement;
|
||||
let searchInput: HTMLInputElement;
|
||||
let filtered = $derived(
|
||||
classicRegularIcons.filter((icon) =>
|
||||
`${icon.name} ${icon.label} ${icon.terms.join(' ')}`
|
||||
@@ -14,18 +15,31 @@
|
||||
)
|
||||
);
|
||||
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}>
|
||||
<summary><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
|
||||
<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">
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
let {
|
||||
name,
|
||||
label,
|
||||
value = $bindable(),
|
||||
rows = 4,
|
||||
placeholder = '',
|
||||
privateField = false,
|
||||
form
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
value: string;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
privateField?: boolean;
|
||||
form?: string;
|
||||
} = $props();
|
||||
|
||||
let textarea: HTMLTextAreaElement;
|
||||
|
||||
async function replaceSelection(before: string, after = before, fallback = 'Text') {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selected = value.slice(start, end) || fallback;
|
||||
value = `${value.slice(0, start)}${before}${selected}${after}${value.slice(end)}`;
|
||||
await tick();
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + before.length, start + before.length + selected.length);
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
async function addList() {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selected = value.slice(start, end) || 'Stichpunkt';
|
||||
const replacement = selected
|
||||
.split('\n')
|
||||
.map((line) => `- ${line}`)
|
||||
.join('\n');
|
||||
value = `${value.slice(0, start)}${replacement}${value.slice(end)}`;
|
||||
await tick();
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + 2, start + replacement.length);
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class:private-field={privateField} class="markdown-editor-field">
|
||||
<div class="markdown-editor-heading">
|
||||
<span
|
||||
>{label}{#if privateField}<small>Nur intern</small>{/if}</span
|
||||
>
|
||||
<span class="markdown-toolbar" role="toolbar" aria-label={`${label} formatieren`}>
|
||||
<button type="button" aria-label="Fett" title="Fett" onclick={() => replaceSelection('**')}
|
||||
><strong>B</strong></button
|
||||
>
|
||||
<button type="button" aria-label="Kursiv" title="Kursiv" onclick={() => replaceSelection('_')}
|
||||
><em>I</em></button
|
||||
>
|
||||
<button type="button" aria-label="Liste" title="Liste" onclick={addList}
|
||||
><AppIcon name="list-check" /> Liste</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Link"
|
||||
title="Link"
|
||||
onclick={() => replaceSelection('[', '](https://)', 'Linktext')}
|
||||
><AppIcon name="link" /> Link</button
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
bind:this={textarea}
|
||||
aria-label={label}
|
||||
{name}
|
||||
{rows}
|
||||
{placeholder}
|
||||
{form}
|
||||
maxlength="50000"
|
||||
bind:value></textarea>
|
||||
</div>
|
||||
@@ -12,7 +12,7 @@
|
||||
let iconName = $derived(aliases[name] ?? name);
|
||||
</script>
|
||||
|
||||
<AppIcon name={iconName} size="1.25rem" />
|
||||
<AppIcon name={iconName} size="1.4rem" />
|
||||
|
||||
<style>
|
||||
:global(.app-icon) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
labelledby,
|
||||
activeSection = $bindable(),
|
||||
onrequestclose,
|
||||
inline = false,
|
||||
header,
|
||||
children,
|
||||
footer
|
||||
@@ -13,12 +14,14 @@
|
||||
labelledby: string;
|
||||
activeSection: 'script' | 'research' | 'presentation';
|
||||
onrequestclose: () => void;
|
||||
inline?: boolean;
|
||||
header: Snippet;
|
||||
children: Snippet;
|
||||
footer: Snippet;
|
||||
footer?: Snippet;
|
||||
} = $props();
|
||||
|
||||
let dialog: HTMLDialogElement;
|
||||
let dialog = $state<HTMLDialogElement>();
|
||||
let closing = $state(false);
|
||||
let workspace: HTMLDivElement;
|
||||
const scrollPositions = { script: 0, research: 0, presentation: 0 };
|
||||
|
||||
@@ -30,53 +33,83 @@
|
||||
}
|
||||
|
||||
export function showModal() {
|
||||
if (!dialog.open) dialog.showModal();
|
||||
if (!inline && dialog && !dialog.open) {
|
||||
closing = false;
|
||||
dialog.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
export function close() {
|
||||
dialog.close();
|
||||
if (!inline) void requestClose();
|
||||
}
|
||||
|
||||
async function requestClose() {
|
||||
if (inline || closing) return;
|
||||
closing = true;
|
||||
await tick();
|
||||
const closeAnimation = dialog
|
||||
?.getAnimations()
|
||||
.find((animation) => animation.effect instanceof KeyframeEffect);
|
||||
if (closeAnimation) {
|
||||
try {
|
||||
await closeAnimation.finished;
|
||||
} catch {
|
||||
// A navigation may remove the dialog while its visual exit is finishing.
|
||||
}
|
||||
}
|
||||
onrequestclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
bind:this={dialog}
|
||||
class="piece-editor-dialog"
|
||||
aria-labelledby={labelledby}
|
||||
oncancel={(event) => {
|
||||
event.preventDefault();
|
||||
onrequestclose();
|
||||
}}
|
||||
onclick={(event) => event.target === dialog && onrequestclose()}
|
||||
>
|
||||
{#snippet editorChrome()}
|
||||
<header class="piece-dialog-header">
|
||||
<div class="piece-dialog-heading">{@render header()}</div>
|
||||
<button
|
||||
class="dialog-close"
|
||||
type="button"
|
||||
aria-label="Editor schließen"
|
||||
onclick={onrequestclose}><AppIcon name="xmark" /></button
|
||||
<button class="dialog-close" type="button" aria-label="Editor schließen" onclick={requestClose}
|
||||
><AppIcon name="xmark" /></button
|
||||
>
|
||||
</header>
|
||||
<nav class="piece-dialog-tabs" aria-label="Editorbereiche">
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeSection === 'script'}
|
||||
aria-current={activeSection === 'script' ? 'page' : undefined}
|
||||
onclick={() => selectSection('script')}>Sendungstext</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeSection === 'research'}
|
||||
aria-current={activeSection === 'research' ? 'page' : undefined}
|
||||
onclick={() => selectSection('research')}>Recherche & Quellen</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeSection === 'presentation'}
|
||||
aria-current={activeSection === 'presentation' ? 'page' : undefined}
|
||||
onclick={() => selectSection('presentation')}>Präsentation</button
|
||||
>
|
||||
</nav>
|
||||
{#if !inline}
|
||||
<nav class="piece-dialog-tabs" aria-label="Editorbereiche">
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeSection === 'script'}
|
||||
aria-current={activeSection === 'script' ? 'page' : undefined}
|
||||
onclick={() => selectSection('script')}>Streamtext</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeSection === 'research'}
|
||||
aria-current={activeSection === 'research' ? 'page' : undefined}
|
||||
onclick={() => selectSection('research')}>Recherche & Quellen</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeSection === 'presentation'}
|
||||
aria-current={activeSection === 'presentation' ? 'page' : undefined}
|
||||
onclick={() => selectSection('presentation')}>Präsentation</button
|
||||
>
|
||||
</nav>
|
||||
{/if}
|
||||
<div bind:this={workspace} class="piece-dialog-workspace">{@render children()}</div>
|
||||
<footer class="piece-dialog-footer">{@render footer()}</footer>
|
||||
</dialog>
|
||||
{#if footer}<footer class="piece-dialog-footer">{@render footer()}</footer>{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if inline}
|
||||
<section class="piece-editor-dialog fundus-editor-surface" aria-labelledby={labelledby}>
|
||||
{@render editorChrome()}
|
||||
</section>
|
||||
{:else}
|
||||
<dialog
|
||||
bind:this={dialog}
|
||||
class:closing
|
||||
class="piece-editor-dialog fundus-editor-surface"
|
||||
aria-labelledby={labelledby}
|
||||
oncancel={(event) => {
|
||||
event.preventDefault();
|
||||
requestClose();
|
||||
}}
|
||||
onclick={(event) => event.target === dialog && requestClose()}
|
||||
>
|
||||
{@render editorChrome()}
|
||||
</dialog>
|
||||
{/if}
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve -- href() starts from resolve('/post') and adds local filters */
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
let {
|
||||
query,
|
||||
progress,
|
||||
sort,
|
||||
counts
|
||||
}: {
|
||||
query: string;
|
||||
progress: string;
|
||||
sort: string;
|
||||
counts: Record<string, number>;
|
||||
} = $props();
|
||||
let collapsed = $state(false);
|
||||
let searchInput = $state<HTMLInputElement>();
|
||||
|
||||
const links = $derived([
|
||||
{ label: 'Alle Streams', value: '', icon: 'clapperboard', count: counts.all },
|
||||
{
|
||||
label: 'Noch nicht begonnen',
|
||||
value: 'Noch nicht begonnen',
|
||||
icon: 'file',
|
||||
count: counts.notStarted
|
||||
},
|
||||
{
|
||||
label: 'In Bearbeitung',
|
||||
value: 'In Bearbeitung',
|
||||
icon: 'pen',
|
||||
count: counts.inProgress
|
||||
},
|
||||
{ label: 'Fertig', value: 'Fertig', icon: 'circle-check', count: counts.done },
|
||||
{
|
||||
label: 'Veröffentlicht',
|
||||
value: 'Veröffentlicht',
|
||||
icon: 'display',
|
||||
count: counts.published
|
||||
}
|
||||
]);
|
||||
|
||||
onMount(() => {
|
||||
collapsed = localStorage.getItem('sendewerk-post-navigation') === 'collapsed';
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
collapsed = !collapsed;
|
||||
localStorage.setItem('sendewerk-post-navigation', collapsed ? 'collapsed' : 'open');
|
||||
}
|
||||
|
||||
async function revealSearch() {
|
||||
if (collapsed) toggle();
|
||||
await tick();
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
function href(value: string) {
|
||||
const params = new SvelteURLSearchParams();
|
||||
if (query) params.set('q', query);
|
||||
if (value) params.set('progress', value);
|
||||
if (sort !== 'newest') params.set('sort', sort);
|
||||
const search = params.toString();
|
||||
const base = resolve('/post');
|
||||
return search ? `${base}?${search}` : base;
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="fundus-navigation post-navigation-panel" class:collapsed aria-label="Post-Navigation">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">Nach dem Stream</p>
|
||||
<h1>Postproduktion</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
aria-label={collapsed ? 'Post-Navigation ausklappen' : 'Post-Navigation einklappen'}
|
||||
aria-expanded={!collapsed}
|
||||
><AppIcon name={collapsed ? 'chevron-right' : 'chevron-left'} /></button
|
||||
>
|
||||
</header>
|
||||
{#if collapsed}
|
||||
<button
|
||||
class="collapsed-search-button"
|
||||
type="button"
|
||||
onclick={revealSearch}
|
||||
aria-label="Postproduktion durchsuchen"><AppIcon name="magnifying-glass" /></button
|
||||
>
|
||||
{:else}
|
||||
<form class="context-search" method="GET" action="/post" role="search">
|
||||
{#if progress}<input type="hidden" name="progress" value={progress} />{/if}
|
||||
{#if sort !== 'newest'}<input type="hidden" name="sort" value={sort} />{/if}
|
||||
<label
|
||||
><AppIcon name="magnifying-glass" /><span class="sr-only">Streams durchsuchen</span><input
|
||||
bind:this={searchInput}
|
||||
name="q"
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Streams durchsuchen"
|
||||
/></label
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
<nav>
|
||||
{#each links as item (item.value)}
|
||||
<a
|
||||
href={href(item.value)}
|
||||
class:active={progress === item.value}
|
||||
aria-current={progress === item.value ? 'page' : undefined}
|
||||
><AppIcon name={item.icon} /><span>{item.label}</span><strong>{item.count}</strong></a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
{#if !collapsed}
|
||||
<section class="post-navigation-sort">
|
||||
<h2>Sortierung</h2>
|
||||
<form method="GET" action="/post">
|
||||
{#if query}<input type="hidden" name="q" value={query} />{/if}
|
||||
{#if progress}<input type="hidden" name="progress" value={progress} />{/if}
|
||||
<label
|
||||
><span class="sr-only">Sortierung</span><select
|
||||
name="sort"
|
||||
value={sort}
|
||||
onchange={(event) => event.currentTarget.form?.requestSubmit()}
|
||||
><option value="newest">Neueste zuerst</option><option value="oldest"
|
||||
>Älteste zuerst</option
|
||||
><option value="title">Titel</option><option value="clips">Meiste Clips</option></select
|
||||
></label
|
||||
>
|
||||
</form>
|
||||
</section>
|
||||
{/if}
|
||||
</aside>
|
||||
@@ -60,6 +60,10 @@
|
||||
{/each}
|
||||
</footer>{/if}
|
||||
</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'}
|
||||
<article class="output-content output-link-card">
|
||||
{#if imageSrc && !imageUnavailable}<div class="output-link-image">
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
import { Editor } from '@tiptap/core';
|
||||
import { Markdown } from '@tiptap/markdown';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { onMount } from 'svelte';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
let {
|
||||
name,
|
||||
label,
|
||||
value = $bindable(),
|
||||
rows = 4,
|
||||
placeholder = '',
|
||||
privateField = false,
|
||||
form
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
value: string;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
privateField?: boolean;
|
||||
form?: string;
|
||||
} = $props();
|
||||
|
||||
let element: HTMLDivElement;
|
||||
let valueInput: HTMLInputElement;
|
||||
let editor = $state<Editor>();
|
||||
let sourceMode = $state(false);
|
||||
let sourceWarning = $state(false);
|
||||
let transaction = $state(0);
|
||||
|
||||
function announceChange(markdown: string) {
|
||||
value = markdown;
|
||||
valueInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function command(run: (chain: ReturnType<Editor['chain']>) => boolean) {
|
||||
if (!editor) return;
|
||||
run(editor.chain().focus());
|
||||
}
|
||||
|
||||
function active(name: string, attributes?: Record<string, unknown>) {
|
||||
return transaction >= 0 && (editor?.isActive(name, attributes) ?? false);
|
||||
}
|
||||
|
||||
function preserveSelection(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function showRichText() {
|
||||
if (!editor) return;
|
||||
editor.commands.setContent(value, { contentType: 'markdown', emitUpdate: false });
|
||||
sourceMode = false;
|
||||
}
|
||||
|
||||
function editLink() {
|
||||
if (!editor) return;
|
||||
const previous = String(editor.getAttributes('link').href ?? '');
|
||||
const href = window.prompt('Linkadresse', previous || 'https://');
|
||||
if (href === null) return;
|
||||
if (!href.trim()) editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
else editor.chain().focus().extendMarkRange('link').setLink({ href: href.trim() }).run();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
editor = new Editor({
|
||||
element,
|
||||
extensions: [StarterKit, Markdown],
|
||||
content: value,
|
||||
contentType: 'markdown',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'rich-markdown-content',
|
||||
'aria-label': label,
|
||||
'data-placeholder': placeholder
|
||||
}
|
||||
},
|
||||
onUpdate: ({ editor: current }) => announceChange(current.getMarkdown()),
|
||||
onTransaction: () => (transaction += 1)
|
||||
});
|
||||
const roundTrip = editor.getMarkdown().trimEnd();
|
||||
if (value.trimEnd() !== roundTrip) {
|
||||
sourceWarning = true;
|
||||
sourceMode = true;
|
||||
}
|
||||
return () => editor?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class:private-field={privateField} class="markdown-editor-field rich-markdown-field">
|
||||
<input bind:this={valueInput} type="hidden" {name} {form} {value} />
|
||||
<div class="markdown-editor-heading">
|
||||
<span
|
||||
>{label}{#if privateField}<small>Nur intern</small>{/if}</span
|
||||
>
|
||||
<button class="markdown-toolbar-toggle" type="button" aria-label={`${label} formatieren`}
|
||||
><AppIcon name="pen" /> Formatieren</button
|
||||
>
|
||||
<span class="markdown-toolbar" role="toolbar" aria-label={`${label} formatieren`}>
|
||||
{#if !sourceMode}
|
||||
<button
|
||||
class:active={active('bold')}
|
||||
type="button"
|
||||
aria-label="Fett"
|
||||
onmousedown={preserveSelection}
|
||||
onclick={() => command((chain) => chain.toggleBold().run())}><strong>B</strong></button
|
||||
>
|
||||
<button
|
||||
class:active={active('italic')}
|
||||
type="button"
|
||||
aria-label="Kursiv"
|
||||
onmousedown={preserveSelection}
|
||||
onclick={() => command((chain) => chain.toggleItalic().run())}><em>I</em></button
|
||||
>
|
||||
<button
|
||||
class:active={active('bulletList')}
|
||||
type="button"
|
||||
aria-label="Liste"
|
||||
onmousedown={preserveSelection}
|
||||
onclick={() => command((chain) => chain.toggleBulletList().run())}
|
||||
><AppIcon name="list-check" /></button
|
||||
>
|
||||
<button
|
||||
class:active={active('heading', { level: 2 })}
|
||||
type="button"
|
||||
aria-label="Zwischenüberschrift"
|
||||
onmousedown={preserveSelection}
|
||||
onclick={() => command((chain) => chain.toggleHeading({ level: 2 }).run())}>H2</button
|
||||
>
|
||||
<button
|
||||
class:active={active('link')}
|
||||
type="button"
|
||||
aria-label="Link"
|
||||
onmousedown={preserveSelection}
|
||||
onclick={editLink}><AppIcon name="link" /></button
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class:active={sourceMode}
|
||||
onmousedown={preserveSelection}
|
||||
onclick={() => (sourceMode ? showRichText() : (sourceMode = true))}
|
||||
>{sourceMode ? 'Visuell' : 'Markdown'}</button
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
{#if sourceWarning}<p class="markdown-source-warning">
|
||||
Dieser Text enthält Markdown, das visuell nicht verlustfrei abgebildet werden kann. Die
|
||||
Quellansicht bleibt deshalb aktiv.
|
||||
</p>{/if}
|
||||
<textarea
|
||||
hidden={!sourceMode}
|
||||
aria-label={`${label}, Markdown-Quelltext`}
|
||||
rows={Math.max(rows, 6)}
|
||||
maxlength="50000"
|
||||
bind:value></textarea>
|
||||
<div hidden={sourceMode} class="rich-markdown-surface" bind:this={element}></div>
|
||||
</div>
|
||||
@@ -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">
|
||||
Streams 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 diesen Stream 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
|
||||
>Stream<select name="showId" required>
|
||||
<option value="">Stream 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 diesen Stream 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,258 @@
|
||||
<script lang="ts">
|
||||
import { enhance, type SubmitFunction } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { PieceUsage, Show, ShowSection } from '#lib/server/shows.js';
|
||||
|
||||
let {
|
||||
show,
|
||||
startsAtLocal,
|
||||
categories,
|
||||
section = null,
|
||||
usage = null,
|
||||
usageSectionName = '',
|
||||
usageSectionId = '',
|
||||
enhanceCommand,
|
||||
copyText,
|
||||
onshow
|
||||
}: {
|
||||
show: Show;
|
||||
startsAtLocal: string;
|
||||
categories: { id: string; name: string }[];
|
||||
section?: ShowSection | null;
|
||||
usage?: PieceUsage | null;
|
||||
usageSectionName?: string;
|
||||
usageSectionId?: string;
|
||||
enhanceCommand: SubmitFunction;
|
||||
copyText: (label: string, value: string) => Promise<void>;
|
||||
onshow: () => void;
|
||||
} = $props();
|
||||
|
||||
function durationInMinutes(seconds: number) {
|
||||
return Number((seconds / 60).toFixed(2));
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="stream-inspector builder-pane" aria-label="Streamdetails">
|
||||
<header class="stream-inspector-header">
|
||||
<div>
|
||||
<p class="eyebrow">{usage ? 'Streamfassung' : section ? 'Abschnitt' : 'Stream'}</p>
|
||||
{#if usage || section}<button class="text-button" type="button" onclick={onshow}
|
||||
>Streamdaten</button
|
||||
>{/if}
|
||||
</div>
|
||||
<h2>{usage?.title ?? section?.name ?? 'Streamdaten und Twitch-Texte'}</h2>
|
||||
{#if usage}
|
||||
<p>
|
||||
{usageSectionName || 'Reservebank'} · {usage.topics.map((topic) => topic.name).join(', ') ||
|
||||
'Ohne Thema'}
|
||||
</p>
|
||||
{:else if section}
|
||||
<p>
|
||||
{section.category ?? 'Ohne Kategorie'} · {section.usages.length}
|
||||
{section.usages.length === 1 ? 'Piece' : 'Pieces'}
|
||||
</p>
|
||||
{:else}
|
||||
<p>Schnelle Änderungen am geplanten Stream.</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if usage}
|
||||
<form
|
||||
id="stream-inspector-form"
|
||||
use:enhance={enhanceCommand}
|
||||
method="POST"
|
||||
action="?/updateUsageMetadata"
|
||||
class="stream-inspector-form"
|
||||
>
|
||||
<input type="hidden" name="revision" value={show.revision} />
|
||||
<input type="hidden" name="usageId" value={usage.id} />
|
||||
<label
|
||||
>Interner Titel<input name="title" required maxlength="200" value={usage.title} /></label
|
||||
>
|
||||
<label
|
||||
>Publikumstitel<input name="outputTitle" maxlength="200" value={usage.outputTitle} /></label
|
||||
>
|
||||
<div class="stream-inspector-grid">
|
||||
<label
|
||||
>Dauer in Minuten<input
|
||||
name="durationMinutes"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1440"
|
||||
step="any"
|
||||
value={durationInMinutes(usage.durationSeconds)}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Vorbereitungsstatus<select name="preparationStatus" value={usage.preparationStatus}
|
||||
><option value="preparing">In Vorbereitung</option><option value="ready"
|
||||
>Sendefertig</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
</div>
|
||||
<label
|
||||
>Vorbereitungsaufwand<select name="preparationEffort" value={usage.preparationEffort ?? ''}
|
||||
><option value="">Nicht angegeben</option><option value="low">Gering</option><option
|
||||
value="medium">Mittel</option
|
||||
><option value="high">Hoch</option></select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Eigene Notizen<textarea name="ownNotes" maxlength="20000" rows="7"
|
||||
>{usage.ownNotes}</textarea
|
||||
></label
|
||||
>
|
||||
</form>
|
||||
<details class="stream-inspector-commands">
|
||||
<summary>Weitere Aktionen</summary>
|
||||
<div>
|
||||
<form use:enhance={enhanceCommand} method="POST" action="?/refreshUsage">
|
||||
<input type="hidden" name="revision" value={show.revision} />
|
||||
<input type="hidden" name="usageId" value={usage.id} />
|
||||
<button class="text-button" type="submit" disabled={!usage.originalPieceId}
|
||||
>Aus Piece aktualisieren</button
|
||||
>
|
||||
</form>
|
||||
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
|
||||
<input type="hidden" name="revision" value={show.revision} />
|
||||
<input type="hidden" name="usageId" value={usage.id} />
|
||||
<button class="text-button" type="submit">Duplizieren</button>
|
||||
</form>
|
||||
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
|
||||
<input type="hidden" name="revision" value={show.revision} />
|
||||
<input type="hidden" name="usageId" value={usage.id} />
|
||||
<input type="hidden" name="position" value="9999" />
|
||||
<label
|
||||
><span class="sr-only">Ziel</span><select name="sectionId"
|
||||
><option value="" selected={!usageSectionId}>Reservebank</option
|
||||
>{#each show.sections as target (target.id)}<option
|
||||
value={target.id}
|
||||
selected={target.id === usageSectionId}>{target.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<button class="text-button" type="submit">Verschieben</button>
|
||||
</form>
|
||||
<form
|
||||
use:enhance={enhanceCommand}
|
||||
method="POST"
|
||||
action="?/removeUsage"
|
||||
onsubmit={(event) =>
|
||||
!confirm('Piece aus diesem Stream entfernen?') && event.preventDefault()}
|
||||
>
|
||||
<input type="hidden" name="revision" value={show.revision} />
|
||||
<input type="hidden" name="usageId" value={usage.id} />
|
||||
<button class="text-button danger-text" type="submit">Aus Stream entfernen</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
<footer class="stream-inspector-actions">
|
||||
{#if usage.originalPieceId}
|
||||
<a class="secondary-button" href={resolve('/fundus/[id]', { id: usage.originalPieceId })}
|
||||
>Piece bearbeiten</a
|
||||
>
|
||||
{:else}
|
||||
<span class="stream-inspector-note">Das Original ist nicht mehr verfügbar.</span>
|
||||
{/if}
|
||||
<button class="primary-button" type="submit" form="stream-inspector-form">Speichern</button>
|
||||
</footer>
|
||||
{:else if section}
|
||||
<form
|
||||
id="stream-inspector-form"
|
||||
use:enhance={enhanceCommand}
|
||||
method="POST"
|
||||
action="?/updateSection"
|
||||
class="stream-inspector-form"
|
||||
>
|
||||
<input type="hidden" name="revision" value={show.revision} />
|
||||
<input type="hidden" name="sectionId" value={section.id} />
|
||||
<label>Name<input name="name" required maxlength="200" value={section.name} /></label>
|
||||
<label
|
||||
>Kategorie<select name="categoryId"
|
||||
><option value="">Ohne Kategorie</option
|
||||
>{#each categories as category (category.id)}<option
|
||||
value={category.id}
|
||||
selected={section.categoryId === category.id}>{category.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Explizite Dauer in Sekunden<input
|
||||
name="explicitDurationSeconds"
|
||||
type="number"
|
||||
min="0"
|
||||
max="86400"
|
||||
value={section.explicitDurationSeconds ?? ''}
|
||||
placeholder="Summe der Pieces"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Twitch-Tags<input
|
||||
name="twitchTags"
|
||||
maxlength="300"
|
||||
value={section.twitchTags.join(', ')}
|
||||
placeholder="Gaming, Deutsch"
|
||||
/></label
|
||||
>
|
||||
</form>
|
||||
<footer class="stream-inspector-actions">
|
||||
<form
|
||||
use:enhance={enhanceCommand}
|
||||
method="POST"
|
||||
action="?/removeSection"
|
||||
onsubmit={(event) =>
|
||||
!confirm('Abschnitt samt Einplanungen entfernen?') && event.preventDefault()}
|
||||
>
|
||||
<input type="hidden" name="revision" value={show.revision} />
|
||||
<input type="hidden" name="sectionId" value={section.id} />
|
||||
<button class="text-button danger-text" type="submit">Entfernen</button>
|
||||
</form>
|
||||
<button class="primary-button" type="submit" form="stream-inspector-form">Speichern</button>
|
||||
</footer>
|
||||
{:else}
|
||||
<form
|
||||
id="stream-inspector-form"
|
||||
use:enhance={enhanceCommand}
|
||||
method="POST"
|
||||
action="?/saveMetadata"
|
||||
class="stream-inspector-form"
|
||||
>
|
||||
<input type="hidden" name="revision" value={show.revision} />
|
||||
<label>Interner Titel<input name="title" required maxlength="200" value={show.title} /></label
|
||||
>
|
||||
<label
|
||||
>Termin<input name="startsAt" type="datetime-local" value={startsAtLocal} /><small
|
||||
>Europe/Berlin</small
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Status<select name="status" value={show.status}
|
||||
><option value="draft">Entwurf</option><option value="ready">Bereit</option><option
|
||||
value="live">Live</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Twitch-Titel<span class="copy-field"
|
||||
><input name="twitchTitle" maxlength="200" value={show.twitchTitle} /><button
|
||||
type="button"
|
||||
onclick={() => copyText('Twitch-Titel', show.twitchTitle)}>Kopieren</button
|
||||
></span
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Ankündigung<span class="copy-field"
|
||||
><textarea name="announcementText" maxlength="20000" rows="7"
|
||||
>{show.announcementText}</textarea
|
||||
><button type="button" onclick={() => copyText('Ankündigung', show.announcementText)}
|
||||
>Kopieren</button
|
||||
></span
|
||||
></label
|
||||
>
|
||||
</form>
|
||||
<footer class="stream-inspector-actions single-action">
|
||||
<button class="primary-button" type="submit" form="stream-inspector-form">Speichern</button>
|
||||
</footer>
|
||||
{/if}
|
||||
</aside>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve -- filter links are fixed local routes assembled below */
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
let {
|
||||
query,
|
||||
state: filterState,
|
||||
counts,
|
||||
shows
|
||||
}: {
|
||||
query: string;
|
||||
state: string;
|
||||
counts: Record<string, number>;
|
||||
shows: { id: string; title: string; startsAtUtc: string | null }[];
|
||||
} = $props();
|
||||
let collapsed = $state(false);
|
||||
let searchInput = $state<HTMLInputElement>();
|
||||
|
||||
const links = $derived([
|
||||
{
|
||||
href: '/sendungen',
|
||||
label: 'Alle Streams',
|
||||
icon: 'calendar-days',
|
||||
count: counts.all,
|
||||
active: !filterState
|
||||
},
|
||||
{
|
||||
href: '/sendungen?state=created',
|
||||
label: 'Angelegt',
|
||||
icon: 'file',
|
||||
count: counts.created,
|
||||
active: filterState === 'created'
|
||||
},
|
||||
{
|
||||
href: '/sendungen?state=preparing',
|
||||
label: 'In Vorbereitung',
|
||||
icon: 'pen',
|
||||
count: counts.preparing,
|
||||
active: filterState === 'preparing'
|
||||
},
|
||||
{
|
||||
href: '/sendungen?state=ready',
|
||||
label: 'Sendefertig',
|
||||
icon: 'circle-check',
|
||||
count: counts.ready,
|
||||
active: filterState === 'ready'
|
||||
},
|
||||
{
|
||||
href: '/sendungen?state=completed',
|
||||
label: 'Abgeschlossen',
|
||||
icon: 'box-archive',
|
||||
count: counts.completed,
|
||||
active: filterState === 'completed'
|
||||
}
|
||||
]);
|
||||
|
||||
onMount(() => {
|
||||
collapsed = localStorage.getItem('sendewerk-stream-navigation') === 'collapsed';
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
collapsed = !collapsed;
|
||||
localStorage.setItem('sendewerk-stream-navigation', collapsed ? 'collapsed' : 'open');
|
||||
}
|
||||
|
||||
async function revealSearch() {
|
||||
if (collapsed) toggle();
|
||||
await tick();
|
||||
searchInput?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="fundus-navigation stream-navigation" class:collapsed aria-label="Streams-Navigation">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">Planung</p>
|
||||
<h1>Streams</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
aria-label={collapsed ? 'Streams-Navigation ausklappen' : 'Streams-Navigation einklappen'}
|
||||
aria-expanded={!collapsed}
|
||||
><AppIcon name={collapsed ? 'chevron-right' : 'chevron-left'} /></button
|
||||
>
|
||||
</header>
|
||||
{#if collapsed}
|
||||
<button
|
||||
class="collapsed-search-button"
|
||||
type="button"
|
||||
onclick={revealSearch}
|
||||
aria-label="Streams durchsuchen"><AppIcon name="magnifying-glass" /></button
|
||||
>
|
||||
{:else}
|
||||
<form class="context-search" method="GET" action={resolve('/sendungen')} role="search">
|
||||
<label
|
||||
><AppIcon name="magnifying-glass" /><span class="sr-only">Streams durchsuchen</span><input
|
||||
bind:this={searchInput}
|
||||
name="q"
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Streams durchsuchen"
|
||||
/></label
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
<nav>
|
||||
{#each links as item (item.href)}
|
||||
<a href={item.href} class:active={item.active} aria-current={item.active ? 'page' : undefined}
|
||||
><AppIcon name={item.icon} /><span>{item.label}</span><strong>{item.count}</strong></a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
{#if shows.length && !collapsed}
|
||||
<section>
|
||||
<h2>Geplant</h2>
|
||||
<nav aria-label="Geplante Streams">
|
||||
{#each shows as show (show.id)}
|
||||
<a href={resolve('/sendungen/[id]', { id: show.id })}
|
||||
><AppIcon name="calendar" /><span>{show.title}</span></a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
</section>
|
||||
{/if}
|
||||
</aside>
|
||||
@@ -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,406 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance, type SubmitFunction } from '$app/forms';
|
||||
import { untrack } from 'svelte';
|
||||
import MarkdownEditorField from './MarkdownEditorField.svelte';
|
||||
import InlineTitleEditor from './InlineTitleEditor.svelte';
|
||||
import NavIcon from './NavIcon.svelte';
|
||||
import PieceEditorDialog from './PieceEditorDialog.svelte';
|
||||
import type { PieceUsage } from '#lib/server/shows.js';
|
||||
|
||||
let {
|
||||
usage,
|
||||
revision,
|
||||
sectionId,
|
||||
sections,
|
||||
error,
|
||||
enhanceCommand
|
||||
}: {
|
||||
usage: PieceUsage;
|
||||
revision: number;
|
||||
sectionId: string;
|
||||
sections: { id: string; name: string }[];
|
||||
error?: string;
|
||||
enhanceCommand: SubmitFunction;
|
||||
} = $props();
|
||||
|
||||
let formId = $derived(`usage-editor-form-${usage.id}`);
|
||||
let editor: { showModal: () => void; close: () => void };
|
||||
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
|
||||
let draft = $state(
|
||||
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
|
||||
);
|
||||
let dirty = $state(false);
|
||||
let selectedMedia = $derived(
|
||||
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
|
||||
);
|
||||
|
||||
function durationInMinutes(seconds: number) {
|
||||
return Number((seconds / 60).toFixed(2));
|
||||
}
|
||||
|
||||
function requestClose() {
|
||||
if (dirty && !confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
|
||||
dirty = false;
|
||||
editor.close();
|
||||
}
|
||||
|
||||
const enhanceEditor: SubmitFunction = async (input) => {
|
||||
const finish = await enhanceCommand(input);
|
||||
return async (result) => {
|
||||
if (finish) await finish(result);
|
||||
if (result.result.type === 'success') dirty = false;
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="usage-edit-button"
|
||||
type="button"
|
||||
aria-label={`${usage.title} bearbeiten und verschieben`}
|
||||
title="Bearbeiten und verschieben"
|
||||
onclick={() => editor.showModal()}
|
||||
>
|
||||
<NavIcon name="edit" />
|
||||
</button>
|
||||
|
||||
<PieceEditorDialog
|
||||
bind:this={editor}
|
||||
bind:activeSection
|
||||
labelledby={`usage-editor-title-${usage.id}`}
|
||||
onrequestclose={requestClose}
|
||||
>
|
||||
{#snippet header()}
|
||||
<p class="eyebrow">Sendungsfassung</p>
|
||||
<InlineTitleEditor form={formId} bind:value={draft.title} oncommit={() => (dirty = true)} />
|
||||
<h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
|
||||
{/snippet}
|
||||
|
||||
<form
|
||||
id={formId}
|
||||
use:enhance={enhanceEditor}
|
||||
method="POST"
|
||||
action="?/updateUsage"
|
||||
class="piece-dialog-form"
|
||||
oninput={() => (dirty = true)}
|
||||
onchange={() => (dirty = true)}
|
||||
>
|
||||
<input type="hidden" name="revision" value={revision} />
|
||||
<input type="hidden" name="usageId" value={usage.id} />
|
||||
|
||||
<section class="piece-editor-panel" hidden={activeSection !== 'script'}>
|
||||
<div class="piece-panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Für die Moderation</p>
|
||||
<h3>Sendungstext</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="piece-writing-stack">
|
||||
<MarkdownEditorField
|
||||
name="intro"
|
||||
label="Einleitung"
|
||||
placeholder="Wie beginnt dieses Thema in der Sendung?"
|
||||
bind:value={draft.intro}
|
||||
/>
|
||||
<MarkdownEditorField
|
||||
name="moderation"
|
||||
label="Moderationsstichpunkte"
|
||||
placeholder="Argumente, Fragen und Ablaufpunkte"
|
||||
rows={10}
|
||||
bind:value={draft.moderation}
|
||||
/>
|
||||
<MarkdownEditorField
|
||||
name="transition"
|
||||
label="Überleitung"
|
||||
placeholder="Wie geht es zum nächsten Thema weiter?"
|
||||
bind:value={draft.transition}
|
||||
/>
|
||||
</div>
|
||||
<details class="piece-meta-details">
|
||||
<summary>Dauer und Einplanung</summary>
|
||||
<div class="piece-meta-grid">
|
||||
<label
|
||||
>Dauer in Minuten<input
|
||||
name="durationMinutes"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1440"
|
||||
step="any"
|
||||
value={durationInMinutes(usage.durationSeconds)}
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="piece-editor-panel" hidden={activeSection !== 'research'}>
|
||||
<div class="piece-panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Nicht öffentlich</p>
|
||||
<h3>Recherche & Quellen</h3>
|
||||
</div>
|
||||
<small>Diese Inhalte bleiben innerhalb der Redaktion.</small>
|
||||
</div>
|
||||
<div class="piece-writing-stack">
|
||||
<MarkdownEditorField
|
||||
name="research"
|
||||
label="Private Recherche"
|
||||
placeholder="Hintergrund, Prüfung und offene Fragen"
|
||||
rows={10}
|
||||
privateField
|
||||
bind:value={draft.research}
|
||||
/>
|
||||
<MarkdownEditorField
|
||||
name="ownNotes"
|
||||
label="Interne Notizen zu dieser Verwendung"
|
||||
placeholder="Absprachen und Hinweise nur für diese Sendung"
|
||||
privateField
|
||||
bind:value={draft.ownNotes}
|
||||
/>
|
||||
</div>
|
||||
<section class="piece-source-summary">
|
||||
<p class="eyebrow">Snapshot-Quellen</p>
|
||||
{#if draft.sources.length}
|
||||
{#each draft.sources as source (source.id)}<div>
|
||||
<strong>{source.label}</strong><small>{source.url}</small>
|
||||
</div>{/each}
|
||||
{:else}<p>Für diese Verwendung wurden keine Quellen kopiert.</p>{/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 class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
|
||||
<div class="piece-panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Öffentliche Ausgabe</p>
|
||||
<h3>Präsentation</h3>
|
||||
</div>
|
||||
<small>Private Recherche und Notizen werden nicht ausgegeben.</small>
|
||||
</div>
|
||||
<div class="piece-presentation-layout">
|
||||
<div class="piece-presentation-controls">
|
||||
<label
|
||||
>Präsentationstitel<input
|
||||
name="outputTitle"
|
||||
maxlength="300"
|
||||
placeholder="Optional, sonst interner Titel"
|
||||
bind:value={draft.outputTitle}
|
||||
/></label
|
||||
>
|
||||
<MarkdownEditorField
|
||||
name="presentation"
|
||||
label="Öffentlicher Text"
|
||||
placeholder="Text für die Publikumsausgabe"
|
||||
bind:value={draft.presentation}
|
||||
/>
|
||||
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
|
||||
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
|
||||
<label
|
||||
><input
|
||||
type="radio"
|
||||
name="presentationMode"
|
||||
value={mode[0]}
|
||||
bind:group={draft.presentationMode}
|
||||
/>{mode[1]}</label
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
name="presentationSourceLabel"
|
||||
value={draft.presentationSourceLabel}
|
||||
/>
|
||||
<input type="hidden" name="presentationSourceUrl" value={draft.presentationSourceUrl} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="presentationSourceTimecodeSeconds"
|
||||
value={draft.presentationSourceTimecodeSeconds ?? ''}
|
||||
/>
|
||||
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
|
||||
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
|
||||
{#if draft.presentationMode !== 'text'}
|
||||
<div class="presentation-settings">
|
||||
<div class="presentation-source-summary wide-field">
|
||||
<span>Ausgewählte Quelle</span>
|
||||
<strong
|
||||
>{draft.presentationSourceLabel ||
|
||||
draft.presentationSourceUrl ||
|
||||
'Keine Quelle'}</strong
|
||||
>
|
||||
{#if draft.presentationSourceUrl}<small>{draft.presentationSourceUrl}</small>{/if}
|
||||
</div>
|
||||
<label
|
||||
>Kartentitel<input
|
||||
name="presentationTitle"
|
||||
maxlength="300"
|
||||
placeholder="Optionaler Anzeigetitel"
|
||||
bind:value={draft.presentationTitle}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Bild-URL<input
|
||||
name="presentationImageUrl"
|
||||
type="url"
|
||||
maxlength="2000"
|
||||
placeholder="https://…"
|
||||
bind:value={draft.presentationImageUrl}
|
||||
/></label
|
||||
>
|
||||
<label class="wide-field"
|
||||
>Anreißer<textarea
|
||||
name="presentationDescription"
|
||||
rows="3"
|
||||
maxlength="1000"
|
||||
placeholder="Kurzer Text zur Vorschau"
|
||||
bind:value={draft.presentationDescription}></textarea></label
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<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>
|
||||
{#if draft.media.length}<fieldset class="usage-media-picker">
|
||||
<legend>Hochgeladenes Präsentationsbild</legend>
|
||||
<label
|
||||
><input
|
||||
type="radio"
|
||||
checked={!draft.presentationMediaId}
|
||||
onchange={() => {
|
||||
draft.presentationMediaId = null;
|
||||
dirty = true;
|
||||
}}
|
||||
/> Kein Bild</label
|
||||
>
|
||||
<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">
|
||||
<p class="output-kicker">Vorschau · {draft.presentationMode}</p>
|
||||
<h4>{draft.outputTitle || draft.title}</h4>
|
||||
{#if draft.presentationMode === 'text'}
|
||||
<p class="preview-markdown-source">
|
||||
{draft.presentation || 'Noch kein öffentlicher Text.'}
|
||||
</p>
|
||||
{#if selectedMedia}<img
|
||||
src={`/media/${selectedMedia.originalMediaId}`}
|
||||
alt={selectedMedia.altText}
|
||||
/>{/if}
|
||||
{:else}
|
||||
{#if selectedMedia}<img
|
||||
src={`/media/${selectedMedia.originalMediaId}`}
|
||||
alt={selectedMedia.altText}
|
||||
/>{:else if draft.presentationImageUrl}<img
|
||||
src={draft.presentationImageUrl}
|
||||
alt=""
|
||||
/>{/if}
|
||||
<p>
|
||||
{draft.presentationDescription ||
|
||||
draft.presentationSourceUrl ||
|
||||
'Noch keine Vorschauangaben.'}
|
||||
</p>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="piece-dialog-secondary-actions">
|
||||
<form use:enhance={enhanceCommand} method="POST" action="?/refreshUsage">
|
||||
<input type="hidden" name="revision" value={revision} />
|
||||
<input type="hidden" name="usageId" value={usage.id} />
|
||||
<button class="text-button" type="submit" disabled={!usage.originalPieceId || dirty}
|
||||
>Aus Fundus aktualisieren</button
|
||||
>
|
||||
</form>
|
||||
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
|
||||
<input type="hidden" name="revision" value={revision} /><input
|
||||
type="hidden"
|
||||
name="usageId"
|
||||
value={usage.id}
|
||||
/>
|
||||
<button class="text-button" type="submit" disabled={dirty}>Duplizieren</button>
|
||||
</form>
|
||||
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
|
||||
<input type="hidden" name="revision" value={revision} /><input
|
||||
type="hidden"
|
||||
name="usageId"
|
||||
value={usage.id}
|
||||
/><input type="hidden" name="position" value="9999" />
|
||||
<label
|
||||
><span class="sr-only">Ziel</span><select name="sectionId"
|
||||
><option value="">Reservebank</option>{#each sections as target (target.id)}<option
|
||||
value={target.id}
|
||||
selected={target.id === sectionId}>{target.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<button class="text-button" type="submit" disabled={dirty}>Verschieben</button>
|
||||
</form>
|
||||
<form
|
||||
use:enhance={enhanceCommand}
|
||||
method="POST"
|
||||
action="?/removeUsage"
|
||||
onsubmit={(event) =>
|
||||
!confirm('Piece aus dieser Sendung entfernen?') && event.preventDefault()}
|
||||
>
|
||||
<input type="hidden" name="revision" value={revision} /><input
|
||||
type="hidden"
|
||||
name="usageId"
|
||||
value={usage.id}
|
||||
/>
|
||||
<button class="text-button danger-text" type="submit" disabled={dirty}
|
||||
>Aus Sendung entfernen</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
<div class="piece-dialog-primary-action">
|
||||
{#if error}<span class="problem" role="alert">{error}</span>{/if}
|
||||
<button class="primary-button" type="submit" form={formId}>Verwendung speichern</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PieceEditorDialog>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
type Preference = 'system' | 'light' | 'dark';
|
||||
const preferences: { value: Preference; label: string; icon: string }[] = [
|
||||
{ value: 'light', label: 'Hell', icon: 'sun' },
|
||||
{ value: 'dark', label: 'Dunkel', icon: 'moon' },
|
||||
{ value: 'system', label: 'System', icon: 'display' }
|
||||
];
|
||||
|
||||
let { displayName, role }: { displayName: string; role: 'barkeeper' | 'thekencrew' } = $props();
|
||||
let preference = $state<Preference>('system');
|
||||
|
||||
function apply(next: Preference) {
|
||||
preference = next;
|
||||
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.dataset.theme = next === 'system' ? (dark ? 'dark' : 'light') : next;
|
||||
document.documentElement.dataset.themePreference = next;
|
||||
localStorage.setItem('sendewerk-theme', next);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const stored = localStorage.getItem('sendewerk-theme');
|
||||
preference = preferences.some((item) => item.value === stored)
|
||||
? (stored as Preference)
|
||||
: 'system';
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const followSystem = () => preference === 'system' && apply('system');
|
||||
media.addEventListener('change', followSystem);
|
||||
return () => media.removeEventListener('change', followSystem);
|
||||
});
|
||||
</script>
|
||||
|
||||
<details class="account-menu user-menu">
|
||||
<summary aria-label={`Benutzermenü für ${displayName}`}>
|
||||
<span aria-hidden="true">{displayName.slice(0, 1).toUpperCase()}</span>
|
||||
</summary>
|
||||
<div class="user-menu-popover">
|
||||
<header>
|
||||
<strong>{displayName}</strong>
|
||||
<small>{role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'}</small>
|
||||
</header>
|
||||
{#if role === 'barkeeper'}
|
||||
<a class="user-menu-link" href={resolve('/verwaltung')}>
|
||||
<AppIcon name="gear" /><span>Einstellungen & Verwaltung</span>
|
||||
</a>
|
||||
{/if}
|
||||
<fieldset>
|
||||
<legend>Erscheinungsbild</legend>
|
||||
<div class="theme-menu-options">
|
||||
{#each preferences as item (item.value)}
|
||||
<button
|
||||
type="button"
|
||||
class:active={preference === item.value}
|
||||
aria-pressed={preference === item.value}
|
||||
onclick={() => apply(item.value)}
|
||||
>
|
||||
<AppIcon name={item.icon} />
|
||||
{item.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
<form method="POST" action="/logout">
|
||||
<button class="user-menu-link" type="submit"
|
||||
><AppIcon name="right-from-bracket" /> Abmelden</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
@@ -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', () => {
|
||||
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 () => {
|
||||
const { createFirstAdmin } = await import('./auth.js');
|
||||
const password = 'Ein-sehr-langes-Testpasswort';
|
||||
|
||||
+35
-3
@@ -26,6 +26,7 @@ const PASSWORD_OPTIONS = {
|
||||
} as const;
|
||||
|
||||
export type Role = 'barkeeper' | 'thekencrew';
|
||||
export type ShowOverviewView = 'list' | 'calendar';
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -66,6 +67,23 @@ export function normalizeEmail(email: string): string {
|
||||
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 {
|
||||
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'));
|
||||
|
||||
function createToken(): string {
|
||||
export function createToken(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
@@ -546,7 +564,13 @@ export async function resetPassword(token: string, password: string): Promise<vo
|
||||
'UPDATE password_reset_tokens SET consumed_at_utc = ? WHERE user_id = ? AND consumed_at_utc IS NULL'
|
||||
)
|
||||
.run(timestamp, row.user_id);
|
||||
database.prepare('DELETE FROM oauth_authorization_codes WHERE user_id = ?').run(row.user_id);
|
||||
database.prepare('DELETE FROM sessions WHERE user_id = ?').run(row.user_id);
|
||||
database
|
||||
.prepare(
|
||||
'UPDATE oauth_access_tokens SET revoked_at_utc = ? WHERE user_id = ? AND revoked_at_utc IS NULL'
|
||||
)
|
||||
.run(timestamp, row.user_id);
|
||||
});
|
||||
reset();
|
||||
}
|
||||
@@ -574,7 +598,15 @@ export function setUserLocked(actor: AuthUser, userId: string, locked: boolean):
|
||||
database
|
||||
.prepare('UPDATE users SET locked_at_utc = ?, updated_at_utc = ? WHERE id = ?')
|
||||
.run(locked ? nowUtc() : null, nowUtc(), userId);
|
||||
if (locked) database.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
||||
if (locked) {
|
||||
database.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
||||
database
|
||||
.prepare(
|
||||
'UPDATE oauth_access_tokens SET revoked_at_utc = ? WHERE user_id = ? AND revoked_at_utc IS NULL'
|
||||
)
|
||||
.run(nowUtc(), userId);
|
||||
database.prepare('DELETE FROM oauth_authorization_codes WHERE user_id = ?').run(userId);
|
||||
}
|
||||
});
|
||||
update();
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
}
|
||||
@@ -18,6 +18,10 @@ export function getAuthConfig() {
|
||||
throw new Error('SENDEWERK_APP_ORIGIN muss im Produktionsbetrieb gesetzt sein.');
|
||||
}
|
||||
const origin = new URL(configuredOrigin || 'http://localhost:5173');
|
||||
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname);
|
||||
if (process.env.NODE_ENV === 'production' && origin.protocol !== 'https:' && !loopback) {
|
||||
throw new Error('SENDEWERK_APP_ORIGIN muss öffentlich HTTPS verwenden.');
|
||||
}
|
||||
return {
|
||||
origin,
|
||||
setupToken: process.env.SENDEWERK_SETUP_TOKEN || '',
|
||||
|
||||
@@ -74,7 +74,8 @@ function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
|
||||
sources: [
|
||||
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
|
||||
],
|
||||
...overrides
|
||||
...overrides,
|
||||
preparationEffort: overrides.preparationEffort ?? null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -332,11 +333,17 @@ describe('content management', () => {
|
||||
presentationMediaId: selected.id
|
||||
});
|
||||
|
||||
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()
|
||||
|
||||
+61
-19
@@ -14,6 +14,7 @@ import {
|
||||
export const PIECE_STATUSES = ['inbox', 'preparing', 'ready', 'archived'] as const;
|
||||
export type PieceStatus = (typeof PIECE_STATUSES)[number];
|
||||
export type EditablePieceStatus = Exclude<PieceStatus, 'archived'>;
|
||||
export type PreparationEffort = 'low' | 'medium' | 'high' | null;
|
||||
export type TaxonomyKind = 'category' | 'format' | 'topic';
|
||||
|
||||
export type PieceSourceInput = {
|
||||
@@ -42,10 +43,12 @@ export type PieceInput = {
|
||||
summary: string;
|
||||
formatId: string | null;
|
||||
status: EditablePieceStatus;
|
||||
preparationEffort: PreparationEffort;
|
||||
durationSeconds: number;
|
||||
intro: string;
|
||||
researchText: string;
|
||||
moderationNotes: string;
|
||||
interactionText?: string;
|
||||
transitionText: string;
|
||||
presentationText: string;
|
||||
presentationMode: PresentationMode;
|
||||
@@ -81,9 +84,13 @@ export type PieceListItem = {
|
||||
sourceCount: number;
|
||||
mediaId: string | null;
|
||||
isFavorite: boolean;
|
||||
preparationEffort: PreparationEffort;
|
||||
};
|
||||
|
||||
export type PieceEditor = Omit<PieceInput, 'status' | 'tools' | 'presentationMediaId'> & {
|
||||
export type PieceEditor = Omit<
|
||||
PieceInput,
|
||||
'status' | 'tools' | 'presentationMediaId' | 'interactionText'
|
||||
> & {
|
||||
id: string;
|
||||
status: PieceStatus;
|
||||
revision: number;
|
||||
@@ -92,9 +99,12 @@ export type PieceEditor = Omit<PieceInput, 'status' | 'tools' | 'presentationMed
|
||||
media: PieceMedia[];
|
||||
presentationMediaId: string | null;
|
||||
isFavorite: boolean;
|
||||
interactionText: string;
|
||||
tools: PieceTool[];
|
||||
};
|
||||
|
||||
type ValidatedPieceInput = PieceInput;
|
||||
|
||||
export type PieceMedia = {
|
||||
id: string;
|
||||
originalName: string;
|
||||
@@ -187,10 +197,13 @@ function requireTaxonomyIds(table: 'formats' | 'topics' | 'section_categories',
|
||||
if (count.count !== ids.length) throw new Error('INVALID_TAXONOMY');
|
||||
}
|
||||
|
||||
function validatePieceInput(input: PieceInput): PieceInput {
|
||||
function validatePieceInput(input: PieceInput): ValidatedPieceInput {
|
||||
const title = cleanText(input.title, 200, 'title');
|
||||
if (!title) throw new Error('INVALID_TITLE');
|
||||
if (!['inbox', 'preparing', 'ready'].includes(input.status)) throw new Error('INVALID_STATUS');
|
||||
if (![null, 'low', 'medium', 'high'].includes(input.preparationEffort)) {
|
||||
throw new Error('INVALID_PREPARATION_EFFORT');
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(input.durationSeconds) ||
|
||||
input.durationSeconds < 0 ||
|
||||
@@ -219,7 +232,7 @@ function validatePieceInput(input: PieceInput): PieceInput {
|
||||
}
|
||||
return { label, url, timecodeSeconds: source.timecodeSeconds };
|
||||
});
|
||||
if (!['text', 'link', 'video'].includes(input.presentationMode)) {
|
||||
if (!['text', 'image', 'link', 'video'].includes(input.presentationMode)) {
|
||||
throw new Error('INVALID_PRESENTATION');
|
||||
}
|
||||
const sourcePosition = input.presentationSourcePosition;
|
||||
@@ -229,7 +242,7 @@ function validatePieceInput(input: PieceInput): PieceInput {
|
||||
) {
|
||||
throw new Error('INVALID_PRESENTATION');
|
||||
}
|
||||
if (input.presentationMode !== 'text' && sourcePosition === null) {
|
||||
if (['link', 'video'].includes(input.presentationMode) && sourcePosition === null) {
|
||||
throw new Error('INVALID_PRESENTATION');
|
||||
}
|
||||
const presentationImageUrl = cleanText(input.presentationImageUrl, 2_000, 'presentation_image');
|
||||
@@ -242,16 +255,24 @@ function validatePieceInput(input: PieceInput): PieceInput {
|
||||
input.presentationMediaId === undefined
|
||||
? undefined
|
||||
: cleanText(input.presentationMediaId, 100, 'presentation_media') || null;
|
||||
if (input.presentationMode === 'image' && !presentationMediaId) {
|
||||
throw new Error('INVALID_PRESENTATION');
|
||||
}
|
||||
return {
|
||||
title,
|
||||
outputTitle: cleanText(input.outputTitle, 300, 'output_title'),
|
||||
summary: cleanText(input.summary, 2_000, 'summary'),
|
||||
formatId: input.formatId || null,
|
||||
status: input.status,
|
||||
preparationEffort: input.preparationEffort,
|
||||
durationSeconds: input.durationSeconds,
|
||||
intro: cleanText(input.intro, 50_000, 'intro'),
|
||||
researchText: cleanText(input.researchText, 50_000, 'research'),
|
||||
moderationNotes: cleanText(input.moderationNotes, 50_000, 'moderation'),
|
||||
interactionText:
|
||||
input.interactionText === undefined
|
||||
? undefined
|
||||
: cleanText(input.interactionText, 50_000, 'interaction'),
|
||||
transitionText: cleanText(input.transitionText, 50_000, 'transition'),
|
||||
presentationText: cleanText(input.presentationText, 50_000, 'presentation'),
|
||||
presentationMode: input.presentationMode,
|
||||
@@ -306,6 +327,7 @@ type PieceListRow = {
|
||||
source_count: number;
|
||||
media_id: string | null;
|
||||
is_favorite: number;
|
||||
preparation_effort: PreparationEffort;
|
||||
};
|
||||
|
||||
export function listPieces(filters: {
|
||||
@@ -355,6 +377,7 @@ export function listPieces(filters: {
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds, p.is_favorite,
|
||||
p.preparation_effort,
|
||||
p.format_id, COALESCE(f.name, 'Ohne Format') AS format,
|
||||
(SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count,
|
||||
(SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id AND pm.removed_at_utc IS NULL ORDER BY pm.position LIMIT 1) AS media_id
|
||||
@@ -387,7 +410,8 @@ export function listPieces(filters: {
|
||||
topics: topicStatement.all(row.id) as PieceListItem['topics'],
|
||||
sourceCount: row.source_count,
|
||||
mediaId: row.media_id,
|
||||
isFavorite: row.is_favorite === 1
|
||||
isFavorite: row.is_favorite === 1,
|
||||
preparationEffort: row.preparation_effort
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -402,6 +426,7 @@ type PieceRow = {
|
||||
intro: string;
|
||||
research_text: string;
|
||||
moderation_notes: string;
|
||||
interaction_text: string;
|
||||
transition_text: string;
|
||||
presentation_text: string;
|
||||
presentation_mode: PresentationMode;
|
||||
@@ -421,14 +446,16 @@ type PieceRow = {
|
||||
updated_at_utc: string;
|
||||
is_favorite: number;
|
||||
presentation_media_id: string | null;
|
||||
preparation_effort: PreparationEffort;
|
||||
};
|
||||
|
||||
export function getPiece(id: string): PieceEditor | null {
|
||||
const database = getDatabase();
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT id, title, output_title, summary, format_id, status, duration_seconds, intro, research_text,
|
||||
moderation_notes, transition_text, presentation_text, presentation_mode,
|
||||
`SELECT id, title, output_title, summary, format_id, status, preparation_effort, duration_seconds, intro, research_text,
|
||||
moderation_notes, interaction_text, transition_text, presentation_text,
|
||||
CASE WHEN presentation_image_only = 1 THEN 'image' ELSE presentation_mode END AS presentation_mode,
|
||||
presentation_source_position, presentation_title, presentation_description,
|
||||
presentation_image_url, presentation_site_name, presentation_provider,
|
||||
presentation_provider_id, presentation_title_manual, presentation_description_manual,
|
||||
@@ -448,10 +475,12 @@ export function getPiece(id: string): PieceEditor | null {
|
||||
summary: row.summary,
|
||||
formatId: row.format_id,
|
||||
status: row.status,
|
||||
preparationEffort: row.preparation_effort,
|
||||
durationSeconds: row.duration_seconds,
|
||||
intro: row.intro,
|
||||
researchText: row.research_text,
|
||||
moderationNotes: row.moderation_notes,
|
||||
interactionText: row.interaction_text,
|
||||
transitionText: row.transition_text,
|
||||
presentationText: row.presentation_text,
|
||||
presentationMode: row.presentation_mode,
|
||||
@@ -599,9 +628,9 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
|
||||
const save = database.transaction(() => {
|
||||
const result = database
|
||||
.prepare(
|
||||
`UPDATE pieces SET title = ?, output_title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?,
|
||||
intro = ?, research_text = ?, moderation_notes = ?, transition_text = ?, presentation_text = ?,
|
||||
presentation_mode = ?, presentation_source_position = ?, presentation_title = ?,
|
||||
`UPDATE pieces SET title = ?, output_title = ?, summary = ?, format_id = ?, status = ?, preparation_effort = ?, duration_seconds = ?,
|
||||
intro = ?, research_text = ?, moderation_notes = ?, interaction_text = COALESCE(?, interaction_text), transition_text = ?, presentation_text = ?,
|
||||
presentation_mode = ?, presentation_image_only = ?, presentation_source_position = ?, presentation_title = ?,
|
||||
presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?,
|
||||
presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?,
|
||||
presentation_description_manual = ?, presentation_image_manual = ?,
|
||||
@@ -615,13 +644,16 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
|
||||
input.summary,
|
||||
input.formatId,
|
||||
input.status,
|
||||
input.preparationEffort,
|
||||
input.durationSeconds,
|
||||
input.intro,
|
||||
input.researchText,
|
||||
input.moderationNotes,
|
||||
input.interactionText ?? null,
|
||||
input.transitionText,
|
||||
input.presentationText,
|
||||
input.presentationMode,
|
||||
input.presentationMode === 'image' ? 'text' : input.presentationMode,
|
||||
input.presentationMode === 'image' ? 1 : 0,
|
||||
input.presentationSourcePosition,
|
||||
input.presentationTitle,
|
||||
input.presentationDescription,
|
||||
@@ -650,17 +682,19 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
|
||||
return { revision: expectedRevision + 1, updatedAtUtc };
|
||||
}
|
||||
|
||||
function blankPieceInput(): PieceInput {
|
||||
function blankPieceInput(): ValidatedPieceInput {
|
||||
return {
|
||||
title: 'Neues Piece',
|
||||
outputTitle: '',
|
||||
summary: '',
|
||||
formatId: null,
|
||||
status: 'preparing',
|
||||
preparationEffort: null,
|
||||
durationSeconds: 0,
|
||||
intro: '',
|
||||
researchText: '',
|
||||
moderationNotes: '',
|
||||
interactionText: '',
|
||||
transitionText: '',
|
||||
presentationText: '',
|
||||
presentationMode: 'text',
|
||||
@@ -682,7 +716,7 @@ function blankPieceInput(): PieceInput {
|
||||
};
|
||||
}
|
||||
|
||||
function templateInput(templateId: string): PieceInput {
|
||||
function templateInput(templateId: string): ValidatedPieceInput {
|
||||
if (templateId === 'blank') return blankPieceInput();
|
||||
const row = getDatabase()
|
||||
.prepare('SELECT snapshot_json FROM piece_templates WHERE id = ?')
|
||||
@@ -739,8 +773,8 @@ export function createPiece(template = 'blank'): string {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro,
|
||||
research_text, moderation_notes, transition_text, presentation_text, created_at_utc,
|
||||
updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
|
||||
research_text, moderation_notes, interaction_text, transition_text, presentation_text, created_at_utc,
|
||||
updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
@@ -753,6 +787,7 @@ export function createPiece(template = 'blank'): string {
|
||||
input.intro,
|
||||
input.researchText,
|
||||
input.moderationNotes,
|
||||
input.interactionText ?? '',
|
||||
input.transitionText,
|
||||
input.presentationText,
|
||||
now,
|
||||
@@ -782,6 +817,7 @@ export function savePieceAsTemplate(
|
||||
delete snapshot.updatedAtUtc;
|
||||
delete snapshot.media;
|
||||
delete snapshot.presentationMediaId;
|
||||
if (snapshot.presentationMode === 'image') snapshot.presentationMode = 'text';
|
||||
snapshot.status = 'preparing';
|
||||
const snapshotJson = JSON.stringify(snapshot);
|
||||
const database = getDatabase();
|
||||
@@ -901,14 +937,17 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
|
||||
database.transaction(() => {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro,
|
||||
research_text, moderation_notes, transition_text, presentation_text, presentation_mode,
|
||||
`INSERT INTO pieces (id, title, output_title, summary, format_id, status, preparation_effort, duration_seconds, intro,
|
||||
research_text, moderation_notes, interaction_text, transition_text, presentation_text, presentation_mode, presentation_image_only,
|
||||
presentation_source_position, presentation_title, presentation_description,
|
||||
presentation_image_url, presentation_site_name, presentation_provider,
|
||||
presentation_provider_id, presentation_title_manual,
|
||||
presentation_description_manual, presentation_image_manual,
|
||||
presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision)
|
||||
VALUES (?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
|
||||
VALUES (
|
||||
?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1
|
||||
)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
@@ -916,13 +955,16 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
|
||||
source.outputTitle,
|
||||
source.summary,
|
||||
source.formatId,
|
||||
source.preparationEffort,
|
||||
source.durationSeconds,
|
||||
source.intro,
|
||||
source.researchText,
|
||||
source.moderationNotes,
|
||||
source.interactionText,
|
||||
source.transitionText,
|
||||
source.presentationText,
|
||||
source.presentationMode,
|
||||
source.presentationMode === 'image' ? 'text' : source.presentationMode,
|
||||
source.presentationMode === 'image' ? 1 : 0,
|
||||
source.presentationSourcePosition,
|
||||
source.presentationTitle,
|
||||
source.presentationDescription,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Database from 'better-sqlite3';
|
||||
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 path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -62,12 +62,12 @@ describe('database setup', () => {
|
||||
database.close();
|
||||
|
||||
expect(counts).toEqual({
|
||||
migrations: 11,
|
||||
migrations: 22,
|
||||
pieces: 6,
|
||||
shows: 2,
|
||||
shows: 3,
|
||||
usages: 5,
|
||||
templates: 2,
|
||||
reserve_banks: 2
|
||||
reserve_banks: 3
|
||||
});
|
||||
expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite'));
|
||||
});
|
||||
@@ -90,4 +90,97 @@ describe('database setup', () => {
|
||||
expect(show.totalDurationSeconds).toBe(5100);
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { getTaxonomy, listPieces } from './content.js';
|
||||
import { getPieceUsageContexts } from './shows.js';
|
||||
|
||||
export function fundusSearch(url: URL) {
|
||||
const search = new URLSearchParams(url.searchParams);
|
||||
for (const key of [...search.keys()]) if (key.startsWith('/')) search.delete(key);
|
||||
const value = search.toString();
|
||||
return value ? `?${value}` : '';
|
||||
}
|
||||
|
||||
export function loadFundusWorkspace(url: URL) {
|
||||
const filters = {
|
||||
query: url.searchParams.get('q') ?? '',
|
||||
formatId: url.searchParams.get('format') ?? '',
|
||||
topicId: url.searchParams.get('topic') ?? '',
|
||||
categoryId: url.searchParams.get('category') ?? '',
|
||||
status: url.searchParams.get('status') ?? '',
|
||||
includeUsed: url.searchParams.get('includeUsed') === '1',
|
||||
includePlanned: url.searchParams.get('includePlanned') === '1',
|
||||
favorites: url.searchParams.get('favorites') === '1',
|
||||
view: url.searchParams.get('view') ?? ''
|
||||
};
|
||||
const statuses =
|
||||
filters.status === 'ready'
|
||||
? (['ready'] as const)
|
||||
: filters.status === 'preparing'
|
||||
? (['preparing'] as const)
|
||||
: (['preparing', 'ready'] as const);
|
||||
let pieces = listPieces({
|
||||
statuses: [...statuses],
|
||||
query: filters.query,
|
||||
formatId: filters.formatId,
|
||||
topicId: filters.topicId,
|
||||
categoryId: filters.categoryId
|
||||
});
|
||||
const usageContexts = getPieceUsageContexts(pieces.map((piece) => piece.id));
|
||||
pieces = pieces.filter((piece) =>
|
||||
filters.view === 'completed'
|
||||
? usageContexts[piece.id]?.sentHistory.length > 0
|
||||
: usageContexts[piece.id]?.sentHistory.length === 0
|
||||
);
|
||||
if (filters.favorites) pieces = pieces.filter((piece) => piece.isFavorite);
|
||||
const taxonomy = getTaxonomy();
|
||||
const allPieces = listPieces({ statuses: ['inbox', 'preparing', 'ready', 'archived'] });
|
||||
const activePieces = allPieces.filter(
|
||||
(piece) => piece.status === 'preparing' || piece.status === 'ready'
|
||||
);
|
||||
const allUsageContexts = getPieceUsageContexts(activePieces.map((piece) => piece.id));
|
||||
const availablePieces = activePieces.filter(
|
||||
(piece) => allUsageContexts[piece.id]?.sentHistory.length === 0
|
||||
);
|
||||
const deletedPieces = listPieces({
|
||||
statuses: ['inbox', 'preparing', 'ready', 'archived'],
|
||||
deleted: true
|
||||
});
|
||||
|
||||
return {
|
||||
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
|
||||
taxonomy,
|
||||
filters,
|
||||
counts: {
|
||||
all: availablePieces.length,
|
||||
inbox: allPieces.filter((piece) => piece.status === 'inbox').length,
|
||||
preparing: allPieces.filter((piece) => piece.status === 'preparing').length,
|
||||
ready: allPieces.filter((piece) => piece.status === 'ready').length,
|
||||
favorites: availablePieces.filter((piece) => piece.isFavorite).length,
|
||||
completed: activePieces.filter((piece) => allUsageContexts[piece.id]?.sentHistory.length > 0)
|
||||
.length,
|
||||
archived: allPieces.filter((piece) => piece.status === 'archived').length,
|
||||
deleted: deletedPieces.length,
|
||||
topics: Object.fromEntries(
|
||||
taxonomy.topics.map((topic) => [
|
||||
topic.id,
|
||||
allPieces.filter(
|
||||
(piece) =>
|
||||
(piece.status === 'preparing' || piece.status === 'ready') &&
|
||||
piece.topics.some((item) => item.id === topic.id)
|
||||
).length
|
||||
])
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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, getDatabase } from './database.js';
|
||||
import { searchWorkspace } from './global-search.js';
|
||||
|
||||
let directory: string;
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-search-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('global workspace search', () => {
|
||||
it('finds private piece fields and keeps archived pieces discoverable', () => {
|
||||
const privateMatch = searchWorkspace('Herstellerangaben');
|
||||
expect(privateMatch.pieces).toEqual([
|
||||
expect.objectContaining({ id: 'piece-console', matchLabel: 'Recherche' })
|
||||
]);
|
||||
|
||||
getDatabase().prepare("UPDATE pieces SET status = 'archived' WHERE id = 'piece-console'").run();
|
||||
const archivedMatch = searchWorkspace('Herstellerangaben');
|
||||
expect(archivedMatch.pieces).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'piece-console',
|
||||
status: 'archived',
|
||||
href: '/archiv#piece-piece-console'
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires at least two characters and groups stream snapshot matches separately', () => {
|
||||
expect(searchWorkspace('x')).toEqual({ pieces: [], shows: [] });
|
||||
const results = searchWorkspace('kleine Konsole');
|
||||
expect(results.pieces.some((result) => result.id === 'piece-console')).toBe(true);
|
||||
expect(results.shows.length).toBeGreaterThan(0);
|
||||
expect(results.shows[0].href).toMatch(/^\/sendungen\//);
|
||||
});
|
||||
|
||||
it('filters result types and piece taxonomy without leaking into stream matches', () => {
|
||||
const pieces = searchWorkspace('kleine Konsole', {
|
||||
type: 'pieces',
|
||||
status: 'ready',
|
||||
topicId: 'topic-gaming',
|
||||
formatId: 'format-find'
|
||||
});
|
||||
expect(pieces.pieces.map((result) => result.id)).toEqual(['piece-console']);
|
||||
expect(pieces.shows).toEqual([]);
|
||||
|
||||
expect(searchWorkspace('kleine Konsole', { topicId: 'topic-film' }).pieces).toEqual([]);
|
||||
expect(searchWorkspace('kleine Konsole', { type: 'shows' }).pieces).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { getDatabase } from './database.js';
|
||||
import type { PieceStatus } from './content.js';
|
||||
import type { ShowStatus } from './shows.js';
|
||||
|
||||
export type SearchResult = {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
status: PieceStatus | ShowStatus;
|
||||
matchLabel: string;
|
||||
excerpt: string;
|
||||
};
|
||||
|
||||
export type GlobalSearchResults = {
|
||||
pieces: SearchResult[];
|
||||
shows: SearchResult[];
|
||||
};
|
||||
|
||||
export type GlobalSearchFilters = {
|
||||
type?: 'all' | 'pieces' | 'shows';
|
||||
status?: string;
|
||||
topicId?: string;
|
||||
formatId?: string;
|
||||
};
|
||||
|
||||
export type GlobalSearchFacets = {
|
||||
topics: { id: string; name: string }[];
|
||||
formats: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
type SearchField = { label: string; value: string };
|
||||
|
||||
function normalized(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function match(
|
||||
fields: SearchField[],
|
||||
query: string
|
||||
): Pick<SearchResult, 'matchLabel' | 'excerpt'> | null {
|
||||
for (const field of fields) {
|
||||
const value = normalized(field.value);
|
||||
const position = value.toLocaleLowerCase('de-DE').indexOf(query);
|
||||
if (position === -1) continue;
|
||||
const start = Math.max(0, position - 70);
|
||||
const end = Math.min(value.length, position + query.length + 100);
|
||||
return {
|
||||
matchLabel: field.label,
|
||||
excerpt: `${start > 0 ? '…' : ''}${value.slice(start, end)}${end < value.length ? '…' : ''}`
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function listGlobalSearchFacets(): GlobalSearchFacets {
|
||||
const database = getDatabase();
|
||||
return {
|
||||
topics: database
|
||||
.prepare('SELECT id, name FROM topics ORDER BY name COLLATE NOCASE')
|
||||
.all() as GlobalSearchFacets['topics'],
|
||||
formats: database
|
||||
.prepare('SELECT id, name FROM formats ORDER BY name COLLATE NOCASE')
|
||||
.all() as GlobalSearchFacets['formats']
|
||||
};
|
||||
}
|
||||
|
||||
export function searchWorkspace(
|
||||
rawQuery: string,
|
||||
filters: GlobalSearchFilters = {}
|
||||
): GlobalSearchResults {
|
||||
const query = normalized(rawQuery).toLocaleLowerCase('de-DE').slice(0, 200);
|
||||
if (query.length < 2) return { pieces: [], shows: [] };
|
||||
|
||||
const database = getDatabase();
|
||||
const pieces =
|
||||
filters.type === 'shows'
|
||||
? []
|
||||
: (
|
||||
database
|
||||
.prepare(
|
||||
`SELECT p.id, p.title, p.status, p.output_title, p.summary, p.intro, p.research_text,
|
||||
p.moderation_notes, p.interaction_text, p.transition_text, p.presentation_text,
|
||||
p.format_id,
|
||||
group_concat(DISTINCT pt.topic_id) AS topic_ids,
|
||||
group_concat(DISTINCT COALESCE(ps.label, '') || ' ' || COALESCE(ps.url, '')) AS sources
|
||||
FROM pieces p LEFT JOIN piece_sources ps ON ps.piece_id = p.id
|
||||
LEFT JOIN piece_topics pt ON pt.piece_id = p.id
|
||||
WHERE p.deleted_at_utc IS NULL
|
||||
GROUP BY p.id ORDER BY p.updated_at_utc DESC`
|
||||
)
|
||||
.all() as Record<string, unknown>[]
|
||||
)
|
||||
.map((row): SearchResult | null => {
|
||||
if (filters.status && row.status !== filters.status) return null;
|
||||
if (filters.formatId && row.format_id !== filters.formatId) return null;
|
||||
if (
|
||||
filters.topicId &&
|
||||
!String(row.topic_ids ?? '')
|
||||
.split(',')
|
||||
.includes(filters.topicId)
|
||||
)
|
||||
return null;
|
||||
const found = match(
|
||||
[
|
||||
{ label: 'Titel', value: String(row.title) },
|
||||
{ label: 'Ausgabetitel', value: String(row.output_title) },
|
||||
{ label: 'Zusammenfassung', value: String(row.summary) },
|
||||
{ label: 'Anmoderation', value: String(row.intro) },
|
||||
{ label: 'Recherche', value: String(row.research_text) },
|
||||
{ label: 'Moderation', value: String(row.moderation_notes) },
|
||||
{ label: 'Interaktion', value: String(row.interaction_text) },
|
||||
{ label: 'Übergang', value: String(row.transition_text) },
|
||||
{ label: 'Präsentation', value: String(row.presentation_text) },
|
||||
{ label: 'Quellen', value: String(row.sources) }
|
||||
],
|
||||
query
|
||||
);
|
||||
if (!found) return null;
|
||||
const status = row.status as PieceStatus;
|
||||
return {
|
||||
id: String(row.id),
|
||||
title: String(row.title),
|
||||
href: status === 'archived' ? `/archiv#piece-${row.id}` : `/fundus/${row.id}`,
|
||||
status,
|
||||
...found
|
||||
};
|
||||
})
|
||||
.filter((result): result is SearchResult => result !== null)
|
||||
.slice(0, 50);
|
||||
|
||||
const shows =
|
||||
filters.type === 'pieces' || filters.topicId || filters.formatId
|
||||
? []
|
||||
: (
|
||||
database
|
||||
.prepare(
|
||||
`SELECT sh.id, sh.title, sh.status, sh.twitch_title, sh.announcement_text,
|
||||
group_concat(COALESCE(s.name, '') || ' ' || COALESCE(u.title_snapshot, '') || ' ' ||
|
||||
COALESCE(u.output_title_snapshot, '') || ' ' || COALESCE(u.intro_snapshot, '') || ' ' ||
|
||||
COALESCE(u.research_snapshot, '') || ' ' || COALESCE(u.moderation_snapshot, '') || ' ' ||
|
||||
COALESCE(u.interaction_snapshot, '') || ' ' || COALESCE(u.transition_snapshot, '') || ' ' ||
|
||||
COALESCE(u.presentation_snapshot, '') || ' ' || COALESCE(u.own_notes, ''), char(10)) AS contents
|
||||
FROM shows sh
|
||||
LEFT JOIN show_sections s ON s.show_id = sh.id
|
||||
LEFT JOIN piece_usages u ON u.show_section_id = s.id
|
||||
GROUP BY sh.id ORDER BY sh.starts_at_utc DESC, sh.created_at_utc DESC`
|
||||
)
|
||||
.all() as Record<string, unknown>[]
|
||||
)
|
||||
.map((row): SearchResult | null => {
|
||||
if (filters.status && row.status !== filters.status) return null;
|
||||
const found = match(
|
||||
[
|
||||
{ label: 'Titel', value: String(row.title) },
|
||||
{ label: 'Twitch-Titel', value: String(row.twitch_title) },
|
||||
{ label: 'Ankündigung', value: String(row.announcement_text) },
|
||||
{ label: 'Ablauf und Pieces', value: String(row.contents) }
|
||||
],
|
||||
query
|
||||
);
|
||||
if (!found) return null;
|
||||
return {
|
||||
id: String(row.id),
|
||||
title: String(row.title),
|
||||
href: `/sendungen/${row.id}`,
|
||||
status: row.status as ShowStatus,
|
||||
...found
|
||||
};
|
||||
})
|
||||
.filter((result): result is SearchResult => result !== null)
|
||||
.slice(0, 50);
|
||||
|
||||
return { pieces, shows };
|
||||
}
|
||||
@@ -7,8 +7,9 @@ import { closeDatabase, getDatabase } from './database.js';
|
||||
import {
|
||||
completeCurrent,
|
||||
clearPresentationChannel,
|
||||
getPresentationChannelView,
|
||||
getLiveView,
|
||||
getPresentationChannelStatus,
|
||||
getPresentationChannelView,
|
||||
getPresentationMedia,
|
||||
getPresentationView,
|
||||
goBack,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
moveOpenUsage,
|
||||
presentationChannelAccess,
|
||||
presentUsage,
|
||||
resumeUsage,
|
||||
rotatePresentationChannelKey,
|
||||
selectUsage,
|
||||
skipCurrent,
|
||||
@@ -40,7 +42,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('live mode', () => {
|
||||
it('persists a frozen section countdown across usage changes and reloads', () => {
|
||||
it('keeps elapsed section time while applying an updated section plan', () => {
|
||||
getDatabase()
|
||||
.prepare(
|
||||
"UPDATE show_sections SET explicit_duration_seconds = 300 WHERE id = 'section-stories'"
|
||||
@@ -59,6 +61,11 @@ describe('live mode', () => {
|
||||
"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',
|
||||
@@ -68,7 +75,7 @@ describe('live mode', () => {
|
||||
live = getLiveView('show-october', new Date('2026-10-23T18:04:00.000Z'));
|
||||
expect(live.current?.id).toBe('usage-effects');
|
||||
expect(live.sectionTimer).toMatchObject({
|
||||
plannedDurationSeconds: 300,
|
||||
plannedDurationSeconds: 600,
|
||||
elapsedSeconds: 240
|
||||
});
|
||||
completeCurrent(
|
||||
@@ -104,15 +111,58 @@ describe('live mode', () => {
|
||||
.find((usage) => usage.id === 'usage-console')?.status
|
||||
).toBe('done');
|
||||
|
||||
selectUsage('show-october', 'usage-console', 'return-to-completed', live.liveRevision);
|
||||
expect(() =>
|
||||
selectUsage('show-october', 'usage-console', 'return-to-completed', live.liveRevision)
|
||||
).toThrow('INVALID_STATUS');
|
||||
resumeUsage('show-october', 'usage-console', 'resume-completed', live.liveRevision);
|
||||
expect(getLiveView('show-october').current).toMatchObject({
|
||||
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({
|
||||
@@ -128,6 +178,7 @@ describe('live mode', () => {
|
||||
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();
|
||||
@@ -239,6 +290,40 @@ describe('live mode', () => {
|
||||
expect(media).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');
|
||||
expect(replacementToken).toBe(token);
|
||||
expect(getPresentationView(token)?.content?.title).toBe(
|
||||
|
||||
+524
-42
@@ -11,6 +11,7 @@ import {
|
||||
movePieceUsage,
|
||||
type MovePieceUsageInput,
|
||||
type PieceUsage,
|
||||
type ShowStatus,
|
||||
type ShowSection
|
||||
} from './shows.js';
|
||||
|
||||
@@ -41,6 +42,7 @@ export type LiveUsage = PieceUsage & {
|
||||
introHtml: string;
|
||||
researchHtml: string;
|
||||
moderationHtml: string;
|
||||
interactionHtml: string;
|
||||
transitionHtml: string;
|
||||
};
|
||||
|
||||
@@ -50,12 +52,16 @@ export type LiveView = {
|
||||
show: {
|
||||
id: string;
|
||||
title: string;
|
||||
startsAtUtc: string | null;
|
||||
status: string;
|
||||
revision: number;
|
||||
totalDurationSeconds: number;
|
||||
};
|
||||
liveRevision: number;
|
||||
calculatedAtUtc: string;
|
||||
startedAtUtc: string | null;
|
||||
endedAtUtc: string | null;
|
||||
isPaused: boolean;
|
||||
updatedAtUtc: string;
|
||||
presentationUsageId: string | null;
|
||||
presentationRevision: number;
|
||||
@@ -72,6 +78,15 @@ export type LiveView = {
|
||||
elapsedSeconds: number;
|
||||
runningSinceUtc: string | null;
|
||||
};
|
||||
totalTimer: {
|
||||
elapsedSeconds: number;
|
||||
runningSinceUtc: string | null;
|
||||
};
|
||||
remainingEstimate: {
|
||||
seconds: number;
|
||||
activeCountdownSeconds: number;
|
||||
unplannedSectionCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type PresentationView = {
|
||||
@@ -135,12 +150,13 @@ function orderedUsages(database: Database.Database, showId: string): OrderedUsag
|
||||
function usageBelongsToShow(database: Database.Database, showId: string, usageId: string) {
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT s.is_reserve_bank FROM piece_usages u
|
||||
`SELECT s.is_reserve_bank, u.status FROM piece_usages u
|
||||
JOIN show_sections s ON s.id = u.show_section_id
|
||||
WHERE u.id = ? AND s.show_id = ?`
|
||||
)
|
||||
.get(usageId, showId) as { is_reserve_bank: number } | undefined;
|
||||
.get(usageId, showId) as { is_reserve_bank: number; status: string } | undefined;
|
||||
if (!row || row.is_reserve_bank) throw new Error('NOT_FOUND');
|
||||
return row;
|
||||
}
|
||||
|
||||
function closeActiveRun(database: Database.Database, showId: string, now: string) {
|
||||
@@ -152,6 +168,52 @@ function closeActiveRun(database: Database.Database, showId: string, now: string
|
||||
.run(now, showId);
|
||||
}
|
||||
|
||||
function closeShowTimerRun(database: Database.Database, showId: string, now: string) {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE live_show_timer_runs SET ended_at_utc = ?
|
||||
WHERE show_id = ? AND ended_at_utc IS NULL`
|
||||
)
|
||||
.run(now, showId);
|
||||
}
|
||||
|
||||
function openShowTimerRun(database: Database.Database, showId: string, now: string) {
|
||||
const active = database
|
||||
.prepare('SELECT 1 FROM live_show_timer_runs WHERE show_id = ? AND ended_at_utc IS NULL')
|
||||
.get(showId);
|
||||
if (!active) {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_show_timer_runs (id, show_id, started_at_utc)
|
||||
VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), showId, now);
|
||||
}
|
||||
}
|
||||
|
||||
function showTimerRunning(database: Database.Database, showId: string) {
|
||||
return Boolean(
|
||||
database
|
||||
.prepare('SELECT 1 FROM live_show_timer_runs WHERE show_id = ? AND ended_at_utc IS NULL')
|
||||
.get(showId)
|
||||
);
|
||||
}
|
||||
|
||||
function elapsedRuns(runs: { started_at_utc: string; ended_at_utc: string | null }[], now: string) {
|
||||
return runs.reduce(
|
||||
(total, run) =>
|
||||
total +
|
||||
Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(new Date(run.ended_at_utc ?? now).getTime() - new Date(run.started_at_utc).getTime()) /
|
||||
1_000
|
||||
)
|
||||
),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function closeSectionTimer(database: Database.Database, showId: string, now: string) {
|
||||
const timer = database
|
||||
.prepare('SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL')
|
||||
@@ -167,6 +229,24 @@ function closeSectionTimer(database: Database.Database, showId: string, now: str
|
||||
.run(now, timer.id);
|
||||
}
|
||||
|
||||
function resumeSectionTimerRun(database: Database.Database, showId: string, now: string) {
|
||||
const timer = database
|
||||
.prepare('SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL')
|
||||
.get(showId) as { id: string } | undefined;
|
||||
if (!timer) return;
|
||||
const running = database
|
||||
.prepare('SELECT 1 FROM live_section_timer_runs WHERE timer_id = ? AND ended_at_utc IS NULL')
|
||||
.get(timer.id);
|
||||
if (!running) {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
|
||||
VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), timer.id, now);
|
||||
}
|
||||
}
|
||||
|
||||
function syncSectionTimer(
|
||||
database: Database.Database,
|
||||
showId: string,
|
||||
@@ -183,12 +263,46 @@ function syncSectionTimer(
|
||||
if (!section) throw new Error('NOT_FOUND');
|
||||
const active = database
|
||||
.prepare(
|
||||
'SELECT id, section_id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL'
|
||||
`SELECT id, section_id, planned_duration_seconds
|
||||
FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL`
|
||||
)
|
||||
.get(showId) as { id: string; section_id: string } | undefined;
|
||||
if (active?.section_id === section.id) return;
|
||||
.get(showId) as
|
||||
{ id: string; section_id: string; planned_duration_seconds: number } | undefined;
|
||||
if (active?.section_id === section.id) {
|
||||
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) {
|
||||
closeSectionTimer(database, showId, now);
|
||||
} else if (active.planned_duration_seconds !== section.explicit_duration_seconds) {
|
||||
database
|
||||
.prepare('UPDATE live_section_timers SET planned_duration_seconds = ? WHERE id = ?')
|
||||
.run(section.explicit_duration_seconds, active.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (active) closeSectionTimer(database, showId, now);
|
||||
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) return;
|
||||
const previous = database
|
||||
.prepare(
|
||||
`SELECT id FROM live_section_timers WHERE show_id = ? AND section_id = ?
|
||||
ORDER BY started_at_utc DESC LIMIT 1`
|
||||
)
|
||||
.get(showId, section.id) as { id: string } | undefined;
|
||||
if (previous) {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE live_section_timers SET planned_duration_seconds = ?, ended_at_utc = NULL
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(section.explicit_duration_seconds, previous.id);
|
||||
if (showTimerRunning(database, showId)) {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
|
||||
VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), previous.id, now);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const timerId = randomUUID();
|
||||
database
|
||||
.prepare(
|
||||
@@ -197,12 +311,14 @@ function syncSectionTimer(
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(timerId, showId, section.id, section.explicit_duration_seconds, now);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
|
||||
VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), timerId, now);
|
||||
if (showTimerRunning(database, showId)) {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
|
||||
VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), timerId, now);
|
||||
}
|
||||
}
|
||||
|
||||
function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) {
|
||||
@@ -237,7 +353,8 @@ function executeCommand(
|
||||
expectedRevision: number,
|
||||
action: string,
|
||||
operation: (database: Database.Database, state: LiveStateRow, now: string) => void,
|
||||
nowValue?: Date
|
||||
nowValue?: Date,
|
||||
allowCompleted = false
|
||||
) {
|
||||
requireCommandId(commandId);
|
||||
const database = getDatabase();
|
||||
@@ -251,7 +368,10 @@ function executeCommand(
|
||||
const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as {
|
||||
status: string;
|
||||
};
|
||||
if (show.status === 'completed') throw new Error('SHOW_COMPLETED');
|
||||
if (database.prepare('SELECT 1 FROM show_finalizations WHERE show_id = ?').get(showId)) {
|
||||
throw new Error('SHOW_COMPLETED');
|
||||
}
|
||||
if (show.status === 'completed' && !allowCompleted) throw new Error('SHOW_COMPLETED');
|
||||
if (state.revision !== expectedRevision) throw new Error('CONFLICT');
|
||||
operation(database, state, now);
|
||||
const updated = database
|
||||
@@ -307,6 +427,7 @@ function liveUsage(
|
||||
introHtml: renderMarkdown(usage.intro),
|
||||
researchHtml: renderMarkdown(usage.research),
|
||||
moderationHtml: renderMarkdown(usage.moderation),
|
||||
interactionHtml: renderMarkdown(usage.interaction),
|
||||
transitionHtml: renderMarkdown(usage.transition)
|
||||
};
|
||||
}
|
||||
@@ -317,6 +438,9 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
|
||||
const state = ensureLiveState(database, showId, now);
|
||||
const show = getShow(showId);
|
||||
if (!show) throw new Error('NOT_FOUND');
|
||||
if (state.started_at_utc && !state.ended_at_utc && state.current_usage_id) {
|
||||
syncSectionTimer(database, showId, state.current_usage_id, now);
|
||||
}
|
||||
const durations = actualDurations(database, showId, now);
|
||||
const sections = show.sections.map((section) => ({
|
||||
...section,
|
||||
@@ -351,18 +475,7 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
|
||||
)
|
||||
.all(timer.id) as { started_at_utc: string; ended_at_utc: string | null }[];
|
||||
const running = runs.find((run) => run.ended_at_utc === null) ?? null;
|
||||
const elapsedSeconds = runs.reduce(
|
||||
(total, run) =>
|
||||
total +
|
||||
Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(new Date(run.ended_at_utc ?? now).getTime() - new Date(run.started_at_utc).getTime()) /
|
||||
1_000
|
||||
)
|
||||
),
|
||||
0
|
||||
);
|
||||
const elapsedSeconds = elapsedRuns(runs, now);
|
||||
sectionTimer = {
|
||||
sectionId: timer.section_id,
|
||||
sectionName: timer.section_name,
|
||||
@@ -371,16 +484,64 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
|
||||
runningSinceUtc: running?.started_at_utc ?? null
|
||||
};
|
||||
}
|
||||
const totalTimerRuns = database
|
||||
.prepare(
|
||||
`SELECT started_at_utc, ended_at_utc FROM live_show_timer_runs
|
||||
WHERE show_id = ? ORDER BY started_at_utc`
|
||||
)
|
||||
.all(showId) as { started_at_utc: string; ended_at_utc: string | null }[];
|
||||
const runningTotalTimer = totalTimerRuns.find((run) => run.ended_at_utc === null) ?? null;
|
||||
const totalElapsedSeconds = elapsedRuns(totalTimerRuns, now);
|
||||
const remainingAnchor = current ?? next;
|
||||
let reachedRemaining = !remainingAnchor;
|
||||
let remainingSeconds = 0;
|
||||
let activeCountdownSeconds = 0;
|
||||
let unplannedSectionCount = 0;
|
||||
for (const section of sections) {
|
||||
if (section.id === remainingAnchor?.sectionId) reachedRemaining = true;
|
||||
if (!reachedRemaining) continue;
|
||||
const remainingUsages = section.usages.filter(
|
||||
(usage) => usage.status === 'open' || usage.status === 'current'
|
||||
);
|
||||
if (remainingUsages.length === 0) continue;
|
||||
|
||||
if (section.id === current?.sectionId) {
|
||||
if (section.durationSeconds > 0 && sectionTimer?.sectionId === section.id) {
|
||||
activeCountdownSeconds = Math.max(0, section.durationSeconds - sectionTimer.elapsedSeconds);
|
||||
remainingSeconds += activeCountdownSeconds;
|
||||
} else {
|
||||
remainingSeconds += remainingUsages.reduce((total, usage) => {
|
||||
const elapsed = usage.id === current?.id ? usage.actualDurationSeconds : 0;
|
||||
const remainder = Math.max(0, usage.durationSeconds - elapsed);
|
||||
if (usage.id === current?.id) activeCountdownSeconds = remainder;
|
||||
return total + remainder;
|
||||
}, 0);
|
||||
if (section.durationSeconds <= 0) unplannedSectionCount += 1;
|
||||
}
|
||||
} else if (section.durationSeconds > 0) {
|
||||
remainingSeconds += section.durationSeconds;
|
||||
} else {
|
||||
remainingSeconds += remainingUsages.reduce(
|
||||
(total, usage) => total + usage.durationSeconds,
|
||||
0
|
||||
);
|
||||
unplannedSectionCount += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
show: {
|
||||
id: show.id,
|
||||
title: show.title,
|
||||
startsAtUtc: show.startsAtUtc,
|
||||
status: show.status,
|
||||
revision: show.revision,
|
||||
totalDurationSeconds: show.totalDurationSeconds
|
||||
},
|
||||
liveRevision: state.revision,
|
||||
calculatedAtUtc: now,
|
||||
startedAtUtc: state.started_at_utc,
|
||||
endedAtUtc: state.ended_at_utc,
|
||||
isPaused: Boolean(state.started_at_utc && !state.ended_at_utc && !runningTotalTimer),
|
||||
updatedAtUtc: state.updated_at_utc,
|
||||
presentationUsageId: state.presentation_usage_id,
|
||||
presentationRevision: state.presentation_revision,
|
||||
@@ -391,7 +552,16 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
|
||||
completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped')
|
||||
.length,
|
||||
totalCount: usages.length,
|
||||
sectionTimer
|
||||
sectionTimer,
|
||||
totalTimer: {
|
||||
elapsedSeconds: totalElapsedSeconds,
|
||||
runningSinceUtc: runningTotalTimer?.started_at_utc ?? null
|
||||
},
|
||||
remainingEstimate: {
|
||||
seconds: remainingSeconds,
|
||||
activeCountdownSeconds,
|
||||
unplannedSectionCount
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -409,7 +579,7 @@ export function startOrResumeLive(
|
||||
(database, state, now) => {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE shows SET status = 'live', updated_at_utc = ?
|
||||
`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ?
|
||||
WHERE id = ? AND status != 'completed'`
|
||||
)
|
||||
.run(now, showId);
|
||||
@@ -419,10 +589,14 @@ export function startOrResumeLive(
|
||||
WHERE show_id = ?`
|
||||
)
|
||||
.run(now, showId);
|
||||
openShowTimerRun(database, showId, now);
|
||||
const activeRun = database
|
||||
.prepare('SELECT 1 FROM live_usage_runs WHERE show_id = ? AND ended_at_utc IS NULL')
|
||||
.get(showId);
|
||||
if (state.current_usage_id && activeRun) return;
|
||||
if (state.current_usage_id && activeRun) {
|
||||
resumeSectionTimerRun(database, showId, now);
|
||||
return;
|
||||
}
|
||||
const current = state.current_usage_id
|
||||
? orderedUsages(database, showId).find((usage) => usage.id === state.current_usage_id)
|
||||
: undefined;
|
||||
@@ -434,6 +608,267 @@ export function startOrResumeLive(
|
||||
);
|
||||
}
|
||||
|
||||
export function pauseLive(
|
||||
showId: string,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
nowValue?: Date
|
||||
) {
|
||||
return executeCommand(
|
||||
showId,
|
||||
commandId,
|
||||
expectedRevision,
|
||||
'pause',
|
||||
(database, state, now) => {
|
||||
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
|
||||
if (!showTimerRunning(database, showId)) throw new Error('ALREADY_PAUSED');
|
||||
closeShowTimerRun(database, showId, now);
|
||||
closeActiveRun(database, showId, now);
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE live_section_timer_runs SET ended_at_utc = ?
|
||||
WHERE timer_id IN (
|
||||
SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL
|
||||
) AND ended_at_utc IS NULL`
|
||||
)
|
||||
.run(now, showId);
|
||||
},
|
||||
nowValue
|
||||
);
|
||||
}
|
||||
|
||||
export function resumeLive(
|
||||
showId: string,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
nowValue?: Date
|
||||
) {
|
||||
return executeCommand(
|
||||
showId,
|
||||
commandId,
|
||||
expectedRevision,
|
||||
'resume',
|
||||
(database, state, now) => {
|
||||
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
|
||||
if (showTimerRunning(database, showId)) throw new Error('ALREADY_RUNNING');
|
||||
openShowTimerRun(database, showId, now);
|
||||
if (state.current_usage_id) {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), showId, state.current_usage_id, now);
|
||||
}
|
||||
resumeSectionTimerRun(database, showId, now);
|
||||
},
|
||||
nowValue
|
||||
);
|
||||
}
|
||||
|
||||
export function resetSectionTimer(
|
||||
showId: string,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
nowValue?: Date
|
||||
) {
|
||||
return executeCommand(
|
||||
showId,
|
||||
commandId,
|
||||
expectedRevision,
|
||||
'reset-section-timer',
|
||||
(database, _state, now) => {
|
||||
const timer = database
|
||||
.prepare(
|
||||
`SELECT section_id, planned_duration_seconds
|
||||
FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL`
|
||||
)
|
||||
.get(showId) as { section_id: string; planned_duration_seconds: number } | undefined;
|
||||
if (!timer) throw new Error('NO_SECTION_TIMER');
|
||||
const running = showTimerRunning(database, showId);
|
||||
closeSectionTimer(database, showId, now);
|
||||
const timerId = randomUUID();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_section_timers
|
||||
(id, show_id, section_id, planned_duration_seconds, started_at_utc)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(timerId, showId, timer.section_id, timer.planned_duration_seconds, now);
|
||||
if (running) {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
|
||||
VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), timerId, now);
|
||||
}
|
||||
},
|
||||
nowValue
|
||||
);
|
||||
}
|
||||
|
||||
export function resetTotalTimer(
|
||||
showId: string,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
nowValue?: Date
|
||||
) {
|
||||
return executeCommand(
|
||||
showId,
|
||||
commandId,
|
||||
expectedRevision,
|
||||
'reset-total-timer',
|
||||
(database, state, now) => {
|
||||
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
|
||||
const running = showTimerRunning(database, showId);
|
||||
database.prepare('DELETE FROM live_show_timer_runs WHERE show_id = ?').run(showId);
|
||||
if (running) openShowTimerRun(database, showId, now);
|
||||
},
|
||||
nowValue
|
||||
);
|
||||
}
|
||||
|
||||
export function setShowStatus(
|
||||
showId: string,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
expectedShowRevision: number,
|
||||
status: Extract<ShowStatus, 'draft' | 'ready'>,
|
||||
nowValue?: Date
|
||||
) {
|
||||
return executeCommand(
|
||||
showId,
|
||||
commandId,
|
||||
expectedRevision,
|
||||
`status-${status}`,
|
||||
(database, state, now) => {
|
||||
if (state.started_at_utc) throw new Error('ALREADY_STARTED');
|
||||
const result = database
|
||||
.prepare(
|
||||
'UPDATE shows SET status = ?, revision = revision + 1, updated_at_utc = ? WHERE id = ? AND revision = ?'
|
||||
)
|
||||
.run(status, now, showId, expectedShowRevision);
|
||||
if (result.changes !== 1) throw new Error('CONFLICT');
|
||||
},
|
||||
nowValue
|
||||
);
|
||||
}
|
||||
|
||||
export function endLive(
|
||||
showId: string,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
nowValue?: Date
|
||||
) {
|
||||
return executeCommand(
|
||||
showId,
|
||||
commandId,
|
||||
expectedRevision,
|
||||
'end',
|
||||
(database, state, now) => {
|
||||
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
|
||||
closeShowTimerRun(database, showId, now);
|
||||
closeActiveRun(database, showId, now);
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE live_section_timer_runs SET ended_at_utc = ?
|
||||
WHERE timer_id IN (
|
||||
SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL
|
||||
) AND ended_at_utc IS NULL`
|
||||
)
|
||||
.run(now, showId);
|
||||
database
|
||||
.prepare(`UPDATE show_live_states SET ended_at_utc = ? WHERE show_id = ?`)
|
||||
.run(now, showId);
|
||||
database
|
||||
.prepare(`UPDATE shows SET revision = revision + 1, updated_at_utc = ? WHERE id = ?`)
|
||||
.run(now, showId);
|
||||
},
|
||||
nowValue
|
||||
);
|
||||
}
|
||||
|
||||
export function resumeEndedLive(
|
||||
showId: string,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
nowValue?: Date
|
||||
) {
|
||||
return executeCommand(
|
||||
showId,
|
||||
commandId,
|
||||
expectedRevision,
|
||||
'resume-ended',
|
||||
(database, state, now) => {
|
||||
if (!state.ended_at_utc) throw new Error('NOT_ENDED');
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?`
|
||||
)
|
||||
.run(now, showId);
|
||||
database
|
||||
.prepare('UPDATE show_live_states SET ended_at_utc = NULL WHERE show_id = ?')
|
||||
.run(showId);
|
||||
openShowTimerRun(database, showId, now);
|
||||
if (state.current_usage_id) {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), showId, state.current_usage_id, now);
|
||||
resumeSectionTimerRun(database, showId, now);
|
||||
}
|
||||
},
|
||||
nowValue,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
export function startNewLiveRun(
|
||||
showId: string,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
nowValue?: Date
|
||||
) {
|
||||
return executeCommand(
|
||||
showId,
|
||||
commandId,
|
||||
expectedRevision,
|
||||
'start-new-run',
|
||||
(database, state, now) => {
|
||||
if (!state.ended_at_utc) throw new Error('NOT_ENDED');
|
||||
database.prepare('DELETE FROM live_usage_runs WHERE show_id = ?').run(showId);
|
||||
database.prepare('DELETE FROM live_show_timer_runs WHERE show_id = ?').run(showId);
|
||||
database.prepare('DELETE FROM live_section_timers WHERE show_id = ?').run(showId);
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE piece_usages SET status = 'open'
|
||||
WHERE show_section_id IN (SELECT id FROM show_sections WHERE show_id = ?)`
|
||||
)
|
||||
.run(showId);
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE show_live_states
|
||||
SET current_usage_id = NULL, started_at_utc = ?, ended_at_utc = NULL
|
||||
WHERE show_id = ?`
|
||||
)
|
||||
.run(now, showId);
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?`
|
||||
)
|
||||
.run(now, showId);
|
||||
openShowTimerRun(database, showId, now);
|
||||
const target = orderedUsages(database, showId).find((usage) => usage.status === 'open');
|
||||
if (target) activateUsage(database, showId, target.id, now);
|
||||
},
|
||||
nowValue,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
function advanceCurrent(
|
||||
showId: string,
|
||||
commandId: string,
|
||||
@@ -447,6 +882,7 @@ function advanceCurrent(
|
||||
expectedRevision,
|
||||
status,
|
||||
(database, state, now) => {
|
||||
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
|
||||
if (!state.current_usage_id) throw new Error('NO_CURRENT');
|
||||
const ordered = orderedUsages(database, showId);
|
||||
const currentIndex = ordered.findIndex((usage) => usage.id === state.current_usage_id);
|
||||
@@ -498,6 +934,7 @@ export function goBack(
|
||||
expectedRevision,
|
||||
'back',
|
||||
(database, state, now) => {
|
||||
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
|
||||
const ordered = orderedUsages(database, showId);
|
||||
const currentIndex = state.current_usage_id
|
||||
? ordered.findIndex((usage) => usage.id === state.current_usage_id)
|
||||
@@ -533,7 +970,9 @@ export function selectUsage(
|
||||
'select',
|
||||
(database, state, now) => {
|
||||
if (!state.started_at_utc) throw new Error('NOT_STARTED');
|
||||
usageBelongsToShow(database, showId, usageId);
|
||||
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
|
||||
const target = usageBelongsToShow(database, showId, usageId);
|
||||
if (target.status !== 'open') throw new Error('INVALID_STATUS');
|
||||
if (state.current_usage_id === usageId) return;
|
||||
if (state.current_usage_id) {
|
||||
closeActiveRun(database, showId, now);
|
||||
@@ -547,6 +986,36 @@ export function selectUsage(
|
||||
);
|
||||
}
|
||||
|
||||
export function resumeUsage(
|
||||
showId: string,
|
||||
usageId: string,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
nowValue?: Date
|
||||
) {
|
||||
return executeCommand(
|
||||
showId,
|
||||
commandId,
|
||||
expectedRevision,
|
||||
'resume-usage',
|
||||
(database, state, now) => {
|
||||
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
|
||||
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
|
||||
const target = usageBelongsToShow(database, showId, usageId);
|
||||
if (target.status !== 'done' && target.status !== 'skipped')
|
||||
throw new Error('INVALID_STATUS');
|
||||
if (state.current_usage_id) {
|
||||
closeActiveRun(database, showId, now);
|
||||
database
|
||||
.prepare("UPDATE piece_usages SET status = 'open' WHERE id = ?")
|
||||
.run(state.current_usage_id);
|
||||
}
|
||||
activateUsage(database, showId, usageId, now);
|
||||
},
|
||||
nowValue
|
||||
);
|
||||
}
|
||||
|
||||
export function presentUsage(
|
||||
showId: string,
|
||||
usageId: string | null,
|
||||
@@ -691,6 +1160,14 @@ export function presentationChannelAccess(channelId: string, accessKey: string)
|
||||
);
|
||||
}
|
||||
|
||||
export function getPresentationChannelStatus(showId: string) {
|
||||
const channel = getDatabase()
|
||||
.prepare("SELECT show_id FROM presentation_channels WHERE id = 'default'")
|
||||
.get() as { show_id: string | null } | undefined;
|
||||
if (!channel?.show_id) return 'disconnected' as const;
|
||||
return channel.show_id === showId ? ('connected' as const) : ('other_show' as const);
|
||||
}
|
||||
|
||||
function presentationShowId(database: Database.Database, token: string) {
|
||||
if (!token || token.length > 200) return null;
|
||||
const channel = database
|
||||
@@ -730,7 +1207,8 @@ function getPresentationViewForShow(showId: string): PresentationView {
|
||||
const usage = database
|
||||
.prepare(
|
||||
`SELECT u.title_snapshot, u.output_title_snapshot, u.presentation_snapshot,
|
||||
u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
|
||||
CASE WHEN u.presentation_image_only_snapshot = 1 THEN 'image' ELSE u.presentation_mode_snapshot END AS presentation_mode_snapshot,
|
||||
u.presentation_source_label_snapshot,
|
||||
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
|
||||
u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
|
||||
u.presentation_image_url_snapshot, u.presentation_site_name_snapshot,
|
||||
@@ -756,10 +1234,11 @@ function getPresentationViewForShow(showId: string): PresentationView {
|
||||
}
|
||||
| undefined;
|
||||
if (!usage) return { revision: state.presentation_revision, content: null };
|
||||
const mode = usage.presentation_mode_snapshot;
|
||||
return {
|
||||
revision: state.presentation_revision,
|
||||
content: {
|
||||
mode: usage.presentation_mode_snapshot,
|
||||
mode,
|
||||
topics: (
|
||||
database
|
||||
.prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position')
|
||||
@@ -776,7 +1255,7 @@ function getPresentationViewForShow(showId: string): PresentationView {
|
||||
: null,
|
||||
previewTitle: usage.presentation_title_meta_snapshot,
|
||||
description: usage.presentation_description_snapshot,
|
||||
imageUrl: usage.presentation_image_url_snapshot,
|
||||
imageUrl: mode === 'link' ? usage.presentation_image_url_snapshot : '',
|
||||
siteName: usage.presentation_site_name_snapshot,
|
||||
provider: usage.presentation_provider_snapshot,
|
||||
providerId: usage.presentation_provider_id_snapshot,
|
||||
@@ -786,21 +1265,23 @@ function getPresentationViewForShow(showId: string): PresentationView {
|
||||
usage.presentation_source_timecode_snapshot
|
||||
),
|
||||
sources: [],
|
||||
images: (
|
||||
database
|
||||
.prepare(
|
||||
`SELECT id, alt_text FROM usage_media_snapshots
|
||||
WHERE usage_id = ? AND is_presentation = 1`
|
||||
)
|
||||
.all(state.presentation_usage_id) as { id: string; alt_text: string }[]
|
||||
).map((media) => ({ id: media.id, altText: media.alt_text }))
|
||||
images: ['text', 'image'].includes(mode)
|
||||
? (
|
||||
database
|
||||
.prepare(
|
||||
`SELECT id, alt_text FROM usage_media_snapshots
|
||||
WHERE usage_id = ? AND is_presentation = 1`
|
||||
)
|
||||
.all(state.presentation_usage_id) as { id: string; alt_text: string }[]
|
||||
).map((media) => ({ id: media.id, altText: media.alt_text }))
|
||||
: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPresentationPreviewImage(token: string) {
|
||||
const view = getPresentationView(token);
|
||||
if (!view?.content?.imageUrl || view.content.mode === 'text') return null;
|
||||
if (!view?.content?.imageUrl || view.content.mode !== 'link') return null;
|
||||
try {
|
||||
return await fetchPresentationImage(view.content.imageUrl);
|
||||
} catch {
|
||||
@@ -824,7 +1305,8 @@ export async function getPresentationMedia(
|
||||
JOIN show_sections s ON s.id = u.show_section_id
|
||||
JOIN piece_media pm ON pm.id = ums.original_media_id
|
||||
WHERE state.show_id = ? AND s.show_id = state.show_id
|
||||
AND s.is_reserve_bank = 0 AND ums.id = ? AND ums.is_presentation = 1`
|
||||
AND s.is_reserve_bank = 0 AND ums.id = ? AND ums.is_presentation = 1
|
||||
AND (u.presentation_image_only_snapshot = 1 OR u.presentation_mode_snapshot = 'text')`
|
||||
)
|
||||
.get(showId, snapshotId) as
|
||||
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
import type { AuthUser } from './auth.js';
|
||||
import { PIECE_STATUSES, getPiece, listPieces, type PieceStatus } from './content.js';
|
||||
import { SHOW_STATUSES, getShow, listShows, type ShowStatus } from './shows.js';
|
||||
import { MCP_SCOPE } from './oauth.js';
|
||||
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
type JsonRpcId = string | number | null;
|
||||
type JsonRpcRequest = {
|
||||
jsonrpc: '2.0';
|
||||
id?: JsonRpcId;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const oauthSecurity = [{ type: 'oauth2', scopes: [MCP_SCOPE] }];
|
||||
const readOnlyAnnotations = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false
|
||||
};
|
||||
|
||||
const profileOutputSchema = {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', minLength: 1, pattern: '\\S' },
|
||||
name: { type: 'string' },
|
||||
email: { type: 'string' },
|
||||
nickname: { type: 'string' }
|
||||
},
|
||||
required: ['id'],
|
||||
additionalProperties: false
|
||||
};
|
||||
|
||||
const paginationSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer' },
|
||||
offset: { type: 'integer' },
|
||||
total: { type: 'integer' }
|
||||
},
|
||||
required: ['limit', 'offset', 'total'],
|
||||
additionalProperties: false
|
||||
};
|
||||
|
||||
const tools = [
|
||||
{
|
||||
name: 'get_profile',
|
||||
title: 'Sendewerk-Profil',
|
||||
description: 'Gibt das mit dieser Verbindung verknüpfte Sendewerk-Konto zurück.',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
outputSchema: profileOutputSchema,
|
||||
annotations: readOnlyAnnotations,
|
||||
securitySchemes: oauthSecurity,
|
||||
_meta: { 'openai/profile': true, securitySchemes: oauthSecurity }
|
||||
},
|
||||
{
|
||||
name: 'list_pieces',
|
||||
title: 'Pieces durchsuchen',
|
||||
description: 'Listet und durchsucht redaktionelle Pieces in Sendewerk.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', maxLength: 200 },
|
||||
statuses: {
|
||||
type: 'array',
|
||||
items: { type: 'string', enum: PIECE_STATUSES },
|
||||
uniqueItems: true
|
||||
},
|
||||
limit: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE, default: 20 },
|
||||
offset: { type: 'integer', minimum: 0, default: 0 }
|
||||
},
|
||||
additionalProperties: false
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pieces: { type: 'array', items: { type: 'object' } },
|
||||
pagination: paginationSchema
|
||||
},
|
||||
required: ['pieces', 'pagination'],
|
||||
additionalProperties: false
|
||||
},
|
||||
annotations: readOnlyAnnotations,
|
||||
securitySchemes: oauthSecurity,
|
||||
_meta: { securitySchemes: oauthSecurity }
|
||||
},
|
||||
{
|
||||
name: 'get_piece',
|
||||
title: 'Piece lesen',
|
||||
description: 'Liest die redaktionellen Texte und Quellen eines einzelnen Pieces.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string', minLength: 1, maxLength: 100 } },
|
||||
required: ['id'],
|
||||
additionalProperties: false
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string' }, title: { type: 'string' } },
|
||||
required: ['id', 'title']
|
||||
},
|
||||
annotations: readOnlyAnnotations,
|
||||
securitySchemes: oauthSecurity,
|
||||
_meta: { securitySchemes: oauthSecurity }
|
||||
},
|
||||
{
|
||||
name: 'list_shows',
|
||||
title: 'Streams auflisten',
|
||||
description: 'Listet Streams mit Status, Termin, Dauer und Abschnittszahl.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
statuses: {
|
||||
type: 'array',
|
||||
items: { type: 'string', enum: SHOW_STATUSES },
|
||||
uniqueItems: true
|
||||
},
|
||||
limit: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE, default: 20 },
|
||||
offset: { type: 'integer', minimum: 0, default: 0 }
|
||||
},
|
||||
additionalProperties: false
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
shows: { type: 'array', items: { type: 'object' } },
|
||||
pagination: paginationSchema
|
||||
},
|
||||
required: ['shows', 'pagination'],
|
||||
additionalProperties: false
|
||||
},
|
||||
annotations: readOnlyAnnotations,
|
||||
securitySchemes: oauthSecurity,
|
||||
_meta: { securitySchemes: oauthSecurity }
|
||||
},
|
||||
{
|
||||
name: 'get_show_rundown',
|
||||
title: 'Streamablauf lesen',
|
||||
description: 'Liest Metadaten, Abschnitte, eingeplante Pieces und Reserve eines Streams.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string', minLength: 1, maxLength: 100 } },
|
||||
required: ['id'],
|
||||
additionalProperties: false
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
sections: { type: 'array', items: { type: 'object' } },
|
||||
reserve: { type: 'array', items: { type: 'object' } }
|
||||
},
|
||||
required: ['id', 'title', 'sections', 'reserve']
|
||||
},
|
||||
annotations: readOnlyAnnotations,
|
||||
securitySchemes: oauthSecurity,
|
||||
_meta: { securitySchemes: oauthSecurity }
|
||||
}
|
||||
];
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum = Number.MAX_SAFE_INTEGER
|
||||
) {
|
||||
if (value === undefined) return fallback;
|
||||
if (!Number.isInteger(value) || Number(value) < minimum || Number(value) > maximum) {
|
||||
throw new Error('INVALID_ARGUMENTS');
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function stringArgument(value: unknown, maximum: number, required = false): string {
|
||||
if (value === undefined && !required) return '';
|
||||
if (typeof value !== 'string' || (required && !value) || value.length > maximum) {
|
||||
throw new Error('INVALID_ARGUMENTS');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function enumArray<T extends string>(value: unknown, allowed: readonly T[], fallback: T[]): T[] {
|
||||
if (value === undefined) return fallback;
|
||||
if (!Array.isArray(value) || value.some((item) => !allowed.includes(item as T))) {
|
||||
throw new Error('INVALID_ARGUMENTS');
|
||||
}
|
||||
return [...new Set(value as T[])];
|
||||
}
|
||||
|
||||
function exactKeys(input: Record<string, unknown>, allowed: string[]): void {
|
||||
if (Object.keys(input).some((key) => !allowed.includes(key)))
|
||||
throw new Error('INVALID_ARGUMENTS');
|
||||
}
|
||||
|
||||
function toolResult(value: unknown) {
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(value) }],
|
||||
structuredContent: value,
|
||||
isError: false
|
||||
};
|
||||
}
|
||||
|
||||
function toolError(message: string) {
|
||||
return { content: [{ type: 'text', text: message }], isError: true };
|
||||
}
|
||||
|
||||
function projectPieceUsage(
|
||||
usage: NonNullable<ReturnType<typeof getShow>>['sections'][number]['usages'][number]
|
||||
) {
|
||||
return {
|
||||
id: usage.id,
|
||||
originalPieceId: usage.originalPieceId,
|
||||
position: usage.position,
|
||||
status: usage.status,
|
||||
preparationStatus: usage.preparationStatus,
|
||||
preparationEffort: usage.preparationEffort,
|
||||
durationSeconds: usage.durationSeconds,
|
||||
title: usage.title,
|
||||
outputTitle: usage.outputTitle,
|
||||
intro: usage.intro,
|
||||
research: usage.research,
|
||||
moderation: usage.moderation,
|
||||
interaction: usage.interaction,
|
||||
transition: usage.transition,
|
||||
presentation: usage.presentation,
|
||||
ownNotes: usage.ownNotes,
|
||||
sources: usage.sources.map(({ label, url, timecodeSeconds }) => ({
|
||||
label,
|
||||
url,
|
||||
timecodeSeconds
|
||||
})),
|
||||
topics: usage.topics.map(({ name }) => name)
|
||||
};
|
||||
}
|
||||
|
||||
function callTool(name: string, input: unknown, user: AuthUser) {
|
||||
if (!isObject(input)) throw new Error('INVALID_ARGUMENTS');
|
||||
switch (name) {
|
||||
case 'get_profile': {
|
||||
exactKeys(input, []);
|
||||
const profile = { id: user.id, name: user.displayName };
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(profile) }],
|
||||
structuredContent: profile,
|
||||
isError: false
|
||||
};
|
||||
}
|
||||
case 'list_pieces': {
|
||||
exactKeys(input, ['query', 'statuses', 'limit', 'offset']);
|
||||
const statuses = enumArray<PieceStatus>(input.statuses, PIECE_STATUSES, [
|
||||
'inbox',
|
||||
'preparing',
|
||||
'ready'
|
||||
]);
|
||||
const query = stringArgument(input.query, 200);
|
||||
const limit = integer(input.limit, 20, 1, MAX_PAGE_SIZE);
|
||||
const offset = integer(input.offset, 0, 0);
|
||||
const all = listPieces({ statuses, query });
|
||||
return toolResult({
|
||||
pieces: all.slice(offset, offset + limit).map(({ mediaId, formatId, ...piece }) => {
|
||||
void mediaId;
|
||||
void formatId;
|
||||
return piece;
|
||||
}),
|
||||
pagination: { limit, offset, total: all.length }
|
||||
});
|
||||
}
|
||||
case 'get_piece': {
|
||||
exactKeys(input, ['id']);
|
||||
const id = stringArgument(input.id, 100, true);
|
||||
const piece = getPiece(id);
|
||||
if (!piece) return toolError('Piece nicht gefunden.');
|
||||
const listEntry = listPieces({ statuses: [...PIECE_STATUSES] }).find(
|
||||
(entry) => entry.id === id
|
||||
);
|
||||
return toolResult({
|
||||
id: piece.id,
|
||||
title: piece.title,
|
||||
outputTitle: piece.outputTitle,
|
||||
summary: piece.summary,
|
||||
status: piece.status,
|
||||
preparationEffort: piece.preparationEffort,
|
||||
durationSeconds: piece.durationSeconds,
|
||||
format: listEntry?.format ?? '',
|
||||
categories: listEntry?.categories.map(({ name }) => name) ?? [],
|
||||
topics: listEntry?.topics.map(({ name }) => name) ?? [],
|
||||
intro: piece.intro,
|
||||
research: piece.researchText,
|
||||
moderation: piece.moderationNotes,
|
||||
interaction: piece.interactionText,
|
||||
transition: piece.transitionText,
|
||||
presentation: piece.presentationText,
|
||||
sources: piece.sources,
|
||||
createdAtUtc: piece.createdAtUtc,
|
||||
updatedAtUtc: piece.updatedAtUtc
|
||||
});
|
||||
}
|
||||
case 'list_shows': {
|
||||
exactKeys(input, ['statuses', 'limit', 'offset']);
|
||||
const statuses = enumArray<ShowStatus>(input.statuses, SHOW_STATUSES, [...SHOW_STATUSES]);
|
||||
const limit = integer(input.limit, 20, 1, MAX_PAGE_SIZE);
|
||||
const offset = integer(input.offset, 0, 0);
|
||||
const all = listShows(statuses);
|
||||
return toolResult({
|
||||
shows: all.slice(offset, offset + limit),
|
||||
pagination: { limit, offset, total: all.length }
|
||||
});
|
||||
}
|
||||
case 'get_show_rundown': {
|
||||
exactKeys(input, ['id']);
|
||||
const show = getShow(stringArgument(input.id, 100, true));
|
||||
if (!show) return toolError('Stream nicht gefunden.');
|
||||
return toolResult({
|
||||
id: show.id,
|
||||
title: show.title,
|
||||
startsAtUtc: show.startsAtUtc,
|
||||
twitchTitle: show.twitchTitle,
|
||||
announcementText: show.announcementText,
|
||||
status: show.status,
|
||||
totalDurationSeconds: show.totalDurationSeconds,
|
||||
sections: show.sections.map((section) => ({
|
||||
id: section.id,
|
||||
name: section.name,
|
||||
category: section.category,
|
||||
position: section.position,
|
||||
explicitDurationSeconds: section.explicitDurationSeconds,
|
||||
durationSeconds: section.durationSeconds,
|
||||
twitchTags: section.twitchTags,
|
||||
pieces: section.usages.map(projectPieceUsage)
|
||||
})),
|
||||
reserve: show.reserve.map(projectPieceUsage),
|
||||
createdAtUtc: show.createdAtUtc,
|
||||
updatedAtUtc: show.updatedAtUtc,
|
||||
completedAtUtc: show.completedAtUtc
|
||||
});
|
||||
}
|
||||
default:
|
||||
throw new Error('UNKNOWN_TOOL');
|
||||
}
|
||||
}
|
||||
|
||||
export function validJsonRpcRequest(value: unknown): value is JsonRpcRequest {
|
||||
if (!isObject(value) || value.jsonrpc !== '2.0' || typeof value.method !== 'string') return false;
|
||||
if (
|
||||
'id' in value &&
|
||||
value.id !== null &&
|
||||
typeof value.id !== 'string' &&
|
||||
typeof value.id !== 'number'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return value.params === undefined || isObject(value.params);
|
||||
}
|
||||
|
||||
export function handleMcpRequest(request: JsonRpcRequest, user: AuthUser) {
|
||||
const id = request.id ?? null;
|
||||
try {
|
||||
switch (request.method) {
|
||||
case 'server/discover':
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
resultType: 'complete',
|
||||
supportedVersions: ['2026-07-28', '2025-11-25', '2025-06-18', '2025-03-26'],
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
_meta: {
|
||||
'io.modelcontextprotocol/serverInfo': { name: 'Sendewerk', version: '0.7.3' }
|
||||
},
|
||||
instructions: 'Lesezugriff auf Pieces, Streams und redaktionelle Abläufe.',
|
||||
ttlMs: 3_600_000,
|
||||
cacheScope: 'private'
|
||||
}
|
||||
};
|
||||
case 'initialize': {
|
||||
const requestedVersion = request.params?.protocolVersion;
|
||||
const supported = ['2025-11-25', '2025-06-18', '2025-03-26'];
|
||||
const protocolVersion =
|
||||
typeof requestedVersion === 'string' && supported.includes(requestedVersion)
|
||||
? requestedVersion
|
||||
: supported[0];
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
protocolVersion,
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: 'Sendewerk', version: '0.7.3' },
|
||||
instructions: 'Lesezugriff auf Pieces, Streams und redaktionelle Abläufe.'
|
||||
}
|
||||
};
|
||||
}
|
||||
case 'ping':
|
||||
return { jsonrpc: '2.0', id, result: { resultType: 'complete' } };
|
||||
case 'tools/list':
|
||||
return { jsonrpc: '2.0', id, result: { resultType: 'complete', tools } };
|
||||
case 'tools/call': {
|
||||
const name = request.params?.name;
|
||||
if (typeof name !== 'string') throw new Error('INVALID_ARGUMENTS');
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
resultType: 'complete',
|
||||
...callTool(name, request.params?.arguments ?? {}, user)
|
||||
}
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } };
|
||||
}
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : '';
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error:
|
||||
message === 'INVALID_ARGUMENTS'
|
||||
? { code: -32602, message: 'Invalid params' }
|
||||
: message === 'UNKNOWN_TOOL'
|
||||
? { code: -32602, message: 'Unknown tool' }
|
||||
: { code: -32603, message: 'Internal error' }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import type { AuthUser } from './auth.js';
|
||||
import { getAuthConfig } from './config.js';
|
||||
import { getDatabase } from './database.js';
|
||||
|
||||
export const MCP_SCOPE = 'sendewerk:read';
|
||||
const AUTHORIZATION_CODE_DURATION_MS = 5 * 60 * 1000;
|
||||
const ACCESS_TOKEN_DURATION_SECONDS = 60 * 60;
|
||||
const MAX_REGISTERED_CLIENTS = 1000;
|
||||
const MCP_REQUEST_LIMIT_PER_HOUR = 600;
|
||||
|
||||
type OAuthClientRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
redirect_uris_json: string;
|
||||
};
|
||||
|
||||
type AuthorizationCodeRow = {
|
||||
client_id: string;
|
||||
user_id: string;
|
||||
redirect_uri: string;
|
||||
scope: string;
|
||||
resource: string;
|
||||
code_challenge: string;
|
||||
expires_at_utc: string;
|
||||
consumed_at_utc: string | null;
|
||||
};
|
||||
|
||||
export type OAuthClient = {
|
||||
id: string;
|
||||
name: string;
|
||||
redirectUris: string[];
|
||||
};
|
||||
|
||||
export type OAuthAuthorizationRequest = {
|
||||
client: OAuthClient;
|
||||
redirectUri: string;
|
||||
state: string;
|
||||
scope: typeof MCP_SCOPE;
|
||||
resource: string;
|
||||
codeChallenge: string;
|
||||
};
|
||||
|
||||
export class OAuthError extends Error {
|
||||
constructor(
|
||||
public code: string,
|
||||
message: string,
|
||||
public status = 400
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function nowUtc(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function futureUtc(durationMs: number): string {
|
||||
return new Date(Date.now() + durationMs).toISOString();
|
||||
}
|
||||
|
||||
function tokenHash(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
function randomToken(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
export function oauthIssuer(): string {
|
||||
return getAuthConfig().origin.origin;
|
||||
}
|
||||
|
||||
export function mcpResource(): string {
|
||||
return new URL('/mcp', getAuthConfig().origin).toString();
|
||||
}
|
||||
|
||||
export function protectedResourceMetadataUrl(): string {
|
||||
return new URL('/.well-known/oauth-protected-resource/mcp', getAuthConfig().origin).toString();
|
||||
}
|
||||
|
||||
function validRedirectUri(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.hash || url.username || url.password) return false;
|
||||
if (url.protocol === 'https:') return true;
|
||||
return (
|
||||
url.protocol === 'http:' &&
|
||||
(url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clientFromRow(row: OAuthClientRow): OAuthClient {
|
||||
return { id: row.id, name: row.name, redirectUris: JSON.parse(row.redirect_uris_json) };
|
||||
}
|
||||
|
||||
export function registerOAuthClient(input: unknown): OAuthClient {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
throw new OAuthError('invalid_client_metadata', 'Client-Metadaten fehlen.');
|
||||
}
|
||||
const metadata = input as Record<string, unknown>;
|
||||
const name = typeof metadata.client_name === 'string' ? metadata.client_name.trim() : '';
|
||||
const redirectUris = Array.isArray(metadata.redirect_uris)
|
||||
? [...new Set(metadata.redirect_uris.filter((uri): uri is string => typeof uri === 'string'))]
|
||||
: [];
|
||||
if (
|
||||
!name ||
|
||||
name.length > 120 ||
|
||||
// OAuth client names must not contain control or bidi-control characters.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(name) ||
|
||||
!redirectUris.length ||
|
||||
redirectUris.length > 10
|
||||
) {
|
||||
throw new OAuthError('invalid_client_metadata', 'Name und Redirect-URIs sind erforderlich.');
|
||||
}
|
||||
if (redirectUris.some((uri) => uri.length > 1000 || !validRedirectUri(uri))) {
|
||||
throw new OAuthError(
|
||||
'invalid_redirect_uri',
|
||||
'Redirect-URIs müssen HTTPS oder lokale HTTP-Adressen sein.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
metadata.token_endpoint_auth_method !== undefined &&
|
||||
metadata.token_endpoint_auth_method !== 'none'
|
||||
) {
|
||||
throw new OAuthError(
|
||||
'invalid_client_metadata',
|
||||
'Nur öffentliche PKCE-Clients werden unterstützt.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
(Array.isArray(metadata.grant_types) && !metadata.grant_types.includes('authorization_code')) ||
|
||||
(Array.isArray(metadata.response_types) && !metadata.response_types.includes('code'))
|
||||
) {
|
||||
throw new OAuthError('invalid_client_metadata', 'Nur Authorization Code wird unterstützt.');
|
||||
}
|
||||
const client: OAuthClient = { id: randomToken(), name, redirectUris };
|
||||
const database = getDatabase();
|
||||
const timestamp = nowUtc();
|
||||
database
|
||||
.prepare('DELETE FROM oauth_authorization_codes WHERE expires_at_utc <= ?')
|
||||
.run(timestamp);
|
||||
database
|
||||
.prepare(
|
||||
'DELETE FROM oauth_access_tokens WHERE expires_at_utc <= ? OR revoked_at_utc IS NOT NULL'
|
||||
)
|
||||
.run(timestamp);
|
||||
database
|
||||
.prepare(
|
||||
`DELETE FROM oauth_clients WHERE created_at_utc < ?
|
||||
AND NOT EXISTS (SELECT 1 FROM oauth_authorization_codes c WHERE c.client_id = oauth_clients.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM oauth_access_tokens t WHERE t.client_id = oauth_clients.id)`
|
||||
)
|
||||
.run(new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());
|
||||
const count = database.prepare('SELECT count(*) AS count FROM oauth_clients').get() as {
|
||||
count: number;
|
||||
};
|
||||
if (count.count >= MAX_REGISTERED_CLIENTS) {
|
||||
throw new OAuthError(
|
||||
'temporarily_unavailable',
|
||||
'Derzeit sind keine weiteren Registrierungen möglich.',
|
||||
503
|
||||
);
|
||||
}
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO oauth_clients (id, name, redirect_uris_json, created_at_utc) VALUES (?, ?, ?, ?)'
|
||||
)
|
||||
.run(client.id, client.name, JSON.stringify(client.redirectUris), timestamp);
|
||||
return client;
|
||||
}
|
||||
|
||||
export function reserveOAuthAttempt(kind: 'registration' | 'token', address: string): boolean {
|
||||
const database = getDatabase();
|
||||
const since = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
const limit = kind === 'registration' ? 20 : 120;
|
||||
const addressHash = tokenHash(address || 'unknown');
|
||||
return database.transaction(() => {
|
||||
database.prepare('DELETE FROM oauth_rate_limit_attempts WHERE attempted_at_utc < ?').run(since);
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT count(*) AS count FROM oauth_rate_limit_attempts
|
||||
WHERE kind = ? AND address_hash = ? AND attempted_at_utc >= ?`
|
||||
)
|
||||
.get(kind, addressHash, since) as { count: number };
|
||||
if (row.count >= limit) return false;
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO oauth_rate_limit_attempts (id, kind, address_hash, attempted_at_utc)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), kind, addressHash, nowUtc());
|
||||
return true;
|
||||
})();
|
||||
}
|
||||
|
||||
export function getOAuthClient(clientId: string): OAuthClient | null {
|
||||
if (!clientId || clientId.length > 200) return null;
|
||||
const row = getDatabase()
|
||||
.prepare('SELECT id, name, redirect_uris_json FROM oauth_clients WHERE id = ?')
|
||||
.get(clientId) as OAuthClientRow | undefined;
|
||||
return row ? clientFromRow(row) : null;
|
||||
}
|
||||
|
||||
export function parseAuthorizationRequest(url: URL): OAuthAuthorizationRequest {
|
||||
const client = getOAuthClient(url.searchParams.get('client_id') ?? '');
|
||||
if (!client) throw new OAuthError('invalid_client', 'Der OAuth-Client ist nicht registriert.');
|
||||
const redirectUri = url.searchParams.get('redirect_uri') ?? '';
|
||||
if (!client.redirectUris.includes(redirectUri)) {
|
||||
throw new OAuthError('invalid_request', 'Die Redirect-URI ist nicht registriert.');
|
||||
}
|
||||
if (url.searchParams.get('response_type') !== 'code') {
|
||||
throw new OAuthError('unsupported_response_type', 'Nur response_type=code wird unterstützt.');
|
||||
}
|
||||
const scope = url.searchParams.get('scope') ?? '';
|
||||
if (scope !== MCP_SCOPE)
|
||||
throw new OAuthError('invalid_scope', 'Der angeforderte Scope ist ungültig.');
|
||||
const resource = url.searchParams.get('resource') ?? '';
|
||||
if (resource !== mcpResource()) {
|
||||
throw new OAuthError('invalid_target', 'Die angeforderte MCP-Ressource ist ungültig.');
|
||||
}
|
||||
const codeChallenge = url.searchParams.get('code_challenge') ?? '';
|
||||
if (
|
||||
url.searchParams.get('code_challenge_method') !== 'S256' ||
|
||||
!/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)
|
||||
) {
|
||||
throw new OAuthError('invalid_request', 'PKCE mit S256 ist erforderlich.');
|
||||
}
|
||||
const state = url.searchParams.get('state') ?? '';
|
||||
if (state.length > 1000) throw new OAuthError('invalid_request', 'Der OAuth-State ist zu lang.');
|
||||
return { client, redirectUri, state, scope: MCP_SCOPE, resource, codeChallenge };
|
||||
}
|
||||
|
||||
export function authorizationErrorRedirect(url: URL, problem: OAuthError): string | null {
|
||||
const client = getOAuthClient(url.searchParams.get('client_id') ?? '');
|
||||
const redirectUri = url.searchParams.get('redirect_uri') ?? '';
|
||||
if (!client?.redirectUris.includes(redirectUri)) return null;
|
||||
const target = new URL(redirectUri);
|
||||
target.searchParams.set('error', problem.code);
|
||||
target.searchParams.set('error_description', problem.message);
|
||||
const state = url.searchParams.get('state') ?? '';
|
||||
if (state && state.length <= 1000) target.searchParams.set('state', state);
|
||||
target.searchParams.set('iss', oauthIssuer());
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
export function authorizationRedirect(
|
||||
request: OAuthAuthorizationRequest,
|
||||
values: Record<string, string>
|
||||
): string {
|
||||
const target = new URL(request.redirectUri);
|
||||
for (const [key, value] of Object.entries(values)) target.searchParams.set(key, value);
|
||||
if (request.state) target.searchParams.set('state', request.state);
|
||||
target.searchParams.set('iss', oauthIssuer());
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
export function createAuthorizationCode(
|
||||
request: OAuthAuthorizationRequest,
|
||||
user: AuthUser
|
||||
): string {
|
||||
const code = randomToken();
|
||||
const timestamp = nowUtc();
|
||||
const database = getDatabase();
|
||||
const store = database.transaction(() => {
|
||||
database
|
||||
.prepare('DELETE FROM oauth_authorization_codes WHERE expires_at_utc <= ?')
|
||||
.run(timestamp);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO oauth_authorization_codes (
|
||||
code_hash, client_id, user_id, redirect_uri, scope, resource, code_challenge,
|
||||
created_at_utc, expires_at_utc
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
tokenHash(code),
|
||||
request.client.id,
|
||||
user.id,
|
||||
request.redirectUri,
|
||||
request.scope,
|
||||
request.resource,
|
||||
request.codeChallenge,
|
||||
timestamp,
|
||||
futureUtc(AUTHORIZATION_CODE_DURATION_MS)
|
||||
);
|
||||
});
|
||||
store();
|
||||
return code;
|
||||
}
|
||||
|
||||
function pkceChallenge(verifier: string): string {
|
||||
return createHash('sha256').update(verifier).digest('base64url');
|
||||
}
|
||||
|
||||
export function exchangeAuthorizationCode(input: {
|
||||
code: string;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
codeVerifier: string;
|
||||
resource: string;
|
||||
}) {
|
||||
if (!/^[A-Za-z0-9._~-]{43,128}$/.test(input.codeVerifier)) {
|
||||
throw new OAuthError('invalid_grant', 'Der PKCE-Verifier ist ungültig.');
|
||||
}
|
||||
const database = getDatabase();
|
||||
const exchange = database.transaction(() => {
|
||||
const timestamp = nowUtc();
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT client_id, user_id, redirect_uri, scope, resource, code_challenge,
|
||||
expires_at_utc, consumed_at_utc
|
||||
FROM oauth_authorization_codes WHERE code_hash = ?`
|
||||
)
|
||||
.get(tokenHash(input.code)) as AuthorizationCodeRow | undefined;
|
||||
if (
|
||||
!row ||
|
||||
row.consumed_at_utc ||
|
||||
row.expires_at_utc <= timestamp ||
|
||||
row.client_id !== input.clientId ||
|
||||
row.redirect_uri !== input.redirectUri ||
|
||||
row.resource !== input.resource ||
|
||||
row.code_challenge !== pkceChallenge(input.codeVerifier)
|
||||
) {
|
||||
throw new OAuthError('invalid_grant', 'Der Autorisierungscode ist ungültig oder abgelaufen.');
|
||||
}
|
||||
const user = database
|
||||
.prepare('SELECT 1 FROM users WHERE id = ? AND locked_at_utc IS NULL')
|
||||
.get(row.user_id);
|
||||
if (!user) throw new OAuthError('invalid_grant', 'Das zugehörige Konto ist nicht aktiv.');
|
||||
const token = randomToken();
|
||||
database
|
||||
.prepare('UPDATE oauth_authorization_codes SET consumed_at_utc = ? WHERE code_hash = ?')
|
||||
.run(timestamp, tokenHash(input.code));
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO oauth_access_tokens (
|
||||
token_hash, client_id, user_id, scope, resource, created_at_utc, expires_at_utc,
|
||||
request_window_started_at_utc
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
tokenHash(token),
|
||||
row.client_id,
|
||||
row.user_id,
|
||||
row.scope,
|
||||
row.resource,
|
||||
timestamp,
|
||||
futureUtc(ACCESS_TOKEN_DURATION_SECONDS * 1000),
|
||||
timestamp
|
||||
);
|
||||
return { accessToken: token, scope: row.scope };
|
||||
});
|
||||
return exchange();
|
||||
}
|
||||
|
||||
export function authenticateOAuth(request: Request): AuthUser {
|
||||
const authorization = request.headers.get('authorization') ?? '';
|
||||
const token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : '';
|
||||
if (!token || token.length > 100)
|
||||
throw new OAuthError('invalid_token', 'OAuth-Token fehlt.', 401);
|
||||
const timestamp = nowUtc();
|
||||
const database = getDatabase();
|
||||
const row = database.transaction(() => {
|
||||
const current = database
|
||||
.prepare(
|
||||
`SELECT u.id, u.email, u.display_name AS displayName, u.role,
|
||||
u.locked_at_utc AS lockedAtUtc, t.token_hash,
|
||||
t.request_window_started_at_utc, t.request_count
|
||||
FROM oauth_access_tokens t JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token_hash = ? AND t.revoked_at_utc IS NULL AND t.expires_at_utc > ?
|
||||
AND t.scope = ? AND t.resource = ? AND u.locked_at_utc IS NULL`
|
||||
)
|
||||
.get(tokenHash(token), timestamp, MCP_SCOPE, mcpResource()) as
|
||||
| (AuthUser & {
|
||||
token_hash: string;
|
||||
request_window_started_at_utc: string;
|
||||
request_count: number;
|
||||
})
|
||||
| undefined;
|
||||
if (!current) {
|
||||
throw new OAuthError('invalid_token', 'OAuth-Token ist ungültig oder abgelaufen.', 401);
|
||||
}
|
||||
const windowExpired =
|
||||
current.request_window_started_at_utc <= new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
if (!windowExpired && current.request_count >= MCP_REQUEST_LIMIT_PER_HOUR) {
|
||||
throw new OAuthError('temporarily_unavailable', 'Zu viele MCP-Anfragen.', 429);
|
||||
}
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE oauth_access_tokens SET last_used_at_utc = ?,
|
||||
request_window_started_at_utc = ?, request_count = ? WHERE token_hash = ?`
|
||||
)
|
||||
.run(
|
||||
timestamp,
|
||||
windowExpired ? timestamp : current.request_window_started_at_utc,
|
||||
windowExpired ? 1 : current.request_count + 1,
|
||||
current.token_hash
|
||||
);
|
||||
return current;
|
||||
})();
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
displayName: row.displayName,
|
||||
role: row.role,
|
||||
lockedAtUtc: row.lockedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
export function revokeOAuthToken(token: string, clientId: string): void {
|
||||
if (!token || token.length > 200 || !clientId || clientId.length > 200) return;
|
||||
getDatabase()
|
||||
.prepare(
|
||||
`UPDATE oauth_access_tokens SET revoked_at_utc = ?
|
||||
WHERE token_hash = ? AND client_id = ? AND revoked_at_utc IS NULL`
|
||||
)
|
||||
.run(nowUtc(), tokenHash(token), clientId);
|
||||
}
|
||||
|
||||
export function oauthTokenDurationSeconds(): number {
|
||||
return ACCESS_TOKEN_DURATION_SECONDS;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Provider-neutral contract for a future Post-production assistant.
|
||||
*
|
||||
* This module deliberately performs no network request. A future adapter must
|
||||
* require explicit user approval, identify the fields it proposes to change and
|
||||
* return suggestions separately from persisted editorial data.
|
||||
*/
|
||||
export type PostAnalysisRequest = {
|
||||
show: { id: string; title: string; startsAtUtc: string | null };
|
||||
sources: {
|
||||
id: string;
|
||||
originalName: string;
|
||||
recordingPartId: string | null;
|
||||
content: string;
|
||||
}[];
|
||||
rules: {
|
||||
materialIsAuthoritative: true;
|
||||
preserveExactQuotes: true;
|
||||
inventStatements: false;
|
||||
considerLongStorytimes: true;
|
||||
fixedCandidateQuota: false;
|
||||
};
|
||||
};
|
||||
|
||||
export type PostSuggestion = {
|
||||
id: string;
|
||||
kind: 'clip-candidate' | 'youtube-copy' | 'thumbnail-idea' | 'highlight-candidate';
|
||||
sourceFileId: string;
|
||||
startSeconds: number | null;
|
||||
endSeconds: number | null;
|
||||
exactQuote: string;
|
||||
rationale: string;
|
||||
fields: Record<string, string>;
|
||||
};
|
||||
|
||||
export type PostAnalysisResult = {
|
||||
provider: string;
|
||||
model: string;
|
||||
createdAtUtc: string;
|
||||
suggestions: PostSuggestion[];
|
||||
};
|
||||
|
||||
export interface PostAnalysisAdapter {
|
||||
analyze(request: PostAnalysisRequest): Promise<PostAnalysisResult>;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
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, getDatabase } from './database.js';
|
||||
import {
|
||||
createPostItem,
|
||||
createRecordingPart,
|
||||
getExport,
|
||||
getPostWorkspace,
|
||||
getThumbnailFile,
|
||||
importPostSource,
|
||||
listPostPieceOptions,
|
||||
listPostShows,
|
||||
savePostItem,
|
||||
uploadThumbnail
|
||||
} from './post.js';
|
||||
|
||||
let directory: string;
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-post-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(
|
||||
`UPDATE shows SET status = 'completed', completed_at_utc = ?, revision = revision + 1
|
||||
WHERE id = 'show-october'`
|
||||
)
|
||||
.run('2026-10-24T00:00:00.000Z');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
delete process.env.SENDEWERK_DB_PATH;
|
||||
delete process.env.SENDEWERK_UPLOAD_DIR;
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Postproduktion', () => {
|
||||
it('projects every completed show even before post-production starts', () => {
|
||||
expect(listPostShows()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
showId: 'show-october',
|
||||
sourceCount: 0,
|
||||
youtubeCount: 0,
|
||||
clipCount: 0,
|
||||
highlightCount: 0,
|
||||
progress: 'Noch nicht begonnen'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps imported Markdown exact, extracts only present timecodes and rejects duplicates', async () => {
|
||||
const content =
|
||||
'# Kontext\n\n[00:01:12] Exaktes Zitat\n01:15:03 – Später Moment\nOhne Zeitmarke';
|
||||
const sourceId = await importPostSource(
|
||||
'show-october',
|
||||
null,
|
||||
new File([content], 'heinzlive-teil-1.md', { type: 'text/markdown' })
|
||||
);
|
||||
const source = getPostWorkspace('show-october').sources.find((item) => item.id === sourceId)!;
|
||||
expect(source.timecodes).toEqual([
|
||||
{ seconds: 72, label: 'Exaktes Zitat', line: 3 },
|
||||
{ seconds: 4503, label: 'Später Moment', line: 4 }
|
||||
]);
|
||||
expect(source.transcriptText).toBe('Exaktes Zitat\nSpäter Moment');
|
||||
await expect(
|
||||
importPostSource(
|
||||
'show-october',
|
||||
null,
|
||||
new File([content], 'anderer-name.md', { type: 'text/markdown' })
|
||||
)
|
||||
).rejects.toThrow('DUPLICATE_SOURCE');
|
||||
});
|
||||
|
||||
it('defaults the only context source and offers only pieces used in the completed stream', async () => {
|
||||
const sourceId = await importPostSource(
|
||||
'show-october',
|
||||
null,
|
||||
new File(['# Kontext'], 'kontext.md', { type: 'text/markdown' })
|
||||
);
|
||||
const clipId = createPostItem('show-october', 'clip', 'Schneller Clip');
|
||||
expect(
|
||||
getPostWorkspace('show-october').items.find((item) => item.id === clipId)?.sourceFileId
|
||||
).toBe(sourceId);
|
||||
const pieces = listPostPieceOptions('show-october');
|
||||
expect(pieces.length).toBeGreaterThan(0);
|
||||
expect(pieces.every((piece) => piece.id !== 'piece-quiz')).toBe(true);
|
||||
});
|
||||
|
||||
it('stores independent video, clip and highlight records with revision protection', () => {
|
||||
const youtubeId = createPostItem('show-october', 'youtube', 'Uncut Teil 1');
|
||||
const clipId = createPostItem('show-october', 'clip', 'Linux ist keine Religion');
|
||||
const highlightId = createPostItem('show-october', 'highlight', 'Die Diskussion über Linux');
|
||||
savePostItem('show-october', youtubeId, 1, {
|
||||
title: 'Uncut Teil 1',
|
||||
status: 'review',
|
||||
description: '00:00 Begrüßung',
|
||||
titleVariants: ['Variante A', 'Variante B'],
|
||||
publicationStatus: 'unpublished',
|
||||
videoFormat: '16:9'
|
||||
});
|
||||
savePostItem('show-october', clipId, 1, {
|
||||
title: 'Linux ist keine Religion',
|
||||
status: 'editing',
|
||||
clipType: 'opinion',
|
||||
originalTranscript: 'Das wurde genau so gesagt.',
|
||||
startSeconds: 72,
|
||||
endSeconds: 103,
|
||||
primaryHook: 'Linux ist keine Religion!',
|
||||
socialCaption: 'Gemeinsamer Text\n\n#linux #heinzlive',
|
||||
videoFormat: '9:16',
|
||||
targetTikTok: true,
|
||||
targetInstagram: true,
|
||||
targetShorts: true
|
||||
});
|
||||
savePostItem('show-october', highlightId, 1, {
|
||||
title: 'Die Diskussion über Linux',
|
||||
status: 'draft',
|
||||
description: 'Ein längerer Ausschnitt.',
|
||||
startSeconds: 60,
|
||||
endSeconds: 360,
|
||||
videoFormat: '16:9'
|
||||
});
|
||||
expect(() =>
|
||||
savePostItem('show-october', clipId, 1, {
|
||||
title: 'Veraltete Änderung',
|
||||
status: 'done'
|
||||
})
|
||||
).toThrow('CONFLICT');
|
||||
const workspace = getPostWorkspace('show-october');
|
||||
expect(workspace.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: youtubeId, description: '00:00 Begrüßung' }),
|
||||
expect.objectContaining({
|
||||
id: clipId,
|
||||
originalTranscript: 'Das wurde genau so gesagt.',
|
||||
socialCaption: 'Gemeinsamer Text\n\n#linux #heinzlive',
|
||||
clipType: 'opinion'
|
||||
}),
|
||||
expect.objectContaining({ id: highlightId, endSeconds: 360 })
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('stores real thumbnail files and only emits technically ready exports', async () => {
|
||||
const youtubeId = createPostItem('show-october', 'youtube', 'Uncut');
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
);
|
||||
const thumbnailId = await uploadThumbnail(
|
||||
'show-october',
|
||||
youtubeId,
|
||||
1,
|
||||
new File([png], 'thumb.png', { type: 'image/png' }),
|
||||
'Ein fertiges Thumbnail',
|
||||
true
|
||||
);
|
||||
const thumbnail = await getThumbnailFile('show-october', thumbnailId);
|
||||
expect(thumbnail?.data).toEqual(png);
|
||||
|
||||
const partId = createRecordingPart('show-october', {
|
||||
label: 'Teil 1',
|
||||
mediaFilePath: '/media/stream-teil-1.mov',
|
||||
frameRate: 25,
|
||||
timebase: 25
|
||||
});
|
||||
const clipId = createPostItem('show-october', 'clip', 'Exportclip');
|
||||
expect(() => getExport('show-october', clipId, 'resolve')).toThrow('EXPORT_NOT_READY');
|
||||
savePostItem('show-october', clipId, 1, {
|
||||
title: 'Exportclip',
|
||||
status: 'done',
|
||||
recordingPartId: partId,
|
||||
startSeconds: 10,
|
||||
endSeconds: 20,
|
||||
videoFormat: '9:16',
|
||||
subtitleSrt: '1\n00:00:00,000 --> 00:00:02,000\nOriginaltext'
|
||||
});
|
||||
expect(getExport('show-october', clipId, 'resolve').body).toContain(
|
||||
'<pathurl>file:///media/stream-teil-1.mov</pathurl>'
|
||||
);
|
||||
expect(getExport('show-october', clipId, 'srt').body).toContain('Originaltext');
|
||||
});
|
||||
});
|
||||
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 title from 'metascraper-title';
|
||||
|
||||
export type PresentationMode = 'text' | 'link' | 'video';
|
||||
export type PresentationMode = 'text' | 'image' | 'link' | 'video';
|
||||
export type VideoProvider = 'youtube' | 'tiktok';
|
||||
|
||||
export type PresentationMetadata = {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
duplicatePieceUsage,
|
||||
getPieceUsageContexts,
|
||||
getShow,
|
||||
listPreparationUsages,
|
||||
listShowTemplates,
|
||||
movePieceUsage,
|
||||
moveShowSection,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
removePieceUsage,
|
||||
removeShowSection,
|
||||
updatePieceUsage,
|
||||
updatePieceUsageMetadata,
|
||||
updateShowSection,
|
||||
type PieceUsage
|
||||
} from './shows.js';
|
||||
@@ -60,12 +62,57 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
|
||||
presentationImageUrl: edited.presentationImageUrl,
|
||||
presentationSiteName: edited.presentationSiteName,
|
||||
presentationMediaId: edited.presentationMediaId,
|
||||
preparationStatus: edited.preparationStatus,
|
||||
preparationEffort: edited.preparationEffort,
|
||||
ownNotes: edited.ownNotes,
|
||||
durationSeconds: edited.durationSeconds
|
||||
};
|
||||
}
|
||||
|
||||
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, {
|
||||
@@ -135,7 +182,7 @@ describe('show builder data layer', () => {
|
||||
|
||||
const blank = createShow({ title: null, startsAtUtc: null, templateId: null });
|
||||
expect(getShow(blank.id)).toMatchObject({
|
||||
title: 'Neue Sendung',
|
||||
title: 'Neuer Stream',
|
||||
startsAtUtc: null,
|
||||
sections: [],
|
||||
reserve: []
|
||||
@@ -224,6 +271,60 @@ describe('show builder data layer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updates usage metadata without changing editorial or presentation snapshots', () => {
|
||||
const created = createShow({ title: 'Schnelländerung', startsAtUtc: null, templateId: null });
|
||||
const section = addShowSection(created.id, created.revision, {
|
||||
name: 'Fundstücke',
|
||||
categoryId: 'cat-reaction',
|
||||
explicitDurationSeconds: null
|
||||
});
|
||||
const added = addPieceUsage(created.id, section.revision, {
|
||||
pieceId: 'piece-console',
|
||||
sectionId: section.id
|
||||
});
|
||||
const before = getShow(created.id)!.sections[0].usages[0];
|
||||
|
||||
updatePieceUsageMetadata(created.id, added.id, added.revision, {
|
||||
title: 'Konsole im Inspector',
|
||||
outputTitle: 'Konsole live',
|
||||
durationSeconds: 600,
|
||||
ownNotes: 'Direkt im Streamplan geändert',
|
||||
preparationStatus: 'ready',
|
||||
preparationEffort: 'low'
|
||||
});
|
||||
|
||||
const after = getShow(created.id)!.sections[0].usages[0];
|
||||
expect(after).toMatchObject({
|
||||
title: 'Konsole im Inspector',
|
||||
outputTitle: 'Konsole live',
|
||||
durationSeconds: 600,
|
||||
ownNotes: 'Direkt im Streamplan geändert',
|
||||
preparationStatus: 'ready',
|
||||
preparationEffort: 'low'
|
||||
});
|
||||
expect({
|
||||
intro: after.intro,
|
||||
research: after.research,
|
||||
moderation: after.moderation,
|
||||
interaction: after.interaction,
|
||||
transition: after.transition,
|
||||
presentation: after.presentation,
|
||||
presentationMode: after.presentationMode,
|
||||
presentationMediaId: after.presentationMediaId,
|
||||
tools: after.tools
|
||||
}).toEqual({
|
||||
intro: before.intro,
|
||||
research: before.research,
|
||||
moderation: before.moderation,
|
||||
interaction: before.interaction,
|
||||
transition: before.transition,
|
||||
presentation: before.presentation,
|
||||
presentationMode: before.presentationMode,
|
||||
presentationMediaId: before.presentationMediaId,
|
||||
tools: before.tools
|
||||
});
|
||||
});
|
||||
|
||||
it('derives sent history and current planning only from qualifying usages by original id', () => {
|
||||
const completed = createShow({
|
||||
title: 'Abgeschlossene Sendung',
|
||||
@@ -253,7 +354,29 @@ describe('show builder data layer', () => {
|
||||
getDatabase()
|
||||
.prepare("UPDATE piece_usages SET status = 'skipped' WHERE 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 activeSection = addShowSection(active.id, active.revision, {
|
||||
|
||||
+446
-66
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createPiece } from './content.js';
|
||||
import { getDatabase } from './database.js';
|
||||
import { detectVideoProvider, type PresentationMode } from './presentation.js';
|
||||
import { detectVideoProvider, type PresentationMode, type VideoProvider } from './presentation.js';
|
||||
|
||||
export const SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const;
|
||||
export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const;
|
||||
@@ -10,6 +10,8 @@ export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const;
|
||||
export type ShowStatus = (typeof SHOW_STATUSES)[number];
|
||||
export type EditableShowStatus = Exclude<ShowStatus, 'completed'>;
|
||||
export type UsageStatus = (typeof USAGE_STATUSES)[number];
|
||||
export type UsagePreparationStatus = 'preparing' | 'ready';
|
||||
export type PreparationEffort = 'low' | 'medium' | 'high' | null;
|
||||
|
||||
export type ShowListItem = {
|
||||
id: string;
|
||||
@@ -21,12 +23,24 @@ export type ShowListItem = {
|
||||
sectionCount: number;
|
||||
};
|
||||
|
||||
export type PreparationUsage = {
|
||||
usageId: string;
|
||||
showId: string;
|
||||
showTitle: string;
|
||||
showStartsAtUtc: string | null;
|
||||
sectionName: string;
|
||||
title: string;
|
||||
preparationEffort: PreparationEffort;
|
||||
topics: UsageTopic[];
|
||||
};
|
||||
|
||||
export type ShowTemplateSection = {
|
||||
id: string;
|
||||
name: string;
|
||||
categoryId: string | null;
|
||||
position: number;
|
||||
explicitDurationSeconds: number | null;
|
||||
twitchTags: string[];
|
||||
};
|
||||
|
||||
export type ShowTemplate = {
|
||||
@@ -41,12 +55,15 @@ export type PieceUsage = {
|
||||
originalPieceId: string | null;
|
||||
position: number;
|
||||
status: UsageStatus;
|
||||
preparationStatus: UsagePreparationStatus;
|
||||
preparationEffort: PreparationEffort;
|
||||
durationSeconds: number;
|
||||
title: string;
|
||||
outputTitle: string;
|
||||
intro: string;
|
||||
research: string;
|
||||
moderation: string;
|
||||
interaction: string;
|
||||
transition: string;
|
||||
presentation: string;
|
||||
presentationMode: PresentationMode;
|
||||
@@ -57,7 +74,7 @@ export type PieceUsage = {
|
||||
presentationDescription: string;
|
||||
presentationImageUrl: string;
|
||||
presentationSiteName: string;
|
||||
presentationProvider: string;
|
||||
presentationProvider: VideoProvider | '';
|
||||
presentationProviderId: string;
|
||||
ownNotes: string;
|
||||
sources: { id: string; label: string; url: string; timecodeSeconds: number | null }[];
|
||||
@@ -105,6 +122,7 @@ export type ShowSection = {
|
||||
categoryColor: string | null;
|
||||
position: number;
|
||||
explicitDurationSeconds: number | null;
|
||||
twitchTags: string[];
|
||||
durationSeconds: number;
|
||||
usages: PieceUsage[];
|
||||
};
|
||||
@@ -119,6 +137,7 @@ export type Show = {
|
||||
revision: number;
|
||||
createdAtUtc: string;
|
||||
updatedAtUtc: string;
|
||||
completedAtUtc: string | null;
|
||||
sections: ShowSection[];
|
||||
reserve: PieceUsage[];
|
||||
totalDurationSeconds: number;
|
||||
@@ -142,6 +161,7 @@ export type ShowSectionInput = {
|
||||
name: string;
|
||||
categoryId: string | null;
|
||||
explicitDurationSeconds: number | null;
|
||||
twitchTags?: string[];
|
||||
position?: number;
|
||||
};
|
||||
|
||||
@@ -157,6 +177,7 @@ export type PieceUsageUpdate = {
|
||||
intro: string;
|
||||
research: string;
|
||||
moderation: string;
|
||||
interaction?: string;
|
||||
transition: string;
|
||||
presentation: string;
|
||||
presentationMode: PresentationMode;
|
||||
@@ -169,10 +190,22 @@ export type PieceUsageUpdate = {
|
||||
presentationSiteName: string;
|
||||
ownNotes: string;
|
||||
durationSeconds: number;
|
||||
preparationStatus?: UsagePreparationStatus;
|
||||
preparationEffort?: PreparationEffort;
|
||||
tools?: { id: string; workUrl: string; note: string }[];
|
||||
presentationMediaId?: string | null;
|
||||
};
|
||||
|
||||
export type PieceUsageMetadataUpdate = Pick<
|
||||
PieceUsageUpdate,
|
||||
| 'title'
|
||||
| 'outputTitle'
|
||||
| 'durationSeconds'
|
||||
| 'ownNotes'
|
||||
| 'preparationStatus'
|
||||
| 'preparationEffort'
|
||||
>;
|
||||
|
||||
export type MovePieceUsageInput = {
|
||||
sectionId: string | null;
|
||||
position: number;
|
||||
@@ -191,6 +224,7 @@ type ShowRow = {
|
||||
revision: number;
|
||||
created_at_utc: string;
|
||||
updated_at_utc: string;
|
||||
completed_at_utc: string | null;
|
||||
};
|
||||
|
||||
type SectionRow = {
|
||||
@@ -199,8 +233,11 @@ type SectionRow = {
|
||||
category_id: string | null;
|
||||
category: string | null;
|
||||
category_color: string | null;
|
||||
category_name_snapshot: string | null;
|
||||
category_color_snapshot: string | null;
|
||||
position: number;
|
||||
explicit_duration_seconds: number | null;
|
||||
twitch_tags: string;
|
||||
is_reserve_bank: number;
|
||||
};
|
||||
|
||||
@@ -210,15 +247,19 @@ type UsageRow = {
|
||||
original_piece_id: string | null;
|
||||
position: number;
|
||||
status: UsageStatus;
|
||||
preparation_status_snapshot: UsagePreparationStatus;
|
||||
preparation_effort_snapshot: PreparationEffort;
|
||||
duration_seconds: number;
|
||||
title_snapshot: string;
|
||||
output_title_snapshot: string;
|
||||
intro_snapshot: string;
|
||||
research_snapshot: string;
|
||||
moderation_snapshot: string;
|
||||
interaction_snapshot: string;
|
||||
transition_snapshot: string;
|
||||
presentation_snapshot: string;
|
||||
presentation_mode_snapshot: PresentationMode;
|
||||
presentation_image_only_snapshot: number;
|
||||
presentation_source_label_snapshot: string;
|
||||
presentation_source_url_snapshot: string;
|
||||
presentation_source_timecode_snapshot: number | null;
|
||||
@@ -226,7 +267,7 @@ type UsageRow = {
|
||||
presentation_description_snapshot: string;
|
||||
presentation_image_url_snapshot: string;
|
||||
presentation_site_name_snapshot: string;
|
||||
presentation_provider_snapshot: string;
|
||||
presentation_provider_snapshot: VideoProvider | '';
|
||||
presentation_provider_id_snapshot: string;
|
||||
own_notes: string;
|
||||
};
|
||||
@@ -247,6 +288,14 @@ function validDuration(value: number | null): number | null {
|
||||
return value;
|
||||
}
|
||||
|
||||
function validTwitchTags(values: string[]) {
|
||||
const tags = [...new Set(values.map((tag) => tag.trim()).filter(Boolean))];
|
||||
if (tags.length > 10 || tags.some((tag) => tag.length > 25 || tag.includes(','))) {
|
||||
throw new Error('INVALID_TWITCH_TAGS');
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
function validPosition(value: number, maximum: number): number {
|
||||
if (!Number.isInteger(value) || value < 0) throw new Error('INVALID_POSITION');
|
||||
return Math.min(value, maximum);
|
||||
@@ -270,7 +319,7 @@ function validPresentation(
|
||||
imageUrl: string,
|
||||
siteName: string
|
||||
) {
|
||||
if (!['text', 'link', 'video'].includes(mode)) throw new Error('INVALID_PRESENTATION');
|
||||
if (!['text', 'image', 'link', 'video'].includes(mode)) throw new Error('INVALID_PRESENTATION');
|
||||
const cleanUrl = cleanText(sourceUrl, 2_000, 'presentation_source');
|
||||
const cleanImageUrl = cleanText(imageUrl, 2_000, 'presentation_image');
|
||||
for (const value of [cleanUrl, cleanImageUrl]) {
|
||||
@@ -282,7 +331,7 @@ function validPresentation(
|
||||
throw new Error('INVALID_PRESENTATION');
|
||||
}
|
||||
}
|
||||
if (mode !== 'text' && !cleanUrl) throw new Error('INVALID_PRESENTATION');
|
||||
if ((mode === 'link' || mode === 'video') && !cleanUrl) throw new Error('INVALID_PRESENTATION');
|
||||
if (
|
||||
timecodeSeconds !== null &&
|
||||
(!Number.isInteger(timecodeSeconds) || timecodeSeconds < 0 || timecodeSeconds > 86_400)
|
||||
@@ -439,12 +488,15 @@ function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage {
|
||||
originalPieceId: row.original_piece_id,
|
||||
position: row.position,
|
||||
status: row.status,
|
||||
preparationStatus: row.preparation_status_snapshot,
|
||||
preparationEffort: row.preparation_effort_snapshot,
|
||||
durationSeconds: row.duration_seconds,
|
||||
title: row.title_snapshot,
|
||||
outputTitle: row.output_title_snapshot,
|
||||
intro: row.intro_snapshot,
|
||||
research: row.research_snapshot,
|
||||
moderation: row.moderation_snapshot,
|
||||
interaction: row.interaction_snapshot,
|
||||
transition: row.transition_snapshot,
|
||||
presentation: row.presentation_snapshot,
|
||||
presentationMode: row.presentation_mode_snapshot,
|
||||
@@ -709,7 +761,11 @@ function copyUsageRelations(database: Database.Database, sourceUsageId: string,
|
||||
.run(usageId, sourceUsageId);
|
||||
}
|
||||
|
||||
export function listShows(): ShowListItem[] {
|
||||
export function listShows(statuses?: ShowStatus[]): ShowListItem[] {
|
||||
const requestedStatuses = statuses?.filter((status) => SHOW_STATUSES.includes(status));
|
||||
const where = requestedStatuses?.length
|
||||
? `WHERE sh.status IN (${requestedStatuses.map(() => '?').join(', ')})`
|
||||
: '';
|
||||
return (
|
||||
getDatabase()
|
||||
.prepare(
|
||||
@@ -720,10 +776,11 @@ export function listShows(): ShowListItem[] {
|
||||
(SELECT sum(u.duration_seconds) FROM piece_usages u WHERE u.show_section_id = s.id))
|
||||
ELSE 0 END), 0) AS total_duration_seconds
|
||||
FROM shows sh LEFT JOIN show_sections s ON s.show_id = sh.id
|
||||
${where}
|
||||
GROUP BY sh.id
|
||||
ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc DESC, sh.created_at_utc DESC`
|
||||
)
|
||||
.all() as {
|
||||
.all(...(requestedStatuses ?? [])) as {
|
||||
id: string;
|
||||
title: string;
|
||||
starts_at_utc: string | null;
|
||||
@@ -743,13 +800,57 @@ export function listShows(): ShowListItem[] {
|
||||
}));
|
||||
}
|
||||
|
||||
export function listPreparationUsages(): PreparationUsage[] {
|
||||
const database = getDatabase();
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT u.id AS usage_id, sh.id AS show_id, sh.title AS show_title,
|
||||
sh.starts_at_utc AS show_starts_at_utc, s.name AS section_name,
|
||||
u.title_snapshot, u.preparation_effort_snapshot
|
||||
FROM piece_usages u
|
||||
JOIN show_sections s ON s.id = u.show_section_id
|
||||
JOIN shows sh ON sh.id = s.show_id
|
||||
WHERE sh.status IN ('draft', 'ready', 'live')
|
||||
AND s.is_reserve_bank = 0 AND u.preparation_status_snapshot = 'preparing'
|
||||
ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc,
|
||||
CASE u.preparation_effort_snapshot
|
||||
WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END,
|
||||
s.position, u.position`
|
||||
)
|
||||
.all() as {
|
||||
usage_id: string;
|
||||
show_id: string;
|
||||
show_title: string;
|
||||
show_starts_at_utc: string | null;
|
||||
section_name: string;
|
||||
title_snapshot: string;
|
||||
preparation_effort_snapshot: PreparationUsage['preparationEffort'];
|
||||
}[];
|
||||
const topics = database.prepare(
|
||||
'SELECT name, icon_name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position'
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
usageId: row.usage_id,
|
||||
showId: row.show_id,
|
||||
showTitle: row.show_title,
|
||||
showStartsAtUtc: row.show_starts_at_utc,
|
||||
sectionName: row.section_name,
|
||||
title: row.title_snapshot,
|
||||
preparationEffort: row.preparation_effort_snapshot,
|
||||
topics: (topics.all(row.usage_id) as { name: string; icon_name: string }[]).map((topic) => ({
|
||||
name: topic.name,
|
||||
iconName: topic.icon_name
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
export function listShowTemplates(): ShowTemplate[] {
|
||||
const database = getDatabase();
|
||||
const templates = database
|
||||
.prepare('SELECT id, name, description FROM show_templates ORDER BY name')
|
||||
.all() as Omit<ShowTemplate, 'sections'>[];
|
||||
const sectionStatement = database.prepare(
|
||||
`SELECT id, name, category_id, position, explicit_duration_seconds
|
||||
`SELECT id, name, category_id, position, explicit_duration_seconds, twitch_tags
|
||||
FROM show_template_sections WHERE template_id = ? ORDER BY position`
|
||||
);
|
||||
return templates.map((template) => ({
|
||||
@@ -761,13 +862,15 @@ export function listShowTemplates(): ShowTemplate[] {
|
||||
category_id: string | null;
|
||||
position: number;
|
||||
explicit_duration_seconds: number | null;
|
||||
twitch_tags: string;
|
||||
}[]
|
||||
).map((section) => ({
|
||||
id: section.id,
|
||||
name: section.name,
|
||||
categoryId: section.category_id,
|
||||
position: section.position,
|
||||
explicitDurationSeconds: section.explicit_duration_seconds
|
||||
explicitDurationSeconds: section.explicit_duration_seconds,
|
||||
twitchTags: section.twitch_tags ? section.twitch_tags.split(',') : []
|
||||
}))
|
||||
}));
|
||||
}
|
||||
@@ -777,14 +880,15 @@ export function getShow(id: string): Show | null {
|
||||
const show = database
|
||||
.prepare(
|
||||
`SELECT id, title, starts_at_utc, twitch_title, announcement_text, status, revision,
|
||||
created_at_utc, updated_at_utc FROM shows WHERE id = ?`
|
||||
created_at_utc, updated_at_utc, completed_at_utc FROM shows WHERE id = ?`
|
||||
)
|
||||
.get(id) as ShowRow | undefined;
|
||||
if (!show) return null;
|
||||
const sectionRows = database
|
||||
.prepare(
|
||||
`SELECT s.id, s.name, s.category_id, c.name AS category, c.color AS category_color,
|
||||
s.position, s.explicit_duration_seconds, s.is_reserve_bank
|
||||
s.category_name_snapshot, s.category_color_snapshot,
|
||||
s.position, s.explicit_duration_seconds, s.twitch_tags, s.is_reserve_bank
|
||||
FROM show_sections s LEFT JOIN section_categories c ON c.id = s.category_id
|
||||
WHERE s.show_id = ? ORDER BY s.position`
|
||||
)
|
||||
@@ -792,9 +896,12 @@ export function getShow(id: string): Show | null {
|
||||
const usageRows = database
|
||||
.prepare(
|
||||
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
|
||||
u.preparation_status_snapshot, u.preparation_effort_snapshot,
|
||||
u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot,
|
||||
u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot,
|
||||
u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
|
||||
u.moderation_snapshot, u.interaction_snapshot, u.transition_snapshot, u.presentation_snapshot,
|
||||
CASE WHEN u.presentation_image_only_snapshot = 1 THEN 'image' ELSE u.presentation_mode_snapshot END AS presentation_mode_snapshot,
|
||||
u.presentation_image_only_snapshot,
|
||||
u.presentation_source_label_snapshot,
|
||||
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
|
||||
u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
|
||||
u.presentation_image_url_snapshot, u.presentation_site_name_snapshot,
|
||||
@@ -817,10 +924,17 @@ export function getShow(id: string): Show | null {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
categoryId: row.category_id,
|
||||
category: row.category,
|
||||
categoryColor: row.category_color,
|
||||
category:
|
||||
show.status === 'completed'
|
||||
? row.category_name_snapshot
|
||||
: (row.category_name_snapshot ?? row.category),
|
||||
categoryColor:
|
||||
show.status === 'completed'
|
||||
? row.category_color_snapshot
|
||||
: (row.category_color_snapshot ?? row.category_color),
|
||||
position: row.position,
|
||||
explicitDurationSeconds: row.explicit_duration_seconds,
|
||||
twitchTags: row.twitch_tags ? row.twitch_tags.split(',') : [],
|
||||
durationSeconds:
|
||||
row.explicit_duration_seconds ??
|
||||
usages.reduce((total, usage) => total + usage.durationSeconds, 0),
|
||||
@@ -838,6 +952,7 @@ export function getShow(id: string): Show | null {
|
||||
revision: show.revision,
|
||||
createdAtUtc: show.created_at_utc,
|
||||
updatedAtUtc: show.updated_at_utc,
|
||||
completedAtUtc: show.completed_at_utc,
|
||||
sections,
|
||||
reserve: reserveRow ? (usagesBySection.get(reserveRow.id) ?? []) : [],
|
||||
totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0)
|
||||
@@ -864,14 +979,9 @@ export function getPieceUsageContexts(
|
||||
const database = getDatabase();
|
||||
const history = database
|
||||
.prepare(
|
||||
`SELECT u.id AS usage_id, u.original_piece_id, sh.id AS show_id,
|
||||
sh.title AS show_title, COALESCE(sh.starts_at_utc, sh.updated_at_utc) AS used_at_utc
|
||||
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.original_piece_id IS NOT NULL AND u.status = 'done'
|
||||
AND sh.status = 'completed' AND s.is_reserve_bank = 0
|
||||
ORDER BY used_at_utc DESC, u.id DESC`
|
||||
`SELECT usage_id, original_piece_id, show_id,
|
||||
show_title_snapshot AS show_title, used_at_utc
|
||||
FROM piece_usage_history ORDER BY used_at_utc DESC, usage_id DESC`
|
||||
)
|
||||
.all() as {
|
||||
usage_id: string;
|
||||
@@ -921,7 +1031,7 @@ export function getPieceUsageContexts(
|
||||
export function createShow(input: CreateShowInput): CreatedMutationResult {
|
||||
const database = getDatabase();
|
||||
const id = randomUUID();
|
||||
const title = cleanText(input.title ?? 'Neue Sendung', 200, 'title', true);
|
||||
const title = cleanText(input.title ?? 'Neuer Stream', 200, 'title', true);
|
||||
const startsAtUtc = validTimestamp(input.startsAtUtc);
|
||||
const now = new Date().toISOString();
|
||||
const create = database.transaction(() => {
|
||||
@@ -941,7 +1051,7 @@ export function createShow(input: CreateShowInput): CreatedMutationResult {
|
||||
const templateSections = input.templateId
|
||||
? (database
|
||||
.prepare(
|
||||
`SELECT name, category_id, position, explicit_duration_seconds
|
||||
`SELECT name, category_id, position, explicit_duration_seconds, twitch_tags
|
||||
FROM show_template_sections WHERE template_id = ? ORDER BY position`
|
||||
)
|
||||
.all(input.templateId) as {
|
||||
@@ -949,11 +1059,12 @@ export function createShow(input: CreateShowInput): CreatedMutationResult {
|
||||
category_id: string | null;
|
||||
position: number;
|
||||
explicit_duration_seconds: number | null;
|
||||
twitch_tags: string;
|
||||
}[])
|
||||
: [];
|
||||
const insertSection = database.prepare(
|
||||
`INSERT INTO show_sections (id, show_id, name, category_id, position,
|
||||
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
explicit_duration_seconds, twitch_tags, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
for (const section of templateSections) {
|
||||
insertSection.run(
|
||||
@@ -963,15 +1074,154 @@ export function createShow(input: CreateShowInput): CreatedMutationResult {
|
||||
section.category_id,
|
||||
section.position,
|
||||
section.explicit_duration_seconds,
|
||||
section.twitch_tags,
|
||||
0
|
||||
);
|
||||
}
|
||||
insertSection.run(randomUUID(), id, 'Reservebank', null, templateSections.length, null, 1);
|
||||
insertSection.run(randomUUID(), id, 'Reservebank', null, templateSections.length, null, '', 1);
|
||||
});
|
||||
create();
|
||||
return { id, revision: 1, updatedAtUtc: now };
|
||||
}
|
||||
|
||||
export function duplicateCompletedShow(
|
||||
sourceShowId: string,
|
||||
commandId: string
|
||||
): CreatedMutationResult {
|
||||
if (!commandId || commandId.length > 200) throw new Error('INVALID_COMMAND');
|
||||
const database = getDatabase();
|
||||
return database.transaction(() => {
|
||||
const duplicate = database
|
||||
.prepare(
|
||||
`SELECT created_show_id FROM show_duplication_commands
|
||||
WHERE source_show_id = ? AND command_id = ?`
|
||||
)
|
||||
.get(sourceShowId, commandId) as { created_show_id: string } | undefined;
|
||||
if (duplicate) {
|
||||
const show = database
|
||||
.prepare('SELECT revision, updated_at_utc FROM shows WHERE id = ?')
|
||||
.get(duplicate.created_show_id) as { revision: number; updated_at_utc: string };
|
||||
return {
|
||||
id: duplicate.created_show_id,
|
||||
revision: show.revision,
|
||||
updatedAtUtc: show.updated_at_utc
|
||||
};
|
||||
}
|
||||
const source = database
|
||||
.prepare(
|
||||
`SELECT sh.title, sh.twitch_title, sh.announcement_text
|
||||
FROM shows sh JOIN show_finalizations f ON f.show_id = sh.id WHERE sh.id = ?`
|
||||
)
|
||||
.get(sourceShowId) as
|
||||
{ title: string; twitch_title: string; announcement_text: string } | undefined;
|
||||
if (!source) throw new Error('NOT_FOUND');
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const title = `${source.title} (Kopie)`.slice(0, 200);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text,
|
||||
status, revision, created_at_utc, updated_at_utc)
|
||||
VALUES (?, ?, NULL, ?, ?, 'draft', 1, ?, ?)`
|
||||
)
|
||||
.run(id, title, source.twitch_title, source.announcement_text, now, now);
|
||||
|
||||
const sections = database
|
||||
.prepare(
|
||||
`SELECT id, name, category_id, category_name_snapshot, category_color_snapshot,
|
||||
position, explicit_duration_seconds, twitch_tags, is_reserve_bank
|
||||
FROM show_sections WHERE show_id = ? ORDER BY position`
|
||||
)
|
||||
.all(sourceShowId) as {
|
||||
id: string;
|
||||
name: string;
|
||||
category_id: string | null;
|
||||
category_name_snapshot: string | null;
|
||||
category_color_snapshot: string | null;
|
||||
position: number;
|
||||
explicit_duration_seconds: number | null;
|
||||
twitch_tags: string;
|
||||
is_reserve_bank: number;
|
||||
}[];
|
||||
const insertSection = database.prepare(
|
||||
`INSERT INTO show_sections (id, show_id, name, category_id, category_name_snapshot,
|
||||
category_color_snapshot, position, explicit_duration_seconds, twitch_tags, is_reserve_bank)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const usages = database.prepare(
|
||||
'SELECT * FROM piece_usages WHERE show_section_id = ? ORDER BY position'
|
||||
);
|
||||
const insertUsage = database.prepare(
|
||||
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
|
||||
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
|
||||
moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
|
||||
presentation_mode_snapshot, presentation_source_label_snapshot,
|
||||
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
|
||||
presentation_title_meta_snapshot, presentation_description_snapshot,
|
||||
presentation_image_url_snapshot, presentation_site_name_snapshot,
|
||||
presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_image_only_snapshot,
|
||||
preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
|
||||
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
for (const section of sections) {
|
||||
const sectionId = randomUUID();
|
||||
insertSection.run(
|
||||
sectionId,
|
||||
id,
|
||||
section.name,
|
||||
section.category_id,
|
||||
section.category_name_snapshot,
|
||||
section.category_color_snapshot,
|
||||
section.position,
|
||||
section.explicit_duration_seconds,
|
||||
section.twitch_tags,
|
||||
section.is_reserve_bank
|
||||
);
|
||||
for (const usage of usages.all(section.id) as (UsageRow & { is_reserve: number })[]) {
|
||||
const usageId = randomUUID();
|
||||
insertUsage.run(
|
||||
usageId,
|
||||
sectionId,
|
||||
usage.original_piece_id,
|
||||
usage.position,
|
||||
usage.duration_seconds,
|
||||
usage.title_snapshot,
|
||||
usage.output_title_snapshot,
|
||||
usage.intro_snapshot,
|
||||
usage.research_snapshot,
|
||||
usage.moderation_snapshot,
|
||||
usage.interaction_snapshot,
|
||||
usage.transition_snapshot,
|
||||
usage.presentation_snapshot,
|
||||
usage.presentation_mode_snapshot,
|
||||
usage.presentation_source_label_snapshot,
|
||||
usage.presentation_source_url_snapshot,
|
||||
usage.presentation_source_timecode_snapshot,
|
||||
usage.presentation_title_meta_snapshot,
|
||||
usage.presentation_description_snapshot,
|
||||
usage.presentation_image_url_snapshot,
|
||||
usage.presentation_site_name_snapshot,
|
||||
usage.presentation_provider_snapshot,
|
||||
usage.presentation_provider_id_snapshot,
|
||||
usage.presentation_image_only_snapshot,
|
||||
usage.preparation_status_snapshot,
|
||||
usage.preparation_effort_snapshot,
|
||||
usage.own_notes,
|
||||
usage.is_reserve
|
||||
);
|
||||
copyUsageRelations(database, usage.id, usageId);
|
||||
}
|
||||
}
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO show_duplication_commands
|
||||
(source_show_id, command_id, created_show_id, created_at_utc) VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run(sourceShowId, commandId, id, now);
|
||||
return { id, revision: 1, updatedAtUtc: now };
|
||||
})();
|
||||
}
|
||||
|
||||
export function saveShowMetadata(
|
||||
showId: string,
|
||||
expectedRevision: number,
|
||||
@@ -1002,6 +1252,7 @@ export function addShowSection(
|
||||
): CreatedMutationResult {
|
||||
const name = cleanText(input.name, 200, 'section_name', true);
|
||||
const duration = validDuration(input.explicitDurationSeconds);
|
||||
const twitchTags = validTwitchTags(input.twitchTags ?? []);
|
||||
const database = getDatabase();
|
||||
requireCategory(database, input.categoryId);
|
||||
return database.transaction(() => {
|
||||
@@ -1026,9 +1277,9 @@ export function addShowSection(
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO show_sections (id, show_id, name, category_id, position,
|
||||
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, 0)`
|
||||
explicit_duration_seconds, twitch_tags, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?, 0)`
|
||||
)
|
||||
.run(id, showId, name, input.categoryId, temporaryPosition, duration);
|
||||
.run(id, showId, name, input.categoryId, temporaryPosition, duration, twitchTags.join(','));
|
||||
visibleIds.splice(position, 0, id);
|
||||
const reserveId = reserveSectionId(database, showId);
|
||||
normalizePositions(database, 'show_sections', [...visibleIds, reserveId]);
|
||||
@@ -1044,6 +1295,7 @@ export function updateShowSection(
|
||||
): MutationResult {
|
||||
const name = cleanText(input.name, 200, 'section_name', true);
|
||||
const duration = validDuration(input.explicitDurationSeconds);
|
||||
const twitchTags = validTwitchTags(input.twitchTags ?? []);
|
||||
const database = getDatabase();
|
||||
requireCategory(database, input.categoryId);
|
||||
return database.transaction(() => {
|
||||
@@ -1051,10 +1303,11 @@ export function updateShowSection(
|
||||
sectionBelongsToShow(database, showId, sectionId);
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ?
|
||||
`UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ?, twitch_tags = ?,
|
||||
category_name_snapshot = NULL, category_color_snapshot = NULL
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(name, input.categoryId, duration, sectionId);
|
||||
.run(name, input.categoryId, duration, twitchTags.join(','), sectionId);
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
@@ -1126,12 +1379,14 @@ export function addPieceUsage(
|
||||
const piece = database
|
||||
.prepare(
|
||||
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes,
|
||||
p.transition_text, p.presentation_text, p.status, p.presentation_mode,
|
||||
p.interaction_text, p.transition_text, p.presentation_text, p.status,
|
||||
CASE WHEN p.presentation_image_only = 1 THEN 'image' ELSE p.presentation_mode END AS presentation_mode,
|
||||
COALESCE(ps.label, '') AS presentation_source_label,
|
||||
COALESCE(ps.url, '') AS presentation_source_url,
|
||||
ps.timecode_seconds AS presentation_source_timecode,
|
||||
p.presentation_title, p.presentation_description, p.presentation_image_url,
|
||||
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id
|
||||
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id,
|
||||
p.preparation_effort
|
||||
FROM pieces p LEFT JOIN piece_sources ps ON ps.piece_id = p.id
|
||||
AND ps.position = p.presentation_source_position WHERE p.id = ?`
|
||||
)
|
||||
@@ -1143,6 +1398,7 @@ export function addPieceUsage(
|
||||
intro: string;
|
||||
research_text: string;
|
||||
moderation_notes: string;
|
||||
interaction_text: string;
|
||||
transition_text: string;
|
||||
presentation_text: string;
|
||||
presentation_mode: PresentationMode;
|
||||
@@ -1156,6 +1412,7 @@ export function addPieceUsage(
|
||||
presentation_provider: string;
|
||||
presentation_provider_id: string;
|
||||
status: string;
|
||||
preparation_effort: PreparationEffort;
|
||||
}
|
||||
| undefined;
|
||||
if (!piece) throw new Error('NOT_FOUND');
|
||||
@@ -1166,7 +1423,7 @@ export function addPieceUsage(
|
||||
) {
|
||||
throw new Error('NOT_FOUND');
|
||||
}
|
||||
if (piece.status !== 'ready') throw new Error('PIECE_NOT_READY');
|
||||
if (!['preparing', 'ready'].includes(piece.status)) throw new Error('PIECE_NOT_READY');
|
||||
const ids = (
|
||||
database
|
||||
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
|
||||
@@ -1178,13 +1435,14 @@ export function addPieceUsage(
|
||||
.prepare(
|
||||
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
|
||||
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
|
||||
moderation_snapshot, transition_snapshot, presentation_snapshot,
|
||||
presentation_mode_snapshot, presentation_source_label_snapshot,
|
||||
moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
|
||||
presentation_mode_snapshot, presentation_image_only_snapshot, presentation_source_label_snapshot,
|
||||
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
|
||||
presentation_title_meta_snapshot, presentation_description_snapshot,
|
||||
presentation_image_url_snapshot, presentation_site_name_snapshot,
|
||||
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
|
||||
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
|
||||
presentation_provider_snapshot, presentation_provider_id_snapshot,
|
||||
preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
|
||||
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
@@ -1197,9 +1455,11 @@ export function addPieceUsage(
|
||||
piece.intro,
|
||||
piece.research_text,
|
||||
piece.moderation_notes,
|
||||
piece.interaction_text,
|
||||
piece.transition_text,
|
||||
piece.presentation_text,
|
||||
piece.presentation_mode,
|
||||
piece.presentation_mode === 'image' ? 'text' : piece.presentation_mode,
|
||||
piece.presentation_mode === 'image' ? 1 : 0,
|
||||
piece.presentation_source_label,
|
||||
piece.presentation_source_url,
|
||||
piece.presentation_source_timecode,
|
||||
@@ -1209,6 +1469,8 @@ export function addPieceUsage(
|
||||
piece.presentation_site_name,
|
||||
piece.presentation_provider,
|
||||
piece.presentation_provider_id,
|
||||
piece.status,
|
||||
piece.preparation_effort,
|
||||
input.sectionId === null ? 1 : 0
|
||||
);
|
||||
snapshotPieceRelations(database, id, input.pieceId);
|
||||
@@ -1284,12 +1546,13 @@ export function duplicatePieceUsage(
|
||||
const source = database
|
||||
.prepare(
|
||||
`SELECT original_piece_id, status, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot,
|
||||
research_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot,
|
||||
presentation_mode_snapshot, presentation_source_label_snapshot,
|
||||
research_snapshot, moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
|
||||
presentation_mode_snapshot, presentation_image_only_snapshot, presentation_source_label_snapshot,
|
||||
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
|
||||
presentation_title_meta_snapshot, presentation_description_snapshot,
|
||||
presentation_image_url_snapshot, presentation_site_name_snapshot,
|
||||
presentation_provider_snapshot, presentation_provider_id_snapshot,
|
||||
preparation_status_snapshot, preparation_effort_snapshot,
|
||||
own_notes, is_reserve FROM piece_usages WHERE id = ?`
|
||||
)
|
||||
.get(usageId) as Omit<UsageRow, 'id' | 'show_section_id' | 'position'> & {
|
||||
@@ -1307,13 +1570,14 @@ export function duplicatePieceUsage(
|
||||
.prepare(
|
||||
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
|
||||
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
|
||||
moderation_snapshot, transition_snapshot, presentation_snapshot,
|
||||
presentation_mode_snapshot, presentation_source_label_snapshot,
|
||||
moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
|
||||
presentation_mode_snapshot, presentation_image_only_snapshot, presentation_source_label_snapshot,
|
||||
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
|
||||
presentation_title_meta_snapshot, presentation_description_snapshot,
|
||||
presentation_image_url_snapshot, presentation_site_name_snapshot,
|
||||
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
|
||||
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
presentation_provider_snapshot, presentation_provider_id_snapshot,
|
||||
preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
|
||||
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
@@ -1326,9 +1590,11 @@ export function duplicatePieceUsage(
|
||||
source.intro_snapshot,
|
||||
source.research_snapshot,
|
||||
source.moderation_snapshot,
|
||||
source.interaction_snapshot,
|
||||
source.transition_snapshot,
|
||||
source.presentation_snapshot,
|
||||
source.presentation_mode_snapshot,
|
||||
source.presentation_image_only_snapshot,
|
||||
source.presentation_source_label_snapshot,
|
||||
source.presentation_source_url_snapshot,
|
||||
source.presentation_source_timecode_snapshot,
|
||||
@@ -1338,6 +1604,8 @@ export function duplicatePieceUsage(
|
||||
source.presentation_site_name_snapshot,
|
||||
source.presentation_provider_snapshot,
|
||||
source.presentation_provider_id_snapshot,
|
||||
source.preparation_status_snapshot,
|
||||
source.preparation_effort_snapshot,
|
||||
source.own_notes,
|
||||
source.is_reserve
|
||||
);
|
||||
@@ -1360,12 +1628,14 @@ export function refreshPieceUsageFromOriginal(
|
||||
const source = database
|
||||
.prepare(
|
||||
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text,
|
||||
p.moderation_notes, p.transition_text, p.presentation_text, p.presentation_mode,
|
||||
p.moderation_notes, p.interaction_text, p.transition_text, p.presentation_text,
|
||||
CASE WHEN p.presentation_image_only = 1 THEN 'image' ELSE p.presentation_mode END AS presentation_mode,
|
||||
COALESCE(ps.label, '') AS presentation_source_label,
|
||||
COALESCE(ps.url, '') AS presentation_source_url,
|
||||
ps.timecode_seconds AS presentation_source_timecode,
|
||||
p.presentation_title, p.presentation_description, p.presentation_image_url,
|
||||
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id
|
||||
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id,
|
||||
p.status, p.preparation_effort
|
||||
FROM piece_usages u JOIN pieces p ON p.id = u.original_piece_id
|
||||
LEFT JOIN piece_sources ps ON ps.piece_id = p.id
|
||||
AND ps.position = p.presentation_source_position
|
||||
@@ -1379,6 +1649,7 @@ export function refreshPieceUsageFromOriginal(
|
||||
intro: string;
|
||||
research_text: string;
|
||||
moderation_notes: string;
|
||||
interaction_text: string;
|
||||
transition_text: string;
|
||||
presentation_text: string;
|
||||
presentation_mode: PresentationMode;
|
||||
@@ -1391,19 +1662,23 @@ export function refreshPieceUsageFromOriginal(
|
||||
presentation_site_name: string;
|
||||
presentation_provider: string;
|
||||
presentation_provider_id: string;
|
||||
status: string;
|
||||
preparation_effort: PreparationEffort;
|
||||
}
|
||||
| undefined;
|
||||
if (!source) throw new Error('ORIGINAL_NOT_FOUND');
|
||||
if (!['preparing', 'ready'].includes(source.status)) throw new Error('PIECE_NOT_READY');
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
|
||||
research_snapshot = ?, moderation_snapshot = ?, transition_snapshot = ?,
|
||||
presentation_snapshot = ?, presentation_mode_snapshot = ?,
|
||||
research_snapshot = ?, moderation_snapshot = ?, interaction_snapshot = ?, transition_snapshot = ?,
|
||||
presentation_snapshot = ?, presentation_mode_snapshot = ?, presentation_image_only_snapshot = ?,
|
||||
presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?,
|
||||
presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
|
||||
presentation_description_snapshot = ?, presentation_image_url_snapshot = ?,
|
||||
presentation_site_name_snapshot = ?, presentation_provider_snapshot = ?,
|
||||
presentation_provider_id_snapshot = ? WHERE id = ?`
|
||||
presentation_provider_id_snapshot = ?, preparation_status_snapshot = ?,
|
||||
preparation_effort_snapshot = ? WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
source.title,
|
||||
@@ -1412,9 +1687,11 @@ export function refreshPieceUsageFromOriginal(
|
||||
source.intro,
|
||||
source.research_text,
|
||||
source.moderation_notes,
|
||||
source.interaction_text,
|
||||
source.transition_text,
|
||||
source.presentation_text,
|
||||
source.presentation_mode,
|
||||
source.presentation_mode === 'image' ? 'text' : source.presentation_mode,
|
||||
source.presentation_mode === 'image' ? 1 : 0,
|
||||
source.presentation_source_label,
|
||||
source.presentation_source_url,
|
||||
source.presentation_source_timecode,
|
||||
@@ -1424,6 +1701,8 @@ export function refreshPieceUsageFromOriginal(
|
||||
source.presentation_site_name,
|
||||
source.presentation_provider,
|
||||
source.presentation_provider_id,
|
||||
source.status,
|
||||
source.preparation_effort,
|
||||
usageId
|
||||
);
|
||||
snapshotPieceRelations(
|
||||
@@ -1448,15 +1727,29 @@ export function updatePieceUsage(
|
||||
const title = cleanText(input.title, 200, 'title', true);
|
||||
const outputTitle = cleanText(input.outputTitle, 300, 'output_title');
|
||||
const duration = validDuration(input.durationSeconds)!;
|
||||
if (
|
||||
input.preparationStatus !== undefined &&
|
||||
!['preparing', 'ready'].includes(input.preparationStatus)
|
||||
) {
|
||||
throw new Error('INVALID_PREPARATION_STATUS');
|
||||
}
|
||||
if (
|
||||
input.preparationEffort !== undefined &&
|
||||
![null, 'low', 'medium', 'high'].includes(input.preparationEffort)
|
||||
) {
|
||||
throw new Error('INVALID_PREPARATION_EFFORT');
|
||||
}
|
||||
const values = {
|
||||
intro: cleanText(input.intro, 50_000, 'intro'),
|
||||
research: cleanText(input.research, 50_000, 'research'),
|
||||
moderation: cleanText(input.moderation, 50_000, 'moderation'),
|
||||
interaction:
|
||||
input.interaction === undefined ? null : cleanText(input.interaction, 50_000, 'interaction'),
|
||||
transition: cleanText(input.transition, 50_000, 'transition'),
|
||||
presentation: cleanText(input.presentation, 50_000, 'presentation'),
|
||||
ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
|
||||
};
|
||||
const presentation = validPresentation(
|
||||
const submittedPresentation = validPresentation(
|
||||
input.presentationMode,
|
||||
input.presentationSourceLabel,
|
||||
input.presentationSourceUrl,
|
||||
@@ -1470,16 +1763,68 @@ export function updatePieceUsage(
|
||||
return database.transaction(() => {
|
||||
const result = bumpShow(database, showId, expectedRevision);
|
||||
usageSection(database, showId, usageId);
|
||||
const currentPreparation = database
|
||||
.prepare(
|
||||
`SELECT preparation_status_snapshot, preparation_effort_snapshot
|
||||
FROM piece_usages WHERE id = ?`
|
||||
)
|
||||
.get(usageId) as {
|
||||
preparation_status_snapshot: UsagePreparationStatus;
|
||||
preparation_effort_snapshot: PreparationEffort;
|
||||
};
|
||||
let presentation = submittedPresentation;
|
||||
if (presentation.mode === 'link' || presentation.mode === 'video') {
|
||||
const source = database
|
||||
.prepare(
|
||||
`SELECT label, url, timecode_seconds FROM usage_source_snapshots
|
||||
WHERE usage_id = ? AND url = ?`
|
||||
)
|
||||
.get(usageId, presentation.sourceUrl) as
|
||||
{ label: string; url: string; timecode_seconds: number | null } | undefined;
|
||||
if (!source) throw new Error('INVALID_PRESENTATION');
|
||||
presentation = validPresentation(
|
||||
presentation.mode,
|
||||
source.label,
|
||||
source.url,
|
||||
source.timecode_seconds,
|
||||
presentation.title,
|
||||
presentation.description,
|
||||
presentation.imageUrl,
|
||||
presentation.siteName
|
||||
);
|
||||
}
|
||||
const selectedMediaId =
|
||||
input.presentationMediaId === undefined
|
||||
? ((
|
||||
database
|
||||
.prepare(
|
||||
'SELECT id FROM usage_media_snapshots WHERE usage_id = ? AND is_presentation = 1'
|
||||
)
|
||||
.get(usageId) as { id: string } | undefined
|
||||
)?.id ?? null)
|
||||
: input.presentationMediaId;
|
||||
if (
|
||||
selectedMediaId !== null &&
|
||||
!database
|
||||
.prepare('SELECT 1 FROM usage_media_snapshots WHERE id = ? AND usage_id = ?')
|
||||
.get(selectedMediaId, usageId)
|
||||
) {
|
||||
throw new Error('INVALID_PRESENTATION_MEDIA');
|
||||
}
|
||||
if (presentation.mode === 'image' && !selectedMediaId) {
|
||||
throw new Error('INVALID_PRESENTATION_MEDIA');
|
||||
}
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
|
||||
moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?,
|
||||
presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?,
|
||||
moderation_snapshot = ?, interaction_snapshot = COALESCE(?, interaction_snapshot), transition_snapshot = ?, presentation_snapshot = ?,
|
||||
presentation_mode_snapshot = ?, presentation_image_only_snapshot = ?, presentation_source_label_snapshot = ?,
|
||||
presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?,
|
||||
presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?,
|
||||
presentation_image_url_snapshot = ?, presentation_site_name_snapshot = ?,
|
||||
presentation_provider_snapshot = ?, presentation_provider_id_snapshot = ?,
|
||||
own_notes = ?, duration_seconds = ? WHERE id = ?`
|
||||
own_notes = ?, duration_seconds = ?, preparation_status_snapshot = ?,
|
||||
preparation_effort_snapshot = ? WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
title,
|
||||
@@ -1487,9 +1832,11 @@ export function updatePieceUsage(
|
||||
values.intro,
|
||||
values.research,
|
||||
values.moderation,
|
||||
values.interaction,
|
||||
values.transition,
|
||||
values.presentation,
|
||||
presentation.mode,
|
||||
presentation.mode === 'image' ? 'text' : presentation.mode,
|
||||
presentation.mode === 'image' ? 1 : 0,
|
||||
presentation.sourceLabel,
|
||||
presentation.sourceUrl,
|
||||
presentation.timecodeSeconds,
|
||||
@@ -1501,6 +1848,10 @@ export function updatePieceUsage(
|
||||
presentation.providerId,
|
||||
values.ownNotes,
|
||||
duration,
|
||||
input.preparationStatus ?? currentPreparation.preparation_status_snapshot,
|
||||
input.preparationEffort === undefined
|
||||
? currentPreparation.preparation_effort_snapshot
|
||||
: input.preparationEffort,
|
||||
usageId
|
||||
);
|
||||
if (input.tools) {
|
||||
@@ -1533,29 +1884,58 @@ export function updatePieceUsage(
|
||||
}
|
||||
}
|
||||
if (input.presentationMediaId !== undefined) {
|
||||
if (
|
||||
input.presentationMediaId !== null &&
|
||||
!database
|
||||
.prepare('SELECT 1 FROM usage_media_snapshots WHERE id = ? AND usage_id = ?')
|
||||
.get(input.presentationMediaId, usageId)
|
||||
) {
|
||||
throw new Error('INVALID_PRESENTATION_MEDIA');
|
||||
}
|
||||
database
|
||||
.prepare('UPDATE usage_media_snapshots SET is_presentation = 0 WHERE usage_id = ?')
|
||||
.run(usageId);
|
||||
if (input.presentationMediaId !== null) {
|
||||
if (selectedMediaId !== null) {
|
||||
database
|
||||
.prepare(
|
||||
'UPDATE usage_media_snapshots SET is_presentation = 1 WHERE id = ? AND usage_id = ?'
|
||||
)
|
||||
.run(input.presentationMediaId, usageId);
|
||||
.run(selectedMediaId, usageId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
|
||||
export function updatePieceUsageMetadata(
|
||||
showId: string,
|
||||
usageId: string,
|
||||
expectedRevision: number,
|
||||
input: PieceUsageMetadataUpdate
|
||||
): MutationResult {
|
||||
const show = getShow(showId);
|
||||
const usage = [
|
||||
...(show?.sections.flatMap((section) => section.usages) ?? []),
|
||||
...(show?.reserve ?? [])
|
||||
].find((item) => item.id === usageId);
|
||||
if (!usage) throw new Error('NOT_FOUND');
|
||||
return updatePieceUsage(showId, usageId, expectedRevision, {
|
||||
...input,
|
||||
intro: usage.intro,
|
||||
research: usage.research,
|
||||
moderation: usage.moderation,
|
||||
interaction: usage.interaction,
|
||||
transition: usage.transition,
|
||||
presentation: usage.presentation,
|
||||
presentationMode: usage.presentationMode,
|
||||
presentationSourceLabel: usage.presentationSourceLabel,
|
||||
presentationSourceUrl: usage.presentationSourceUrl,
|
||||
presentationSourceTimecodeSeconds: usage.presentationSourceTimecodeSeconds,
|
||||
presentationTitle: usage.presentationTitle,
|
||||
presentationDescription: usage.presentationDescription,
|
||||
presentationImageUrl: usage.presentationImageUrl,
|
||||
presentationSiteName: usage.presentationSiteName,
|
||||
presentationMediaId: usage.presentationMediaId,
|
||||
tools: usage.tools.map((tool) => ({
|
||||
id: tool.id,
|
||||
workUrl: tool.workUrl,
|
||||
note: tool.note
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
export function removePieceUsage(
|
||||
showId: string,
|
||||
usageId: string,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = ({ locals, url }) => ({
|
||||
user: url.pathname.startsWith('/heinzlive/') ? null : locals.user,
|
||||
user:
|
||||
url.pathname.startsWith('/heinzlive/') || url.pathname.startsWith('/freigabe/')
|
||||
? null
|
||||
: locals.user,
|
||||
presentationMode: url.pathname.startsWith('/heinzlive/'),
|
||||
liveControllerMode: url.pathname === '/live'
|
||||
guestMode: url.pathname.startsWith('/freigabe/'),
|
||||
oauthMode: url.pathname === '/oauth/authorize'
|
||||
});
|
||||
+58
-44
@@ -5,22 +5,22 @@
|
||||
import NavIcon from '#lib/components/NavIcon.svelte';
|
||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
|
||||
import UserMenu from '#lib/components/UserMenu.svelte';
|
||||
|
||||
let { children, data } = $props();
|
||||
const primaryNavigation = [
|
||||
{ href: '/', label: 'Start', icon: 'house' },
|
||||
{ href: '/eingang', label: 'Eingang', icon: 'inbox' },
|
||||
{ href: '/fundus', label: 'Fundus', icon: 'library' },
|
||||
{ href: '/sendungen', label: 'Sendungen', icon: 'shows' },
|
||||
{ href: '/archiv', label: 'Archiv', icon: 'archive' }
|
||||
{ href: '/fundus', label: 'Pieces', icon: 'library' },
|
||||
{ href: '/sendungen', label: 'Streams', icon: 'shows' },
|
||||
{ href: '/live', label: 'Live', icon: 'tower-broadcast' },
|
||||
{ href: '/post', label: 'Post', icon: 'clapperboard' }
|
||||
] as const;
|
||||
const administration = { href: '/verwaltung', label: 'Verwaltung', icon: 'settings' } as const;
|
||||
const mobileNavigation = $derived(
|
||||
data.user?.role === 'barkeeper' ? [...primaryNavigation, administration] : primaryNavigation
|
||||
);
|
||||
|
||||
function isActive(href: string) {
|
||||
if (href === '/') return page.url.pathname === '/';
|
||||
return (
|
||||
page.url.pathname.startsWith(href) || (href === '/sendungen' && page.url.pathname === '/live')
|
||||
page.url.pathname.startsWith(href) || (href === '/fundus' && page.url.pathname === '/archiv')
|
||||
);
|
||||
}
|
||||
</script>
|
||||
@@ -28,20 +28,31 @@
|
||||
<svelte:head>
|
||||
<meta
|
||||
name="description"
|
||||
content="Sendewerk organisiert redaktionelle Inhalte und heinzlive-Sendungen."
|
||||
content="Sendewerk organisiert redaktionelle Pieces, heinzlive-Streams und Postproduktion."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
{#if data.presentationMode}
|
||||
{@render children()}
|
||||
{:else if data.user && data.liveControllerMode}
|
||||
<main id="main-content" class="live-controller-shell">{@render children()}</main>
|
||||
{:else if data.guestMode || data.oauthMode}
|
||||
<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 class="brand-name">Sendewerk</span>
|
||||
<small class="brand-version">0.9.0</small>
|
||||
</span>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
{@render children()}
|
||||
</div>
|
||||
{:else if data.user}
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
|
||||
<header class="app-header">
|
||||
<a class="brand" href={resolve('/')} aria-label="Sendewerk Startseite">
|
||||
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
<span>Sendewerk</span>
|
||||
<span class="brand-name">Sendewerk</span>
|
||||
<small class="brand-version">0.9.0</small>
|
||||
</a>
|
||||
|
||||
<nav class="main-navigation" aria-label="Hauptnavigation">
|
||||
@@ -50,6 +61,7 @@
|
||||
href={resolve(item.href)}
|
||||
class:active={isActive(item.href)}
|
||||
aria-current={isActive(item.href) ? 'page' : undefined}
|
||||
aria-label={item.label}
|
||||
>
|
||||
<NavIcon name={item.icon} />
|
||||
<span>{item.label}</span>
|
||||
@@ -57,47 +69,48 @@
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
{#if data.user.role === 'barkeeper'}
|
||||
<nav class="utility-navigation" aria-label="Verwaltung">
|
||||
<a
|
||||
href={resolve(administration.href)}
|
||||
class:active={isActive(administration.href)}
|
||||
aria-current={isActive(administration.href) ? 'page' : undefined}
|
||||
>
|
||||
<NavIcon name={administration.icon} />
|
||||
<span>{administration.label}</span>
|
||||
</a>
|
||||
</nav>
|
||||
{/if}
|
||||
<div class="account-summary">
|
||||
<span>{data.user.displayName}</span>
|
||||
<small>{data.user.role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'}</small>
|
||||
</div>
|
||||
<form method="POST" action="/logout">
|
||||
<button class="logout-button" type="submit">Abmelden</button>
|
||||
<div class="app-header-actions">
|
||||
<form class="global-search-form" method="GET" action={resolve('/suche')} role="search">
|
||||
<label>
|
||||
<AppIcon name="magnifying-glass" />
|
||||
<span class="sr-only">Pieces und Streams durchsuchen</span>
|
||||
<input
|
||||
name="q"
|
||||
type="search"
|
||||
value={page.url.pathname === '/suche' ? (page.url.searchParams.get('q') ?? '') : ''}
|
||||
placeholder="Pieces und Streams suchen"
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
<ThemeToggle />
|
||||
<UserMenu displayName={data.user.displayName} role={data.user.role} />
|
||||
</div>
|
||||
</aside>
|
||||
</header>
|
||||
|
||||
<header class="mobile-header">
|
||||
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
|
||||
<a class="brand" href={resolve('/')} aria-label="Sendewerk Startseite">
|
||||
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
<span>Sendewerk</span>
|
||||
<span class="brand-name">Sendewerk</span>
|
||||
<small class="brand-version">0.9.0</small>
|
||||
</a>
|
||||
<form method="POST" action="/logout">
|
||||
<button class="mobile-logout" type="submit" aria-label="Abmelden"
|
||||
><AppIcon name="right-from-bracket" /></button
|
||||
>
|
||||
<form
|
||||
class="global-search-form mobile-global-search"
|
||||
method="GET"
|
||||
action={resolve('/suche')}
|
||||
role="search"
|
||||
>
|
||||
<label>
|
||||
<AppIcon name="magnifying-glass" />
|
||||
<span class="sr-only">Pieces und Streams durchsuchen</span>
|
||||
<input name="q" type="search" placeholder="Suchen" />
|
||||
</label>
|
||||
</form>
|
||||
<ThemeToggle />
|
||||
<UserMenu displayName={data.user.displayName} role={data.user.role} />
|
||||
</header>
|
||||
|
||||
<main id="main-content">{@render children()}</main>
|
||||
|
||||
<nav class="mobile-navigation" aria-label="Hauptnavigation">
|
||||
{#each mobileNavigation as item (item.href)}
|
||||
{#each primaryNavigation as item (item.href)}
|
||||
<a
|
||||
href={resolve(item.href)}
|
||||
class:active={isActive(item.href)}
|
||||
@@ -114,7 +127,8 @@
|
||||
<header class="auth-header">
|
||||
<a class="brand" href={resolve('/login')} aria-label="Sendewerk Anmeldung">
|
||||
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
<span>Sendewerk</span>
|
||||
<span class="brand-name">Sendewerk</span>
|
||||
<small class="brand-version">0.9.0</small>
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
@@ -1,7 +1,38 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { hasUsers } from '#lib/server/auth.js';
|
||||
import { listPieces } from '#lib/server/content.js';
|
||||
import { getPieceUsageContexts, getShow, listShows } from '#lib/server/shows.js';
|
||||
|
||||
export function load({ locals }) {
|
||||
if (!hasUsers()) redirect(307, '/setup');
|
||||
redirect(307, locals.user ? '/fundus' : '/login');
|
||||
if (!locals.user) redirect(307, '/login');
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const activeShows = listShows(['draft', 'ready', 'live']);
|
||||
const orderedShows = [...activeShows].sort((left, right) => {
|
||||
if (left.status === 'live' && right.status !== 'live') return -1;
|
||||
if (right.status === 'live' && left.status !== 'live') return 1;
|
||||
if (left.startsAtUtc && right.startsAtUtc) {
|
||||
const leftPast = left.startsAtUtc < now;
|
||||
const rightPast = right.startsAtUtc < now;
|
||||
if (leftPast !== rightPast) return leftPast ? 1 : -1;
|
||||
return leftPast
|
||||
? right.startsAtUtc.localeCompare(left.startsAtUtc)
|
||||
: left.startsAtUtc.localeCompare(right.startsAtUtc);
|
||||
}
|
||||
return left.startsAtUtc ? -1 : right.startsAtUtc ? 1 : 0;
|
||||
});
|
||||
const nextShowSummary = orderedShows[0] ?? null;
|
||||
const nextShow = nextShowSummary ? getShow(nextShowSummary.id) : null;
|
||||
const pieceCandidates = listPieces({ statuses: ['preparing', 'ready'] });
|
||||
const contexts = getPieceUsageContexts(pieceCandidates.map((piece) => piece.id));
|
||||
const currentPieces = pieceCandidates
|
||||
.filter((piece) => !(contexts[piece.id]?.sentHistory.length > 0) || piece.isFavorite)
|
||||
.slice(0, 6);
|
||||
|
||||
return {
|
||||
nextShow,
|
||||
currentPieces,
|
||||
upcomingShows: orderedShows.filter((show) => show.id !== nextShowSummary?.id).slice(0, 4)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||
import { formatDuration } from '#lib/format.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
function berlinDate(value: string | null) {
|
||||
if (!value) return 'Termin offen';
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'ready') return 'Sendefertig';
|
||||
if (status === 'live') return 'Live';
|
||||
return 'In Vorbereitung';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Start | Sendewerk</title></svelte:head>
|
||||
|
||||
<section class="page-wrap dashboard-page">
|
||||
<header class="dashboard-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Redaktion</p>
|
||||
<h1>Was als Nächstes ansteht</h1>
|
||||
</div>
|
||||
<nav class="dashboard-quick-actions" aria-label="Schnellaktionen">
|
||||
<a class="secondary-button" href={resolve('/fundus?create=1')}
|
||||
><AppIcon name="plus" /> Neues Piece</a
|
||||
>
|
||||
<a class="primary-button" href={resolve('/sendungen?create=1')}
|
||||
><AppIcon name="calendar-plus" /> Neuen Stream planen</a
|
||||
>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<section class="dashboard-next-stream" aria-labelledby="next-stream-heading">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">Nächster Stream</p>
|
||||
<h2 id="next-stream-heading">{data.nextShow?.title ?? 'Noch kein Stream geplant'}</h2>
|
||||
{#if data.nextShow}<p>
|
||||
{berlinDate(data.nextShow.startsAtUtc)} Uhr · {statusLabel(data.nextShow.status)}
|
||||
</p>{/if}
|
||||
</div>
|
||||
{#if data.nextShow}<a
|
||||
class="text-link"
|
||||
href={resolve('/sendungen/[id]', { id: data.nextShow.id })}
|
||||
>Planung öffnen <AppIcon name="arrow-right" /></a
|
||||
>{/if}
|
||||
</header>
|
||||
|
||||
{#if data.nextShow}
|
||||
<div class="dashboard-run-list">
|
||||
{#each data.nextShow.sections as section (section.id)}
|
||||
<div class="dashboard-run-section">
|
||||
<span class="run-marker" style:--marker={section.categoryColor ?? 'var(--gold)'}
|
||||
></span>
|
||||
<div>
|
||||
<strong>{section.name}</strong>
|
||||
<small>{formatDuration(section.durationSeconds)}</small>
|
||||
{#if section.usages.length}<p>
|
||||
{section.usages.map((usage) => usage.title).join(' · ')}
|
||||
</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if !data.nextShow.sections.length}<p class="empty-state">
|
||||
Der Ablauf ist noch leer.
|
||||
</p>{/if}
|
||||
</div>
|
||||
<div class="dashboard-stream-actions">
|
||||
<a class="secondary-button" href={resolve('/sendungen/[id]', { id: data.nextShow.id })}
|
||||
><AppIcon name="pen" /> Ablauf bearbeiten</a
|
||||
>
|
||||
<a
|
||||
class="primary-button"
|
||||
href={resolve(`/live?show=${encodeURIComponent(data.nextShow.id)}`)}
|
||||
><AppIcon name="tower-broadcast" /> Live öffnen</a
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state compact">
|
||||
<p>Lege einen Stream an, um hier den nächsten Ablauf zu sehen.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<aside class="dashboard-side-stack">
|
||||
<section aria-labelledby="upcoming-heading">
|
||||
<header>
|
||||
<h2 id="upcoming-heading">Weitere Streams</h2>
|
||||
<a href={resolve('/sendungen')}>Alle</a>
|
||||
</header>
|
||||
{#if data.upcomingShows.length}
|
||||
<div class="dashboard-mini-list">
|
||||
{#each data.upcomingShows as show (show.id)}
|
||||
<a href={resolve('/sendungen/[id]', { id: show.id })}
|
||||
><span
|
||||
><strong>{show.title}</strong><small>{berlinDate(show.startsAtUtc)} Uhr</small
|
||||
></span
|
||||
><AppIcon name="chevron-right" /></a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}<p class="empty-state compact">Keine weiteren Streams geplant.</p>{/if}
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="dashboard-pieces" aria-labelledby="current-pieces-heading">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">Pieces</p>
|
||||
<h2 id="current-pieces-heading">Aktuell in Arbeit</h2>
|
||||
</div>
|
||||
<a href={resolve('/fundus')}>Alle Pieces <AppIcon name="arrow-right" /></a>
|
||||
</header>
|
||||
{#if data.currentPieces.length}
|
||||
<div class="dashboard-piece-grid">
|
||||
{#each data.currentPieces as piece (piece.id)}
|
||||
<a href={resolve('/fundus/[id]', { id: piece.id })}>
|
||||
<span class={`show-state ${piece.status}`}
|
||||
>{piece.status === 'ready' ? 'Bereit' : 'In Vorbereitung'}</span
|
||||
>
|
||||
<strong>{piece.title}</strong>
|
||||
<p>{piece.summary || piece.format}</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}<p class="empty-state">Keine aktuellen Pieces.</p>{/if}
|
||||
</section>
|
||||
</section>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { MCP_SCOPE, oauthIssuer } from '#lib/server/oauth.js';
|
||||
|
||||
export const GET: RequestHandler = () => {
|
||||
const issuer = oauthIssuer();
|
||||
return json(
|
||||
{
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/oauth/authorize`,
|
||||
token_endpoint: `${issuer}/oauth/token`,
|
||||
registration_endpoint: `${issuer}/oauth/register`,
|
||||
revocation_endpoint: `${issuer}/oauth/revoke`,
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code'],
|
||||
token_endpoint_auth_methods_supported: ['none'],
|
||||
revocation_endpoint_auth_methods_supported: ['none'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
scopes_supported: [MCP_SCOPE],
|
||||
authorization_response_iss_parameter_supported: true
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'public, max-age=3600' } }
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { GET } from './mcp/+server.js';
|
||||
@@ -0,0 +1,14 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { MCP_SCOPE, mcpResource, oauthIssuer } from '#lib/server/oauth.js';
|
||||
|
||||
export const GET: RequestHandler = () =>
|
||||
json(
|
||||
{
|
||||
resource: mcpResource(),
|
||||
authorization_servers: [oauthIssuer()],
|
||||
scopes_supported: [MCP_SCOPE],
|
||||
bearer_methods_supported: ['header']
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'public, max-age=3600' } }
|
||||
);
|
||||
@@ -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: 'Streams lesen', responses: ok },
|
||||
post: {
|
||||
summary: 'Stream anlegen',
|
||||
parameters: [idempotency],
|
||||
requestBody: jsonBody({ $ref: '#/components/schemas/ShowWrite' }),
|
||||
responses: { '201': { description: 'Angelegt' } }
|
||||
}
|
||||
},
|
||||
'/shows/{id}': { get: { summary: 'Stream 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,15 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { requireUser } from '#lib/server/auth.js';
|
||||
import { listPieces, setPieceArchived, setPieceDeleted } from '#lib/server/content.js';
|
||||
import { duplicateCompletedShow, listShows } from '#lib/server/shows.js';
|
||||
|
||||
export const load: PageServerLoad = ({ locals }) => {
|
||||
requireUser(locals.user);
|
||||
return {
|
||||
completedShows: listShows(['completed']),
|
||||
duplicateCommandId: randomUUID(),
|
||||
pieces: listPieces({ statuses: ['archived'] }),
|
||||
deletedPieces: listPieces({
|
||||
statuses: ['inbox', 'preparing', 'ready', 'archived'],
|
||||
@@ -34,5 +38,19 @@ export const actions = {
|
||||
} 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: 'Der Stream konnte nicht dupliziert werden.' });
|
||||
}
|
||||
}
|
||||
} satisfies Actions;
|
||||
@@ -4,17 +4,19 @@
|
||||
let { data, form }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Archiv | Sendewerk</title></svelte:head>
|
||||
<svelte:head><title>Archivierte Pieces | Sendewerk</title></svelte:head>
|
||||
|
||||
<div class="page-wrap narrow-page">
|
||||
<header class="page-heading">
|
||||
<h1>Archiv</h1>
|
||||
<p class="page-intro">Abgelegte Pieces; abgeschlossene Sendungen folgen in Etappe 6.</p>
|
||||
<p class="eyebrow">Pieces</p>
|
||||
<h1>Archiviert und gelöscht</h1>
|
||||
<p class="page-intro">Bewusst abgelegte Pieces bleiben auffindbar und wiederherstellbar.</p>
|
||||
</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}
|
||||
<h2 class="section-heading">Archivierte Pieces</h2>
|
||||
{#if data.pieces.length}<div class="archive-list">
|
||||
{#each data.pieces as piece (piece.id)}<article>
|
||||
{#each data.pieces as piece (piece.id)}<article id={`piece-${piece.id}`}>
|
||||
<div>
|
||||
<h2>{piece.title}</h2>
|
||||
<p>{piece.summary || 'Keine Kurzbeschreibung.'}</p>
|
||||
@@ -36,7 +38,7 @@
|
||||
<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.
|
||||
Diese Einträge bleiben wiederherstellbar; bestehende Streamfassungen werden nicht verändert.
|
||||
</p>
|
||||
{#if data.deletedPieces.length}<div class="archive-list">
|
||||
{#each data.deletedPieces as piece (piece.id)}<article>
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { requireUser } from '#lib/server/auth.js';
|
||||
import { captureInbox, listPieces, setPieceDeleted } from '#lib/server/content.js';
|
||||
import {
|
||||
captureInbox,
|
||||
getPiece,
|
||||
getTaxonomy,
|
||||
listPieces,
|
||||
savePiece,
|
||||
setPieceDeleted
|
||||
} from '#lib/server/content.js';
|
||||
import { discardFeedEntries, importFeedEntries, listFeedEntries } from '#lib/server/feeds.js';
|
||||
|
||||
export const load: PageServerLoad = ({ locals }) => {
|
||||
requireUser(locals.user);
|
||||
return { pieces: listPieces({ statuses: ['inbox'] }) };
|
||||
return {
|
||||
pieces: listPieces({ statuses: ['inbox'] }),
|
||||
feedEntries: listFeedEntries(),
|
||||
taxonomy: getTaxonomy()
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
@@ -36,5 +48,72 @@ export const actions = {
|
||||
} 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.`
|
||||
};
|
||||
},
|
||||
quickSaveInbox: async ({ locals, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
const id = String(data.get('pieceId') || '');
|
||||
const piece = getPiece(id);
|
||||
if (!piece || piece.status !== 'inbox')
|
||||
return fail(404, {
|
||||
intent: 'quickSaveInbox',
|
||||
error: 'Der Eingangseintrag wurde nicht gefunden.'
|
||||
});
|
||||
try {
|
||||
const saved = savePiece(id, Number(data.get('revision')), {
|
||||
...piece,
|
||||
status: 'inbox',
|
||||
title: String(data.get('title') || ''),
|
||||
summary: String(data.get('summary') || ''),
|
||||
topicIds: piece.topicIds,
|
||||
categoryIds: piece.categoryIds,
|
||||
sources: piece.sources,
|
||||
tools: piece.tools.map((tool) => ({
|
||||
toolId: tool.toolId,
|
||||
workUrl: tool.workUrl,
|
||||
note: tool.note
|
||||
}))
|
||||
});
|
||||
return { intent: 'quickSaveInbox', saved: true, revision: saved.revision };
|
||||
} catch {
|
||||
return fail(409, {
|
||||
intent: 'quickSaveInbox',
|
||||
error: 'Der Eingangseintrag wurde inzwischen geändert. Bitte neu laden.'
|
||||
});
|
||||
}
|
||||
}
|
||||
} satisfies Actions;
|
||||
+225
-72
@@ -1,83 +1,236 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { untrack } from 'svelte';
|
||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||
import RssSelectionDialog from '#lib/components/RssSelectionDialog.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
let { data, form }: PageProps = $props();
|
||||
let rssDialog: { showModal: () => void };
|
||||
let selectedKey = $state(
|
||||
untrack(() =>
|
||||
data.feedEntries[0]
|
||||
? `feed:${data.feedEntries[0].id}`
|
||||
: data.pieces[0]
|
||||
? `piece:${data.pieces[0].id}`
|
||||
: ''
|
||||
)
|
||||
);
|
||||
let selectedFeed = $derived(
|
||||
data.feedEntries.find((entry) => selectedKey === `feed:${entry.id}`) ?? null
|
||||
);
|
||||
let selectedPiece = $derived(
|
||||
data.pieces.find((piece) => selectedKey === `piece:${piece.id}`) ?? null
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Eingang | Sendewerk</title></svelte:head>
|
||||
|
||||
<div class="page-wrap narrow-page">
|
||||
<header class="page-heading">
|
||||
<h1>Eingang</h1>
|
||||
<p class="page-intro">Gedanken und Fundstücke schnell festhalten und später ausarbeiten.</p>
|
||||
</header>
|
||||
<section class="quiet-panel capture-panel">
|
||||
<div>
|
||||
<p class="eyebrow">Schnellaufnahme</p>
|
||||
<h2>Was soll nicht verloren gehen?</h2>
|
||||
</div>
|
||||
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
|
||||
{#if form?.captured}<div class="form-message success" role="status">
|
||||
Im Eingang gespeichert.
|
||||
<a href={resolve('/fundus/[id]', { id: form.capturedId })}>Jetzt ausarbeiten</a>
|
||||
</div>{/if}
|
||||
<form method="POST" action="?/capture" class="capture-form">
|
||||
<label
|
||||
>Gedanke <span>optional bei URL</span><input
|
||||
name="title"
|
||||
maxlength="200"
|
||||
value={form?.title ?? ''}
|
||||
placeholder="Kurze Idee festhalten"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Link <span>optional</span><input
|
||||
name="url"
|
||||
type="url"
|
||||
maxlength="2000"
|
||||
value={form?.url ?? ''}
|
||||
placeholder="https://…"
|
||||
/></label
|
||||
>
|
||||
<button class="primary-button" type="submit">Im Eingang ablegen</button>
|
||||
</form>
|
||||
</section>
|
||||
{#if data.pieces.length}
|
||||
<section class="inbox-list">
|
||||
<h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2>
|
||||
{#each data.pieces as piece (piece.id)}<article class="inbox-item">
|
||||
<a href={resolve('/fundus/[id]', { id: piece.id })}
|
||||
><span
|
||||
><strong>{piece.title}</strong><small
|
||||
>{piece.sourceCount ? `${piece.sourceCount} Quelle` : 'Gedanke ohne Link'}</small
|
||||
></span
|
||||
><span>Ausarbeiten <AppIcon name="arrow-right" /></span></a
|
||||
>
|
||||
<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}
|
||||
<div class="unified-workspace inbox-workspace">
|
||||
<aside class="workspace-navigation" aria-label="Eingangsquellen">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">Sichtung</p>
|
||||
<h1>Eingang</h1>
|
||||
</div>
|
||||
<span>{data.pieces.length + data.feedEntries.length}</span>
|
||||
</header>
|
||||
<section class="capture-panel">
|
||||
<h2>Schnellaufnahme</h2>
|
||||
{#if form?.captured}<div class="form-message success" role="status">
|
||||
Im Eingang gespeichert.
|
||||
</div>{/if}
|
||||
<form method="POST" action="?/capture" class="capture-form">
|
||||
<label
|
||||
>Gedanke<input
|
||||
name="title"
|
||||
maxlength="200"
|
||||
value={form?.title ?? ''}
|
||||
placeholder="Kurze Idee festhalten"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Link <span>optional</span><input
|
||||
name="url"
|
||||
type="url"
|
||||
maxlength="2000"
|
||||
value={form?.url ?? ''}
|
||||
placeholder="https://…"
|
||||
/></label
|
||||
>
|
||||
<button class="primary-button" type="submit">Im Eingang ablegen</button>
|
||||
</form>
|
||||
</section>
|
||||
{:else}<section class="quiet-panel planned-panel">
|
||||
<div class="planned-icon" aria-hidden="true"><AppIcon name="plus" /></div>
|
||||
<h2>Eingang ist leer</h2>
|
||||
<p>Neue Gedanken und Links können oben direkt aufgenommen werden.</p>
|
||||
</section>{/if}
|
||||
<section class="workspace-navigation-group rss-inbox-panel">
|
||||
<h2>Quellen</h2>
|
||||
<p class="catalog-note">
|
||||
{data.feedEntries.length}
|
||||
{data.feedEntries.length === 1 ? 'Beitrag ist' : 'Beiträge sind'} verfügbar.
|
||||
</p>
|
||||
<button
|
||||
class:active={selectedKey.startsWith('feed:')}
|
||||
type="button"
|
||||
onclick={() => data.feedEntries[0] && (selectedKey = `feed:${data.feedEntries[0].id}`)}
|
||||
><AppIcon name="rss" /><span>RSS-Beiträge</span><b>{data.feedEntries.length}</b></button
|
||||
>
|
||||
<button
|
||||
class:active={selectedKey.startsWith('piece:')}
|
||||
type="button"
|
||||
onclick={() => data.pieces[0] && (selectedKey = `piece:${data.pieces[0].id}`)}
|
||||
><AppIcon name="inbox" /><span>Redaktioneller Eingang</span><b>{data.pieces.length}</b
|
||||
></button
|
||||
>
|
||||
</section>
|
||||
<button
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
disabled={!data.feedEntries.length}
|
||||
onclick={() => rssDialog.showModal()}><AppIcon name="list" /> RSS-Beiträge auswählen</button
|
||||
>
|
||||
</aside>
|
||||
<main class="workspace-content-pane inbox-catalog">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">Noch nicht übernommen</p>
|
||||
<h2>Neue Beiträge und Gedanken</h2>
|
||||
</div>
|
||||
</header>
|
||||
<div class="inbox-catalog-content">
|
||||
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
|
||||
{#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}
|
||||
<section class="inbox-catalog-group">
|
||||
<h3>RSS</h3>
|
||||
{#each data.feedEntries as entry (entry.id)}<button
|
||||
class="inbox-catalog-row"
|
||||
class:active={selectedKey === `feed:${entry.id}`}
|
||||
type="button"
|
||||
onclick={() => (selectedKey = `feed:${entry.id}`)}
|
||||
><span><small>{entry.feedName}</small><strong>{entry.title}</strong></span><AppIcon
|
||||
name="chevron-right"
|
||||
/></button
|
||||
>{:else}<p class="empty-copy">Keine neuen RSS-Beiträge.</p>{/each}
|
||||
</section>
|
||||
<section class="inbox-catalog-group">
|
||||
<h3>Redaktioneller Eingang</h3>
|
||||
{#each data.pieces as piece (piece.id)}<button
|
||||
class="inbox-catalog-row"
|
||||
class:active={selectedKey === `piece:${piece.id}`}
|
||||
type="button"
|
||||
onclick={() => (selectedKey = `piece:${piece.id}`)}
|
||||
><span
|
||||
><small
|
||||
>{piece.sourceCount ? `${piece.sourceCount} Quelle` : 'Gedanke ohne Link'}</small
|
||||
><strong>{piece.title}</strong></span
|
||||
><AppIcon name="chevron-right" /></button
|
||||
>{:else}<p class="empty-copy">Keine offenen Pieces.</p>{/each}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<aside class="content-quick-inspector" aria-label="Eingangsvorschau">
|
||||
{#if selectedFeed}<header>
|
||||
<div>
|
||||
<p class="eyebrow">RSS-Vorschau</p>
|
||||
<h2>{selectedFeed.title}</h2>
|
||||
</div>
|
||||
<a
|
||||
class="inspector-expand-button"
|
||||
href={selectedFeed.url}
|
||||
target="_blank"
|
||||
rel="noopener external"
|
||||
aria-label="Originalbeitrag öffnen"
|
||||
title="Original öffnen"><AppIcon name="arrow-up-right-from-square" /></a
|
||||
>
|
||||
</header>
|
||||
<div class="content-quick-inspector-scroll">
|
||||
<section>
|
||||
<strong>{selectedFeed.feedName}</strong>{#if selectedFeed.publishedAtUtc}<small
|
||||
>{new Date(selectedFeed.publishedAtUtc).toLocaleDateString('de-DE')}</small
|
||||
>{/if}
|
||||
</section>
|
||||
{#if selectedFeed.summary}<section>
|
||||
<h3>Vorschau</h3>
|
||||
<p>{selectedFeed.summary}</p>
|
||||
</section>{/if}
|
||||
<section>
|
||||
<h3>Übernahme</h3>
|
||||
<p>
|
||||
Bei der Übernahme entsteht ein normales Piece im redaktionellen Eingang. Ohne Auswahl
|
||||
gelten die Standardkategorien des Feeds.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
<footer>
|
||||
<form method="POST" action="?/importFeeds">
|
||||
<input type="hidden" name="entryIds" value={selectedFeed.id} /><button
|
||||
class="text-button danger-text"
|
||||
type="submit"
|
||||
formaction="?/discardFeeds">Verwerfen</button
|
||||
><button class="primary-button" type="submit">Übernehmen</button>
|
||||
</form>
|
||||
</footer>
|
||||
{:else if selectedPiece}<header>
|
||||
<div>
|
||||
<p class="eyebrow">Schnellbearbeitung</p>
|
||||
<h2>{selectedPiece.title}</h2>
|
||||
</div>
|
||||
<a
|
||||
class="inspector-expand-button"
|
||||
href={resolve('/fundus/[id]', { id: selectedPiece.id })}
|
||||
aria-label="Piece vollständig bearbeiten"
|
||||
title="Vollständig bearbeiten"><AppIcon name="expand" /></a
|
||||
>
|
||||
</header>
|
||||
<div class="content-quick-inspector-scroll">
|
||||
<form id="inbox-quick-form" method="POST" action="?/quickSaveInbox">
|
||||
<input type="hidden" name="pieceId" value={selectedPiece.id} /><input
|
||||
type="hidden"
|
||||
name="revision"
|
||||
value={selectedPiece.revision}
|
||||
/><label
|
||||
>Titel<input name="title" required maxlength="200" value={selectedPiece.title} /></label
|
||||
><label
|
||||
>Kurzbeschreibung<textarea name="summary" rows="7" maxlength="2000"
|
||||
>{selectedPiece.summary}</textarea
|
||||
></label
|
||||
>
|
||||
</form>
|
||||
<section>
|
||||
<h3>Herkunft</h3>
|
||||
<p>
|
||||
{selectedPiece.sourceCount
|
||||
? `${selectedPiece.sourceCount} Quelle`
|
||||
: 'Schnell aufgenommener Gedanke ohne Link'}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
<footer>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
onsubmit={(event) =>
|
||||
!confirm(`„${selectedPiece.title}“ löschen?`) && event.preventDefault()}
|
||||
>
|
||||
<input type="hidden" name="pieceId" value={selectedPiece.id} /><input
|
||||
type="hidden"
|
||||
name="revision"
|
||||
value={selectedPiece.revision}
|
||||
/><button class="text-button danger-text" type="submit">Löschen</button>
|
||||
</form>
|
||||
<button class="primary-button" type="submit" form="inbox-quick-form">Speichern</button>
|
||||
</footer>
|
||||
{:else}<div class="content-inspector-empty">
|
||||
<AppIcon name="inbox" />
|
||||
<h2>Eintrag auswählen</h2>
|
||||
<p>Vorschau und Übernahme erscheinen hier.</p>
|
||||
</div>{/if}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<RssSelectionDialog
|
||||
bind:this={rssDialog}
|
||||
entries={data.feedEntries}
|
||||
categories={data.taxonomy.categories}
|
||||
{form}
|
||||
/>
|
||||
@@ -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>Streamfreigabe | 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">Stream öffnen</button>
|
||||
</form>
|
||||
</section>
|
||||
{:else}
|
||||
<header class="share-heading">
|
||||
<p class="eyebrow">
|
||||
{data.share.isCompleted ? 'Abgeschlossener Stream' : 'Aktuelle Streamplanung'} · 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -5,47 +5,52 @@ import {
|
||||
createPiece,
|
||||
deletePieceTemplate,
|
||||
duplicatePiece,
|
||||
getTaxonomy,
|
||||
listPieces,
|
||||
getPiece,
|
||||
listPieceTemplates,
|
||||
savePiece,
|
||||
setPieceArchived,
|
||||
setPieceDeleted,
|
||||
setPieceFavorite
|
||||
setPieceFavorite,
|
||||
uploadPieceImage,
|
||||
type EditablePieceStatus,
|
||||
type PieceEditor
|
||||
} from '#lib/server/content.js';
|
||||
import { getPieceUsageContexts } from '#lib/server/shows.js';
|
||||
import { fundusSearch, loadFundusWorkspace } from '#lib/server/fundus-workspace.js';
|
||||
|
||||
export const load: PageServerLoad = ({ locals, url }) => {
|
||||
requireUser(locals.user);
|
||||
const filters = {
|
||||
query: url.searchParams.get('q') ?? '',
|
||||
formatId: url.searchParams.get('format') ?? '',
|
||||
topicId: url.searchParams.get('topic') ?? '',
|
||||
categoryId: url.searchParams.get('category') ?? '',
|
||||
status: url.searchParams.get('status') ?? '',
|
||||
includeUsed: url.searchParams.get('includeUsed') === '1',
|
||||
includePlanned: url.searchParams.get('includePlanned') === '1'
|
||||
};
|
||||
const pieces = listPieces({
|
||||
statuses:
|
||||
filters.status === 'ready'
|
||||
? ['ready']
|
||||
: filters.status === 'preparing'
|
||||
? ['preparing']
|
||||
: ['preparing', 'ready'],
|
||||
query: filters.query,
|
||||
formatId: filters.formatId,
|
||||
topicId: filters.topicId,
|
||||
categoryId: filters.categoryId
|
||||
});
|
||||
const usageContexts = getPieceUsageContexts(pieces.map((piece) => piece.id));
|
||||
const workspace = loadFundusWorkspace(url);
|
||||
const selectedId = url.searchParams.get('selected') ?? workspace.pieces[0]?.id ?? '';
|
||||
return {
|
||||
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
|
||||
taxonomy: getTaxonomy(),
|
||||
templates: listPieceTemplates(),
|
||||
filters
|
||||
...workspace,
|
||||
selectedPiece: workspace.pieces.some((piece) => piece.id === selectedId)
|
||||
? getPiece(selectedId)
|
||||
: null,
|
||||
templates: listPieceTemplates()
|
||||
};
|
||||
};
|
||||
|
||||
function preservedInput(piece: PieceEditor, data: FormData) {
|
||||
return {
|
||||
...piece,
|
||||
title: String(data.get('title') || ''),
|
||||
summary: String(data.get('summary') || ''),
|
||||
status: String(data.get('status') || piece.status) as EditablePieceStatus,
|
||||
formatId: String(data.get('formatId') || '') || null,
|
||||
durationSeconds: Math.round(Number(data.get('durationMinutes')) * 60),
|
||||
intro: String(data.get('intro') || ''),
|
||||
moderationNotes: String(data.get('moderationNotes') || ''),
|
||||
topicIds: piece.topicIds,
|
||||
categoryIds: piece.categoryIds,
|
||||
sources: piece.sources,
|
||||
tools: piece.tools.map((tool) => ({
|
||||
toolId: tool.toolId,
|
||||
workUrl: tool.workUrl,
|
||||
note: tool.note
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function actionError(cause: unknown) {
|
||||
const code = cause instanceof Error ? cause.message : '';
|
||||
if (code === 'CONFLICT') return 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.';
|
||||
@@ -54,7 +59,7 @@ function actionError(cause: unknown) {
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
create: async ({ locals, request }) => {
|
||||
create: async ({ locals, request, url }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
let id: string;
|
||||
@@ -63,7 +68,7 @@ export const actions = {
|
||||
} catch (cause) {
|
||||
return fail(400, { intent: 'create', error: actionError(cause) });
|
||||
}
|
||||
redirect(303, `/fundus/${id}`);
|
||||
redirect(303, `/fundus/${id}${fundusSearch(url)}`);
|
||||
},
|
||||
deleteTemplate: async ({ locals, request }) => {
|
||||
requireUser(locals.user);
|
||||
@@ -75,7 +80,7 @@ export const actions = {
|
||||
return fail(409, { intent: 'deleteTemplate', error: actionError(cause) });
|
||||
}
|
||||
},
|
||||
duplicate: async ({ locals, request }) => {
|
||||
duplicate: async ({ locals, request, url }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
let id: string;
|
||||
@@ -84,7 +89,7 @@ export const actions = {
|
||||
} catch (cause) {
|
||||
return fail(400, { intent: 'duplicate', error: actionError(cause) });
|
||||
}
|
||||
redirect(303, `/fundus/${id}`);
|
||||
redirect(303, `/fundus/${id}${fundusSearch(url)}`);
|
||||
},
|
||||
archive: async ({ locals, request }) => {
|
||||
requireUser(locals.user);
|
||||
@@ -113,10 +118,43 @@ export const actions = {
|
||||
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), true);
|
||||
return {
|
||||
intent: 'delete',
|
||||
success: 'Piece gelöscht. Bestehende Sendungsfassungen bleiben erhalten.'
|
||||
success: 'Piece gelöscht. Bestehende Streamfassungen bleiben erhalten.'
|
||||
};
|
||||
} catch (cause) {
|
||||
return fail(409, { intent: 'delete', error: actionError(cause) });
|
||||
}
|
||||
},
|
||||
quickSave: async ({ locals, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
const id = String(data.get('pieceId') || '');
|
||||
const piece = getPiece(id);
|
||||
if (!piece || piece.status === 'archived')
|
||||
return fail(404, { intent: 'quickSave', error: 'Das Piece wurde nicht gefunden.' });
|
||||
try {
|
||||
const saved = savePiece(id, Number(data.get('revision')), preservedInput(piece, data));
|
||||
return { intent: 'quickSave', success: 'Schnelländerungen gespeichert.', ...saved };
|
||||
} catch (cause) {
|
||||
return fail(409, { intent: 'quickSave', error: actionError(cause) });
|
||||
}
|
||||
},
|
||||
quickUpload: async ({ locals, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
const image = data.get('image');
|
||||
if (!(image instanceof File))
|
||||
return fail(400, { intent: 'quickUpload', error: 'Bitte ein Bild auswählen.' });
|
||||
try {
|
||||
await uploadPieceImage(
|
||||
String(data.get('pieceId') || ''),
|
||||
Number(data.get('revision')),
|
||||
image,
|
||||
String(data.get('altText') || ''),
|
||||
false
|
||||
);
|
||||
return { intent: 'quickUpload', success: 'Bild hochgeladen.' };
|
||||
} catch (cause) {
|
||||
return fail(400, { intent: 'quickUpload', error: actionError(cause) });
|
||||
}
|
||||
}
|
||||
} satisfies Actions;
|
||||
+382
-228
@@ -4,8 +4,10 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
|
||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||
import FundusNavigation from '#lib/components/FundusNavigation.svelte';
|
||||
import { formatClock } from '#lib/format.js';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
@@ -15,8 +17,8 @@
|
||||
let visiblePieces = $derived(
|
||||
data.pieces.filter(
|
||||
(piece) =>
|
||||
(showUsedPieces || piece.sentHistory.length === 0 || piece.isFavorite) &&
|
||||
(showPlannedPieces || !piece.isPlannedInActiveShow)
|
||||
(data.filters.view === 'completed' || showUsedPieces || piece.sentHistory.length === 0) &&
|
||||
(data.filters.view === 'completed' || showPlannedPieces || !piece.isPlannedInActiveShow)
|
||||
)
|
||||
);
|
||||
onMount(() => {
|
||||
@@ -26,9 +28,36 @@
|
||||
showPlannedPieces = search.get('includePlanned') === '1';
|
||||
};
|
||||
window.addEventListener('popstate', syncFiltersFromHistory);
|
||||
const scrollKey = `fundus-scroll:${window.location.search}`;
|
||||
const storedScroll = Number(sessionStorage.getItem(scrollKey));
|
||||
if (Number.isFinite(storedScroll))
|
||||
requestAnimationFrame(() => window.scrollTo(0, storedScroll));
|
||||
if (new URL(window.location.href).searchParams.get('create') === '1') {
|
||||
document.querySelector<HTMLDetailsElement>('.template-picker')?.setAttribute('open', '');
|
||||
}
|
||||
return () => window.removeEventListener('popstate', syncFiltersFromHistory);
|
||||
});
|
||||
|
||||
function rememberListPosition() {
|
||||
sessionStorage.setItem(`fundus-scroll:${window.location.search}`, String(window.scrollY));
|
||||
}
|
||||
|
||||
function pieceSearch(selectedId = '') {
|
||||
const search = new SvelteURLSearchParams();
|
||||
if (data.filters.query) search.set('q', data.filters.query);
|
||||
if (data.filters.status) search.set('status', data.filters.status);
|
||||
if (data.filters.formatId) search.set('format', data.filters.formatId);
|
||||
if (data.filters.topicId) search.set('topic', data.filters.topicId);
|
||||
if (data.filters.categoryId) search.set('category', data.filters.categoryId);
|
||||
if (data.filters.favorites) search.set('favorites', '1');
|
||||
if (data.filters.view) search.set('view', data.filters.view);
|
||||
if (showUsedPieces) search.set('includeUsed', '1');
|
||||
if (showPlannedPieces) search.set('includePlanned', '1');
|
||||
if (selectedId) search.set('selected', selectedId);
|
||||
const query = search.toString();
|
||||
return query ? `?${query}` : '';
|
||||
}
|
||||
|
||||
function setSourceFilter(name: 'includeUsed' | 'includePlanned', checked: boolean) {
|
||||
if (name === 'includeUsed') showUsedPieces = checked;
|
||||
else showPlannedPieces = checked;
|
||||
@@ -53,239 +82,364 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Fundus | Sendewerk</title></svelte:head>
|
||||
<svelte:head><title>Pieces | Sendewerk</title></svelte:head>
|
||||
|
||||
<div class="page-wrap">
|
||||
<header class="page-heading split-heading">
|
||||
<div>
|
||||
<h1>Fundus</h1>
|
||||
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
|
||||
</div>
|
||||
<div class="fundus-heading-actions">
|
||||
<div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}>
|
||||
<strong>{visiblePieces.length}</strong><span>Inhalte</span>
|
||||
<div class="fundus-workspace fundus-list-workspace unified-workspace">
|
||||
<FundusNavigation filters={data.filters} counts={data.counts} topics={data.taxonomy.topics} />
|
||||
<section class="fundus-list-pane">
|
||||
<header class="page-heading split-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Redaktionelle Bibliothek</p>
|
||||
<h1>Pieces</h1>
|
||||
<p class="page-intro">Vorbereitete Inhalte für kommende Streams.</p>
|
||||
</div>
|
||||
<details class="template-picker">
|
||||
<summary><AppIcon name="plus" /> Neues Piece</summary>
|
||||
<div class="template-popover">
|
||||
<div class="template-grid">
|
||||
{#each data.templates as template (template.id)}
|
||||
<form method="POST" action="?/create">
|
||||
<input type="hidden" name="template" value={template.id} />
|
||||
<button type="submit"
|
||||
><AppIcon name={template.iconName} size="1.35rem" /><strong
|
||||
>{template.name}</strong
|
||||
><span>{template.description}</span></button
|
||||
>
|
||||
</form>
|
||||
{/each}
|
||||
</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>
|
||||
<div class="fundus-heading-actions">
|
||||
<div class="heading-stat" aria-label={`${visiblePieces.length} gefundene Inhalte`}>
|
||||
<strong>{visiblePieces.length}</strong><span>Inhalte</span>
|
||||
</div>
|
||||
<details class="template-picker">
|
||||
<summary><AppIcon name="plus" /> Neues Piece</summary>
|
||||
<div class="template-popover">
|
||||
<div class="template-grid">
|
||||
{#each data.templates as template (template.id)}
|
||||
<form method="POST" action="?/create">
|
||||
<input type="hidden" name="template" value={template.id} />
|
||||
<button type="submit"
|
||||
><AppIcon name={template.iconName} size="1.35rem" /><strong
|
||||
>{template.name}</strong
|
||||
><span>{template.description}</span></button
|
||||
>
|
||||
</form>
|
||||
{/each}
|
||||
</details>{/if}
|
||||
</div>
|
||||
</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
|
||||
id="library-filters"
|
||||
class="library-filters"
|
||||
method="GET"
|
||||
aria-label="Fundus durchsuchen und filtern"
|
||||
>
|
||||
<div class="fundus-search-row">
|
||||
<label class="search-field"
|
||||
><AppIcon name="magnifying-glass" /><span class="sr-only">Fundus durchsuchen</span><input
|
||||
name="q"
|
||||
type="search"
|
||||
value={data.filters.query}
|
||||
placeholder="Titel, Quelle oder Thema suchen"
|
||||
/></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
|
||||
><span>Status</span><select name="status"
|
||||
><option value="">Alle</option><option
|
||||
value="ready"
|
||||
selected={data.filters.status === 'ready'}>Sendefertig</option
|
||||
><option value="preparing" selected={data.filters.status === 'preparing'}
|
||||
>In Arbeit</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
><span>Format</span><select name="format"
|
||||
><option value="">Alle</option>{#each data.taxonomy.formats as item (item.id)}<option
|
||||
value={item.id}
|
||||
selected={data.filters.formatId === item.id}>{item.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
><span>Thema</span><select name="topic"
|
||||
><option value="">Alle</option>{#each data.taxonomy.topics as item (item.id)}<option
|
||||
value={item.id}
|
||||
selected={data.filters.topicId === item.id}>{item.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
><span>Abschnitt</span><select name="category"
|
||||
><option value="">Alle</option
|
||||
>{#each data.taxonomy.categories as item (item.id)}<option
|
||||
value={item.id}
|
||||
selected={data.filters.categoryId === item.id}>{item.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<div class="library-filter-options" aria-label="Fundusauswahl erweitern">
|
||||
<label class="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="includeUsed"
|
||||
value="1"
|
||||
checked={showUsedPieces}
|
||||
onchange={(event) => setSourceFilter('includeUsed', event.currentTarget.checked)}
|
||||
/>
|
||||
<span>Auch verwendete Inhalte zeigen</span>
|
||||
</label>
|
||||
<label class="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="includePlanned"
|
||||
value="1"
|
||||
checked={showPlannedPieces}
|
||||
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)}
|
||||
/>
|
||||
<span>Auch eingeplante Inhalte zeigen</span>
|
||||
</label>
|
||||
</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}
|
||||
<div class="piece-grid">
|
||||
{#each visiblePieces as piece (piece.id)}
|
||||
<article
|
||||
class="piece-card"
|
||||
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
|
||||
class="piece-thumbnail"
|
||||
src={`/media/${piece.mediaId}`}
|
||||
alt=""
|
||||
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true">
|
||||
<AppIcon name={piece.topics[0]?.iconName || 'newspaper'} size="2rem" />
|
||||
</span>{/if}
|
||||
<div class="piece-primary">
|
||||
<h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2>
|
||||
{#if piece.summary}<p>{piece.summary}</p>{/if}
|
||||
<PieceUsageHistory history={piece.sentHistory} />
|
||||
</div>
|
||||
<div class="piece-classification">
|
||||
<span class="category-label"
|
||||
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
|
||||
>
|
||||
<div class="piece-tags">
|
||||
{#if piece.formatId}<span>{piece.format}</span
|
||||
>{/if}{#each piece.topics as topic (topic.id)}<span>{topic.name}</span>{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="piece-state">
|
||||
<span class:ready={piece.status === 'ready'} class="status-label"
|
||||
>{statusLabel[piece.status]}</span
|
||||
>{#if piece.durationSeconds || piece.sourceCount}<span class="piece-facts"
|
||||
>{#if piece.durationSeconds}<span
|
||||
><b>{formatClock(piece.durationSeconds)}</b> geplant</span
|
||||
>{/if}{#if piece.sourceCount}<span
|
||||
>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span
|
||||
>{/if}</span
|
||||
>{/if}
|
||||
>
|
||||
</div>
|
||||
<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="?/duplicate">
|
||||
<input type="hidden" name="pieceId" value={piece.id} /><button
|
||||
class="text-button"
|
||||
type="submit">Duplizieren</button
|
||||
>
|
||||
</form>
|
||||
<form method="POST" action="?/archive">
|
||||
<input type="hidden" name="pieceId" value={piece.id} /><input
|
||||
type="hidden"
|
||||
name="revision"
|
||||
value={piece.revision}
|
||||
/><button class="text-button danger-text" type="submit">Archivieren</button>
|
||||
</form>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
onsubmit={(event) =>
|
||||
!confirm(
|
||||
`Piece „${piece.title}“ löschen? Bestehende Sendungsfassungen bleiben erhalten.`
|
||||
) && event.preventDefault()}
|
||||
{#if data.templates.some((template) => template.id !== 'blank')}<details
|
||||
class="template-management"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
<div class="fundus-library-content">
|
||||
{#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 id="library-filters" class="library-filters" method="GET" aria-label="Pieces filtern">
|
||||
<div class="fundus-search-row">
|
||||
<input type="hidden" name="q" value={data.filters.query} />
|
||||
{#if data.filters.view}<input type="hidden" name="view" value={data.filters.view} />{/if}
|
||||
<details class="filter-picker">
|
||||
<summary><AppIcon name="list-check" /> Weitere Filter</summary>
|
||||
<div class="filter-panel">
|
||||
<label
|
||||
><span>Status</span><select name="status"
|
||||
><option value="">Alle</option><option
|
||||
value="ready"
|
||||
selected={data.filters.status === 'ready'}>Sendefertig</option
|
||||
><option value="preparing" selected={data.filters.status === 'preparing'}
|
||||
>In Arbeit</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
><span>Format</span><select name="format"
|
||||
><option value="">Alle</option
|
||||
>{#each data.taxonomy.formats as item (item.id)}<option
|
||||
value={item.id}
|
||||
selected={data.filters.formatId === item.id}>{item.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
><span>Thema</span><select name="topic"
|
||||
><option value="">Alle</option
|
||||
>{#each data.taxonomy.topics as item (item.id)}<option
|
||||
value={item.id}
|
||||
selected={data.filters.topicId === item.id}>{item.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
><span>Abschnitt</span><select name="category"
|
||||
><option value="">Alle</option
|
||||
>{#each data.taxonomy.categories as item (item.id)}<option
|
||||
value={item.id}
|
||||
selected={data.filters.categoryId === item.id}>{item.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<div class="library-filter-options" aria-label="Piece-Auswahl erweitern">
|
||||
<label class="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="includeUsed"
|
||||
value="1"
|
||||
checked={showUsedPieces}
|
||||
onchange={(event) =>
|
||||
setSourceFilter('includeUsed', event.currentTarget.checked)}
|
||||
/>
|
||||
<span>Auch verwendete Inhalte zeigen</span>
|
||||
</label>
|
||||
<label class="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="includePlanned"
|
||||
value="1"
|
||||
checked={showPlannedPieces}
|
||||
onchange={(event) =>
|
||||
setSourceFilter('includePlanned', event.currentTarget.checked)}
|
||||
/>
|
||||
<span>Auch eingeplante Inhalte zeigen</span>
|
||||
</label>
|
||||
</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>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if visiblePieces.length}
|
||||
<div class="piece-grid">
|
||||
{#each visiblePieces as piece (piece.id)}
|
||||
<article
|
||||
class="piece-card"
|
||||
style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`}
|
||||
>
|
||||
<a
|
||||
class="piece-row-link"
|
||||
href={resolve(`/fundus${pieceSearch(piece.id)}` as '/fundus')}
|
||||
onclick={rememberListPosition}
|
||||
aria-label={`${piece.title} auswählen`}
|
||||
></a>
|
||||
<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
|
||||
class="piece-thumbnail"
|
||||
src={`/media/${piece.mediaId}`}
|
||||
alt=""
|
||||
/>{/if}
|
||||
<div class="piece-primary">
|
||||
<h2>
|
||||
<a
|
||||
href={resolve(`/fundus${pieceSearch(piece.id)}` as '/fundus')}
|
||||
onclick={rememberListPosition}>{piece.title}</a
|
||||
>
|
||||
</h2>
|
||||
{#if piece.summary}<p>{piece.summary}</p>{/if}
|
||||
<PieceUsageHistory history={piece.sentHistory} />
|
||||
</div>
|
||||
<div class="piece-classification">
|
||||
<span class="category-label"
|
||||
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
|
||||
>
|
||||
<div class="piece-tags">
|
||||
{#if piece.formatId}<span>{piece.format}</span
|
||||
>{/if}{#each piece.topics as topic (topic.id)}<span>{topic.name}</span>{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="piece-state">
|
||||
<span class:ready={piece.status === 'ready'} class="status-label"
|
||||
>{statusLabel[piece.status]}</span
|
||||
>{#if piece.durationSeconds || piece.sourceCount}<span class="piece-facts"
|
||||
>{#if piece.durationSeconds}<span
|
||||
><b>{formatClock(piece.durationSeconds)}</b> geplant</span
|
||||
>{/if}{#if piece.sourceCount}<span
|
||||
>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span
|
||||
>{/if}</span
|
||||
>{/if}
|
||||
>
|
||||
</div>
|
||||
<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="?/duplicate">
|
||||
<input type="hidden" name="pieceId" value={piece.id} /><button
|
||||
class="text-button"
|
||||
type="submit">Duplizieren</button
|
||||
>
|
||||
</form>
|
||||
<form method="POST" action="?/archive">
|
||||
<input type="hidden" name="pieceId" value={piece.id} /><input
|
||||
type="hidden"
|
||||
name="revision"
|
||||
value={piece.revision}
|
||||
/><button class="text-button danger-text" type="submit">Archivieren</button>
|
||||
</form>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
onsubmit={(event) =>
|
||||
!confirm(
|
||||
`Piece „${piece.title}“ löschen? Bestehende Streamfassungen 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>
|
||||
</details>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<span aria-hidden="true"><AppIcon name="magnifying-glass" /></span>
|
||||
<h2>Kein passendes Piece</h2>
|
||||
<p>Andere Filter wählen oder ein neues Piece anlegen.</p>
|
||||
<a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<span aria-hidden="true"><AppIcon name="magnifying-glass" /></span>
|
||||
<h2>Kein passendes Piece</h2>
|
||||
<p>Andere Filter wählen oder ein neues Piece anlegen.</p>
|
||||
<a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<aside
|
||||
class="content-quick-inspector stream-inspector builder-pane"
|
||||
aria-label="Piece-Schnellbearbeitung"
|
||||
>
|
||||
{#if data.selectedPiece}
|
||||
<header class="stream-inspector-header">
|
||||
<div>
|
||||
<p class="eyebrow">Schnellbearbeitung</p>
|
||||
<a
|
||||
class="inspector-expand-button"
|
||||
href={resolve(
|
||||
`/fundus/${encodeURIComponent(data.selectedPiece.id)}${pieceSearch()}` as '/fundus'
|
||||
)}
|
||||
aria-label="Vollständigen Piece-Editor öffnen"
|
||||
title="Vollständig bearbeiten"><AppIcon name="expand" /></a
|
||||
>
|
||||
</div>
|
||||
<h2>{data.selectedPiece.title}</h2>
|
||||
<p>Wichtige Texte und Metadaten dieses Pieces.</p>
|
||||
</header>
|
||||
<div class="content-quick-inspector-scroll piece-quick-inspector-scroll">
|
||||
{#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
|
||||
id="piece-quick-form"
|
||||
class="stream-inspector-form"
|
||||
method="POST"
|
||||
action="?/quickSave"
|
||||
>
|
||||
<input type="hidden" name="pieceId" value={data.selectedPiece.id} />
|
||||
<input type="hidden" name="revision" value={data.selectedPiece.revision} />
|
||||
<label
|
||||
>Interner Titel<input
|
||||
name="title"
|
||||
required
|
||||
maxlength="200"
|
||||
value={data.selectedPiece.title}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Kurzbeschreibung<textarea name="summary" rows="4" maxlength="2000"
|
||||
>{data.selectedPiece.summary}</textarea
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Einleitung<textarea name="intro" rows="6" maxlength="20000"
|
||||
>{data.selectedPiece.intro}</textarea
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Moderationsstichpunkte<textarea name="moderationNotes" rows="7" maxlength="20000"
|
||||
>{data.selectedPiece.moderationNotes}</textarea
|
||||
></label
|
||||
>
|
||||
<div class="content-quick-grid">
|
||||
<label
|
||||
>Status<select name="status" value={data.selectedPiece.status}
|
||||
><option value="preparing">In Vorbereitung</option><option value="ready"
|
||||
>Sendefertig</option
|
||||
><option value="inbox">Eingang</option></select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Dauer in Minuten<input
|
||||
name="durationMinutes"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1440"
|
||||
step="any"
|
||||
value={Number((data.selectedPiece.durationSeconds / 60).toFixed(2))}
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
<label
|
||||
>Format<select name="formatId"
|
||||
><option value="">Ohne Format</option
|
||||
>{#each data.taxonomy.formats as format (format.id)}<option
|
||||
value={format.id}
|
||||
selected={data.selectedPiece.formatId === format.id}>{format.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
</form>
|
||||
<section class="quick-upload-section">
|
||||
<h3>Bild hochladen</h3>
|
||||
<form method="POST" action="?/quickUpload" enctype="multipart/form-data">
|
||||
<input type="hidden" name="pieceId" value={data.selectedPiece.id} />
|
||||
<input type="hidden" name="revision" value={data.selectedPiece.revision} />
|
||||
<label
|
||||
>Bild<input
|
||||
name="image"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
required
|
||||
/></label
|
||||
>
|
||||
<label>Alternativtext<input name="altText" maxlength="500" /></label>
|
||||
<button class="secondary-button" type="submit">Hochladen</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="stream-inspector-actions">
|
||||
<a class="secondary-button" href={resolve('/fundus/[id]', { id: data.selectedPiece.id })}
|
||||
>Vollständig bearbeiten</a
|
||||
>
|
||||
<button class="primary-button" type="submit" form="piece-quick-form">Speichern</button>
|
||||
</footer>
|
||||
{:else}
|
||||
<div class="content-inspector-empty">
|
||||
<AppIcon name="arrow-pointer" />
|
||||
<h2>Piece auswählen</h2>
|
||||
<p>Wichtige Texte, Einordnung und Uploads erscheinen hier.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
@@ -19,18 +19,25 @@ import {
|
||||
type PieceSourceInput
|
||||
} from '#lib/server/content.js';
|
||||
import { renderMarkdown } from '#lib/server/markdown.js';
|
||||
import { fundusSearch, loadFundusWorkspace } from '#lib/server/fundus-workspace.js';
|
||||
import { listTools } from '#lib/server/tools.js';
|
||||
|
||||
function renderedFields(
|
||||
piece: Pick<
|
||||
PieceInput,
|
||||
'intro' | 'researchText' | 'moderationNotes' | 'transitionText' | 'presentationText'
|
||||
| 'intro'
|
||||
| 'researchText'
|
||||
| 'moderationNotes'
|
||||
| 'interactionText'
|
||||
| 'transitionText'
|
||||
| 'presentationText'
|
||||
>
|
||||
) {
|
||||
return {
|
||||
intro: renderMarkdown(piece.intro),
|
||||
research: renderMarkdown(piece.researchText),
|
||||
moderation: renderMarkdown(piece.moderationNotes),
|
||||
interaction: renderMarkdown(piece.interactionText ?? ''),
|
||||
transition: renderMarkdown(piece.transitionText),
|
||||
presentation: renderMarkdown(piece.presentationText)
|
||||
};
|
||||
@@ -69,10 +76,12 @@ function pieceInput(data: FormData): PieceInput {
|
||||
summary: text(data, 'summary'),
|
||||
formatId: text(data, 'formatId') || null,
|
||||
status: text(data, 'status') as EditablePieceStatus,
|
||||
preparationEffort: (text(data, 'preparationEffort') || null) as PieceInput['preparationEffort'],
|
||||
durationSeconds: durationInSeconds(data),
|
||||
intro: text(data, 'intro'),
|
||||
researchText: text(data, 'researchText'),
|
||||
moderationNotes: text(data, 'moderationNotes'),
|
||||
interactionText: text(data, 'interactionText'),
|
||||
transitionText: text(data, 'transitionText'),
|
||||
presentationText: text(data, 'presentationText'),
|
||||
presentationMode: text(data, 'presentationMode') as PieceInput['presentationMode'],
|
||||
@@ -130,12 +139,13 @@ function saveError(cause: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = ({ locals, params }) => {
|
||||
export const load: PageServerLoad = ({ locals, params, url }) => {
|
||||
requireUser(locals.user);
|
||||
const piece = getPiece(params.id);
|
||||
if (!piece) error(404, 'Piece nicht gefunden');
|
||||
if (piece.status === 'archived') redirect(303, '/archiv');
|
||||
return {
|
||||
workspace: loadFundusWorkspace(url),
|
||||
piece,
|
||||
taxonomy: getTaxonomy(),
|
||||
tools: listTools(true),
|
||||
@@ -157,7 +167,7 @@ export const actions = {
|
||||
return fail(problem.status, { intent: 'save', ...problem });
|
||||
}
|
||||
},
|
||||
loadPreview: async ({ locals, params, request }) => {
|
||||
loadPreview: async ({ locals, params, request, url }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
@@ -180,7 +190,7 @@ export const actions = {
|
||||
const problem = saveError(cause);
|
||||
return fail(problem.status, { intent: 'loadPreview', ...problem });
|
||||
}
|
||||
redirect(303, `/fundus/${params.id}`);
|
||||
redirect(303, `/fundus/${params.id}${fundusSearch(url)}`);
|
||||
},
|
||||
saveTemplate: async ({ locals, params, request }) => {
|
||||
requireUser(locals.user);
|
||||
@@ -201,7 +211,7 @@ export const actions = {
|
||||
return fail(problem.status, { intent: 'saveTemplate', ...problem });
|
||||
}
|
||||
},
|
||||
upload: async ({ locals, params, request }) => {
|
||||
upload: async ({ locals, params, request, url }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
const image = data.get('image');
|
||||
@@ -231,9 +241,9 @@ export const actions = {
|
||||
return fail(400, { intent: 'upload', error: 'Erlaubt sind JPEG, PNG, WebP und GIF.' });
|
||||
return fail(400, { intent: 'upload', error: 'Bild konnte nicht gespeichert werden.' });
|
||||
}
|
||||
redirect(303, `/fundus/${params.id}`);
|
||||
redirect(303, `/fundus/${params.id}${fundusSearch(url)}`);
|
||||
},
|
||||
deleteImage: async ({ locals, params, request }) => {
|
||||
deleteImage: async ({ locals, params, request, url }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
@@ -241,9 +251,9 @@ export const actions = {
|
||||
} catch (cause) {
|
||||
return fail(409, { intent: 'deleteImage', error: saveError(cause).error });
|
||||
}
|
||||
redirect(303, `/fundus/${params.id}`);
|
||||
redirect(303, `/fundus/${params.id}${fundusSearch(url)}`);
|
||||
},
|
||||
duplicate: async ({ locals, params }) => {
|
||||
duplicate: async ({ locals, params, url }) => {
|
||||
requireUser(locals.user);
|
||||
let id: string;
|
||||
try {
|
||||
@@ -251,7 +261,7 @@ export const actions = {
|
||||
} catch {
|
||||
return fail(400, { intent: 'duplicate', error: 'Das Piece konnte nicht dupliziert werden.' });
|
||||
}
|
||||
redirect(303, `/fundus/${id}`);
|
||||
redirect(303, `/fundus/${id}${fundusSearch(url)}`);
|
||||
},
|
||||
archive: async ({ locals, params, request }) => {
|
||||
requireUser(locals.user);
|
||||
|
||||
+680
-548
File diff suppressed because it is too large.
Load diff
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user