Release 0.5.9
This commit is contained in:
1 parent
2851c7d936
commit
9327176deb
32 files changed
+1598
-50
No files matched your search
@@ -337,3 +337,36 @@
|
||||
- `bash -n start.sh`: erfolgreich
|
||||
- reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt;
|
||||
Anbieterrequests wurden in den Browsertests lokal abgefangen
|
||||
|
||||
## Live-Werkbank 0.5.9
|
||||
|
||||
| Kriterium | Nachweis | Stand |
|
||||
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| Favorit ist globale Originaleigenschaft | Service prüft Revision/Status; Browser prüft Stern, Sortierung und weiterhin wirksamen Planfilter | erfüllt am 23.10.2026 |
|
||||
| Werkzeugkatalog und Rechte | Barkeeper-Verwaltung serverseitig geschützt; Browser prüft Anlage, Formatbezug und Deaktivierung | erfüllt am 23.10.2026 |
|
||||
| Deaktivierte Zuordnung bleibt bearbeitbar | Browser öffnet das Piece erneut und findet Werkzeug, Arbeitslink, Notiz und Deaktivierungsstatus | erfüllt am 23.10.2026 |
|
||||
| Relationen werden unveränderlich gesnapshottet | Service ändert Quelle, Werkzeug und Thema und sieht Änderungen erst nach expliziter Aktualisierung | erfüllt am 23.10.2026 |
|
||||
| Private Live-Materialien bleiben privat | Live-Steuerung zeigt Snapshot-Quelle; anonymes HTML/JSON enthält den privaten Materiallink nicht | erfüllt am 23.10.2026 |
|
||||
| Thematische Publikum-Dachzeile | Usage-Themen werden stabil projiziert; Chromium zeigt „Gaming“ oberhalb des Präsentationstitels | erfüllt am 23.10.2026 |
|
||||
| Persistenter Abschnitts-Countdown | kontrollierte Testzeit prüft eingefrorenes Soll, Reload, Piece-Wechsel und Abschluss ohne Reset | erfüllt am 23.10.2026 |
|
||||
| Responsive Bedienung | komplette Browsermatrix und gezielter Werkzeugeditor bei 390 × 844 ohne horizontalen Überlauf | erfüllt am 23.10.2026 |
|
||||
| Reale OBS- und Anbieterwiedergabe | Keine OBS-Installation und keine echten Anbieteraufrufe in der lokalen Prüfumgebung | offen |
|
||||
|
||||
## Ausgeführte Prüfungen 0.5.9
|
||||
|
||||
- `npm run lint`: erfolgreich
|
||||
- `npm run check`: 0 Fehler und 0 Warnungen
|
||||
- `npm test`: 37 Tests in 7 Dateien erfolgreich
|
||||
- `npm run test:e2e`: 16 von 16 Chromium-Browsertests erfolgreich
|
||||
- zusätzlicher gezielter Chromium-Lauf des Werkzeugeditors bei 390 × 844 Pixeln:
|
||||
1 von 1 erfolgreich, ohne horizontalen Überlauf
|
||||
- Browsermatrix bei 390, 768, 960, 1280 und 1440 Pixeln einschließlich
|
||||
Light/Dark, Planung, Live-Steuerung, anonymer Ausgabe und privater Materialien
|
||||
erfolgreich; Screenshots wurden durch die Suite erzeugt
|
||||
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
|
||||
- lokale Entwicklungsdatenbank vorab nach
|
||||
`.data/backups/0.5.9-pre-migration/sendewerk.sqlite` gesichert;
|
||||
`npm run db:migrate` hat `0009_live_workbench.sql` erfolgreich angewendet
|
||||
- `bash -n start.sh` und `git diff --check`: erfolgreich
|
||||
- reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt;
|
||||
Anbieterrequests wurden in den Browsertests lokal abgefangen
|
||||
+29
-16
@@ -37,18 +37,18 @@ Die ersten fünf Etappen materialisieren Identität, Inhaltsverwaltung,
|
||||
Sendungsplanung und Live-Durchführung. Folgende Grenzen gelten für umgesetzte
|
||||
und spätere Bereiche.
|
||||
|
||||
| Bereich | Geplante Entitäten und Beziehungen |
|
||||
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später |
|
||||
| Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft |
|
||||
| Inhalt | `pieces` mit Status, Dauer, Revision, getrennten Textbereichen, Quellen und privaten Medien; revisionierte Vorlagen speichern Snapshots |
|
||||
| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen sind umgesetzt; Original-ID bleibt Referenz |
|
||||
| Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe |
|
||||
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
|
||||
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
|
||||
| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen |
|
||||
| API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel |
|
||||
| Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie |
|
||||
| Bereich | Geplante Entitäten und Beziehungen |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später |
|
||||
| Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft |
|
||||
| Inhalt | `pieces` mit Status, Favorit, Dauer, Revision, Texten, Quellen, Werkzeugzuordnungen und privaten Medien; Vorlagen speichern Snapshots |
|
||||
| Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themensnapshots, Reservebank und Vorlagen; Original-ID bleibt Referenz |
|
||||
| Live | expliziter Live-Stand, aktuelles Element, tatsächliche Piece-Zeiten, persistente Abschnittstimer und getrennte Präsentationsausgabe |
|
||||
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
|
||||
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
|
||||
| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen |
|
||||
| API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel |
|
||||
| Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie |
|
||||
|
||||
### Historien- und Kopierregel
|
||||
|
||||
@@ -64,6 +64,11 @@ oder eine explizite Abschnittsdauer. Beides wird nie addiert. Reserveelemente
|
||||
zählen erst nach dem Einplanen. Geplante und tatsächliche Dauer bleiben
|
||||
getrennt.
|
||||
|
||||
Ein Live-Abschnittstimer entsteht ausschließlich beim tatsächlichen Eintritt in
|
||||
einen Abschnitt mit positiver expliziter Dauer. Er speichert dieses Soll als
|
||||
unveränderlichen Wert und summiert eigene Laufintervalle; Piece-Wechsel im
|
||||
selben Abschnitt erzeugen deshalb weder Reset noch doppelte Piece-Laufzeit.
|
||||
|
||||
## Anmeldung und Rechte
|
||||
|
||||
Etappe 2 ergänzt lokale Konten mit den festen Rollen `barkeeper` und
|
||||
@@ -97,6 +102,11 @@ erhalten ausschließlich kompakte Piece-Daten; private Recherche und vollständi
|
||||
Redaktionstexte werden nur an den angemeldeten Editor ausgeliefert. Themen und
|
||||
Abschnittskategorien sind Mehrfachzuordnungen, das Format bleibt eine einzelne
|
||||
unabhängige Zuordnung. Quellen werden gemeinsam mit einem Piece atomar ersetzt.
|
||||
`is_favorite` ist eine globale Eigenschaft des Originals und keine Nutzung oder
|
||||
Historienmarkierung. Der Werkzeugkatalog liegt getrennt von Formaten; Formate
|
||||
sind nur Eignungszuordnungen. `piece_tools` hält pro Piece den privaten
|
||||
Arbeitslink und eine Notiz, während Deaktivierung das Katalogobjekt und alle
|
||||
bestehenden Zuordnungen erhält.
|
||||
Ein Piece kann für die Präsentation Text oder genau eine vorhandene Quelle als
|
||||
Linkkarte beziehungsweise Video auswählen. Abgerufene Metadaten und Kennzeichen
|
||||
für manuell korrigierte Felder liegen am Piece; ein erneuter Abruf überschreibt
|
||||
@@ -137,7 +147,9 @@ weitere Bearbeitung geschützt.
|
||||
Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation,
|
||||
Überleitung und der vollständige Präsentationszustand aus dem sendefertigen
|
||||
Piece kopiert. Dazu gehören Modus, ausgewählte Quelladresse samt Zeitmarke,
|
||||
Metadaten und validierte Anbieter-ID. Eigene Notizen liegen ausschließlich an
|
||||
Metadaten und validierte Anbieter-ID. Zusätzlich speichern eigene geordnete
|
||||
Tabellen alle Quellen, Werkzeugnamen samt Start-/Arbeitslink und Notiz sowie die
|
||||
Themennamen. Eigene Notizen liegen ausschließlich an
|
||||
der Verwendung. Die Referenz zum Original bleibt für Historie und
|
||||
Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt. Eine explizite
|
||||
Aktualisierung aus dem Fundus ersetzt Titel, Dauer, alle fünf Redaktionstexte
|
||||
@@ -213,9 +225,10 @@ erneut ausgeben können muss. Datenbank und Sicherungen sind deshalb Teil der
|
||||
Geheimnisgrenze. Eine Rotation widerruft den bisherigen Schlüssel, normales
|
||||
erneutes Öffnen behält ihn bei.
|
||||
Page, Polling-JSON und Proxyrouten leiten ihre Daten aus einer eigenen
|
||||
Positivliste ab. Implizite Piece-Quellen und private Uploads werden nicht
|
||||
veröffentlicht; nur die ausdrücklich gewählte Link- oder Videoquelle und deren
|
||||
erlaubte Vorschau-URL können die Grenze passieren.
|
||||
Positivliste ab. Private Quellen-/Werkzeug-Snapshots, Arbeitslinks, Notizen und
|
||||
Uploads werden nicht veröffentlicht; nur Themennamen sowie die ausdrücklich
|
||||
gewählte Link- oder Videoquelle und deren erlaubte Vorschau-URL können die
|
||||
Grenze passieren.
|
||||
Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den
|
||||
Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den
|
||||
gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen
|
||||
|
||||
@@ -310,3 +310,27 @@ geschrieben. `planning_commands` macht die Befehls-ID eindeutig und speichert
|
||||
das Ergebnis, damit Wiederholungen nach Verbindungsabbrüchen keine Dubletten
|
||||
erzeugen. Interner und öffentlicher Titel werden getrennt gespeichert und beim
|
||||
Einplanen getrennt in den Verwendungssnapshot kopiert.
|
||||
|
||||
## D-022: Werkzeuge und Live-Abschnittszeit als eigene Snapshot-Grenzen
|
||||
|
||||
Favoriten bleiben ein boolesches Merkmal des Fundus-Originals. Sie ändern weder
|
||||
Revision, Status noch Verwendungshistorie und werden nicht pro Konto
|
||||
dupliziert. Der Werkzeugkatalog ist eine eigene Entität: Formatbeziehungen
|
||||
beschreiben nur die Eignung, während Piece-Zuordnungen den privaten Arbeitslink
|
||||
und die Notiz tragen. Katalogeinträge werden deaktiviert statt gelöscht, damit
|
||||
bestehende Piece- und Sendungsbezüge lesbar bleiben.
|
||||
|
||||
Beim Einplanen kopieren eigene Relationstabellen alle Quellen, Werkzeuge und
|
||||
sortierten Themennamen an die Verwendung. Damit können Katalognamen, URLs,
|
||||
Themen und Originalzuordnungen später geändert werden, ohne eine vorbereitete
|
||||
Sendung still umzuschreiben. Nur die bewusste Aktion **Aus Fundus aktualisieren**
|
||||
ersetzt diese Snapshots. Die öffentliche Positivliste darf daraus ausschließlich
|
||||
die Themen-Dachzeile übernehmen; private Materiallinks und Werkzeugdaten bleiben
|
||||
auf der authentifizierten Live-Route.
|
||||
|
||||
Der Abschnitts-Countdown verwendet von den Piece-Laufintervallen getrennte
|
||||
`live_section_timers` und `live_section_timer_runs`. Beim ersten tatsächlichen
|
||||
Eintritt wird eine positive explizite Abschnittsdauer eingefroren. Weitere
|
||||
Pieces desselben Abschnitts nutzen denselben Timer; Abschnittswechsel schließen
|
||||
ihn. So bleiben Reload und Wiederaufnahme persistent, ohne die vorhandene
|
||||
Piece-Zeiterfassung doppelt zu zählen.
|
||||
@@ -7,6 +7,33 @@ Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
|
||||
Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert.
|
||||
Etappe 6 wurde nicht begonnen.
|
||||
|
||||
## Live-Werkbank 0.5.9
|
||||
|
||||
- globale Favoriten am Fundus-Original mit stabiler Vorsortierung; verwendete
|
||||
Favoriten bleiben sichtbar, geplante Favoriten respektieren weiterhin den
|
||||
Einplanungsfilter
|
||||
- Barkeeper-Werkzeugkatalog mit Start-URL, Beschreibung, passenden Formaten und
|
||||
verlustfreier Deaktivierung; Arbeitslink und Notiz bleiben für die gesamte
|
||||
Thekencrew im gemeinsamen Piece- und Verwendungseditor bearbeitbar
|
||||
- vollständige, voneinander unabhängige Usage-Snapshots für Quellen, Werkzeuge
|
||||
und alphabetisch stabilisierte Themen; spätere Original- oder
|
||||
Katalogänderungen wirken erst nach bewusster Aktualisierung aus dem Fundus
|
||||
- kompakte private Materialliste in der Live-Steuerung mit Arbeitslink vor
|
||||
Start-URL und verständlichem Zustand ohne URL
|
||||
- responsive Themen-Dachzeile in der Publikumsausgabe; ohne Thema bleibt sie
|
||||
vollständig aus, während Präsentations- und interner Titel getrennt bleiben
|
||||
- persistenter Abschnitts-Countdown für positive explizite Abschnittsdauern mit
|
||||
eingefrorenem Soll, sekündlicher Anzeige, Goldwarnung ab zwei Minuten und
|
||||
roter Überziehung ohne Ton, Blinken oder automatische Weiterschaltung
|
||||
|
||||
Etappe 6 bleibt unverändert unbegonnen. Reales OBS und echte
|
||||
YouTube-/TikTok-Wiedergabe bleiben mangels verfügbarer Umgebung offen.
|
||||
|
||||
Die lokale Prüfmatrix umfasst 37 Verhaltenstests und 16 Chromium-Abläufe.
|
||||
Desktop, Smartphone, Light/Dark, Favoritenfilter, Werkzeug-Deaktivierung,
|
||||
Snapshot-Stabilität, private Live-Materialien, öffentliche Themenprojektion und
|
||||
persistente Abschnittszeit wurden geprüft.
|
||||
|
||||
## Redaktionelle Arbeitsabläufe 0.5.8
|
||||
|
||||
- persistente Piece-Vorlagen als unabhängige, revisionierte Snapshots; eigene
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
ALTER TABLE pieces ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (is_favorite IN (0, 1));
|
||||
|
||||
CREATE TABLE tools (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
start_url TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||
created_at_utc TEXT NOT NULL,
|
||||
updated_at_utc TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE tool_formats (
|
||||
tool_id TEXT NOT NULL REFERENCES tools(id) ON DELETE CASCADE,
|
||||
format_id TEXT NOT NULL REFERENCES formats(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (tool_id, format_id)
|
||||
);
|
||||
|
||||
CREATE TABLE piece_tools (
|
||||
id TEXT PRIMARY KEY,
|
||||
piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE CASCADE,
|
||||
tool_id TEXT NOT NULL REFERENCES tools(id) ON DELETE RESTRICT,
|
||||
work_url TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
position INTEGER NOT NULL CHECK (position >= 0),
|
||||
UNIQUE (piece_id, tool_id),
|
||||
UNIQUE (piece_id, position)
|
||||
);
|
||||
|
||||
CREATE TABLE usage_source_snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
timecode_seconds INTEGER CHECK (timecode_seconds IS NULL OR timecode_seconds >= 0),
|
||||
position INTEGER NOT NULL CHECK (position >= 0),
|
||||
UNIQUE (usage_id, position)
|
||||
);
|
||||
|
||||
CREATE TABLE usage_tool_snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
|
||||
original_tool_id TEXT REFERENCES tools(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
start_url TEXT NOT NULL DEFAULT '',
|
||||
work_url TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
position INTEGER NOT NULL CHECK (position >= 0),
|
||||
UNIQUE (usage_id, position)
|
||||
);
|
||||
|
||||
CREATE TABLE usage_topic_snapshots (
|
||||
usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL CHECK (position >= 0),
|
||||
PRIMARY KEY (usage_id, position)
|
||||
);
|
||||
|
||||
CREATE TABLE live_section_timers (
|
||||
id TEXT PRIMARY KEY,
|
||||
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
|
||||
section_id TEXT NOT NULL REFERENCES show_sections(id) ON DELETE CASCADE,
|
||||
planned_duration_seconds INTEGER NOT NULL CHECK (planned_duration_seconds > 0),
|
||||
started_at_utc TEXT NOT NULL,
|
||||
ended_at_utc TEXT,
|
||||
CHECK (ended_at_utc IS NULL OR ended_at_utc >= started_at_utc)
|
||||
);
|
||||
|
||||
CREATE TABLE live_section_timer_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
timer_id TEXT NOT NULL REFERENCES live_section_timers(id) ON DELETE CASCADE,
|
||||
started_at_utc TEXT NOT NULL,
|
||||
ended_at_utc TEXT,
|
||||
CHECK (ended_at_utc IS NULL OR ended_at_utc >= started_at_utc)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_piece_tools_piece ON piece_tools(piece_id, position);
|
||||
CREATE INDEX idx_tool_formats_format ON tool_formats(format_id, tool_id);
|
||||
CREATE INDEX idx_usage_sources_usage ON usage_source_snapshots(usage_id, position);
|
||||
CREATE INDEX idx_usage_tools_usage ON usage_tool_snapshots(usage_id, position);
|
||||
CREATE UNIQUE INDEX idx_one_open_section_timer_per_show
|
||||
ON live_section_timers(show_id) WHERE ended_at_utc IS NULL;
|
||||
CREATE UNIQUE INDEX idx_one_open_run_per_section_timer
|
||||
ON live_section_timer_runs(timer_id) WHERE ended_at_utc IS NULL;
|
||||
|
||||
INSERT INTO usage_source_snapshots (
|
||||
id, usage_id, label, url, timecode_seconds, position
|
||||
)
|
||||
SELECT lower(hex(randomblob(16))), u.id, ps.label, ps.url, ps.timecode_seconds, ps.position
|
||||
FROM piece_usages u
|
||||
JOIN piece_sources ps ON ps.piece_id = u.original_piece_id;
|
||||
|
||||
INSERT INTO usage_topic_snapshots (usage_id, name, position)
|
||||
SELECT usage_id, name, position
|
||||
FROM (
|
||||
SELECT u.id AS usage_id, t.name,
|
||||
row_number() OVER (PARTITION BY u.id ORDER BY t.name, t.id) - 1 AS position
|
||||
FROM piece_usages u
|
||||
JOIN piece_topics pt ON pt.piece_id = u.original_piece_id
|
||||
JOIN topics t ON t.id = pt.topic_id
|
||||
);
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sendewerk",
|
||||
"version": "0.5.8",
|
||||
"version": "0.5.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sendewerk",
|
||||
"version": "0.5.8",
|
||||
"version": "0.5.9",
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.2.2",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "sendewerk",
|
||||
"private": true,
|
||||
"version": "0.5.8",
|
||||
"version": "0.5.9",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.17 <25"
|
||||
|
||||
@@ -425,6 +425,28 @@ export function seed(databasePath = getPaths().database) {
|
||||
]
|
||||
]
|
||||
);
|
||||
database.prepare('DELETE FROM usage_source_snapshots').run();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO usage_source_snapshots
|
||||
(id, usage_id, label, url, timecode_seconds, position)
|
||||
SELECT 'seed-source-' || u.id || '-' || ps.position, u.id, ps.label, ps.url,
|
||||
ps.timecode_seconds, ps.position
|
||||
FROM piece_usages u JOIN piece_sources ps ON ps.piece_id = u.original_piece_id`
|
||||
)
|
||||
.run();
|
||||
database.prepare('DELETE FROM usage_topic_snapshots').run();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO usage_topic_snapshots (usage_id, name, position)
|
||||
SELECT usage_id, name, position FROM (
|
||||
SELECT u.id AS usage_id, t.name,
|
||||
row_number() OVER (PARTITION BY u.id ORDER BY t.name, t.id) - 1 AS position
|
||||
FROM piece_usages u JOIN piece_topics pt ON pt.piece_id = u.original_piece_id
|
||||
JOIN topics t ON t.id = pt.topic_id
|
||||
)`
|
||||
)
|
||||
.run();
|
||||
});
|
||||
|
||||
seedDatabase();
|
||||
|
||||
+116
@@ -1439,6 +1439,87 @@ textarea:focus {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.output-topics {
|
||||
max-width: 100%;
|
||||
margin: 0 0 0.55rem;
|
||||
overflow-wrap: anywhere;
|
||||
color: #f3ba63;
|
||||
font-size: clamp(0.72rem, 1.4vw, 1rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.09em;
|
||||
line-height: 1.35;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-countdown {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.section-countdown strong {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.section-countdown.warning {
|
||||
border-color: #f3ba63;
|
||||
color: #f3ba63;
|
||||
}
|
||||
|
||||
.section-countdown.overrun {
|
||||
border-color: #ff776d;
|
||||
color: #ff776d;
|
||||
}
|
||||
|
||||
.live-materials {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(9rem, 0.45fr) repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.7rem;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.live-materials h3,
|
||||
.live-materials ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.live-materials ul {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.live-material-group li,
|
||||
.live-material-group small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.live-material-group small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.live-materials {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-countdown {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.output-content h1,
|
||||
.output-idle h1 {
|
||||
max-width: 16ch;
|
||||
@@ -2347,6 +2428,41 @@ textarea:focus {
|
||||
grid-template-columns: auto minmax(0, 1.65fr) minmax(8rem, 0.85fr) minmax(9rem, auto) auto;
|
||||
}
|
||||
|
||||
.piece-favorite-form {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
left: 0.35rem;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.piece-favorite {
|
||||
display: grid;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--glass-strong);
|
||||
color: var(--text-faint);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.piece-favorite.active,
|
||||
.piece-favorite:hover,
|
||||
.piece-favorite:focus-visible {
|
||||
border-color: #f3ba63;
|
||||
color: #f3ba63;
|
||||
}
|
||||
|
||||
.piece-editor-kicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.piece-card:has(.piece-actions[open]) {
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
{#if content.mode === 'text'}
|
||||
<article class="output-content output-text">
|
||||
<p class="output-kicker">heinzlive</p>
|
||||
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
|
||||
<h1>{content.title}</h1>
|
||||
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
@@ -66,6 +66,7 @@
|
||||
<img src={imageSrc} alt="" onerror={() => (imageUnavailable = true)} />
|
||||
</div>{/if}
|
||||
<div class="output-link-copy">
|
||||
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
|
||||
<p class="output-kicker">{content.siteName || content.source?.label || 'Webseite'}</p>
|
||||
<h1>{content.previewTitle || content.title}</h1>
|
||||
{#if content.description}<p>{content.description}</p>{/if}
|
||||
@@ -79,6 +80,7 @@
|
||||
{:else}
|
||||
<article class:portrait={content.provider === 'tiktok'} class="output-content output-video">
|
||||
<header>
|
||||
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
|
||||
<p class="output-kicker">{content.siteName || content.source?.label || 'Video'}</p>
|
||||
<h1>{content.previewTitle || content.title}</h1>
|
||||
</header>
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
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 })));
|
||||
let draft = $state(
|
||||
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
|
||||
);
|
||||
let dirty = $state(false);
|
||||
|
||||
function durationInMinutes(seconds: number) {
|
||||
@@ -163,13 +165,40 @@
|
||||
/>
|
||||
</div>
|
||||
<section class="piece-source-summary">
|
||||
<p class="eyebrow">Snapshot-Quelle</p>
|
||||
{#if draft.presentationSourceUrl}
|
||||
<strong>{draft.presentationSourceLabel || draft.presentationSourceUrl}</strong>
|
||||
<small>{draft.presentationSourceUrl}</small>
|
||||
{:else}
|
||||
<p>Für diese Verwendung wurde keine Präsentationsquelle kopiert.</p>
|
||||
{/if}
|
||||
<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>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
savePiece,
|
||||
savePieceAsTemplate,
|
||||
setPieceArchived,
|
||||
setPieceFavorite,
|
||||
uploadPieceImage,
|
||||
type PieceInput
|
||||
} from './content.js';
|
||||
@@ -76,6 +77,18 @@ function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
|
||||
}
|
||||
|
||||
describe('content management', () => {
|
||||
it('sorts global favorites first without changing their revision', () => {
|
||||
const before = getPiece('piece-console')!;
|
||||
setPieceFavorite('piece-console', true);
|
||||
const pieces = listPieces({ statuses: ['ready'] });
|
||||
expect(pieces[0]).toMatchObject({ id: 'piece-console', isFavorite: true });
|
||||
expect(getPiece('piece-console')).toMatchObject({
|
||||
isFavorite: true,
|
||||
revision: before.revision,
|
||||
status: before.status
|
||||
});
|
||||
});
|
||||
|
||||
it('persists independently versioned templates and copies their complete snapshot', () => {
|
||||
const sourceId = createPiece('talk');
|
||||
const source = getPiece(sourceId)!;
|
||||
|
||||
@@ -21,6 +21,19 @@ export type PieceSourceInput = {
|
||||
timecodeSeconds: number | null;
|
||||
};
|
||||
|
||||
export type PieceToolInput = {
|
||||
toolId: string;
|
||||
workUrl: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export type PieceTool = PieceToolInput & {
|
||||
id: string;
|
||||
name: string;
|
||||
startUrl: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type PieceInput = {
|
||||
title: string;
|
||||
outputTitle: string;
|
||||
@@ -48,6 +61,7 @@ export type PieceInput = {
|
||||
topicIds: string[];
|
||||
categoryIds: string[];
|
||||
sources: PieceSourceInput[];
|
||||
tools?: PieceToolInput[];
|
||||
};
|
||||
|
||||
export type PieceListItem = {
|
||||
@@ -63,15 +77,18 @@ export type PieceListItem = {
|
||||
topics: { id: string; name: string }[];
|
||||
sourceCount: number;
|
||||
mediaId: string | null;
|
||||
isFavorite: boolean;
|
||||
};
|
||||
|
||||
export type PieceEditor = Omit<PieceInput, 'status'> & {
|
||||
export type PieceEditor = Omit<PieceInput, 'status' | 'tools'> & {
|
||||
id: string;
|
||||
status: PieceStatus;
|
||||
revision: number;
|
||||
createdAtUtc: string;
|
||||
updatedAtUtc: string;
|
||||
media: PieceMedia[];
|
||||
isFavorite: boolean;
|
||||
tools: PieceTool[];
|
||||
};
|
||||
|
||||
export type PieceMedia = {
|
||||
@@ -123,6 +140,32 @@ function validUrl(value: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanPieceTools(values: PieceToolInput[] = []): PieceToolInput[] {
|
||||
if (values.length > 20) throw new Error('INVALID_TOOLS');
|
||||
const ids = uniqueIds(values.map((value) => value.toolId));
|
||||
if (ids.length !== values.length) throw new Error('INVALID_TOOLS');
|
||||
if (ids.length) {
|
||||
const placeholders = ids.map(() => '?').join(', ');
|
||||
const row = getDatabase()
|
||||
.prepare(`SELECT count(*) AS count FROM tools WHERE id IN (${placeholders})`)
|
||||
.get(...ids) as { count: number };
|
||||
if (row.count !== ids.length) throw new Error('INVALID_TOOLS');
|
||||
}
|
||||
return values.map((value) => {
|
||||
const workUrl = cleanText(value.workUrl, 2_000, 'tools');
|
||||
if (workUrl) {
|
||||
if (!validUrl(workUrl)) throw new Error('INVALID_TOOLS');
|
||||
const parsed = new URL(workUrl);
|
||||
if (parsed.username || parsed.password) throw new Error('INVALID_TOOLS');
|
||||
}
|
||||
return {
|
||||
toolId: value.toolId,
|
||||
workUrl,
|
||||
note: cleanText(value.note, 2_000, 'tools')
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function requireTaxonomyIds(table: 'formats' | 'topics' | 'section_categories', ids: string[]) {
|
||||
if (!ids.length) return;
|
||||
const placeholders = ids.map(() => '?').join(', ');
|
||||
@@ -213,7 +256,8 @@ function validatePieceInput(input: PieceInput): PieceInput {
|
||||
presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null,
|
||||
topicIds,
|
||||
categoryIds,
|
||||
sources
|
||||
sources,
|
||||
tools: cleanPieceTools(input.tools)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -244,6 +288,7 @@ type PieceListRow = {
|
||||
format: string;
|
||||
source_count: number;
|
||||
media_id: string | null;
|
||||
is_favorite: number;
|
||||
};
|
||||
|
||||
export function listPieces(filters: {
|
||||
@@ -288,13 +333,14 @@ export function listPieces(filters: {
|
||||
const database = getDatabase();
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds,
|
||||
`SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds, p.is_favorite,
|
||||
p.format_id, COALESCE(f.name, 'Ohne Format') AS format,
|
||||
(SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count,
|
||||
(SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id ORDER BY pm.position LIMIT 1) AS media_id
|
||||
FROM pieces p LEFT JOIN formats f ON f.id = p.format_id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY CASE p.status WHEN 'ready' THEN 0 WHEN 'preparing' THEN 1 WHEN 'inbox' THEN 2 ELSE 3 END,
|
||||
ORDER BY p.is_favorite DESC,
|
||||
CASE p.status WHEN 'ready' THEN 0 WHEN 'preparing' THEN 1 WHEN 'inbox' THEN 2 ELSE 3 END,
|
||||
p.updated_at_utc DESC, p.title`
|
||||
)
|
||||
.all(...parameters) as PieceListRow[];
|
||||
@@ -319,7 +365,8 @@ export function listPieces(filters: {
|
||||
categories: categoryStatement.all(row.id) as PieceListItem['categories'],
|
||||
topics: topicStatement.all(row.id) as PieceListItem['topics'],
|
||||
sourceCount: row.source_count,
|
||||
mediaId: row.media_id
|
||||
mediaId: row.media_id,
|
||||
isFavorite: row.is_favorite === 1
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -351,6 +398,7 @@ type PieceRow = {
|
||||
revision: number;
|
||||
created_at_utc: string;
|
||||
updated_at_utc: string;
|
||||
is_favorite: number;
|
||||
};
|
||||
|
||||
export function getPiece(id: string): PieceEditor | null {
|
||||
@@ -363,7 +411,7 @@ export function getPiece(id: string): PieceEditor | null {
|
||||
presentation_image_url, presentation_site_name, presentation_provider,
|
||||
presentation_provider_id, presentation_title_manual, presentation_description_manual,
|
||||
presentation_image_manual, presentation_metadata_fetched_at_utc,
|
||||
revision, created_at_utc, updated_at_utc
|
||||
revision, created_at_utc, updated_at_utc, is_favorite
|
||||
FROM pieces WHERE id = ?`
|
||||
)
|
||||
.get(id) as PieceRow | undefined;
|
||||
@@ -396,6 +444,7 @@ export function getPiece(id: string): PieceEditor | null {
|
||||
revision: row.revision,
|
||||
createdAtUtc: row.created_at_utc,
|
||||
updatedAtUtc: row.updated_at_utc,
|
||||
isFavorite: row.is_favorite === 1,
|
||||
topicIds: (
|
||||
database.prepare('SELECT topic_id FROM piece_topics WHERE piece_id = ?').all(id) as {
|
||||
topic_id: string;
|
||||
@@ -438,6 +487,31 @@ export function getPiece(id: string): PieceEditor | null {
|
||||
byteSize: media.byte_size,
|
||||
altText: media.alt_text,
|
||||
position: media.position
|
||||
})),
|
||||
tools: (
|
||||
database
|
||||
.prepare(
|
||||
`SELECT pt.id, pt.tool_id, pt.work_url, pt.note, t.name, t.start_url, t.is_active
|
||||
FROM piece_tools pt JOIN tools t ON t.id = pt.tool_id
|
||||
WHERE pt.piece_id = ? ORDER BY pt.position`
|
||||
)
|
||||
.all(id) as {
|
||||
id: string;
|
||||
tool_id: string;
|
||||
work_url: string;
|
||||
note: string;
|
||||
name: string;
|
||||
start_url: string;
|
||||
is_active: number;
|
||||
}[]
|
||||
).map((tool) => ({
|
||||
id: tool.id,
|
||||
toolId: tool.tool_id,
|
||||
workUrl: tool.work_url,
|
||||
note: tool.note,
|
||||
name: tool.name,
|
||||
startUrl: tool.start_url,
|
||||
isActive: tool.is_active === 1
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -460,6 +534,21 @@ function replaceRelations(pieceId: string, input: PieceInput) {
|
||||
input.sources.forEach((source, position) =>
|
||||
addSource.run(randomUUID(), pieceId, source.label, source.url, source.timecodeSeconds, position)
|
||||
);
|
||||
database.prepare('DELETE FROM piece_tools WHERE piece_id = ?').run(pieceId);
|
||||
const addTool = database.prepare(
|
||||
`INSERT INTO piece_tools (id, piece_id, tool_id, work_url, note, position)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
(input.tools ?? []).forEach((tool, position) =>
|
||||
addTool.run(randomUUID(), pieceId, tool.toolId, tool.workUrl, tool.note, position)
|
||||
);
|
||||
}
|
||||
|
||||
export function setPieceFavorite(id: string, favorite: boolean) {
|
||||
const result = getDatabase()
|
||||
.prepare('UPDATE pieces SET is_favorite = ? WHERE id = ?')
|
||||
.run(favorite ? 1 : 0, id);
|
||||
if (result.changes !== 1) throw new Error('NOT_FOUND');
|
||||
}
|
||||
|
||||
export function savePiece(id: string, expectedRevision: number, rawInput: PieceInput) {
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('database setup', () => {
|
||||
database.close();
|
||||
|
||||
expect(counts).toEqual({
|
||||
migrations: 8,
|
||||
migrations: 9,
|
||||
pieces: 6,
|
||||
shows: 2,
|
||||
usages: 5,
|
||||
|
||||
@@ -40,6 +40,48 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('live mode', () => {
|
||||
it('persists a frozen section countdown across usage changes and reloads', () => {
|
||||
getDatabase()
|
||||
.prepare(
|
||||
"UPDATE show_sections SET explicit_duration_seconds = 300 WHERE id = 'section-stories'"
|
||||
)
|
||||
.run();
|
||||
const start = new Date('2026-10-23T18:00:00.000Z');
|
||||
startOrResumeLive('show-october', 'timer-start', 0, start);
|
||||
let live = getLiveView('show-october', new Date('2026-10-23T18:01:40.000Z'));
|
||||
expect(live.sectionTimer).toMatchObject({
|
||||
sectionId: 'section-stories',
|
||||
plannedDurationSeconds: 300,
|
||||
elapsedSeconds: 100
|
||||
});
|
||||
getDatabase()
|
||||
.prepare(
|
||||
"UPDATE show_sections SET explicit_duration_seconds = 600 WHERE id = 'section-stories'"
|
||||
)
|
||||
.run();
|
||||
completeCurrent(
|
||||
'show-october',
|
||||
'timer-next-piece',
|
||||
live.liveRevision,
|
||||
new Date('2026-10-23T18:02:00.000Z')
|
||||
);
|
||||
live = getLiveView('show-october', new Date('2026-10-23T18:04:00.000Z'));
|
||||
expect(live.current?.id).toBe('usage-effects');
|
||||
expect(live.sectionTimer).toMatchObject({
|
||||
plannedDurationSeconds: 300,
|
||||
elapsedSeconds: 240
|
||||
});
|
||||
completeCurrent(
|
||||
'show-october',
|
||||
'timer-finish',
|
||||
live.liveRevision,
|
||||
new Date('2026-10-23T18:06:00.000Z')
|
||||
);
|
||||
expect(
|
||||
getLiveView('show-october', new Date('2026-10-23T18:07:00.000Z')).sectionTimer
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('selects any rundown usage by stable id, including the first and completed entries', () => {
|
||||
let live = getLiveView('show-october');
|
||||
expect(() =>
|
||||
@@ -161,6 +203,14 @@ describe('live mode', () => {
|
||||
0,
|
||||
'2026-10-22T18:00:00.000Z'
|
||||
);
|
||||
getDatabase()
|
||||
.prepare(
|
||||
`INSERT INTO usage_tool_snapshots
|
||||
(id, usage_id, original_tool_id, name, start_url, work_url, note, position)
|
||||
VALUES ('private-live-tool', 'usage-console', NULL, 'Privates Werkzeug', '',
|
||||
'https://private.example.test/board', 'PRIVATE_TOOL_NOTE', 0)`
|
||||
)
|
||||
.run();
|
||||
|
||||
const token = issuePresentationAccess('show-october');
|
||||
expect(getPresentationView(token)).toEqual({ revision: 0, content: null });
|
||||
@@ -171,6 +221,7 @@ describe('live mode', () => {
|
||||
const output = getPresentationView(token)!;
|
||||
expect(output.content).toMatchObject({
|
||||
title: 'Die kleine Konsole mit dem großen Dreh',
|
||||
topics: ['Gaming'],
|
||||
images: [],
|
||||
sources: []
|
||||
});
|
||||
@@ -180,6 +231,9 @@ describe('live mode', () => {
|
||||
expect(serialized).not.toContain('Moderationsstichpunkte');
|
||||
expect(serialized).not.toContain('private-original-name');
|
||||
expect(serialized).not.toContain('presentation.png');
|
||||
expect(serialized).not.toContain('https://example.com/console');
|
||||
expect(serialized).not.toContain('https://private.example.test/board');
|
||||
expect(serialized).not.toContain('PRIVATE_TOOL_NOTE');
|
||||
|
||||
const media = await getPresentationMedia(token, 'presentation-image');
|
||||
expect(media).toBeNull();
|
||||
|
||||
+110
-2
@@ -62,12 +62,20 @@ export type LiveView = {
|
||||
next: LiveUsage | null;
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
sectionTimer: null | {
|
||||
sectionId: string;
|
||||
sectionName: string;
|
||||
plannedDurationSeconds: number;
|
||||
elapsedSeconds: number;
|
||||
runningSinceUtc: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type PresentationView = {
|
||||
revision: number;
|
||||
content: null | {
|
||||
mode: PresentationContent['mode'];
|
||||
topics: string[];
|
||||
title: string;
|
||||
html: string;
|
||||
source: PresentationContent['source'];
|
||||
@@ -141,6 +149,59 @@ function closeActiveRun(database: Database.Database, showId: string, now: string
|
||||
.run(now, showId);
|
||||
}
|
||||
|
||||
function closeSectionTimer(database: Database.Database, showId: string, now: string) {
|
||||
const timer = database
|
||||
.prepare('SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL')
|
||||
.get(showId) as { id: string } | undefined;
|
||||
if (!timer) return;
|
||||
database
|
||||
.prepare(
|
||||
'UPDATE live_section_timer_runs SET ended_at_utc = ? WHERE timer_id = ? AND ended_at_utc IS NULL'
|
||||
)
|
||||
.run(now, timer.id);
|
||||
database
|
||||
.prepare('UPDATE live_section_timers SET ended_at_utc = ? WHERE id = ?')
|
||||
.run(now, timer.id);
|
||||
}
|
||||
|
||||
function syncSectionTimer(
|
||||
database: Database.Database,
|
||||
showId: string,
|
||||
usageId: string,
|
||||
now: string
|
||||
) {
|
||||
const section = database
|
||||
.prepare(
|
||||
`SELECT s.id, s.explicit_duration_seconds FROM piece_usages u
|
||||
JOIN show_sections s ON s.id = u.show_section_id
|
||||
WHERE u.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0`
|
||||
)
|
||||
.get(usageId, showId) as { id: string; explicit_duration_seconds: number | null } | undefined;
|
||||
if (!section) throw new Error('NOT_FOUND');
|
||||
const active = database
|
||||
.prepare(
|
||||
'SELECT id, section_id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL'
|
||||
)
|
||||
.get(showId) as { id: string; section_id: string } | undefined;
|
||||
if (active?.section_id === section.id) return;
|
||||
if (active) closeSectionTimer(database, showId, now);
|
||||
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) return;
|
||||
const timerId = randomUUID();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_section_timers
|
||||
(id, show_id, section_id, planned_duration_seconds, started_at_utc)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(timerId, showId, section.id, section.explicit_duration_seconds, now);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
|
||||
VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), timerId, now);
|
||||
}
|
||||
|
||||
function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) {
|
||||
usageBelongsToShow(database, showId, usageId);
|
||||
database
|
||||
@@ -164,6 +225,7 @@ function activateUsage(database: Database.Database, showId: string, usageId: str
|
||||
'UPDATE show_live_states SET current_usage_id = ?, ended_at_utc = NULL WHERE show_id = ?'
|
||||
)
|
||||
.run(usageId, showId);
|
||||
syncSectionTimer(database, showId, usageId, now);
|
||||
}
|
||||
|
||||
function executeCommand(
|
||||
@@ -268,6 +330,44 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
|
||||
const next =
|
||||
usages.slice(currentIndex + 1).find((usage) => usage.status === 'open') ??
|
||||
(current ? null : (usages.find((usage) => usage.status === 'open') ?? null));
|
||||
const timer = database
|
||||
.prepare(
|
||||
`SELECT t.id, t.section_id, s.name AS section_name, t.planned_duration_seconds
|
||||
FROM live_section_timers t JOIN show_sections s ON s.id = t.section_id
|
||||
WHERE t.show_id = ? AND t.ended_at_utc IS NULL`
|
||||
)
|
||||
.get(showId) as
|
||||
| { id: string; section_id: string; section_name: string; planned_duration_seconds: number }
|
||||
| undefined;
|
||||
let sectionTimer: LiveView['sectionTimer'] = null;
|
||||
if (timer) {
|
||||
const runs = database
|
||||
.prepare(
|
||||
`SELECT started_at_utc, ended_at_utc FROM live_section_timer_runs
|
||||
WHERE timer_id = ? ORDER BY started_at_utc`
|
||||
)
|
||||
.all(timer.id) as { started_at_utc: string; ended_at_utc: string | null }[];
|
||||
const running = runs.find((run) => run.ended_at_utc === null) ?? null;
|
||||
const elapsedSeconds = runs.reduce(
|
||||
(total, run) =>
|
||||
total +
|
||||
Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(new Date(run.ended_at_utc ?? now).getTime() - new Date(run.started_at_utc).getTime()) /
|
||||
1_000
|
||||
)
|
||||
),
|
||||
0
|
||||
);
|
||||
sectionTimer = {
|
||||
sectionId: timer.section_id,
|
||||
sectionName: timer.section_name,
|
||||
plannedDurationSeconds: timer.planned_duration_seconds,
|
||||
elapsedSeconds,
|
||||
runningSinceUtc: running?.started_at_utc ?? null
|
||||
};
|
||||
}
|
||||
return {
|
||||
show: {
|
||||
id: show.id,
|
||||
@@ -287,7 +387,8 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
|
||||
next,
|
||||
completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped')
|
||||
.length,
|
||||
totalCount: usages.length
|
||||
totalCount: usages.length,
|
||||
sectionTimer
|
||||
};
|
||||
}
|
||||
|
||||
@@ -353,10 +454,12 @@ function advanceCurrent(
|
||||
.run(status, state.current_usage_id);
|
||||
const next = ordered.slice(currentIndex + 1).find((usage) => usage.status === 'open');
|
||||
if (next) activateUsage(database, showId, next.id, now);
|
||||
else
|
||||
else {
|
||||
closeSectionTimer(database, showId, now);
|
||||
database
|
||||
.prepare('UPDATE show_live_states SET current_usage_id = NULL WHERE show_id = ?')
|
||||
.run(showId);
|
||||
}
|
||||
},
|
||||
nowValue
|
||||
);
|
||||
@@ -655,6 +758,11 @@ function getPresentationViewForShow(showId: string): PresentationView {
|
||||
revision: state.presentation_revision,
|
||||
content: {
|
||||
mode: usage.presentation_mode_snapshot,
|
||||
topics: (
|
||||
database
|
||||
.prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position')
|
||||
.all(state.presentation_usage_id) as { name: string }[]
|
||||
).map((topic) => topic.name),
|
||||
title: usage.output_title_snapshot || usage.title_snapshot,
|
||||
html: renderMarkdown(usage.presentation_snapshot),
|
||||
source: usage.presentation_source_url_snapshot
|
||||
|
||||
@@ -28,6 +28,7 @@ export type PresentationSource = {
|
||||
|
||||
export type PresentationContent = {
|
||||
mode: PresentationMode;
|
||||
topics: string[];
|
||||
title: string;
|
||||
html: string;
|
||||
source: PresentationSource | null;
|
||||
|
||||
@@ -464,6 +464,72 @@ describe('show builder data layer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps private source, tool and topic snapshots stable until an explicit refresh', () => {
|
||||
const database = getDatabase();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO tools (id, name, start_url, description, is_active, created_at_utc, updated_at_utc)
|
||||
VALUES ('tool-tierlist', 'Tierlist', 'https://example.com/start', '', 1, ?, ?)`
|
||||
)
|
||||
.run('2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z');
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO piece_tools (id, piece_id, tool_id, work_url, note, position)
|
||||
VALUES ('piece-tool-console', 'piece-console', 'tool-tierlist', 'https://example.com/board', 'Erster Stand', 0)`
|
||||
)
|
||||
.run();
|
||||
const show = createShow({ title: 'Relationen', startsAtUtc: null, templateId: null });
|
||||
const section = addShowSection(show.id, show.revision, {
|
||||
name: 'Block',
|
||||
categoryId: null,
|
||||
explicitDurationSeconds: null
|
||||
});
|
||||
const added = addPieceUsage(show.id, section.revision, {
|
||||
pieceId: 'piece-console',
|
||||
sectionId: section.id
|
||||
});
|
||||
let usage = getShow(show.id)!.sections[0].usages[0];
|
||||
expect(usage).toMatchObject({
|
||||
sources: [{ label: 'Herstellerseite', url: 'https://example.com/console' }],
|
||||
tools: [{ name: 'Tierlist', workUrl: 'https://example.com/board', note: 'Erster Stand' }],
|
||||
topics: ['Gaming']
|
||||
});
|
||||
|
||||
database
|
||||
.prepare("UPDATE piece_sources SET label = 'Neue Quelle' WHERE piece_id = 'piece-console'")
|
||||
.run();
|
||||
database
|
||||
.prepare("UPDATE tools SET name = 'Neuer Katalogname' WHERE id = 'tool-tierlist'")
|
||||
.run();
|
||||
database
|
||||
.prepare(
|
||||
"UPDATE piece_tools SET work_url = 'https://example.com/new' WHERE id = 'piece-tool-console'"
|
||||
)
|
||||
.run();
|
||||
database.prepare("DELETE FROM piece_topics WHERE piece_id = 'piece-console'").run();
|
||||
database
|
||||
.prepare(
|
||||
"INSERT INTO piece_topics (piece_id, topic_id) VALUES ('piece-console', 'topic-internet')"
|
||||
)
|
||||
.run();
|
||||
usage = getShow(show.id)!.sections[0].usages[0];
|
||||
expect(usage.sources[0].label).toBe('Herstellerseite');
|
||||
expect(usage.tools[0]).toMatchObject({
|
||||
name: 'Tierlist',
|
||||
workUrl: 'https://example.com/board'
|
||||
});
|
||||
expect(usage.topics).toEqual(['Gaming']);
|
||||
|
||||
refreshPieceUsageFromOriginal(show.id, usage.id, added.revision);
|
||||
usage = getShow(show.id)!.sections[0].usages[0];
|
||||
expect(usage.sources[0].label).toBe('Neue Quelle');
|
||||
expect(usage.tools[0]).toMatchObject({
|
||||
name: 'Neuer Katalogname',
|
||||
workUrl: 'https://example.com/new'
|
||||
});
|
||||
expect(usage.topics).toEqual(['Netzkultur']);
|
||||
});
|
||||
|
||||
it('persists normalized section and usage reordering, moving and removal', () => {
|
||||
const created = createShow({ title: 'Sortierung', startsAtUtc: null, templateId: null });
|
||||
const firstSection = addShowSection(created.id, created.revision, {
|
||||
|
||||
+233
-3
@@ -60,6 +60,16 @@ export type PieceUsage = {
|
||||
presentationProvider: string;
|
||||
presentationProviderId: string;
|
||||
ownNotes: string;
|
||||
sources: { id: string; label: string; url: string; timecodeSeconds: number | null }[];
|
||||
tools: {
|
||||
id: string;
|
||||
originalToolId: string | null;
|
||||
name: string;
|
||||
startUrl: string;
|
||||
workUrl: string;
|
||||
note: string;
|
||||
}[];
|
||||
topics: string[];
|
||||
};
|
||||
|
||||
export type SentPieceUsage = {
|
||||
@@ -147,6 +157,7 @@ export type PieceUsageUpdate = {
|
||||
presentationSiteName: string;
|
||||
ownNotes: string;
|
||||
durationSeconds: number;
|
||||
tools?: { id: string; workUrl: string; note: string }[];
|
||||
};
|
||||
|
||||
export type MovePieceUsageInput = {
|
||||
@@ -372,7 +383,7 @@ function normalizePositions(
|
||||
ids.forEach((id, position) => update.run(position, id));
|
||||
}
|
||||
|
||||
function usageFromRow(row: UsageRow): PieceUsage {
|
||||
function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage {
|
||||
return {
|
||||
id: row.id,
|
||||
originalPieceId: row.original_piece_id,
|
||||
@@ -396,10 +407,189 @@ function usageFromRow(row: UsageRow): PieceUsage {
|
||||
presentationSiteName: row.presentation_site_name_snapshot,
|
||||
presentationProvider: row.presentation_provider_snapshot,
|
||||
presentationProviderId: row.presentation_provider_id_snapshot,
|
||||
ownNotes: row.own_notes
|
||||
ownNotes: row.own_notes,
|
||||
sources: (
|
||||
database
|
||||
.prepare(
|
||||
`SELECT id, label, url, timecode_seconds FROM usage_source_snapshots
|
||||
WHERE usage_id = ? ORDER BY position`
|
||||
)
|
||||
.all(row.id) as {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
timecode_seconds: number | null;
|
||||
}[]
|
||||
).map((source) => ({
|
||||
id: source.id,
|
||||
label: source.label,
|
||||
url: source.url,
|
||||
timecodeSeconds: source.timecode_seconds
|
||||
})),
|
||||
tools: (
|
||||
database
|
||||
.prepare(
|
||||
`SELECT id, original_tool_id, name, start_url, work_url, note
|
||||
FROM usage_tool_snapshots WHERE usage_id = ? ORDER BY position`
|
||||
)
|
||||
.all(row.id) as {
|
||||
id: string;
|
||||
original_tool_id: string | null;
|
||||
name: string;
|
||||
start_url: string;
|
||||
work_url: string;
|
||||
note: string;
|
||||
}[]
|
||||
).map((tool) => ({
|
||||
id: tool.id,
|
||||
originalToolId: tool.original_tool_id,
|
||||
name: tool.name,
|
||||
startUrl: tool.start_url,
|
||||
workUrl: tool.work_url,
|
||||
note: tool.note
|
||||
})),
|
||||
topics: (
|
||||
database
|
||||
.prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position')
|
||||
.all(row.id) as { name: string }[]
|
||||
).map((topic) => topic.name)
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotPieceRelations(database: Database.Database, usageId: string, pieceId: string) {
|
||||
database.prepare('DELETE FROM usage_source_snapshots WHERE usage_id = ?').run(usageId);
|
||||
database.prepare('DELETE FROM usage_tool_snapshots WHERE usage_id = ?').run(usageId);
|
||||
database.prepare('DELETE FROM usage_topic_snapshots WHERE usage_id = ?').run(usageId);
|
||||
const sources = database
|
||||
.prepare(
|
||||
`SELECT label, url, timecode_seconds, position FROM piece_sources
|
||||
WHERE piece_id = ? ORDER BY position`
|
||||
)
|
||||
.all(pieceId) as {
|
||||
label: string;
|
||||
url: string;
|
||||
timecode_seconds: number | null;
|
||||
position: number;
|
||||
}[];
|
||||
const addSource = database.prepare(
|
||||
`INSERT INTO usage_source_snapshots (id, usage_id, label, url, timecode_seconds, position)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
for (const source of sources) {
|
||||
addSource.run(
|
||||
randomUUID(),
|
||||
usageId,
|
||||
source.label,
|
||||
source.url,
|
||||
source.timecode_seconds,
|
||||
source.position
|
||||
);
|
||||
}
|
||||
const tools = database
|
||||
.prepare(
|
||||
`SELECT pt.tool_id, t.name, t.start_url, pt.work_url, pt.note, pt.position
|
||||
FROM piece_tools pt JOIN tools t ON t.id = pt.tool_id
|
||||
WHERE pt.piece_id = ? ORDER BY pt.position`
|
||||
)
|
||||
.all(pieceId) as {
|
||||
tool_id: string;
|
||||
name: string;
|
||||
start_url: string;
|
||||
work_url: string;
|
||||
note: string;
|
||||
position: number;
|
||||
}[];
|
||||
const addTool = database.prepare(
|
||||
`INSERT INTO usage_tool_snapshots
|
||||
(id, usage_id, original_tool_id, name, start_url, work_url, note, position)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
for (const tool of tools) {
|
||||
addTool.run(
|
||||
randomUUID(),
|
||||
usageId,
|
||||
tool.tool_id,
|
||||
tool.name,
|
||||
tool.start_url,
|
||||
tool.work_url,
|
||||
tool.note,
|
||||
tool.position
|
||||
);
|
||||
}
|
||||
const topics = database
|
||||
.prepare(
|
||||
`SELECT t.name FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id
|
||||
WHERE pt.piece_id = ? ORDER BY t.name, t.id`
|
||||
)
|
||||
.all(pieceId) as { name: string }[];
|
||||
const addTopic = database.prepare(
|
||||
'INSERT INTO usage_topic_snapshots (usage_id, name, position) VALUES (?, ?, ?)'
|
||||
);
|
||||
topics.forEach((topic, position) => addTopic.run(usageId, topic.name, position));
|
||||
}
|
||||
|
||||
function copyUsageRelations(database: Database.Database, sourceUsageId: string, usageId: string) {
|
||||
const sources = database
|
||||
.prepare(
|
||||
`SELECT label, url, timecode_seconds, position FROM usage_source_snapshots
|
||||
WHERE usage_id = ? ORDER BY position`
|
||||
)
|
||||
.all(sourceUsageId) as {
|
||||
label: string;
|
||||
url: string;
|
||||
timecode_seconds: number | null;
|
||||
position: number;
|
||||
}[];
|
||||
const addSource = database.prepare(
|
||||
`INSERT INTO usage_source_snapshots (id, usage_id, label, url, timecode_seconds, position)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
for (const source of sources)
|
||||
addSource.run(
|
||||
randomUUID(),
|
||||
usageId,
|
||||
source.label,
|
||||
source.url,
|
||||
source.timecode_seconds,
|
||||
source.position
|
||||
);
|
||||
const tools = database
|
||||
.prepare(
|
||||
`SELECT original_tool_id, name, start_url, work_url, note, position
|
||||
FROM usage_tool_snapshots WHERE usage_id = ? ORDER BY position`
|
||||
)
|
||||
.all(sourceUsageId) as {
|
||||
original_tool_id: string | null;
|
||||
name: string;
|
||||
start_url: string;
|
||||
work_url: string;
|
||||
note: string;
|
||||
position: number;
|
||||
}[];
|
||||
const addTool = database.prepare(
|
||||
`INSERT INTO usage_tool_snapshots
|
||||
(id, usage_id, original_tool_id, name, start_url, work_url, note, position)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
for (const tool of tools)
|
||||
addTool.run(
|
||||
randomUUID(),
|
||||
usageId,
|
||||
tool.original_tool_id,
|
||||
tool.name,
|
||||
tool.start_url,
|
||||
tool.work_url,
|
||||
tool.note,
|
||||
tool.position
|
||||
);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO usage_topic_snapshots (usage_id, name, position)
|
||||
SELECT ?, name, position FROM usage_topic_snapshots WHERE usage_id = ?`
|
||||
)
|
||||
.run(usageId, sourceUsageId);
|
||||
}
|
||||
|
||||
export function listShows(): ShowListItem[] {
|
||||
return (
|
||||
getDatabase()
|
||||
@@ -497,7 +687,7 @@ export function getShow(id: string): Show | null {
|
||||
const usagesBySection = new Map<string, PieceUsage[]>();
|
||||
for (const row of usageRows) {
|
||||
const usages = usagesBySection.get(row.show_section_id) ?? [];
|
||||
usages.push(usageFromRow(row));
|
||||
usages.push(usageFromRow(database, row));
|
||||
usagesBySection.set(row.show_section_id, usages);
|
||||
}
|
||||
const sections = sectionRows
|
||||
@@ -889,6 +1079,7 @@ export function addPieceUsage(
|
||||
piece.presentation_provider_id,
|
||||
input.sectionId === null ? 1 : 0
|
||||
);
|
||||
snapshotPieceRelations(database, id, input.pieceId);
|
||||
ids.splice(position, 0, id);
|
||||
normalizePositions(database, 'piece_usages', ids);
|
||||
return { ...result, id };
|
||||
@@ -1018,6 +1209,7 @@ export function duplicatePieceUsage(
|
||||
source.own_notes,
|
||||
source.is_reserve
|
||||
);
|
||||
copyUsageRelations(database, usageId, id);
|
||||
ids.splice(sourcePosition + 1, 0, id);
|
||||
normalizePositions(database, 'piece_usages', ids);
|
||||
return { ...result, id };
|
||||
@@ -1102,6 +1294,15 @@ export function refreshPieceUsageFromOriginal(
|
||||
source.presentation_provider_id,
|
||||
usageId
|
||||
);
|
||||
snapshotPieceRelations(
|
||||
database,
|
||||
usageId,
|
||||
(
|
||||
database
|
||||
.prepare('SELECT original_piece_id FROM piece_usages WHERE id = ?')
|
||||
.get(usageId) as { original_piece_id: string }
|
||||
).original_piece_id
|
||||
);
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
@@ -1170,6 +1371,35 @@ export function updatePieceUsage(
|
||||
duration,
|
||||
usageId
|
||||
);
|
||||
if (input.tools) {
|
||||
const existing = database
|
||||
.prepare('SELECT id FROM usage_tool_snapshots WHERE usage_id = ?')
|
||||
.all(usageId) as { id: string }[];
|
||||
const existingIds = new Set(existing.map((tool) => tool.id));
|
||||
if (
|
||||
input.tools.length !== existing.length ||
|
||||
input.tools.some((tool) => !existingIds.has(tool.id))
|
||||
) {
|
||||
throw new Error('INVALID_TOOLS');
|
||||
}
|
||||
const updateTool = database.prepare(
|
||||
'UPDATE usage_tool_snapshots SET work_url = ?, note = ? WHERE id = ? AND usage_id = ?'
|
||||
);
|
||||
for (const tool of input.tools) {
|
||||
const workUrl = cleanText(tool.workUrl, 2_000, 'tools');
|
||||
if (workUrl) {
|
||||
try {
|
||||
const url = new URL(workUrl);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
||||
throw new Error();
|
||||
}
|
||||
} catch {
|
||||
throw new Error('INVALID_TOOLS');
|
||||
}
|
||||
}
|
||||
updateTool.run(workUrl, cleanText(tool.note, 2_000, 'tools'), tool.id, usageId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { closeDatabase } from './database.js';
|
||||
import { createTool, listTools, updateTool } from './tools.js';
|
||||
|
||||
let directory: string;
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-tools-test-'));
|
||||
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
|
||||
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
|
||||
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
|
||||
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
delete process.env.SENDEWERK_DB_PATH;
|
||||
delete process.env.SENDEWERK_UPLOAD_DIR;
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('tool catalog', () => {
|
||||
it('validates, classifies and deactivates tools without deleting them', () => {
|
||||
const id = createTool({
|
||||
name: 'Tierlist',
|
||||
startUrl: 'https://example.com/tierlist',
|
||||
description: 'Gemeinsames Ranking',
|
||||
isActive: true,
|
||||
formatIds: ['format-quiz']
|
||||
});
|
||||
expect(listTools()).toEqual([
|
||||
{
|
||||
id,
|
||||
name: 'Tierlist',
|
||||
startUrl: 'https://example.com/tierlist',
|
||||
description: 'Gemeinsames Ranking',
|
||||
isActive: true,
|
||||
formatIds: ['format-quiz']
|
||||
}
|
||||
]);
|
||||
updateTool(id, {
|
||||
name: 'Tierlist',
|
||||
startUrl: '',
|
||||
description: '',
|
||||
isActive: false,
|
||||
formatIds: []
|
||||
});
|
||||
expect(listTools()).toEqual([]);
|
||||
expect(listTools(true)[0]).toMatchObject({ id, isActive: false });
|
||||
expect(() =>
|
||||
createTool({
|
||||
name: 'Unsicher',
|
||||
startUrl: 'javascript:alert(1)',
|
||||
description: '',
|
||||
isActive: true,
|
||||
formatIds: []
|
||||
})
|
||||
).toThrow('INVALID_TOOL_URL');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDatabase } from './database.js';
|
||||
|
||||
export type Tool = {
|
||||
id: string;
|
||||
name: string;
|
||||
startUrl: string;
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
formatIds: string[];
|
||||
};
|
||||
|
||||
export type ToolInput = Omit<Tool, 'id'>;
|
||||
|
||||
function clean(value: unknown, maximum: number, required = false) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (text.length > maximum || (required && !text)) throw new Error('INVALID_TOOL');
|
||||
return text;
|
||||
}
|
||||
|
||||
function validUrl(value: string) {
|
||||
if (!value) return '';
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
|
||||
throw new Error();
|
||||
return url.href;
|
||||
} catch {
|
||||
throw new Error('INVALID_TOOL_URL');
|
||||
}
|
||||
}
|
||||
|
||||
function validated(input: ToolInput): ToolInput {
|
||||
const formatIds = [...new Set(input.formatIds.filter(Boolean))];
|
||||
if (formatIds.length > 30) throw new Error('INVALID_TOOL');
|
||||
if (formatIds.length) {
|
||||
const placeholders = formatIds.map(() => '?').join(', ');
|
||||
const row = getDatabase()
|
||||
.prepare(`SELECT count(*) AS count FROM formats WHERE id IN (${placeholders})`)
|
||||
.get(...formatIds) as { count: number };
|
||||
if (row.count !== formatIds.length) throw new Error('INVALID_TOOL_FORMAT');
|
||||
}
|
||||
return {
|
||||
name: clean(input.name, 120, true),
|
||||
startUrl: validUrl(clean(input.startUrl, 2_000)),
|
||||
description: clean(input.description, 2_000),
|
||||
isActive: Boolean(input.isActive),
|
||||
formatIds
|
||||
};
|
||||
}
|
||||
|
||||
export function listTools(includeInactive = false): Tool[] {
|
||||
const database = getDatabase();
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT id, name, start_url, description, is_active FROM tools
|
||||
${includeInactive ? '' : 'WHERE is_active = 1'} ORDER BY is_active DESC, name`
|
||||
)
|
||||
.all() as {
|
||||
id: string;
|
||||
name: string;
|
||||
start_url: string;
|
||||
description: string;
|
||||
is_active: number;
|
||||
}[];
|
||||
const formats = database.prepare(
|
||||
'SELECT format_id FROM tool_formats WHERE tool_id = ? ORDER BY format_id'
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
startUrl: row.start_url,
|
||||
description: row.description,
|
||||
isActive: row.is_active === 1,
|
||||
formatIds: (formats.all(row.id) as { format_id: string }[]).map((item) => item.format_id)
|
||||
}));
|
||||
}
|
||||
|
||||
function replaceFormats(toolId: string, formatIds: string[]) {
|
||||
const database = getDatabase();
|
||||
database.prepare('DELETE FROM tool_formats WHERE tool_id = ?').run(toolId);
|
||||
const insert = database.prepare('INSERT INTO tool_formats (tool_id, format_id) VALUES (?, ?)');
|
||||
for (const formatId of formatIds) insert.run(toolId, formatId);
|
||||
}
|
||||
|
||||
export function createTool(rawInput: ToolInput) {
|
||||
const input = validated(rawInput);
|
||||
const database = getDatabase();
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
try {
|
||||
database.transaction(() => {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO tools (id, name, start_url, description, is_active, created_at_utc, updated_at_utc)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(id, input.name, input.startUrl, input.description, input.isActive ? 1 : 0, now, now);
|
||||
replaceFormats(id, input.formatIds);
|
||||
})();
|
||||
} catch (cause) {
|
||||
if (cause instanceof Error && cause.message.includes('UNIQUE')) {
|
||||
throw new Error('TOOL_EXISTS', { cause });
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function updateTool(id: string, rawInput: ToolInput) {
|
||||
const input = validated(rawInput);
|
||||
const database = getDatabase();
|
||||
try {
|
||||
database.transaction(() => {
|
||||
const result = database
|
||||
.prepare(
|
||||
`UPDATE tools SET name = ?, start_url = ?, description = ?, is_active = ?, updated_at_utc = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
input.name,
|
||||
input.startUrl,
|
||||
input.description,
|
||||
input.isActive ? 1 : 0,
|
||||
new Date().toISOString(),
|
||||
id
|
||||
);
|
||||
if (result.changes !== 1) throw new Error('NOT_FOUND');
|
||||
replaceFormats(id, input.formatIds);
|
||||
})();
|
||||
} catch (cause) {
|
||||
if (cause instanceof Error && cause.message.includes('UNIQUE')) {
|
||||
throw new Error('TOOL_EXISTS', { cause });
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
getTaxonomy,
|
||||
listPieces,
|
||||
listPieceTemplates,
|
||||
setPieceArchived
|
||||
setPieceArchived,
|
||||
setPieceFavorite
|
||||
} from '#lib/server/content.js';
|
||||
import { getPieceUsageContexts } from '#lib/server/shows.js';
|
||||
|
||||
@@ -93,5 +94,15 @@ export const actions = {
|
||||
} catch (cause) {
|
||||
return fail(409, { intent: 'archive', error: actionError(cause) });
|
||||
}
|
||||
},
|
||||
favorite: async ({ locals, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
setPieceFavorite(String(data.get('pieceId') || ''), data.get('favorite') === '1');
|
||||
return { intent: 'favorite', success: 'Favorit aktualisiert.' };
|
||||
} catch (cause) {
|
||||
return fail(404, { intent: 'favorite', error: actionError(cause) });
|
||||
}
|
||||
}
|
||||
} satisfies Actions;
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { goto, replaceState } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
@@ -13,7 +14,7 @@
|
||||
let visiblePieces = $derived(
|
||||
data.pieces.filter(
|
||||
(piece) =>
|
||||
(showUsedPieces || piece.sentHistory.length === 0) &&
|
||||
(showUsedPieces || piece.sentHistory.length === 0 || piece.isFavorite) &&
|
||||
(showPlannedPieces || !piece.isPlannedInActiveShow)
|
||||
)
|
||||
);
|
||||
@@ -193,6 +194,19 @@
|
||||
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}>★</button
|
||||
>
|
||||
</form>
|
||||
{#if piece.mediaId}<img
|
||||
class="piece-thumbnail"
|
||||
src={`/media/${piece.mediaId}`}
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
savePiece,
|
||||
savePieceAsTemplate,
|
||||
setPieceArchived,
|
||||
setPieceFavorite,
|
||||
uploadPieceImage,
|
||||
type EditablePieceStatus,
|
||||
type PieceInput,
|
||||
type PieceSourceInput
|
||||
} from '#lib/server/content.js';
|
||||
import { renderMarkdown } from '#lib/server/markdown.js';
|
||||
import { listTools } from '#lib/server/tools.js';
|
||||
|
||||
function renderedFields(
|
||||
piece: Pick<
|
||||
@@ -57,6 +59,9 @@ function pieceInput(data: FormData): PieceInput {
|
||||
timecodeSeconds: timecode ? Number(timecode) : null
|
||||
});
|
||||
}
|
||||
const toolIds = data.getAll('toolId').map(String);
|
||||
const toolWorkUrls = data.getAll('toolWorkUrl').map(String);
|
||||
const toolNotes = data.getAll('toolNote').map(String);
|
||||
return {
|
||||
title: text(data, 'title'),
|
||||
outputTitle: text(data, 'outputTitle'),
|
||||
@@ -85,7 +90,12 @@ function pieceInput(data: FormData): PieceInput {
|
||||
presentationMetadataFetchedAtUtc: text(data, 'presentationMetadataFetchedAtUtc') || null,
|
||||
topicIds: data.getAll('topicIds').map(String),
|
||||
categoryIds: data.getAll('categoryIds').map(String),
|
||||
sources
|
||||
sources,
|
||||
tools: toolIds.map((toolId, index) => ({
|
||||
toolId,
|
||||
workUrl: toolWorkUrls[index] ?? '',
|
||||
note: toolNotes[index] ?? ''
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,6 +136,7 @@ export const load: PageServerLoad = ({ locals, params }) => {
|
||||
return {
|
||||
piece,
|
||||
taxonomy: getTaxonomy(),
|
||||
tools: listTools(true),
|
||||
templates: listPieceTemplates().filter((template) => template.id !== 'blank'),
|
||||
rendered: renderedFields(piece)
|
||||
};
|
||||
@@ -243,5 +254,15 @@ export const actions = {
|
||||
return fail(409, { intent: 'archive', error: saveError(cause).error });
|
||||
}
|
||||
redirect(303, '/archiv');
|
||||
},
|
||||
favorite: async ({ locals, params, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
setPieceFavorite(params.id, data.get('favorite') === '1');
|
||||
return { intent: 'favorite', success: true };
|
||||
} catch (cause) {
|
||||
return fail(404, { intent: 'favorite', error: saveError(cause).error });
|
||||
}
|
||||
}
|
||||
} satisfies Actions;
|
||||
@@ -14,6 +14,8 @@
|
||||
let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2))));
|
||||
let revision = $state(untrack(() => data.piece.revision));
|
||||
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
|
||||
let pieceTools = $state(untrack(() => data.piece.tools.map((tool) => ({ ...tool }))));
|
||||
let toolToAdd = $state('');
|
||||
let rendered = $state(untrack(() => ({ ...data.rendered })));
|
||||
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
|
||||
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
|
||||
@@ -44,6 +46,9 @@
|
||||
);
|
||||
let previewContent = $derived<PresentationContent>({
|
||||
mode: draft.presentationMode,
|
||||
topics: data.taxonomy.topics
|
||||
.filter((topic) => draft.topicIds.includes(topic.id))
|
||||
.map((topic) => topic.name),
|
||||
title: draft.outputTitle || draft.title,
|
||||
html: rendered.presentation,
|
||||
source: featuredSource
|
||||
@@ -147,6 +152,27 @@
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function addTool() {
|
||||
const tool = data.tools.find((item) => item.id === toolToAdd);
|
||||
if (!tool || pieceTools.some((item) => item.toolId === tool.id)) return;
|
||||
pieceTools.push({
|
||||
id: crypto.randomUUID(),
|
||||
toolId: tool.id,
|
||||
name: tool.name,
|
||||
startUrl: tool.startUrl,
|
||||
isActive: tool.isActive,
|
||||
workUrl: '',
|
||||
note: ''
|
||||
});
|
||||
toolToAdd = '';
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function removeTool(index: number) {
|
||||
pieceTools.splice(index, 1);
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function presentationSourceChanged() {
|
||||
draft.presentationMetadataFetchedAtUtc = null;
|
||||
draft.presentationProvider = '';
|
||||
@@ -209,7 +235,19 @@
|
||||
onrequestclose={requestClose}
|
||||
>
|
||||
{#snippet header()}
|
||||
<p class="eyebrow">Fundusfassung</p>
|
||||
<div class="piece-editor-kicker">
|
||||
<p class="eyebrow">Fundusfassung</p>
|
||||
<form method="POST" action="?/favorite" use:enhance>
|
||||
<input type="hidden" name="favorite" value={data.piece.isFavorite ? '0' : '1'} />
|
||||
<button
|
||||
class="piece-favorite"
|
||||
class:active={data.piece.isFavorite}
|
||||
type="submit"
|
||||
aria-label={data.piece.isFavorite ? 'Aus Favoriten entfernen' : 'Als Favorit markieren'}
|
||||
aria-pressed={data.piece.isFavorite}>★</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
<label class="piece-dialog-title">
|
||||
<span class="sr-only">Titel</span>
|
||||
<textarea
|
||||
@@ -396,6 +434,62 @@
|
||||
</div>{/each}
|
||||
</div>{:else}<p class="context-empty">Noch keine Quelle hinterlegt.</p>{/if}
|
||||
</section>
|
||||
<section class="piece-source-editor">
|
||||
<div class="piece-subsection-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Arbeitsmittel</p>
|
||||
<h3>Werkzeuge</h3>
|
||||
</div>
|
||||
<div class="tool-picker">
|
||||
<label class="sr-only" for="piece-tool-picker">Werkzeug auswählen</label>
|
||||
<select id="piece-tool-picker" bind:value={toolToAdd}>
|
||||
<option value="">Werkzeug auswählen</option>
|
||||
{#each data.tools.filter((tool) => tool.isActive && !pieceTools.some((assigned) => assigned.toolId === tool.id)) as tool (tool.id)}
|
||||
<option value={tool.id}
|
||||
>{tool.name}{tool.formatIds.includes(draft.formatId ?? '')
|
||||
? ' · passt zum Format'
|
||||
: ''}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="secondary-button" type="button" onclick={addTool} disabled={!toolToAdd}
|
||||
>Hinzufügen</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if pieceTools.length}<div class="source-list">
|
||||
{#each pieceTools as tool, index (tool.id)}<div
|
||||
class="source-row tool-assignment-row"
|
||||
>
|
||||
<input type="hidden" name="toolId" value={tool.toolId} />
|
||||
<div class="tool-assignment-name">
|
||||
<strong>{tool.name}</strong>{#if !tool.isActive}<small>Deaktiviert</small>{/if}
|
||||
</div>
|
||||
<label
|
||||
>Arbeitslink<input
|
||||
name="toolWorkUrl"
|
||||
type="url"
|
||||
maxlength="2000"
|
||||
placeholder={tool.startUrl || 'https://…'}
|
||||
bind:value={tool.workUrl}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Notiz<input
|
||||
name="toolNote"
|
||||
maxlength="2000"
|
||||
placeholder="Optional"
|
||||
bind:value={tool.note}
|
||||
/></label
|
||||
>
|
||||
<button
|
||||
class="text-button danger-text"
|
||||
type="button"
|
||||
onclick={() => removeTool(index)}>Entfernen</button
|
||||
>
|
||||
</div>{/each}
|
||||
</div>{:else}<p class="context-empty">Noch kein Werkzeug zugeordnet.</p>{/if}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="piece-editor-panel" hidden={activeSection !== 'presentation'}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { requireUser } from '#lib/server/auth.js';
|
||||
import { getPiece } from '#lib/server/content.js';
|
||||
import { getPiece, getTaxonomy } from '#lib/server/content.js';
|
||||
import { renderMarkdown } from '#lib/server/markdown.js';
|
||||
import { buildEmbedUrl, type PresentationContent } from '#lib/server/presentation.js';
|
||||
|
||||
@@ -18,6 +18,9 @@ export const load: PageServerLoad = ({ locals, params }) => {
|
||||
images: { id: string; altText: string }[];
|
||||
} = {
|
||||
mode: piece.presentationMode,
|
||||
topics: getTaxonomy()
|
||||
.topics.filter((topic) => piece.topicIds.includes(topic.id))
|
||||
.map((topic) => topic.name),
|
||||
title: piece.outputTitle || piece.title,
|
||||
html: renderMarkdown(piece.presentationText),
|
||||
source,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
let now = $state(Date.now());
|
||||
let loadedAt = $state(Date.now());
|
||||
let currentId = $state(untrack(() => data.live.current?.id ?? null));
|
||||
let loadedRevision = $state(untrack(() => data.live.liveRevision));
|
||||
let outputWindow: Window | null = null;
|
||||
let progress = $derived(
|
||||
data.live.totalCount ? Math.round((data.live.completedCount / data.live.totalCount) * 100) : 0
|
||||
@@ -21,11 +22,21 @@
|
||||
(data.live.current?.actualDurationSeconds ?? 0) +
|
||||
(data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0)
|
||||
);
|
||||
let sectionElapsedSeconds = $derived(
|
||||
(data.live.sectionTimer?.elapsedSeconds ?? 0) +
|
||||
(data.live.sectionTimer?.runningSinceUtc
|
||||
? Math.max(0, Math.floor((now - loadedAt) / 1000))
|
||||
: 0)
|
||||
);
|
||||
let sectionRemainingSeconds = $derived(
|
||||
(data.live.sectionTimer?.plannedDurationSeconds ?? 0) - sectionElapsedSeconds
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const nextCurrentId = data.live.current?.id ?? null;
|
||||
if (nextCurrentId !== currentId) {
|
||||
if (nextCurrentId !== currentId || data.live.liveRevision !== loadedRevision) {
|
||||
currentId = nextCurrentId;
|
||||
loadedRevision = data.live.liveRevision;
|
||||
loadedAt = Date.now();
|
||||
}
|
||||
});
|
||||
@@ -73,6 +84,15 @@
|
||||
persistSettings();
|
||||
}
|
||||
|
||||
function formatCountdown(totalSeconds: number) {
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
return hours > 0
|
||||
? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
: `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function liveAction(name: string) {
|
||||
return `?show=${encodeURIComponent(data.live.show.id)}&/${name}`;
|
||||
}
|
||||
@@ -187,6 +207,21 @@
|
||||
style={`--prompt-size: ${promptSize}rem`}
|
||||
aria-labelledby="current-title"
|
||||
>
|
||||
{#if data.live.sectionTimer?.sectionId === data.live.current.sectionId}
|
||||
<section
|
||||
class:warning={sectionRemainingSeconds <= 120 && sectionRemainingSeconds >= 0}
|
||||
class:overrun={sectionRemainingSeconds < 0}
|
||||
class="section-countdown"
|
||||
aria-live="off"
|
||||
>
|
||||
<span>{data.live.sectionTimer.sectionName} · Abschnitt</span>
|
||||
<strong
|
||||
>{sectionRemainingSeconds < 0
|
||||
? `Überzogen +${formatCountdown(Math.abs(sectionRemainingSeconds))}`
|
||||
: `Noch ${formatCountdown(sectionRemainingSeconds)}`}</strong
|
||||
>
|
||||
</section>
|
||||
{/if}
|
||||
<div class="live-kicker">
|
||||
<span>Aktuell · {data.live.current.sectionName}</span>
|
||||
{#if showTimes}<strong
|
||||
@@ -209,6 +244,45 @@
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
<div class="live-markdown">{@html data.live.current.researchHtml}</div>
|
||||
</details>
|
||||
{#if data.live.current.sources.length || data.live.current.tools.length}
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<section class="live-materials" aria-labelledby="live-materials-title">
|
||||
<div>
|
||||
<span>Nur Redaktion</span>
|
||||
<h3 id="live-materials-title">Materialien</h3>
|
||||
</div>
|
||||
{#if data.live.current.sources.length}<div class="live-material-group">
|
||||
<strong>Quellen</strong>
|
||||
<ul>
|
||||
{#each data.live.current.sources as source (source.id)}<li>
|
||||
<!-- The URL is a server-validated private HTTP(S) snapshot. -->
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={source.url} target="_blank" rel="noopener noreferrer"
|
||||
>{source.label}</a
|
||||
>{#if source.timecodeSeconds !== null}<small
|
||||
>ab {formatClock(source.timecodeSeconds)}</small
|
||||
>{/if}
|
||||
</li>{/each}
|
||||
</ul>
|
||||
</div>{/if}
|
||||
{#if data.live.current.tools.length}<div class="live-material-group">
|
||||
<strong>Werkzeuge</strong>
|
||||
<ul>
|
||||
{#each data.live.current.tools as tool (tool.id)}<li>
|
||||
{#if tool.workUrl || tool.startUrl}<!-- The URL is a server-validated private HTTP(S) snapshot. -->
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a
|
||||
href={tool.workUrl || tool.startUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">{tool.name}</a
|
||||
>{:else}<span>{tool.name}</span><small>Kein Link hinterlegt</small>{/if}
|
||||
{#if tool.note}<small>{tool.note}</small>{/if}
|
||||
</li>{/each}
|
||||
</ul>
|
||||
</div>{/if}
|
||||
</section>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||
{/if}
|
||||
{#if data.live.current.transitionHtml}<section class="transition-card">
|
||||
<span>Überleitung</span>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
|
||||
@@ -215,6 +215,9 @@ export const actions = {
|
||||
updateUsage: async ({ locals, params, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
const toolIds = data.getAll('usageToolId').map(String);
|
||||
const toolWorkUrls = data.getAll('toolWorkUrl').map(String);
|
||||
const toolNotes = data.getAll('toolNote').map(String);
|
||||
return attempt('updateUsage', () =>
|
||||
updatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')), {
|
||||
title: text(data, 'title'),
|
||||
@@ -235,7 +238,12 @@ export const actions = {
|
||||
presentationTitle: text(data, 'presentationTitle'),
|
||||
presentationDescription: text(data, 'presentationDescription'),
|
||||
presentationImageUrl: text(data, 'presentationImageUrl'),
|
||||
presentationSiteName: text(data, 'presentationSiteName')
|
||||
presentationSiteName: text(data, 'presentationSiteName'),
|
||||
tools: toolIds.map((id, index) => ({
|
||||
id,
|
||||
workUrl: toolWorkUrls[index] ?? '',
|
||||
note: toolNotes[index] ?? ''
|
||||
}))
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
let visiblePieces = $derived(
|
||||
data.pieces.filter(
|
||||
(piece) =>
|
||||
(showUsedPieces || piece.sentHistory.length === 0) &&
|
||||
(showUsedPieces || piece.sentHistory.length === 0 || piece.isFavorite) &&
|
||||
(showPlannedPieces || !piece.isPlannedInCurrentShow)
|
||||
)
|
||||
);
|
||||
@@ -411,7 +411,10 @@
|
||||
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
|
||||
><span>{formatClock(piece.durationSeconds)}</span>
|
||||
</div>
|
||||
<h3>{piece.title}</h3>
|
||||
<h3>
|
||||
{#if piece.isFavorite}<span class="favorite-mark" aria-label="Favorit">★</span
|
||||
>{/if}{piece.title}
|
||||
</h3>
|
||||
<p>
|
||||
{piece.format} · {piece.topics.map((item) => item.name).join(', ') || 'Ohne Thema'}
|
||||
</p>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type TaxonomyKind
|
||||
} from '#lib/server/content.js';
|
||||
import { getMailConfig } from '#lib/server/config.js';
|
||||
import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js';
|
||||
import {
|
||||
inviteUser,
|
||||
isRole,
|
||||
@@ -25,6 +26,7 @@ export const load: PageServerLoad = ({ locals }) => {
|
||||
taxonomy: getTaxonomy(),
|
||||
users: listUsers(),
|
||||
invitations: listOpenInvitations(),
|
||||
tools: listTools(true),
|
||||
mailTransport: getMailConfig().transport
|
||||
};
|
||||
};
|
||||
@@ -40,9 +42,25 @@ function actionError(cause: unknown): string {
|
||||
return 'Verwendete oder für Vorlagen benötigte Einträge können nicht entfernt werden.';
|
||||
if (code === 'INVALID_COLOR') return 'Bitte eine gültige Farbe wählen.';
|
||||
if (code === 'INVALID_TAXONOMY') return 'Bitte einen gültigen Namen eingeben.';
|
||||
if (code === 'TOOL_EXISTS') return 'Ein Werkzeug mit diesem Namen existiert bereits.';
|
||||
if (code === 'INVALID_TOOL_URL')
|
||||
return 'Die Start-URL muss eine vollständige HTTP- oder HTTPS-Adresse sein.';
|
||||
if (code === 'INVALID_TOOL_FORMAT')
|
||||
return 'Mindestens ein gewähltes Format existiert nicht mehr.';
|
||||
if (code === 'INVALID_TOOL') return 'Bitte die Werkzeugdaten prüfen.';
|
||||
return 'Die Aktion konnte nicht ausgeführt werden.';
|
||||
}
|
||||
|
||||
function toolInput(data: FormData): ToolInput {
|
||||
return {
|
||||
name: String(data.get('name') || ''),
|
||||
startUrl: String(data.get('startUrl') || ''),
|
||||
description: String(data.get('description') || ''),
|
||||
isActive: data.get('isActive') === '1',
|
||||
formatIds: data.getAll('formatIds').map(String)
|
||||
};
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
invite: async ({ locals, request }) => {
|
||||
const actor = requireBarkeeper(locals.user);
|
||||
@@ -131,5 +149,25 @@ export const actions = {
|
||||
} catch (cause) {
|
||||
return fail(400, { intent: 'taxonomy', error: actionError(cause) });
|
||||
}
|
||||
},
|
||||
createTool: async ({ locals, request }) => {
|
||||
requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
createTool(toolInput(data));
|
||||
return { intent: 'tool', success: 'Werkzeug angelegt.' };
|
||||
} catch (cause) {
|
||||
return fail(400, { intent: 'tool', error: actionError(cause) });
|
||||
}
|
||||
},
|
||||
updateTool: async ({ locals, request }) => {
|
||||
requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
updateTool(String(data.get('toolId') || ''), toolInput(data));
|
||||
return { intent: 'tool', success: 'Werkzeug aktualisiert.' };
|
||||
} catch (cause) {
|
||||
return fail(400, { intent: 'tool', error: actionError(cause) });
|
||||
}
|
||||
}
|
||||
} satisfies Actions;
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="page-wrap narrow-page">
|
||||
<header class="page-heading">
|
||||
<h1>Verwaltung</h1>
|
||||
<p class="page-intro">Konten, Rollen und bestehende Taxonomie verwalten.</p>
|
||||
<p class="page-intro">Konten, Werkzeuge und bestehende Taxonomie verwalten.</p>
|
||||
</header>
|
||||
{#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}
|
||||
@@ -113,6 +113,89 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<h2 class="section-heading">Werkzeuge</h2>
|
||||
<section class="quiet-panel tool-management">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Redaktionelle Hilfsmittel</p>
|
||||
<h2>Werkzeugkatalog</h2>
|
||||
</div>
|
||||
<small>Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Sendungen erhalten.</small>
|
||||
</div>
|
||||
<div class="tool-settings-list">
|
||||
{#each data.tools as tool (tool.id)}
|
||||
<details class:inactive={!tool.isActive}>
|
||||
<summary
|
||||
><strong>{tool.name}</strong><span>{tool.isActive ? 'Aktiv' : 'Deaktiviert'}</span
|
||||
></summary
|
||||
>
|
||||
<form class="compact-form tool-form" method="POST" action="?/updateTool">
|
||||
<input type="hidden" name="toolId" value={tool.id} />
|
||||
<label>Name<input name="name" required maxlength="120" value={tool.name} /></label>
|
||||
<label
|
||||
>Start-URL<input
|
||||
name="startUrl"
|
||||
type="url"
|
||||
maxlength="2000"
|
||||
value={tool.startUrl}
|
||||
placeholder="https://…"
|
||||
/></label
|
||||
>
|
||||
<label class="wide-field"
|
||||
>Beschreibung<textarea name="description" rows="2" maxlength="2000"
|
||||
>{tool.description}</textarea
|
||||
></label
|
||||
>
|
||||
<fieldset class="wide-field">
|
||||
<legend>Passende Formate</legend>
|
||||
<div class="choice-grid">
|
||||
{#each data.taxonomy.formats as format (format.id)}<label
|
||||
><input
|
||||
type="checkbox"
|
||||
name="formatIds"
|
||||
value={format.id}
|
||||
checked={tool.formatIds.includes(format.id)}
|
||||
/>{format.name}</label
|
||||
>{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
<label class="tool-active"
|
||||
><input type="checkbox" name="isActive" value="1" checked={tool.isActive} /> Aktiv</label
|
||||
>
|
||||
<button class="secondary-button" type="submit">Speichern</button>
|
||||
</form>
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
<details class="new-tool">
|
||||
<summary>Werkzeug hinzufügen</summary>
|
||||
<form class="compact-form tool-form" method="POST" action="?/createTool">
|
||||
<label>Name<input name="name" required maxlength="120" /></label>
|
||||
<label
|
||||
>Start-URL<input
|
||||
name="startUrl"
|
||||
type="url"
|
||||
maxlength="2000"
|
||||
placeholder="https://…"
|
||||
/></label
|
||||
>
|
||||
<label class="wide-field"
|
||||
>Beschreibung<textarea name="description" rows="2" maxlength="2000"></textarea></label
|
||||
>
|
||||
<fieldset class="wide-field">
|
||||
<legend>Passende Formate</legend>
|
||||
<div class="choice-grid">
|
||||
{#each data.taxonomy.formats as format (format.id)}<label
|
||||
><input type="checkbox" name="formatIds" value={format.id} />{format.name}</label
|
||||
>{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
<input type="hidden" name="isActive" value="1" />
|
||||
<button class="primary-button" type="submit">Werkzeug anlegen</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<h2 class="section-heading">Taxonomie</h2>
|
||||
<div class="settings-grid">
|
||||
<section class="quiet-panel taxonomy-panel">
|
||||
|
||||
@@ -190,6 +190,16 @@ function removePresentationFixture() {
|
||||
database.close();
|
||||
}
|
||||
|
||||
function setSectionDuration(sectionId: string, seconds: number | null) {
|
||||
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
|
||||
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
|
||||
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
|
||||
database
|
||||
.prepare('UPDATE show_sections SET explicit_duration_seconds = ? WHERE id = ?')
|
||||
.run(seconds, sectionId);
|
||||
database.close();
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto('/');
|
||||
@@ -311,6 +321,17 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
|
||||
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
|
||||
await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
|
||||
await expect(page.locator('.piece-card')).toHaveCount(4);
|
||||
await page
|
||||
.getByRole('button', { name: 'Die kleine Konsole mit dem großen Dreh als Favorit markieren' })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Die kleine Konsole mit dem großen Dreh aus Favoriten entfernen'
|
||||
})
|
||||
).toBeVisible();
|
||||
await expect(page.locator('.piece-card').first()).toContainText(
|
||||
'Die kleine Konsole mit dem großen Dreh'
|
||||
);
|
||||
await page
|
||||
.locator('.filter-picker')
|
||||
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
|
||||
@@ -856,6 +877,49 @@ test('inbox, archive and administration stay concise', async ({ page }) => {
|
||||
expect(boxes[1]?.y).toBe(boxes[2]?.y);
|
||||
});
|
||||
|
||||
test('tools stay editable in pieces after catalog deactivation', async ({ page }, testInfo) => {
|
||||
await page.goto('/verwaltung');
|
||||
const create = page.locator('.new-tool');
|
||||
await create.getByText('Werkzeug hinzufügen').click();
|
||||
await create.getByLabel('Name').fill('E2E Tierlist');
|
||||
await create.getByLabel('Start-URL').fill('https://example.com/tierlist');
|
||||
await create.getByLabel('Beschreibung').fill('Ranking für die Sendung');
|
||||
await create.getByLabel('Quiz').check();
|
||||
await create.getByRole('button', { name: 'Werkzeug anlegen' }).click();
|
||||
const tool = page.locator('.tool-settings-list details').filter({ hasText: 'E2E Tierlist' });
|
||||
await expect(tool).toContainText('Aktiv');
|
||||
|
||||
await page.goto('/fundus/piece-quiz');
|
||||
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
|
||||
await page
|
||||
.locator('#piece-tool-picker')
|
||||
.selectOption({ label: 'E2E Tierlist · passt zum Format' });
|
||||
await page.getByRole('button', { name: 'Hinzufügen', exact: true }).click();
|
||||
await page.getByLabel('Arbeitslink').fill('https://example.com/tierlist/e2e-board');
|
||||
await page.getByLabel('Notiz').fill('Vor der Sendung sortieren');
|
||||
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
|
||||
|
||||
await page.goto('/verwaltung');
|
||||
const storedTool = page
|
||||
.locator('.tool-settings-list details')
|
||||
.filter({ hasText: 'E2E Tierlist' });
|
||||
await storedTool.getByText('E2E Tierlist', { exact: true }).click();
|
||||
await storedTool.getByLabel('Aktiv', { exact: true }).uncheck();
|
||||
await storedTool.getByRole('button', { name: 'Speichern' }).click();
|
||||
await expect(storedTool).toContainText('Deaktiviert');
|
||||
|
||||
await page.goto('/fundus/piece-quiz');
|
||||
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
|
||||
await expect(page.locator('.tool-assignment-row')).toContainText('E2E Tierlist');
|
||||
await expect(page.locator('.tool-assignment-row')).toContainText('Deaktiviert');
|
||||
await expect(page.getByLabel('Arbeitslink')).toHaveValue(
|
||||
'https://example.com/tierlist/e2e-board'
|
||||
);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await page.screenshot({ path: testInfo.outputPath('piece-tools-mobile.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('validated YouTube and TikTok players switch without exposing private fields', async ({
|
||||
page
|
||||
}, testInfo) => {
|
||||
@@ -918,6 +982,7 @@ test('live control persists state and exposes only explicit presentation data',
|
||||
page,
|
||||
browser
|
||||
}, testInfo) => {
|
||||
setSectionDuration('section-stories', 300);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto('/fundus?q=kleine+Konsole&includePlanned=1');
|
||||
await page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }).click();
|
||||
@@ -938,6 +1003,8 @@ test('live control persists state and exposes only explicit presentation data',
|
||||
await expect(page.getByText('Noch nicht gestartet.')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Sendung starten' }).click();
|
||||
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
|
||||
await expect(page.locator('.section-countdown')).toContainText(/Noch 0[45]:\d{2}/);
|
||||
await expect(page.locator('.live-materials')).toContainText('Herstellerseite');
|
||||
await expect(
|
||||
page.getByText('Herstellerangaben gegen unabhängige Tests prüfen')
|
||||
).not.toBeVisible();
|
||||
@@ -974,6 +1041,7 @@ test('live control persists state and exposes only explicit presentation data',
|
||||
timeout: 10_000
|
||||
});
|
||||
await expect(output.getByText('Weniger Leistung, mehr Idee.')).toBeVisible();
|
||||
await expect(output.locator('.output-topics')).toHaveText('Gaming');
|
||||
await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toHaveCount(0);
|
||||
await output.screenshot({ path: testInfo.outputPath('heinzlive-output.png'), fullPage: true });
|
||||
const outputHtml = await output.content();
|
||||
@@ -981,6 +1049,7 @@ test('live control persists state and exposes only explicit presentation data',
|
||||
expect(outputHtml).not.toContain('Erst Formfaktor zeigen');
|
||||
expect(outputHtml).not.toContain(adminEmail);
|
||||
expect(outputHtml).not.toContain('live-konsole.png');
|
||||
expect(outputHtml).not.toContain('https://example.com/console');
|
||||
const stateUrl = new URL(outputUrl);
|
||||
stateUrl.hash = '';
|
||||
stateUrl.pathname += '/state';
|
||||
|
||||
Reference in new issue
Block a user