diff --git a/README.md b/README.md index 27764db..2c323c5 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,27 @@ automatisch; ein sichtbarer Status meldet Erfolg, Fehler oder eine parallele Änderung. In diesem Fall muss die Seite neu geladen werden, bevor erneut gespeichert wird. +Bereits gesendete Pieces und Pieces mit einer Einplanung in einer noch nicht +abgeschlossenen Sendung sind standardmäßig ausgeblendet. Als gesendet gilt nur +eine erledigte Verwendung in einer abgeschlossenen Sendung; übersprungene oder +reservierte Inhalte gelten nicht als gesendet. Über **Auch verwendete Inhalte +zeigen** und **Auch eingeplante Inhalte zeigen** lassen sich beide Gruppen +unabhängig einblenden. Dann erscheinen auch Datum, Sendung und die aufklappbare +Verwendungshistorie. + Markdown ist in Einleitung, Recherche, Moderation, Überleitung und Präsentation möglich. Die Ausgabe wird serverseitig auf eine begrenzte Auswahl sicherer Elemente und Links reduziert. Bilder dürfen höchstens 5 MB groß sein und müssen als JPEG, PNG, WebP oder GIF erkannt werden. Sie liegen im konfigurierten Uploadverzeichnis und sind nur über eine angemeldete Route erreichbar. +Für die Präsentation kann ein Piece alternativ genau eine seiner vorhandenen +Quellen als Linkkarte oder Video verwenden. Linktitel, Anreißer und Bildadresse +lassen sich manuell korrigieren. **Vorschau laden** ruft Metadaten ausdrücklich +neu ab; manuell geänderte Felder werden dabei nicht überschrieben. Die +**Präsentation testen**-Ansicht öffnet den aktuellen Piece-Inhalt isoliert und +verändert weder eine Sendung noch deren laufende Ausgabe. + ## Sendungen planen Unter **Sendungen** lässt sich ein neuer Stream leer, mit dem Standardablauf @@ -92,11 +107,21 @@ eingeplanten Pieces. Die Reservebank zählt nie zur Planzeit. Der sendefertige Fundus steht neben dem Ablauf. Pieces können mehrfach eingeplant und je Verwendung mit eigenem Titel, eigener Dauer und eigenen -Redaktionstexten bearbeitet werden, ohne das Original zu verändern. Abschnitte -und Verwendungen lassen sich per Drag-and-drop sowie gleichwertig mit sichtbaren -Schaltflächen und Auswahlfeldern verschieben. Twitch-Titel und Ankündigung sind -kopierbar. Gleichzeitige Änderungen werden über die Revision der gesamten -Sendung erkannt und nicht still überschrieben. +Redaktionstexten bearbeitet werden, ohne das Original zu verändern. Das +Stift-Symbol öffnet dafür einen auch mobil scrollbaren Editor-Dialog. **Aus +Fundus aktualisieren** übernimmt auf Wunsch Titel, Dauer und Redaktionstexte des +aktuellen Originals; eigene Notizen, Live-Status und gemessene Zeiten bleiben +erhalten. Abschnitte und Verwendungen lassen sich per Drag-and-drop sowie +gleichwertig mit sichtbaren Schaltflächen und Auswahlfeldern verschieben. +Twitch-Titel und Ankündigung sind kopierbar. Gleichzeitige Änderungen werden +über die Revision der gesamten Sendung erkannt und nicht still überschrieben. + +Die Quellspalte blendet gesendete und bereits in derselben Sendung eingeplante +Originale zunächst aus; beide Gruppen lassen sich unabhängig wieder anzeigen. +Eine konkrete Einplanung kann direkt hinter sich dupliziert werden. Dabei +bleiben ihre angepassten Texte und ihre Dauer erhalten, während Live-Status und +gemessene Zeiten neu beginnen. **Piece duplizieren** im Fundus erzeugt dagegen +weiterhin einen eigenständigen Fundusinhalt. ## Live moderieren und präsentieren @@ -113,11 +138,18 @@ sortieren beziehungsweise als nächster Beitrag einfügen. Das separate Präsentationsfenster unter `/heinzlive/...` verwendet einen langen, widerrufbaren Zufallstoken. Es ändert sich ausschließlich durch die Aktion -**Präsentieren** und erhält nur Präsentationstext, öffentliche Quellen und die -zum präsentierten Piece gehörenden Bilder. Recherche, Moderationsnotizen, -eigene Notizen, Kontodaten und interne Dateipfade werden weder im HTML noch im -JSON-Zustand ausgeliefert. Der Token ist ein Zugangsgeheimnis und darf nicht -weitergegeben oder protokolliert werden. +**Präsentieren** und erhält nur den bei der Einplanung gespeicherten Text-, Link- +oder Video-Snapshot. YouTube- und TikTok-Adressen werden auf validierte IDs +reduziert und ohne Autoplay in offiziellen Player-Iframes dargestellt. Andere +oder unvollständige Videoquellen erscheinen als Linkkarte. Recherche, +Moderationsnotizen, eigene Notizen, Kontodaten und interne Dateipfade werden +weder im HTML noch im JSON-Zustand ausgeliefert. Der Token ist ein +Zugangsgeheimnis und darf nicht weitergegeben oder protokolliert werden. + +Metadaten und externe Vorschaubilder werden ausschließlich serverseitig mit +DNS-, Redirect-, Zeit- und Größenlimits abgerufen. Private und lokale +Netzwerkziele sind gesperrt. Eingebettetes HTML oder Skripte einer Quellseite +werden nicht übernommen. ## Befehle @@ -194,6 +226,7 @@ Medien und redaktionellen Inhalten werden dadurch nicht verändert. Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und -`better-sqlite3`, `@node-rs/argon2`, Marked, sanitize-html und file-type sind -MIT-lizenziert; Nodemailer verwendet MIT-0, TypeScript und Playwright stehen -unter Apache-2.0. Die Lockdatei fixiert den tatsächlich installierten Baum. +`better-sqlite3`, `@node-rs/argon2`, Marked, sanitize-html, file-type und +Metascraper sind MIT-lizenziert; Nodemailer verwendet MIT-0, TypeScript und +Playwright stehen unter Apache-2.0. Die Lockdatei fixiert den tatsächlich +installierten Baum. diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 6e7fef9..893dc47 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -132,19 +132,60 @@ | Getrennte sichere Ausgabe | anonymer Browserkontext prüft Text, Bild, HTML, JSON und fremde Medien ohne private Daten | erfüllt am 22.10.2026 | | Reserve und Reihenfolge | Reserve als nächstes Piece eingefügt; offene Reihenfolge und serverseitige Live-Grenze geprüft | erfüllt am 22.10.2026 | | Popup- und Verbindungsfehler sichtbar | blockiertes Popup sowie unterbrochenes und wiederhergestelltes State-Polling im Browser geprüft | erfüllt am 22.10.2026 | +| Persistente Linkvorschau | Quellwahl, manuelle Felder, Reload, fehlendes Bild und isolierte Testansicht im Browser geprüft | erfüllt am 22.10.2026 | +| Sichere Metadatenabrufe | Tests sperren private IPv4-/IPv6-Ziele; Redirect-, Größen- und Zeitlimits sind implementiert | erfüllt am 22.10.2026 | +| Validierte Videoplayer | YouTube-URL-Varianten, TikTok-ID, Zeitmarke und Playerwechsel automatisiert geprüft | erfüllt am 22.10.2026 | +| Präsentationssnapshot | Einplanung, Duplikation und explizite Aktualisierung aus dem Original im Servicetest geprüft | erfüllt am 22.10.2026 | | Reale OBS-Browserquelle | lokale tokenisierte Browserausgabe vorhanden; kein OBS auf der Prüfmaschine | offen | +| Reale Anbieterwiedergabe | automatisierte Tests fangen YouTube-/TikTok-Requests lokal ab | offen | ## Ausgeführte Prüfungen Etappe 5 - `npm run lint`: erfolgreich - `npm run check`: 0 Fehler und 0 Warnungen -- `npm test`: 20 Tests in 5 Dateien erfolgreich +- `npm test`: 29 Tests in 6 Dateien erfolgreich - `npm run build`: erfolgreich mit `@sveltejs/adapter-node` -- `npm run test:e2e`: 11 von 11 Browsertests erfolgreich +- `npm run test:e2e`: 14 von 14 Browsertests erfolgreich - Anonymer zweiter Chromium-Kontext mit Präsentationstext, Quelle, geschütztem Bild und gefiltertem JSON geprüft - Mobile Aufnahme der Live-Steuerung bei 390 × 844 Pixeln und Desktopaufnahme der heinzlive-Ausgabe visuell kontrolliert - Popup-Blockade sowie Verbindungsabbruch und automatische Wiederverbindung im Browser geprüft +- YouTube-/TikTok-Iframes, Playerwechsel, mobile Hochformatausgabe, + Linkkartenvorschau ohne Bild, isolierte Testansicht und private Datenfilterung + mit lokal abgefangenen Anbieterrequests geprüft +- SSRF-Blockade einer privaten Zieladresse über die sichtbare Editorfehlermeldung + geprüft +- `bash -n start.sh` und `git diff --check`: erfolgreich + +## Gezielte Fundus- und Einplanungsanpassung + +| Kriterium | Nachweis | Stand | +| ------------------------------------------- | ------------------------------------------------------------------------------------------ | --------------------- | +| Gesendet korrekt abgeleitet | Servicetest schließt offene, übersprungene, Reserve- und nicht abgeschlossene Nutzung aus | erfüllt am 22.10.2026 | +| Historie und Fundusfilter | Browser prüft letztes Datum, Sendungslink, weitere Verwendungen und Sofortfilter | erfüllt am 22.10.2026 | +| Unabhängige Quellfilter | Browser kombiniert verwendet/eingeplant getrennt und erhält sie über Suchfilter | erfüllt am 22.10.2026 | +| Reserve und Mehrfacheinplanung | Reserve zählt als eingeplant; bewusstes Wiedereinblenden erlaubt weitere Einplanung | erfüllt am 22.10.2026 | +| Rückkehr nach Entfernen | Browser entfernt die letzte Reserveeinplanung und bietet das Original sofort wieder an | erfüllt am 22.10.2026 | +| Angepasste Einplanung dupliziert | Service und Browser prüfen Position, Texte, Notizen, Dauer und Reload | erfüllt am 22.10.2026 | +| Live-Zustand des Duplikats zurückgesetzt | Servicetest prüft `open`, neue ID und fehlende Zeitintervalle | erfüllt am 22.10.2026 | +| Original unverändert | Piece-Anzahl und Original-ID vor und nach Einplanungsduplikation verglichen | erfüllt am 22.10.2026 | +| Speicherfehler nicht als Erfolg dargestellt | veraltete Revision rollt Duplikation vollständig zurück | erfüllt am 22.10.2026 | +| Globale aktive Einplanung ausgeblendet | Browser prüft den unabhängigen Filter über mehrere nicht abgeschlossene Sendungen | erfüllt am 22.10.2026 | +| Snapshot ausdrücklich aktualisiert | Service prüft neue Originaltexte und erhaltene Notizen/Intervalle; Browser löst Aktion aus | erfüllt am 22.10.2026 | +| Fehlendes Original atomar behandelt | Servicetest prüft Fehler und unveränderte Sendungsrevision | erfüllt am 22.10.2026 | +| Verwendungsdialog responsiv | Browser prüft Desktop/Mobil, Reserve, erreichbare Aktionen und sichtbare Konfliktfehler | erfüllt am 22.10.2026 | + +## Ausgeführte Prüfungen Fundus und Einplanung + +- `npm test`: 22 Tests in 5 Dateien erfolgreich +- `npm run lint`: erfolgreich +- `npm run check`: 0 Fehler und 0 Warnungen +- gezielter Playwright-Lauf: 4 von 4 Browsertests erfolgreich +- `npm run test:e2e`: 12 von 12 Browsertests erfolgreich +- Desktopansichten von Fundushistorie, kombinierten Sendungsfiltern und + Verwendungsdialog sowie mobile Quellspalte und mobiler Dialog bei 390 × 844 + Pixeln visuell kontrolliert +- `npm run build`: erfolgreich mit `@sveltejs/adapter-node` - `bash -n start.sh` und `git diff --check`: erfolgreich diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bc585b6..637f23c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -97,6 +97,10 @@ 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. +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 +diese Felder nicht. Jedes Piece besitzt eine ganzzahlige Revision. Speichern, Archivieren und Medienänderungen verwenden ein bedingtes Update auf die erwartete Revision. @@ -124,9 +128,28 @@ erhöht sie atomar. Abgeschlossene Sendungen sind bereits an dieser Grenze gegen weitere Bearbeitung geschützt. Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation, -Überleitung und Präsentation aus dem sendefertigen Piece kopiert. Eigene Notizen -liegen ausschließlich an der Verwendung. Die Referenz zum Original bleibt für -Historie und Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt. +Ü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 +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 +und den Präsentationszustand atomar aus dem referenzierten Original. Eigene +Notizen, Status und vorhandene Live-Intervalle werden dabei nicht verändert; +fehlt das Original oder ist die Sendungsrevision veraltet, wird die gesamte +Mutation verworfen. +Eine Einplanung zu duplizieren kopiert genau diesen Snapshot mit derselben +Original-ID direkt hinter das Ausgangselement. Die neue Verwendung erhält den +Status `open`, eine neue ID und damit weder Live-Referenzen noch Zeitintervalle. + +Die Verwendungshistorie wird nicht separat gepflegt. Sie wird über die +Original-ID aus Verwendungen mit Status `done` in abgeschlossenen Sendungen +abgeleitet; Reservebank und alle anderen Verwendungs- oder Sendungsstatus sind +ausgeschlossen. Über dieselbe Original-ID wird unabhängig davon ermittelt, ob +mindestens eine Verwendung in einer noch nicht abgeschlossenen Sendung liegt. +Der allgemeine Fundus blendet diesen globalen Kontext standardmäßig aus. Die +Quellspalte einer Sendung betrachtet für ihren separaten Einplanungsfilter nur +die aktuelle Sendung einschließlich Reservebank. Jede Sendung besitzt intern genau einen ausgeblendeten Reserveabschnitt. Dadurch verwenden Plan und Reserve dieselbe geprüfte Verwendungslogik, während die @@ -139,17 +162,24 @@ die Abschnitte mit neuen IDs kopiert; spätere Änderungen an der Sendung könne die Vorlage deshalb nicht verändern. Die Editorformulare bleiben progressive SvelteKit-Form-Actions, werden im -Browser aber mit `use:enhance` gesendet. Dadurch bleibt das Dokument bei -Planungsmutationen bestehen; nur die serverseitigen Seitendaten werden -aktualisiert. Ladeanzeige und FLIP-Animation liegen am betroffenen Abschnitt -oder an der betroffenen Verwendung. Eindeutige Drag-Griffe übertragen entweder -eine Quellenkopie, einen Abschnitt oder eine Verwendung auf dieselben +Browser aber mit `use:enhance` gesendet. Der Verwendungseditor liegt in einem +nativen Dialog, den ein beschrifteter Icon-Button öffnet. Dadurch bleibt das +Dokument bei Planungsmutationen bestehen; nur die serverseitigen Seitendaten +werden aktualisiert. Ladeanzeige und FLIP-Animation liegen am betroffenen +Abschnitt oder an der betroffenen Verwendung. Eindeutige Drag-Griffe übertragen +entweder eine Quellenkopie, einen Abschnitt oder eine Verwendung auf dieselben serverseitig geprüften Actions wie die alternativen Schaltflächen. Während des Ziehens bleibt das Original transparent an seinem Ausgangsort; eine flüchtige Vorschau in der Liste belegt bereits die anhand der Zeigerposition berechnete Zielhöhe und verdrängt dadurch die benachbarten Elemente. Persistiert wird erst beim Loslassen. +Die Ausschlussfilter für gesendete und bereits eingeplante Pieces arbeiten +clientseitig auf der serverseitig berechneten Kontextmenge. Ihr Zustand wird +ohne Navigation in die URL übernommen und bei nachfolgenden Suchfiltern +mitgesendet. Planmutationen invalidieren anschließend die Seitendaten, sodass +das Entfernen der letzten Einplanung ein Original unmittelbar wieder anbietet. + ## Live-Modus und Präsentationsgrenze `src/lib/server/live.ts` ist die Transaktionsgrenze der Live-Durchführung. Der @@ -168,7 +198,27 @@ neu erzeugter Ausgabezugang widerruft den vorherigen. Page, Polling-JSON und Medienroute leiten ihre Daten aus einer eigenen Positivliste ab. Bilder werden nur ausgeliefert, wenn sie zum aktuell ausdrücklich präsentierten Piece gehören. Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den -Präsentationsstand nicht. +Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den +gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen +erst nach einer ausdrücklichen Snapshot-Aktualisierung. + +Linkmetadaten werden nur auf ausdrückliche Aktion durch +`src/lib/server/presentation.ts` geladen. Jeder HTTP(S)-Abruf löst den Zielhost +serverseitig auf, sperrt lokale und private IPv4-/IPv6-Ziele und bindet den +Request an die geprüfte Adresse. Dieselbe Prüfung gilt nach höchstens drei +Redirects. Zeit- und Größenlimits begrenzen HTML, Anbieter-Metadaten und Bilder. +Externe Vorschaubilder gelangen nur über token- beziehungsweise +sitzungsgebundene Proxyrouten in die Ausgabe; fremdes HTML und fremde Skripte +werden nie übernommen. + +Videoquellen werden aus der gespeicherten URL erneut auf eine streng validierte +YouTube- oder TikTok-ID reduziert. Der Browser erhält nur daraus erzeugte +offizielle Embed-URLs, startet sie nicht automatisch und ersetzt das Iframe bei +jedem Inhaltswechsel. YouTube erhält die Startsekunde als Embed-Parameter; +TikTok wird nach dessen Bereitschaft über die offizielle Player-`postMessage`- +Schnittstelle positioniert. Nicht unterstützte oder unvollständige Videoquellen +fallen auf eine normale Linkkarte zurück. Eine authentifizierte Testansicht +verwendet denselben Renderer, schreibt aber keinen Live-Zustand. Die Ausgabe synchronisiert ihren kleinen öffentlichen Zustand per `no-store`-Polling. Der letzte gültige Inhalt bleibt bei einem Verbindungsfehler @@ -189,10 +239,10 @@ Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht ohne interne Anwendungsnavigation. -## Sicherheitsgrenzen ab den Folgeetappen +## Sicherheitsgrenzen Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor -Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Spätere -Netzwerkabrufe erhalten Schutz gegen interne Ziele, Größen-/Zeitlimits und -kontrollierte Redirects. +Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Netzwerkabrufe für +Präsentationsmetadaten und Vorschaubilder schützen interne Ziele auch nach +DNS-Auflösung und Redirects und besitzen Größen-, Zeit- und Redirectlimits. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 938d71c..0cccec8 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1,6 +1,6 @@ # Entscheidungen -Stand der Quellenprüfung: 21. Oktober 2026. Verwendet wurden offizielle +Stand der Quellenprüfung: 22. Oktober 2026. Verwendet wurden offizielle Dokumentationen und die npm-Registry für konkrete Paket-Metadaten. ## D-001: SvelteKit 3 und Node-Unterstützung @@ -225,3 +225,54 @@ Ein zufälliger 256-Bit-Token steht in der URL, während SQLite nur seinen SHA-256-Hash speichert. Medienzugriff prüft Token, aktuelle Präsentation und Bildzuordnung gemeinsam. Synchronisierung erfolgt über kleines `no-store`- Polling statt über eine dauerhaft offene Serververbindung. + +## D-018: Verwendung aus bestehenden Sendungsdaten ableiten + +„Gesendet“ ist kein Zustand am Fundus-Piece. Die Anzeige wird ausschließlich +aus der vorhandenen Original-ID, einer erledigten Verwendung außerhalb der +Reservebank und dem Status `completed` ihrer Sendung abgeleitet. Dadurch können +Einplanung, Reserve und Überspringen ein Original nicht versehentlich als +gesendet markieren, und mehrere tatsächliche Verwendungen bleiben als Historie +sichtbar. + +Eine Duplikation im Sendungsablauf kopiert den konkreten Verwendungssnapshot und +nicht das Original-Piece. Die neue ID, der Status `open` und fehlende +Live-Intervalle trennen sie von der bisherigen Durchführung. Der allgemeine +Verwendungseditor darf den Live-Status nicht mehr aus Formulardaten schreiben; +Statuswechsel bleiben der Live-Geschäftslogik vorbehalten. + +Eine bestehende Einplanung wird nicht automatisch mit späteren Änderungen am +Original synchronisiert. Die ausdrücklich ausgelöste Aktion **Aus Fundus +aktualisieren** überschreibt nur Titel, Dauer und Redaktionstexte. Eigene +Notizen, Status und Messintervalle bleiben erhalten. Alle Zuordnungen und Filter +verwenden ausschließlich `original_piece_id`, niemals veränderliche Titel. Im +allgemeinen Fundus gilt „eingeplant“ global für jede nicht abgeschlossene +Sendung; im Sendungsbau gilt es lokal für die aktuelle Sendung samt Reserve. + +## D-019: Lokale Linkmetadaten und streng abgeleitete Video-Embeds + +Sendewerk verwendet Metascraper ausschließlich serverseitig, um Titel, +Beschreibung, Bild und Herausgeber einer ausdrücklich gewählten vorhandenen +Quelle zu ermitteln. Die Ergebnisse werden gespeichert und nicht bei jedem +Seitenaufruf neu geladen. Manuell korrigierte Felder haben Vorrang. Ein eigener +HTTP(S)-Client prüft Protokoll, Port, DNS-Ergebnisse und jedes Redirectziel, +bindet den Request an die geprüfte öffentliche Adresse und begrenzt Redirects, +Laufzeit und Antwortgröße. Damit ist weder ein externer Vorschaudienst noch die +Übernahme fremden HTMLs erforderlich. + +Player werden nur für streng erkannte YouTube- und TikTok-URLs erzeugt. Aus der +validierten Anbieter-ID baut Sendewerk die offiziellen Embed-Adressen selbst; +gespeichertes oder fremd geliefertes Embed-HTML wird nicht verwendet. YouTube +nutzt die datenschutzreduzierte `youtube-nocookie.com`-Adresse und den +`start`-Parameter. TikTok erhält eine Startposition nach `onPlayerReady` über +die dokumentierte Player-`postMessage`-API. Autoplay bleibt ausgeschaltet, und +ein Inhaltswechsel ersetzt das Iframe vollständig. + +Quellen: + +- +- +- +- +- +- diff --git a/docs/STATUS.md b/docs/STATUS.md index 7290e1d..33bd165 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -67,6 +67,10 @@ als bereits implementiert verstanden werden. - Archivieren und Wiederherstellen in den vorherigen Arbeitsstatus - private Rasterbilder mit Größen-/Signaturprüfung und geschützter Auslieferung - erweiterter Seed mit Eingangs-, Archiv-, Markdown- und Recherchebeispielen +- standardmäßig ausgeblendete gesendete Pieces mit verlinkter, aufklappbarer + Verwendungshistorie; gesendet bedeutet erledigt in abgeschlossener Sendung +- standardmäßig ausgeblendete Pieces, die in mindestens einer noch nicht + abgeschlossenen Sendung eingeplant sind, mit unabhängigem Sofortfilter ## Umgesetzter Stand Etappe 4 @@ -78,6 +82,12 @@ als bereits implementiert verstanden werden. - showweite Reservebank, die nicht in Abschnitts- oder Gesamtdauer einfließt - explizite Abschnittsdauer ersetzt die Piece-Summe statt sie zu addieren - Bearbeiten, Entfernen sowie Verschieben innerhalb und zwischen Abschnitten +- zugänglicher Stift-Dialog für alle sendungsspezifischen Felder und Aktionen, + auf Desktop zweispaltig und auf kleinen Bildschirmen einspaltig scrollbar +- explizites Aktualisieren eines Verwendungssnapshots aus dem Original für + Titel, Dauer und Redaktionstexte bei erhaltenen Notizen und Live-Daten +- Duplizieren einer konkreten Einplanung direkt hinter dem Ausgangselement mit + erhaltenen Anpassungen, aber zurückgesetztem Live-Zustand - Piece-Dauern werden im Editor in Minuten eingegeben und intern weiterhin als ganze Sekunden gespeichert - Drag-and-drop über eindeutige Griffe, auch direkt aus gefilterten Quellen, @@ -85,6 +95,8 @@ als bereits implementiert verstanden werden. gleichwertigen Klick-, Touch- und Tastaturaktionen - Planungsmutationen ohne Dokumentreload mit lokalem Speicherzustand und animierter Neusortierung der betroffenen Elemente +- unabhängige Sofortfilter für verwendete sowie in der aktuellen Sendung schon + eingeplante Originale einschließlich Reservebank - kopierbare Twitch-Titel und Ankündigungstexte - showweite optimistische Revision schützt Metadaten, Reihenfolge und Inhalte vor unbemerktem parallelem Überschreiben @@ -104,6 +116,16 @@ als bereits implementiert verstanden werden. nicht - getrennte, tokenisierte heinzlive-Ausgabe mit Polling, Verbindungsanzeige, Präsentationstext, Quellen und erlaubten Bildern +- wählbare Text-, Linkkarten- und Videodarstellung aus genau einer vorhandenen + Piece-Quelle mit manuell korrigierbaren, persistenten Metadaten +- ausdrückliches serverseitiges Neuladen von Linkmetadaten über Metascraper mit + DNS-, Redirect-, Zeit-, Größen- und Privatnetzschutz +- validierte YouTube- und TikTok-IDs, offizielle Player-Iframes ohne Autoplay + sowie Linkkarten-Fallback für nicht unterstützte Videoquellen +- unveränderliche Präsentationssnapshots je Einplanung und explizite + Aktualisierung aus dem Fundus +- isolierte Präsentationstestansicht ohne Änderung des Live-Stands sowie + tokengebundene, serverseitig geschützte Proxys für externe Vorschaubilder - Ausgabe-HTML, JSON und Medienzugriff enthalten keine Recherche, Moderationsnotizen, eigenen Notizen, Kontodaten oder internen Dateipfade @@ -120,6 +142,10 @@ Präsentationsausgabe wurde stattdessen in einem anonymen zweiten Chromium- Browserkontext einschließlich Bild, JSON-Filterung, Polling und sichtbarem Verbindungsabbruch geprüft. +Die automatisierten Playerprüfungen fingen YouTube- und TikTok-Requests lokal +ab. URL-Erkennung, Iframe-Wechsel, Zeitmarkenübergabe und mobile Ausgabe sind +damit geprüft, nicht jedoch die reale Wiedergabe oder Verfügbarkeit der Anbieter. + ## Verifizierte Umgebung - Node.js 22.23.1, npm 10.9.8 diff --git a/migrations/0006_presentation_previews.sql b/migrations/0006_presentation_previews.sql new file mode 100644 index 0000000..095207e --- /dev/null +++ b/migrations/0006_presentation_previews.sql @@ -0,0 +1,28 @@ +ALTER TABLE pieces ADD COLUMN presentation_mode TEXT NOT NULL DEFAULT 'text' + CHECK (presentation_mode IN ('text', 'link', 'video')); +ALTER TABLE pieces ADD COLUMN presentation_source_position INTEGER; +ALTER TABLE pieces ADD COLUMN presentation_title TEXT NOT NULL DEFAULT ''; +ALTER TABLE pieces ADD COLUMN presentation_description TEXT NOT NULL DEFAULT ''; +ALTER TABLE pieces ADD COLUMN presentation_image_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE pieces ADD COLUMN presentation_site_name TEXT NOT NULL DEFAULT ''; +ALTER TABLE pieces ADD COLUMN presentation_provider TEXT NOT NULL DEFAULT ''; +ALTER TABLE pieces ADD COLUMN presentation_provider_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE pieces ADD COLUMN presentation_title_manual INTEGER NOT NULL DEFAULT 0 + CHECK (presentation_title_manual IN (0, 1)); +ALTER TABLE pieces ADD COLUMN presentation_description_manual INTEGER NOT NULL DEFAULT 0 + CHECK (presentation_description_manual IN (0, 1)); +ALTER TABLE pieces ADD COLUMN presentation_image_manual INTEGER NOT NULL DEFAULT 0 + CHECK (presentation_image_manual IN (0, 1)); +ALTER TABLE pieces ADD COLUMN presentation_metadata_fetched_at_utc TEXT; + +ALTER TABLE piece_usages ADD COLUMN presentation_mode_snapshot TEXT NOT NULL DEFAULT 'text' + CHECK (presentation_mode_snapshot IN ('text', 'link', 'video')); +ALTER TABLE piece_usages ADD COLUMN presentation_source_label_snapshot TEXT NOT NULL DEFAULT ''; +ALTER TABLE piece_usages ADD COLUMN presentation_source_url_snapshot TEXT NOT NULL DEFAULT ''; +ALTER TABLE piece_usages ADD COLUMN presentation_source_timecode_snapshot INTEGER; +ALTER TABLE piece_usages ADD COLUMN presentation_title_meta_snapshot TEXT NOT NULL DEFAULT ''; +ALTER TABLE piece_usages ADD COLUMN presentation_description_snapshot TEXT NOT NULL DEFAULT ''; +ALTER TABLE piece_usages ADD COLUMN presentation_image_url_snapshot TEXT NOT NULL DEFAULT ''; +ALTER TABLE piece_usages ADD COLUMN presentation_site_name_snapshot TEXT NOT NULL DEFAULT ''; +ALTER TABLE piece_usages ADD COLUMN presentation_provider_snapshot TEXT NOT NULL DEFAULT ''; +ALTER TABLE piece_usages ADD COLUMN presentation_provider_id_snapshot TEXT NOT NULL DEFAULT ''; diff --git a/package-lock.json b/package-lock.json index 6f2bb92..8b5a798 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,22 @@ { "name": "sendewerk", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sendewerk", - "version": "0.5.0", + "version": "0.5.1", "dependencies": { "@node-rs/argon2": "^2.2.2", "better-sqlite3": "^13.0.3", "file-type": "^22.2.0", "marked": "^18.1.1", + "metascraper": "^5.58.5", + "metascraper-description": "^5.58.4", + "metascraper-image": "^5.58.4", + "metascraper-publisher": "^5.58.4", + "metascraper-title": "^5.58.4", "nodemailer": "^10.1.7", "sanitize-html": "^2.18.1" }, @@ -41,6 +46,37 @@ "node": ">=22.17 <25" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.7.tgz", + "integrity": "sha512-Q7ioeear07HwixdIitIn9KS1LZi97CEDFCSjPNm9v+y8KNbsu9q4Lrd4zUQW4faED+E7qxvfeVnbNRW46wFKuw==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.3", + "@csstools/css-color-parser": "^4.2.6", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.4.tgz", + "integrity": "sha512-A6f1nKy99I6tyoK82U6kBHvQQZmdHmTmPzJYPY/0eaOW0r8/qPmEOwjGHs+hL/vCgOwQTupu4LT1XtXNUuDruA==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/@borewit/text-codec": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", @@ -51,6 +87,18 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@cacheable/memory": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", @@ -75,6 +123,140 @@ "keyv": "^5.6.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -203,6 +385,23 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -269,6 +468,18 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -343,6 +554,51 @@ "dev": true, "license": "MIT" }, + "node_modules/@kikobeats/time-span": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@kikobeats/time-span/-/time-span-1.0.13.tgz", + "integrity": "sha512-CfBK4/EZ73uN/6b5/wOfvWju1Ev/6H+uXqS2Vqd7/dEQTyOsvv4BdOHDqESp4Efa9YyrPPvN3IHU+C4z9FAo3Q==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@metascraper/helpers": { + "version": "5.58.4", + "resolved": "https://registry.npmjs.org/@metascraper/helpers/-/helpers-5.58.4.tgz", + "integrity": "sha512-3pyzjvl+2IjDoZpBhwmFJVyiTKq0wPk6d8tiL+MjeI07++VNutSyGIQZdcQK3cYhs3u+HL2Z+BH4rBQlGJ2yfg==", + "license": "MIT", + "dependencies": { + "audio-extensions": "0.0.0", + "chrono-node": "~2.10.2", + "condense-whitespace": "~3.0.0", + "data-uri-utils": "~1.0.15", + "debug-logfmt": "~1.4.15", + "entities": "~8.1.0", + "file-extension": "~4.0.5", + "has-values": "~2.0.1", + "image-extensions": "~1.1.0", + "is-relative-url": "~4.1.1", + "is-uri": "~1.2.14", + "iso-639-3": "~3.0.1", + "isostring": "0.0.1", + "jsdom": "~30.1.2", + "jsonrepair": "~3.15.0", + "lodash": "~4.18.1", + "memoize-one": "~6.0.0", + "microsoft-capitalize": "~1.0.8", + "mime": "4", + "normalize-url": "9", + "re2": "~1.27.0", + "smartquotes": "~2.3.2", + "tldts": "~7.4.18", + "url-regex-safe": "~4.0.0", + "video-extensions": "2" + }, + "engines": { + "node": ">= 22.13" + } + }, "node_modules/@node-rs/argon2": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.2.tgz", @@ -1325,6 +1581,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abbrev": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz", + "integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==", + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/acorn": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", @@ -1385,6 +1650,15 @@ "node": ">=12" } }, + "node_modules/audio-extensions": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/audio-extensions/-/audio-extensions-0.0.0.tgz", + "integrity": "sha512-yj9C819u3ED3/OyRd9mLKMXGy8wsElaf6bkkv6OqZEKrNAT461TjiznS4IfPBy8Mmh6DWaXCQCVYSq3+VHkpjQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1417,6 +1691,21 @@ "node": ">=22" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/brace-expansion": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", @@ -1454,6 +1743,213 @@ "node": ">=18" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio-select/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/cheerio-select/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/cheerio-select/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/cheerio-select/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/cheerio-select/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/cheerio/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/cheerio/node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1470,6 +1966,24 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chrono-node": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.10.3.tgz", + "integrity": "sha512-MOfrzXgSMZTH4SZ5gyRKQCVwiCrCKY+uYkSJiWoRLXS88DA6WGJm8UjgS+VVEuUQeew/UUXaxrO+SeDn/WCisg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1480,6 +1994,18 @@ "node": ">=6" } }, + "node_modules/condense-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/condense-whitespace/-/condense-whitespace-3.0.0.tgz", + "integrity": "sha512-Z186uLXZi/AMCd/9R0q3HEXgLZkT5fGFRe3dJqv0DsG5megM6QJ+N7t7KcVWxr3/dU8T0x++Uegq2NpIpW0zNQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cookie": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", @@ -1509,6 +2035,114 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-select/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/css-select/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/css-select/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/css-select/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/css-select/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1522,6 +2156,49 @@ "node": ">=4" } }, + "node_modules/data-uri-to-buffer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-5.0.1.tgz", + "integrity": "sha512-a9l6T1qqDogvvnw0nKlfZzqsyikEBZBClF39V3TFoKhDtGBqHu2HkuomJc02j5zft8zrUaXEuoicLeW54RkzPg==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/data-uri-utils": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/data-uri-utils/-/data-uri-utils-1.0.15.tgz", + "integrity": "sha512-on9aQ6gCke4GWBjWaKLsGG0q/ycibuureXXhMKdCd8iTCVwj3YItyaz36nhfKHTmdEjAEVizsRULEofZadNgzw==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "~5.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/dayjs": { "version": "1.11.23", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", @@ -1545,6 +2222,29 @@ } } }, + "node_modules/debug-logfmt": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/debug-logfmt/-/debug-logfmt-1.4.15.tgz", + "integrity": "sha512-tMgdJRAlvf0sv3We8KpVlAnv0V9hnOoB3QqmvkXSGYnYMUJvJTGawqipROgh5WaPKXyt79cdPYx30jSu9aO7Rg==", + "license": "MIT", + "dependencies": { + "@kikobeats/time-span": "~1.0.5", + "null-prototype-object": "~1.2.2", + "pretty-ms": "~7.0.1" + }, + "engines": { + "node": ">= 8" + }, + "peerDependencies": { + "debug": "*" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1646,6 +2346,19 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, "node_modules/entities": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", @@ -1658,6 +2371,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -1940,6 +2662,12 @@ "node": ">=12.0.0" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1965,7 +2693,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -1989,6 +2716,15 @@ "flat-cache": "^6.1.23" } }, + "node_modules/file-extension": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/file-extension/-/file-extension-4.0.5.tgz", + "integrity": "sha512-l0rOL3aKkoi6ea7MNZe6OHgqYYpn48Qfflr8Pe9G9JPPTx5A+sfboK91ZufzIs59/lPqh351l0eb6iKU9J5oGg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4" + } + }, "node_modules/file-type": { "version": "22.2.0", "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.2.0.tgz", @@ -2084,6 +2820,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-values": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-2.0.1.tgz", + "integrity": "sha512-+QdH3jOmq9P8GfdjFg0eJudqx1FqU62NQJ4P16rOEHeRdl7ckgwn6uqQjzYE0ZoHVV/e5E2esuJ5Gl5+HUW19w==", + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/hashery": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", @@ -2104,6 +2858,18 @@ "dev": true, "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, "node_modules/htmlparser2": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", @@ -2126,6 +2892,18 @@ "node": ">=20.19.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -2156,6 +2934,15 @@ "node": ">= 4" } }, + "node_modules/image-extensions": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/image-extensions/-/image-extensions-1.1.0.tgz", + "integrity": "sha512-P0t7ByhK8Jk9TU05ct/7+f7h8dNuXq5OY4m0IO/T+1aga/qHkpC0Wf472x3FLdq/zFDG17pgapCM3JDTxwZzow==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2166,6 +2953,45 @@ "node": ">=0.8.19" } }, + "node_modules/install-artifact-from-github": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/install-artifact-from-github/-/install-artifact-from-github-1.8.1.tgz", + "integrity": "sha512-lV8dRlR/3i6Tk4X9agaqhMRY6ScVU3YOuKse0VRnP9+rUWfImeCjo1TeAefXEauNVF3a8QgOb6jW3CZJetVopw==", + "license": "BSD-3-Clause", + "bin": { + "hash-github-cache": "bin/hash-github-cache.js", + "install-from-cache": "bin/install-from-cache.js", + "save-to-github-cache": "bin/save-to-github-cache.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/uhop" + } + }, + "node_modules/ip-regex": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-4.3.0.tgz", + "integrity": "sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-absolute-url": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-4.0.1.tgz", + "integrity": "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2198,6 +3024,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -2208,6 +3040,34 @@ "@types/estree": "^1.0.6" } }, + "node_modules/is-relative-url": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/is-relative-url/-/is-relative-url-4.1.1.tgz", + "integrity": "sha512-WPsbw9cI2lRbH8j7/14e4dtq/jGIicXOBLlD5cuFhgQCBNgZUVJzU+xBi9pFhTkcTxZJY8l8osSxkhY3xY6emw==", + "license": "MIT", + "dependencies": { + "is-absolute-url": "^4.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-uri": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/is-uri/-/is-uri-1.2.14.tgz", + "integrity": "sha512-YhTAOlejk4o85c3bPk9ayhr6VLm3e01mp4pBhtYOEXXlkK+Eoj/Nr3x+bqTwZvUI0z0L6TVk0HZViXdxUtsWDQ==", + "license": "MIT", + "dependencies": { + "parse-uri": "~2.0.0", + "punycode2": "~1.0.0" + }, + "engines": { + "node": ">= 4" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2215,6 +3075,90 @@ "dev": true, "license": "ISC" }, + "node_modules/iso-639-3": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/iso-639-3/-/iso-639-3-3.0.1.tgz", + "integrity": "sha512-SdljCYXOexv/JmbQ0tvigHN43yECoscVpe2y2hlEqy/CStXQlroPhZLj7zKLRiGqLJfw8k7B973UAMDoQczVgQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/isostring": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isostring/-/isostring-0.0.1.tgz", + "integrity": "sha512-wRcdJtXCe2LGtXnD14fXMkduWVdbeGkzBIKg8WcKeEOi6SIc+hRjYYw76WNx3v5FebhUWZrBTWB0NOl3/sagdQ==" + }, + "node_modules/jsdom": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz", + "integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^10.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.12.1", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.2.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/jsdom/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -2229,6 +3173,15 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonrepair": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.15.0.tgz", + "integrity": "sha512-wy8OTjwsJwQRnQJkKnMJJ9vcytRdBPAgIF/Hy6+s1dAj42BHMKiyL8JzEieIl3JY7idt8eyHwBWTO8mh/+mtwA==", + "license": "ISC", + "bin": { + "jsonrepair": "bin/cli.js" + } + }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", @@ -2239,6 +3192,15 @@ "@keyv/serialize": "^1.1.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/known-css-properties": { "version": "0.37.0", "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", @@ -2563,6 +3525,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", @@ -2585,6 +3562,105 @@ "node": ">= 20" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/metascraper": { + "version": "5.58.5", + "resolved": "https://registry.npmjs.org/metascraper/-/metascraper-5.58.5.tgz", + "integrity": "sha512-LTHybDFCtlcWHtrWuvM7UUPrpA405/38UvZ7DgEFYYN/5j1HRNExlayV1DHkVtRenUUWLkBRjMesI8fDDoX83A==", + "license": "MIT", + "dependencies": { + "@metascraper/helpers": "5.58.4", + "cheerio": "~1.2.0", + "debug-logfmt": "~1.4.15", + "whoops": "~5.1.4" + }, + "engines": { + "node": ">= 22" + } + }, + "node_modules/metascraper-description": { + "version": "5.58.4", + "resolved": "https://registry.npmjs.org/metascraper-description/-/metascraper-description-5.58.4.tgz", + "integrity": "sha512-uWfbJr7h2FTJKgryAPMGbt5Qr8UMGtl/BsDIehoAZq1sIs+cns4gtJzfclUj22wsEgd5TNJoTXMtMdzVeS2Xdg==", + "license": "MIT", + "dependencies": { + "@metascraper/helpers": "5.58.4" + }, + "engines": { + "node": ">= 22" + } + }, + "node_modules/metascraper-image": { + "version": "5.58.4", + "resolved": "https://registry.npmjs.org/metascraper-image/-/metascraper-image-5.58.4.tgz", + "integrity": "sha512-Ft9XbmVYMjLcf6ohySi8ChVUHt8I5y7XdB8+DYB7QiTarZiAVYojysVzXKj9xhrrpsH/ShFySL3su3uyzSTvLA==", + "license": "MIT", + "dependencies": { + "@metascraper/helpers": "5.58.4" + }, + "engines": { + "node": ">= 22" + } + }, + "node_modules/metascraper-publisher": { + "version": "5.58.4", + "resolved": "https://registry.npmjs.org/metascraper-publisher/-/metascraper-publisher-5.58.4.tgz", + "integrity": "sha512-4Wo3NOIFB8S/EXWMRnmk965Hrv85Ijjc14SQ1QCLfc1ehU2j48AP3dbCvPoKMG7S3pEjksX9LHaKzdD+dhJLdA==", + "license": "MIT", + "dependencies": { + "@metascraper/helpers": "5.58.4" + }, + "engines": { + "node": ">= 22" + } + }, + "node_modules/metascraper-title": { + "version": "5.58.4", + "resolved": "https://registry.npmjs.org/metascraper-title/-/metascraper-title-5.58.4.tgz", + "integrity": "sha512-G0/Vhm3kxNhbF0E19TlyxXa3MeIe5AeRfrf50yCyKEbJspeWVX2E+nskSCt+pibQnYUnHxh942KudUoWrzKW5Q==", + "license": "MIT", + "dependencies": { + "@metascraper/helpers": "5.58.4" + }, + "engines": { + "node": ">= 22" + } + }, + "node_modules/microsoft-capitalize": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/microsoft-capitalize/-/microsoft-capitalize-1.0.8.tgz", + "integrity": "sha512-uSzg9VkW1+ZgNcJG0AcU/Ynje5xLnLuXoJCQBWKs7DeEzmgUTF5mLVbsmj0aYppN43MWI4RGUuRANLJAIaRfLw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/mime": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.1.tgz", + "integrity": "sha512-YAApRsTpM2+gnUFHhRNGfkX8NK0V5xj1nyQLufGlwEI+eX4xRsTcel2IXDuMEGSGjBvzykNW4VuoEACgwYe7BQ==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -2601,6 +3677,27 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -2627,6 +3724,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nan": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.30.0.tgz", + "integrity": "sha512-P5mokDkSvCGBz6mZDD/2l+w9QoICRXdCyZ9ONGmTePr40oihJg3Kpn0YU3twKd6PJnwSlLBWNQeOT6yWEV2c4A==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", @@ -2661,6 +3764,63 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-gyp": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-13.1.0.tgz", + "integrity": "sha512-FpNzOLFACKG0oRBYbZ5WOeWD1EqRTIWX90MaRIWTO18lVYPx8fqIjrdS3pihjnCkSmgop6zVGkSqg1vXZLksuw==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^10.0.0", + "proc-log": "^7.0.0", + "semver": "^7.3.5", + "tar": "^7.5.7", + "tinyglobby": "^0.2.12", + "undici": "^8.4.1", + "which": "^7.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-7.0.0.tgz", + "integrity": "sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==", + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/nodemailer": { "version": "10.1.7", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.7.tgz", @@ -2670,6 +3830,54 @@ "node": ">=20.0.0" } }, + "node_modules/nopt": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", + "integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==", + "license": "ISC", + "dependencies": { + "abbrev": "^5.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/normalize-url": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.1.tgz", + "integrity": "sha512-ARftfC5HdUNu9jJeL8pHj8debUIHA2b91FizCoMzY4lG6dDX13jdvTK0TBe24IBDRf2HvJSzzwEPvmbkQWHRSg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/null-prototype-object": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/null-prototype-object/-/null-prototype-object-1.2.7.tgz", + "integrity": "sha512-pSZWCUew0zed2gxCerA4zdXRGg8ezLiWwvE8RvncezTcROXL0uz3PjSZXl5NsI04Egz0Uu46o1wyX6qr3u/ZWA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, "node_modules/obug": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", @@ -2734,12 +3942,106 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", + "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parse-srcset": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", "license": "MIT" }, + "node_modules/parse-uri": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/parse-uri/-/parse-uri-2.0.6.tgz", + "integrity": "sha512-sB5ONhkIMyztTX2trw4OVqHhUnHS8wDFHPRN+eaHxWQ+8cFlF7nUFIDpzFksQMntTts6SEgX/VOyeAowwLqQuw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2770,7 +4072,6 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2984,16 +4285,48 @@ "svelte": "^5.0.0" } }, + "node_modules/pretty-ms": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", + "integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==", + "license": "MIT", + "dependencies": { + "parse-ms": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/proc-log": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-7.0.0.tgz", + "integrity": "sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==", + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/punycode2": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/punycode2/-/punycode2-1.0.1.tgz", + "integrity": "sha512-+TXpd9YRW4YUZZPoRHJ3DILtWwootGc2DsgvfHmklQ8It1skINAuqSdqizt5nlTaBmwrYACHkHApCXjc9gHk2Q==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/qified": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", @@ -3014,6 +4347,24 @@ "dev": true, "license": "MIT" }, + "node_modules/re2": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/re2/-/re2-1.27.0.tgz", + "integrity": "sha512-z5Vascmj7lQbU3YdfYJUDkBGGTnoAObcVW/CSbpI3WbVb1vZQsSzSWcGkfKU0JKrrGfPZwPr8EVHwYXKQKLVXw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "install-artifact-from-github": "^1.8.1", + "nan": "^2.29.0", + "node-gyp": "^13.0.2" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/uhop" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3028,6 +4379,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.2.14", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", @@ -3075,6 +4435,12 @@ "node": ">=6" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/sanitize-html": { "version": "2.18.1", "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz", @@ -3092,11 +4458,22 @@ "node": ">=22.12.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3143,6 +4520,15 @@ "node": ">=18" } }, + "node_modules/smartquotes": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/smartquotes/-/smartquotes-2.3.2.tgz", + "integrity": "sha512-0R6YJ5hLpDH4mZR7N5eZ12oCMLspvGOHL9A9SEm2e3b/CQmQidekW4SWSKEmor/3x6m3NCBBEqLzikcZC9VJNQ==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", @@ -3316,6 +4702,22 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tinybench": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", @@ -3340,7 +4742,6 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3353,6 +4754,33 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, + "node_modules/tldts": { + "version": "7.4.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz", + "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.19.tgz", + "integrity": "sha512-goNXV6Ho9TIOfpJZanTd6CKG5DyYgYAL+k+22T0SLh8l6+KHIfBUPFuePuSZv05UttIaaLmI9tb7TmGIdK5AkQ==", + "license": "MIT" + }, "node_modules/token-types": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", @@ -3381,6 +4809,30 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3457,6 +4909,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/undici": { + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -3474,6 +4935,27 @@ "punycode": "^2.1.0" } }, + "node_modules/url-regex-safe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/url-regex-safe/-/url-regex-safe-4.0.0.tgz", + "integrity": "sha512-BrnFCWKNFrFnRzKD66NtJqQepfJrUHNPvPxE5y5NSAhXBb4OlobQjt7907Jm4ItPiXaeX+dDWMkcnOd4jR9N8A==", + "license": "MIT", + "dependencies": { + "ip-regex": "4.3.0", + "tlds": "^1.242.0" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "re2": "^1.20.1" + }, + "peerDependenciesMeta": { + "re2": { + "optional": true + } + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3481,6 +4963,18 @@ "dev": true, "license": "MIT" }, + "node_modules/video-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/video-extensions/-/video-extensions-2.0.0.tgz", + "integrity": "sha512-qSXHIVmAVVKYk16x4keCwSqFuvF3D0K9KFuywWxd6ulpDfBHZ5QkGccMbT789IwSX9ykC88dL5kYGK3DZ59/ng==", + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/vite": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", @@ -3676,6 +5170,63 @@ "node": ">=12.20.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz", + "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3692,6 +5243,15 @@ "node": ">= 8" } }, + "node_modules/whoops": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/whoops/-/whoops-5.1.4.tgz", + "integrity": "sha512-qRfCp1x5MB0Fxuw0y91KqHawR9Cs4OBIyyxAemxVtCxlkCDLvjd1ti3NLVmutDMqU7jbgHKc9EX3i1Bppfif8w==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", @@ -3715,6 +5275,30 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/yaml": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", diff --git a/package.json b/package.json index fdd6a0c..48eb5d7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sendewerk", "private": true, - "version": "0.5.0", + "version": "0.5.1", "type": "module", "engines": { "node": ">=22.17 <25" @@ -28,6 +28,11 @@ "better-sqlite3": "^13.0.3", "file-type": "^22.2.0", "marked": "^18.1.1", + "metascraper": "^5.58.5", + "metascraper-description": "^5.58.4", + "metascraper-image": "^5.58.4", + "metascraper-publisher": "^5.58.4", + "metascraper-title": "^5.58.4", "nodemailer": "^10.1.7", "sanitize-html": "^2.18.1" }, diff --git a/src/app.css b/src/app.css index d9b3247..b4693e6 100644 --- a/src/app.css +++ b/src/app.css @@ -1345,6 +1345,111 @@ a { font-size: 0.85rem; } +.output-link-card { + grid-template-columns: minmax(0, 1.15fr) minmax(18rem, 0.85fr); + align-items: center; + gap: clamp(2rem, 5vw, 6rem); +} + +.output-link-image { + display: grid; + min-height: 20rem; + place-items: center; + border-radius: 1.4rem; + background: rgb(255 255 255 / 5%); + overflow: hidden; +} + +.output-link-image img { + width: 100%; + max-height: 68vh; + object-fit: contain; +} + +.output-link-copy h1, +.output-video h1 { + max-width: 24ch; + font-size: clamp(2rem, 4.5vw, 5rem); + line-height: 1; +} + +.output-link-copy > p:not(.output-kicker) { + max-width: 38ch; + color: rgb(255 247 232 / 78%); + font-size: clamp(1.1rem, 2vw, 1.65rem); + line-height: 1.45; +} + +.output-link-copy a, +.output-fallback a, +.output-video-source a { + display: inline-flex; + margin-top: 1rem; + color: #e7b957; + font-weight: 740; +} + +.output-video { + grid-template-rows: auto minmax(0, 1fr) auto; + align-content: stretch; + gap: 1rem; +} + +.output-video header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1.5rem; +} + +.output-video header h1 { + margin: 0; +} + +.output-player { + display: grid; + min-height: 0; + place-items: center; +} + +.output-player iframe { + width: min(100%, calc((100vh - 13rem) * 16 / 9)); + aspect-ratio: 16 / 9; + border: 0; + border-radius: 1rem; + background: #000; +} + +.output-video.portrait .output-player iframe { + width: min(100%, calc((100vh - 13rem) * 9 / 16)); + aspect-ratio: 9 / 16; +} + +.output-fallback { + display: grid; + min-height: min(55vh, 32rem); + place-content: center; + padding: 2rem; + border: 1px solid rgb(255 255 255 / 14%); + border-radius: 1.2rem; + background: rgb(255 255 255 / 5%); + text-align: center; +} + +.presentation-test-status { + position: fixed; + z-index: 2; + top: 1rem; + right: 1rem; + padding: 0.45rem 0.7rem; + border: 1px solid rgb(255 255 255 / 16%); + border-radius: 2rem; + background: rgb(11 18 32 / 82%); + color: #67dde8; + font-size: 0.7rem; + font-weight: 750; +} + .output-idle { display: grid; min-height: calc(100vh - clamp(4rem, 12vw, 12rem)); @@ -1368,6 +1473,40 @@ a { font-size: clamp(0.9rem, 1.8vw, 1.3rem); } +@media (max-width: 52rem) { + .heinzlive-output { + padding: 1.2rem; + } + + .output-link-card { + grid-template-columns: 1fr; + align-content: center; + gap: 1.25rem; + } + + .output-link-image { + min-height: 12rem; + } + + .output-link-image img { + max-height: 40vh; + } + + .output-video { + min-height: calc(100dvh - 2.4rem); + } + + .output-video header { + align-items: flex-start; + flex-direction: column; + gap: 0; + } + + .output-player iframe { + width: 100%; + } +} + .quiet-panel { border: 1px solid var(--line); border-radius: var(--radius-large); @@ -1853,6 +1992,77 @@ a { color: var(--text); } +.filter-checkbox { + display: inline-flex; + align-items: center; + gap: 0.45rem; + min-height: 2rem; + margin: -0.35rem 0 0.75rem; + color: var(--text-muted); + font-size: 0.7rem; + font-weight: 680; + cursor: pointer; +} + +.filter-checkbox input { + width: 1rem; + height: 1rem; + margin: 0; + accent-color: var(--accent); +} + +.library-filter-options { + display: flex; + flex-wrap: wrap; + gap: 0.15rem 1rem; + margin: -0.35rem 0 0.75rem; +} + +.library-filter-options .filter-checkbox { + margin: 0; +} + +.piece-usage-history { + margin-top: 0.35rem; + color: var(--text-muted); + font-size: 0.65rem; +} + +.piece-usage-history p { + margin: 0; +} + +.piece-usage-history a { + color: var(--accent); + font-weight: 700; +} + +.piece-usage-history p span { + margin-left: 0.3rem; + color: var(--text-faint); +} + +.piece-usage-history details { + margin-top: 0.25rem; +} + +.piece-usage-history summary { + width: fit-content; + cursor: pointer; + font-weight: 680; +} + +.piece-usage-history ul { + display: grid; + gap: 0.2rem; + margin: 0.3rem 0 0; + padding-left: 1rem; +} + +.piece-usage-history li a { + margin-left: 0.35rem; +} + .text-link { color: var(--accent); font-size: 0.72rem; @@ -2081,6 +2291,71 @@ a { grid-column: 1 / -1; } +.presentation-mode-picker { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-bottom: 0.85rem; +} + +.presentation-mode-picker label { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.55rem 0.75rem; + border: 1px solid var(--line); + border-radius: 2rem; + background: var(--panel-soft); + font-size: 0.74rem; + font-weight: 700; +} + +.presentation-settings { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.65rem; + margin-top: 0.8rem; +} + +.presentation-settings > label, +.presentation-text-field { + display: grid; + gap: 0.35rem; + color: var(--text-muted); + font-size: 0.7rem; + font-weight: 700; +} + +.presentation-preview-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.presentation-preview-actions small, +.presentation-source-summary small { + color: var(--text-faint); + font-size: 0.66rem; +} + +.presentation-source-summary { + display: grid; + gap: 0.2rem; + padding: 0.7rem; + border: 1px solid var(--line); + border-radius: var(--radius-small); + background: var(--panel-soft); +} + +.presentation-source-summary span { + color: var(--text-muted); + font-size: 0.68rem; +} + +.presentation-source-summary small { + overflow-wrap: anywhere; +} + .editor-fields label, .markdown-grid > label, .source-row label { @@ -2466,7 +2741,8 @@ a { } .copy-field button, -.row-actions button { +.row-actions > button, +.row-actions > form > button { min-width: 2.35rem; min-height: 2.35rem; border: 1px solid var(--line); @@ -2476,7 +2752,156 @@ a { cursor: pointer; } -.row-actions button:disabled { +.usage-edit-button { + display: grid; + width: 1.8rem; + height: 1.8rem; + place-items: center; + padding: 0; + border: 1px solid var(--line-strong); + border-radius: 0.6rem; + background: var(--panel-soft); + color: var(--text-muted); + cursor: pointer; +} + +.usage-edit-button:hover { + border-color: var(--accent); + color: var(--accent); +} + +.usage-editor-dialog { + width: min(60rem, calc(100vw - 2rem)); + max-width: none; + max-height: min(52rem, calc(100dvh - 2rem)); + padding: 0; + border: 1px solid var(--line-strong); + border-radius: var(--radius-large); + background: var(--bg-elevated); + box-shadow: var(--shadow-large); + color: var(--text); +} + +.usage-editor-dialog::backdrop { + background: color-mix(in srgb, #07110e 62%, transparent); + backdrop-filter: blur(6px); +} + +.usage-editor-dialog > header { + display: flex; + position: sticky; + z-index: 2; + top: 0; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.1rem; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--bg-elevated) 94%, transparent); + backdrop-filter: blur(16px); +} + +.usage-editor-dialog h2, +.usage-editor-dialog h3 { + margin: 0.12rem 0 0; +} + +.usage-editor-dialog h2 { + font-size: 1.2rem; +} + +.usage-editor-dialog h3 { + font-size: 0.95rem; +} + +.dialog-close { + width: 2rem; + height: 2rem; + padding: 0; + border: 1px solid var(--line); + border-radius: 50%; + background: var(--panel-soft); + color: var(--text-muted); + font-size: 1.25rem; + cursor: pointer; +} + +.usage-dialog-scroll { + display: grid; + gap: 0.8rem; + max-height: calc(100dvh - 7rem); + padding: 0.9rem; + overflow: auto; +} + +.usage-dialog-message { + margin: 0; +} + +.usage-dialog-editor { + display: grid; + grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.35fr); + align-items: stretch; + gap: 0.8rem; +} + +.usage-dialog-editor > .primary-button { + grid-column: 1 / -1; + justify-self: end; +} + +.usage-dialog-editor input, +.usage-dialog-editor textarea, +.usage-dialog-actions select { + width: 100%; + min-width: 0; + padding: 0.6rem 0.7rem; + border: 1px solid var(--line-strong); + border-radius: 0.7rem; + background: var(--panel-strong); + color: var(--text); +} + +.usage-dialog-actions { + display: grid; + grid-template-columns: minmax(14rem, 1fr) auto minmax(13rem, auto) auto; + align-items: end; + gap: 0.65rem; + padding: 0.85rem; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: var(--panel-soft); +} + +.usage-dialog-actions > div { + display: grid; + gap: 0.3rem; +} + +.usage-dialog-actions small { + color: var(--text-faint); + font-size: 0.62rem; + line-height: 1.35; +} + +.usage-dialog-actions form, +.usage-dialog-actions label { + display: flex; + align-items: end; + gap: 0.4rem; +} + +.usage-dialog-actions label { + min-width: 9rem; + flex-direction: column; + align-items: stretch; + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 700; +} + +.row-actions > button:disabled, +.row-actions > form > button:disabled { cursor: not-allowed; opacity: 0.35; } @@ -2500,6 +2925,17 @@ a { margin-bottom: 0.45rem; } +.builder-filter-options { + display: flex; + flex-wrap: wrap; + gap: 0.15rem 0.8rem; + margin-bottom: 0.35rem; +} + +.builder-filter-options .filter-checkbox { + margin: 0; +} + .builder-source-list { display: grid; border-top: 1px solid var(--line); @@ -2534,6 +2970,17 @@ a { font-size: 0.67rem; } +.planned-label { + display: inline-block; + margin-top: 0.35rem; + padding: 0.16rem 0.4rem; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-size: 0.61rem; + font-weight: 760; +} + .builder-source-card h3 { margin: 0.4rem 0 0.12rem; font-size: 0.86rem; @@ -2674,11 +3121,12 @@ a { gap: 0.25rem; } -.row-actions form { +.row-actions > form { display: flex; } -.row-actions button { +.row-actions > button, +.row-actions > form > button { min-width: 1.8rem; min-height: 1.8rem; padding: 0; @@ -2780,7 +3228,7 @@ a { .reserve-usage { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(12rem, auto) auto; + grid-template-columns: minmax(0, 1fr) minmax(12rem, auto) auto auto; align-items: end; gap: 0.5rem; margin-top: 0.55rem; @@ -2797,7 +3245,7 @@ a { font-size: 0.65rem; } -.reserve-usage form { +.reserve-usage > form { display: flex; align-items: end; gap: 0.35rem; @@ -2952,6 +3400,8 @@ a { .show-create-form, .show-settings-form, .usage-edit-form, + .usage-dialog-editor, + .presentation-settings, .builder-filter { grid-template-columns: 1fr; } @@ -2990,6 +3440,23 @@ a { flex-direction: column; gap: 0.75rem; } + .usage-editor-dialog { + width: calc(100vw - 1rem); + max-height: calc(100dvh - 1rem); + border-radius: var(--radius-medium); + } + .usage-dialog-scroll { + max-height: calc(100dvh - 5.5rem); + padding: 0.65rem; + } + .usage-dialog-actions { + grid-template-columns: 1fr; + align-items: stretch; + } + .usage-dialog-actions form, + .usage-dialog-actions button { + width: 100%; + } .library-toolbar { align-items: stretch; flex-direction: column; @@ -3103,7 +3570,8 @@ a { grid-template-columns: 1fr; } .copy-field button, - .reserve-usage button { + .reserve-usage > button, + .reserve-usage > form > button { width: 100%; } .editable-run-section > .row-actions { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 7a2048d..68ff24a 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -14,6 +14,7 @@ const publicPaths = new Set([ export const handle: Handle = async ({ event, resolve }) => { const presentationPath = event.url.pathname.startsWith('/heinzlive/'); + const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname); const token = event.cookies.get(SESSION_COOKIE); event.locals.user = getSessionUser(token); if (token && !event.locals.user) deleteSession(event.cookies); @@ -26,9 +27,13 @@ export const handle: Handle = async ({ event, resolve }) => { response.headers.set('Referrer-Policy', 'same-origin'); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('X-Frame-Options', 'DENY'); - if (presentationPath) { + if (presentationPath || presentationTestPath) { response.headers.set('Cache-Control', 'no-store'); - response.headers.set('Referrer-Policy', 'no-referrer'); + response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + response.headers.set( + 'Content-Security-Policy', + "frame-src https://www.youtube-nocookie.com https://www.youtube.com https://www.tiktok.com; frame-ancestors 'none'" + ); } if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') { response.headers.set('Cache-Control', 'no-store'); diff --git a/src/lib/components/NavIcon.svelte b/src/lib/components/NavIcon.svelte index 7d9d4fa..b7cc967 100644 --- a/src/lib/components/NavIcon.svelte +++ b/src/lib/components/NavIcon.svelte @@ -16,6 +16,8 @@ + {:else if name === 'edit'} + {/if} diff --git a/src/lib/components/PieceUsageHistory.svelte b/src/lib/components/PieceUsageHistory.svelte new file mode 100644 index 0000000..b14cdd0 --- /dev/null +++ b/src/lib/components/PieceUsageHistory.svelte @@ -0,0 +1,36 @@ + + +{#if history.length} +
+

+ Gesendet am {berlinDate(history[0].usedAtUtc)} + in {history[0].showTitle} + {#if history.length > 1}+ {history.length - 1} weitere{/if} +

+ {#if history.length > 1} +
+ Vollständige Verwendungshistorie +
    + {#each history as usage (usage.usageId)} +
  • + + {usage.showTitle} +
  • + {/each} +
+
+ {/if} +
+{/if} diff --git a/src/lib/components/PresentationDisplay.svelte b/src/lib/components/PresentationDisplay.svelte new file mode 100644 index 0000000..b2ea4a0 --- /dev/null +++ b/src/lib/components/PresentationDisplay.svelte @@ -0,0 +1,113 @@ + + +{#if content.mode === 'text'} +
+

heinzlive

+

{content.title}

+ + +
{@html content.html}
+ {#if content.images?.length}
+ {#each content.images as image (image.id)} + {image.altText} + {/each} +
{/if} + {#if content.sources?.length}
+ Quellen + {#each content.sources as source, index (`${index}-${source.url}`)} + + + {source.label} + {/each} +
{/if} +
+{:else if content.mode === 'link'} + +{:else} +
+
+

{content.siteName || content.source?.label || 'Video'}

+

{content.previewTitle || content.title}

+
+ {#if content.embedUrl && !playerUnavailable} +
+ +
+ {:else} +
+ Video kann nicht eingebettet werden +

Der Anbieter oder diese URL ist für die eingebettete Wiedergabe nicht verfügbar.

+ + + {#if content.source}Original öffnen{/if} +
+ {/if} + {#if content.source}{/if} +
+{/if} diff --git a/src/lib/components/UsageEditorDialog.svelte b/src/lib/components/UsageEditorDialog.svelte new file mode 100644 index 0000000..c5a6835 --- /dev/null +++ b/src/lib/components/UsageEditorDialog.svelte @@ -0,0 +1,228 @@ + + + + + event.target === dialog && dialog.close()} +> +
+
+

Sendungsspezifische Kopie

+

{usage.title}

+
+
+ +
+
+
+ {#if error}{/if} +
+ + +
+
+

Grundlagen

+

Titel, Dauer und Notizen

+
+
+ + + +
+
+ +
+
+

Redaktion

+

Getrennte Arbeitsbereiche

+
+
+ + + + + +
+
+ + {#if presentationMode !== 'text'} + + + + +
+ Ausgewählte Quelle + {usage.presentationSourceLabel || + usage.presentationSourceUrl || + 'Keine Quelle'} + {#if usage.presentationSourceUrl}{usage.presentationSourceUrl}{/if} +
+ + + + {/if} +
+
+ + +
+ +
+
+
+ + + +
+ Übernimmt Titel, Dauer und Redaktionstexte. Eigene Notizen und Live-Daten bleiben. +
+
+ + + +
+
+ + + + + +
+
+ !confirm('Verwendung aus der Sendung entfernen?') && event.preventDefault()} + > + + + +
+
+
+
diff --git a/src/lib/server/content.spec.ts b/src/lib/server/content.spec.ts index a4b1279..5323d58 100644 --- a/src/lib/server/content.spec.ts +++ b/src/lib/server/content.spec.ts @@ -13,6 +13,7 @@ import { getPiece, getTaxonomy, listPieces, + loadPiecePresentationMetadata, savePiece, setPieceArchived, uploadPieceImage, @@ -50,6 +51,18 @@ function completeInput(overrides: Partial = {}): PieceInput { moderationNotes: '- Punkt eins', transitionText: 'Weiter geht es.', presentationText: '## Sichtbare Aussage', + presentationMode: 'text', + presentationSourcePosition: null, + presentationTitle: '', + presentationDescription: '', + presentationImageUrl: '', + presentationSiteName: '', + presentationProvider: '', + presentationProviderId: '', + presentationTitleManual: false, + presentationDescriptionManual: false, + presentationImageManual: false, + presentationMetadataFetchedAtUtc: null, topicIds: ['topic-gaming', 'topic-internet'], categoryIds: ['cat-jc', 'cat-reaction'], sources: [ @@ -86,6 +99,49 @@ describe('content management', () => { }); }); + it('stores fetched preview metadata and preserves manual corrections on reload', async () => { + const id = createPiece('find'); + const initial = getPiece(id)!; + const saved = savePiece( + id, + initial.revision, + completeInput({ + presentationMode: 'link', + presentationSourcePosition: 0, + presentationTitle: 'Manueller Titel', + presentationTitleManual: true + }) + ); + const loaded = await loadPiecePresentationMetadata(id, saved.revision, async () => ({ + title: 'Automatischer Titel', + description: 'Geladener Anreißer', + imageUrl: 'https://example.com/vorschau.jpg', + siteName: 'Example', + provider: '', + providerId: '' + })); + expect(getPiece(id)).toMatchObject({ + revision: loaded.revision, + presentationTitle: 'Manueller Titel', + presentationDescription: 'Geladener Anreißer', + presentationImageUrl: 'https://example.com/vorschau.jpg', + presentationSiteName: 'Example' + }); + + await loadPiecePresentationMetadata(id, loaded.revision, async () => ({ + title: 'Anderer automatischer Titel', + description: 'Aktualisierter Anreißer', + imageUrl: '', + siteName: 'Example', + provider: '', + providerId: '' + })); + expect(getPiece(id)).toMatchObject({ + presentationTitle: 'Manueller Titel', + presentationDescription: 'Aktualisierter Anreißer' + }); + }); + it('searches independent taxonomies and archives, restores and duplicates pieces', async () => { createTaxonomy('topic', 'Technikgeschichte'); const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!; diff --git a/src/lib/server/content.ts b/src/lib/server/content.ts index 928d0db..9d10e11 100644 --- a/src/lib/server/content.ts +++ b/src/lib/server/content.ts @@ -4,6 +4,11 @@ import path from 'node:path'; import { fileTypeFromBuffer } from 'file-type'; import { getDatabase } from './database.js'; import { getServerPaths } from './config.js'; +import { + detectVideoProvider, + fetchPresentationMetadata, + type PresentationMode +} from './presentation.js'; export const PIECE_STATUSES = ['inbox', 'preparing', 'ready', 'archived'] as const; export type PieceStatus = (typeof PIECE_STATUSES)[number]; @@ -27,6 +32,18 @@ export type PieceInput = { moderationNotes: string; transitionText: string; presentationText: string; + presentationMode: PresentationMode; + presentationSourcePosition: number | null; + presentationTitle: string; + presentationDescription: string; + presentationImageUrl: string; + presentationSiteName: string; + presentationProvider: string; + presentationProviderId: string; + presentationTitleManual: boolean; + presentationDescriptionManual: boolean; + presentationImageManual: boolean; + presentationMetadataFetchedAtUtc: string | null; topicIds: string[]; categoryIds: string[]; sources: PieceSourceInput[]; @@ -164,6 +181,25 @@ function validatePieceInput(input: PieceInput): PieceInput { } return { label, url, timecodeSeconds: source.timecodeSeconds }; }); + if (!['text', 'link', 'video'].includes(input.presentationMode)) { + throw new Error('INVALID_PRESENTATION'); + } + const sourcePosition = input.presentationSourcePosition; + if ( + sourcePosition !== null && + (!Number.isInteger(sourcePosition) || sourcePosition < 0 || sourcePosition >= sources.length) + ) { + throw new Error('INVALID_PRESENTATION'); + } + if (input.presentationMode !== 'text' && sourcePosition === null) { + throw new Error('INVALID_PRESENTATION'); + } + const presentationImageUrl = cleanText(input.presentationImageUrl, 2_000, 'presentation_image'); + if (presentationImageUrl && !validUrl(presentationImageUrl)) { + throw new Error('INVALID_PRESENTATION'); + } + const detected = + sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url); return { title, summary: cleanText(input.summary, 2_000, 'summary'), @@ -175,6 +211,22 @@ function validatePieceInput(input: PieceInput): PieceInput { moderationNotes: cleanText(input.moderationNotes, 50_000, 'moderation'), transitionText: cleanText(input.transitionText, 50_000, 'transition'), presentationText: cleanText(input.presentationText, 50_000, 'presentation'), + presentationMode: input.presentationMode, + presentationSourcePosition: sourcePosition, + presentationTitle: cleanText(input.presentationTitle, 300, 'presentation_title'), + presentationDescription: cleanText( + input.presentationDescription, + 1_000, + 'presentation_description' + ), + presentationImageUrl, + presentationSiteName: cleanText(input.presentationSiteName, 200, 'presentation_site'), + presentationProvider: detected?.provider ?? '', + presentationProviderId: detected?.id ?? '', + presentationTitleManual: Boolean(input.presentationTitleManual), + presentationDescriptionManual: Boolean(input.presentationDescriptionManual), + presentationImageManual: Boolean(input.presentationImageManual), + presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null, topicIds, categoryIds, sources @@ -298,6 +350,18 @@ type PieceRow = { moderation_notes: string; transition_text: string; presentation_text: string; + presentation_mode: PresentationMode; + presentation_source_position: number | null; + presentation_title: string; + presentation_description: string; + presentation_image_url: string; + presentation_site_name: string; + presentation_provider: string; + presentation_provider_id: string; + presentation_title_manual: number; + presentation_description_manual: number; + presentation_image_manual: number; + presentation_metadata_fetched_at_utc: string | null; revision: number; created_at_utc: string; updated_at_utc: string; @@ -308,7 +372,12 @@ export function getPiece(id: string): PieceEditor | null { const row = database .prepare( `SELECT id, title, summary, format_id, status, duration_seconds, intro, research_text, - moderation_notes, transition_text, presentation_text, revision, created_at_utc, updated_at_utc + moderation_notes, transition_text, presentation_text, presentation_mode, + presentation_source_position, presentation_title, presentation_description, + presentation_image_url, presentation_site_name, presentation_provider, + presentation_provider_id, presentation_title_manual, presentation_description_manual, + presentation_image_manual, presentation_metadata_fetched_at_utc, + revision, created_at_utc, updated_at_utc FROM pieces WHERE id = ?` ) .get(id) as PieceRow | undefined; @@ -325,6 +394,18 @@ export function getPiece(id: string): PieceEditor | null { moderationNotes: row.moderation_notes, transitionText: row.transition_text, presentationText: row.presentation_text, + presentationMode: row.presentation_mode, + presentationSourcePosition: row.presentation_source_position, + presentationTitle: row.presentation_title, + presentationDescription: row.presentation_description, + presentationImageUrl: row.presentation_image_url, + presentationSiteName: row.presentation_site_name, + presentationProvider: row.presentation_provider, + presentationProviderId: row.presentation_provider_id, + presentationTitleManual: row.presentation_title_manual === 1, + presentationDescriptionManual: row.presentation_description_manual === 1, + presentationImageManual: row.presentation_image_manual === 1, + presentationMetadataFetchedAtUtc: row.presentation_metadata_fetched_at_utc, revision: row.revision, createdAtUtc: row.created_at_utc, updatedAtUtc: row.updated_at_utc, @@ -405,6 +486,11 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI .prepare( `UPDATE pieces SET title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?, intro = ?, research_text = ?, moderation_notes = ?, transition_text = ?, presentation_text = ?, + presentation_mode = ?, presentation_source_position = ?, presentation_title = ?, + presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?, + presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?, + presentation_description_manual = ?, presentation_image_manual = ?, + presentation_metadata_fetched_at_utc = ?, updated_at_utc = ?, revision = revision + 1, archived_from_status = NULL WHERE id = ? AND revision = ? AND status != 'archived'` ) @@ -419,6 +505,18 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI input.moderationNotes, input.transitionText, input.presentationText, + input.presentationMode, + input.presentationSourcePosition, + input.presentationTitle, + input.presentationDescription, + input.presentationImageUrl, + input.presentationSiteName, + input.presentationProvider, + input.presentationProviderId, + input.presentationTitleManual ? 1 : 0, + input.presentationDescriptionManual ? 1 : 0, + input.presentationImageManual ? 1 : 0, + input.presentationMetadataFetchedAtUtc, updatedAtUtc, id, expectedRevision @@ -477,6 +575,18 @@ export function createPiece(template: PieceTemplateId = 'blank'): string { moderationNotes: defaults.moderationNotes ?? '', transitionText: '', presentationText: defaults.presentationText ?? '', + presentationMode: 'text', + presentationSourcePosition: null, + presentationTitle: '', + presentationDescription: '', + presentationImageUrl: '', + presentationSiteName: '', + presentationProvider: '', + presentationProviderId: '', + presentationTitleManual: false, + presentationDescriptionManual: false, + presentationImageManual: false, + presentationMetadataFetchedAtUtc: null, topicIds: [], categoryIds: defaults.categoryIds ?? [], sources: [] @@ -582,8 +692,13 @@ export async function duplicatePiece(sourceId: string): Promise { database .prepare( `INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro, - research_text, moderation_notes, transition_text, presentation_text, created_at_utc, - updated_at_utc, revision) VALUES (?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, 1)` + research_text, moderation_notes, transition_text, presentation_text, presentation_mode, + presentation_source_position, presentation_title, presentation_description, + presentation_image_url, presentation_site_name, presentation_provider, + presentation_provider_id, presentation_title_manual, + presentation_description_manual, presentation_image_manual, + presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision) + VALUES (?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` ) .run( id, @@ -596,6 +711,18 @@ export async function duplicatePiece(sourceId: string): Promise { source.moderationNotes, source.transitionText, source.presentationText, + source.presentationMode, + source.presentationSourcePosition, + source.presentationTitle, + source.presentationDescription, + source.presentationImageUrl, + source.presentationSiteName, + source.presentationProvider, + source.presentationProviderId, + source.presentationTitleManual ? 1 : 0, + source.presentationDescriptionManual ? 1 : 0, + source.presentationImageManual ? 1 : 0, + source.presentationMetadataFetchedAtUtc, now, now ); @@ -627,6 +754,66 @@ export async function duplicatePiece(sourceId: string): Promise { } } +export async function loadPiecePresentationMetadata( + id: string, + expectedRevision: number, + metadataLoader = fetchPresentationMetadata +) { + if (!Number.isInteger(expectedRevision) || expectedRevision < 1) { + throw new Error('INVALID_REVISION'); + } + const database = getDatabase(); + const piece = database + .prepare( + `SELECT p.revision, p.presentation_mode, p.presentation_source_position, + p.presentation_title_manual, p.presentation_description_manual, + p.presentation_image_manual, s.url + FROM pieces p LEFT JOIN piece_sources s ON s.piece_id = p.id + AND s.position = p.presentation_source_position + WHERE p.id = ? AND p.status != 'archived'` + ) + .get(id) as + | { + revision: number; + presentation_mode: PresentationMode; + presentation_source_position: number | null; + presentation_title_manual: number; + presentation_description_manual: number; + presentation_image_manual: number; + url: string | null; + } + | undefined; + if (!piece) throw new Error('NOT_FOUND'); + if (piece.revision !== expectedRevision) throw new Error('CONFLICT'); + if (piece.presentation_mode === 'text' || !piece.url) throw new Error('INVALID_PRESENTATION'); + const metadata = await metadataLoader(piece.url); + const now = new Date().toISOString(); + const result = database + .prepare( + `UPDATE pieces SET + presentation_title = CASE WHEN presentation_title_manual = 1 THEN presentation_title ELSE ? END, + presentation_description = CASE WHEN presentation_description_manual = 1 THEN presentation_description ELSE ? END, + presentation_image_url = CASE WHEN presentation_image_manual = 1 THEN presentation_image_url ELSE ? END, + presentation_site_name = ?, presentation_provider = ?, presentation_provider_id = ?, + presentation_metadata_fetched_at_utc = ?, updated_at_utc = ?, revision = revision + 1 + WHERE id = ? AND revision = ? AND status != 'archived'` + ) + .run( + metadata.title, + metadata.description, + metadata.imageUrl, + metadata.siteName, + metadata.provider, + metadata.providerId, + now, + now, + id, + expectedRevision + ); + if (result.changes !== 1) throw new Error('CONFLICT'); + return { revision: expectedRevision + 1, updatedAtUtc: now }; +} + export async function uploadPieceImage( pieceId: string, expectedRevision: number, diff --git a/src/lib/server/database.spec.ts b/src/lib/server/database.spec.ts index 2b34a0c..1cd7de7 100644 --- a/src/lib/server/database.spec.ts +++ b/src/lib/server/database.spec.ts @@ -62,7 +62,7 @@ describe('database setup', () => { database.close(); expect(counts).toEqual({ - migrations: 5, + migrations: 6, pieces: 6, shows: 2, usages: 5, diff --git a/src/lib/server/live.spec.ts b/src/lib/server/live.spec.ts index 4404d68..8143ca3 100644 --- a/src/lib/server/live.spec.ts +++ b/src/lib/server/live.spec.ts @@ -134,4 +134,52 @@ describe('live mode', () => { 'Die kleine Konsole mit dem großen Dreh' ); }); + + it('projects only snapshotted link and video data and changes players explicitly', () => { + getDatabase() + .prepare( + `UPDATE piece_usages SET presentation_mode_snapshot = 'video', + presentation_source_label_snapshot = 'YouTube', + presentation_source_url_snapshot = 'https://youtu.be/dQw4w9WgXcQ', + presentation_source_timecode_snapshot = 75, + presentation_title_meta_snapshot = 'Videotitel', + presentation_site_name_snapshot = 'YouTube', + presentation_provider_snapshot = 'youtube', + presentation_provider_id_snapshot = 'dQw4w9WgXcQ' + WHERE id = 'usage-console'` + ) + .run(); + getDatabase() + .prepare( + `UPDATE piece_usages SET presentation_mode_snapshot = 'link', + presentation_source_label_snapshot = 'Artikel', + presentation_source_url_snapshot = 'https://example.com/artikel', + presentation_title_meta_snapshot = 'Linktitel', + presentation_description_snapshot = 'Öffentlicher Anreißer', + presentation_site_name_snapshot = 'Example' + WHERE id = 'usage-effects'` + ) + .run(); + const token = issuePresentationAccess('show-october'); + presentUsage('show-october', 'usage-console', 'present-video', 0); + expect(getPresentationView(token)?.content).toMatchObject({ + mode: 'video', + previewTitle: 'Videotitel', + embedUrl: 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=75', + source: { url: 'https://youtu.be/dQw4w9WgXcQ', timecodeSeconds: 75 } + }); + + presentUsage('show-october', 'usage-effects', 'present-link', 1); + const link = getPresentationView(token)!; + expect(link.content).toMatchObject({ + mode: 'link', + previewTitle: 'Linktitel', + description: 'Öffentlicher Anreißer', + embedUrl: '', + source: { label: 'Artikel', url: 'https://example.com/artikel' } + }); + const serialized = JSON.stringify(link); + expect(serialized).not.toContain('Herstellerangaben'); + expect(serialized).not.toContain('Moderationsstichpunkte'); + }); }); diff --git a/src/lib/server/live.ts b/src/lib/server/live.ts index 1e59763..be8e46b 100644 --- a/src/lib/server/live.ts +++ b/src/lib/server/live.ts @@ -3,6 +3,7 @@ import type Database from 'better-sqlite3'; import { getMediaFile } from './content.js'; import { getDatabase } from './database.js'; import { renderMarkdown } from './markdown.js'; +import { buildEmbedUrl, fetchPresentationImage, type PresentationContent } from './presentation.js'; import { getShow, movePieceUsage, @@ -67,8 +68,17 @@ export type LiveView = { export type PresentationView = { revision: number; content: null | { + mode: PresentationContent['mode']; title: string; html: string; + source: PresentationContent['source']; + previewTitle: string; + description: string; + imageUrl: string; + siteName: string; + provider: PresentationContent['provider']; + providerId: string; + embedUrl: string; sources: { label: string; url: string; timecodeSeconds: number | null }[]; images: { id: string; altText: string }[]; }; @@ -506,45 +516,95 @@ export function getPresentationView(token: string): PresentationView | null { if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null }; const usage = database .prepare( - `SELECT u.title_snapshot, u.presentation_snapshot, u.original_piece_id + `SELECT u.title_snapshot, u.presentation_snapshot, u.original_piece_id, + u.presentation_mode_snapshot, u.presentation_source_label_snapshot, + u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot, + u.presentation_title_meta_snapshot, u.presentation_description_snapshot, + u.presentation_image_url_snapshot, u.presentation_site_name_snapshot, + u.presentation_provider_snapshot, u.presentation_provider_id_snapshot 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(state.presentation_usage_id, showId) as - | { title_snapshot: string; presentation_snapshot: string; original_piece_id: string | null } + | { + title_snapshot: string; + presentation_snapshot: string; + original_piece_id: string | null; + presentation_mode_snapshot: PresentationContent['mode']; + presentation_source_label_snapshot: string; + presentation_source_url_snapshot: string; + presentation_source_timecode_snapshot: number | null; + presentation_title_meta_snapshot: string; + presentation_description_snapshot: string; + presentation_image_url_snapshot: string; + presentation_site_name_snapshot: string; + presentation_provider_snapshot: PresentationContent['provider']; + presentation_provider_id_snapshot: string; + } | undefined; if (!usage) return { revision: state.presentation_revision, content: null }; - const sources = usage.original_piece_id - ? (database - .prepare( - `SELECT label, url, timecode_seconds AS timecodeSeconds + const sources = + usage.presentation_mode_snapshot === 'text' && usage.original_piece_id + ? (database + .prepare( + `SELECT label, url, timecode_seconds AS timecodeSeconds FROM piece_sources WHERE piece_id = ? ORDER BY position` - ) - .all(usage.original_piece_id) as { - label: string; - url: string; - timecodeSeconds: number | null; - }[]) - : []; - const images = usage.original_piece_id - ? (database - .prepare( - `SELECT id, alt_text AS altText FROM piece_media + ) + .all(usage.original_piece_id) as { + label: string; + url: string; + timecodeSeconds: number | null; + }[]) + : []; + const images = + usage.presentation_mode_snapshot === 'text' && usage.original_piece_id + ? (database + .prepare( + `SELECT id, alt_text AS altText FROM piece_media WHERE piece_id = ? ORDER BY position` - ) - .all(usage.original_piece_id) as { id: string; altText: string }[]) - : []; + ) + .all(usage.original_piece_id) as { id: string; altText: string }[]) + : []; return { revision: state.presentation_revision, content: { + mode: usage.presentation_mode_snapshot, title: usage.title_snapshot, html: renderMarkdown(usage.presentation_snapshot), + source: usage.presentation_source_url_snapshot + ? { + label: usage.presentation_source_label_snapshot, + url: usage.presentation_source_url_snapshot, + timecodeSeconds: usage.presentation_source_timecode_snapshot + } + : null, + previewTitle: usage.presentation_title_meta_snapshot, + description: usage.presentation_description_snapshot, + imageUrl: usage.presentation_image_url_snapshot, + siteName: usage.presentation_site_name_snapshot, + provider: usage.presentation_provider_snapshot, + providerId: usage.presentation_provider_id_snapshot, + embedUrl: buildEmbedUrl( + usage.presentation_provider_snapshot, + usage.presentation_provider_id_snapshot, + usage.presentation_source_timecode_snapshot + ), sources, images } }; } +export async function getPresentationPreviewImage(token: string) { + const view = getPresentationView(token); + if (!view?.content?.imageUrl || view.content.mode === 'text') return null; + try { + return await fetchPresentationImage(view.content.imageUrl); + } catch { + return null; + } +} + export async function getPresentationMedia(token: string, mediaId: string) { const database = getDatabase(); const showId = presentationShowId(database, token); diff --git a/src/lib/server/presentation.spec.ts b/src/lib/server/presentation.spec.ts new file mode 100644 index 0000000..ede315f --- /dev/null +++ b/src/lib/server/presentation.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + buildEmbedUrl, + detectVideoProvider, + extractMetadataFromHtml, + fetchPresentationMetadata +} from './presentation.js'; + +describe('presentation metadata and providers', () => { + it('recognizes supported YouTube URL forms and creates privacy-enhanced embeds with start time', () => { + for (const url of [ + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'https://youtu.be/dQw4w9WgXcQ', + 'https://www.youtube.com/shorts/dQw4w9WgXcQ', + 'https://youtube.com/live/dQw4w9WgXcQ' + ]) { + expect(detectVideoProvider(url)).toEqual({ provider: 'youtube', id: 'dQw4w9WgXcQ' }); + } + expect(buildEmbedUrl('youtube', 'dQw4w9WgXcQ', 75)).toBe( + 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=75' + ); + expect(detectVideoProvider('https://evil.example/watch?v=dQw4w9WgXcQ')).toBeNull(); + expect(detectVideoProvider('https://youtube.com/watch?v=invalid')).toBeNull(); + }); + + it('recognizes canonical TikTok videos without inventing a start parameter', () => { + expect( + detectVideoProvider('https://www.tiktok.com/@scout2015/video/6718335390845095173') + ).toEqual({ provider: 'tiktok', id: '6718335390845095173' }); + expect(buildEmbedUrl('tiktok', '6718335390845095173', 90)).toBe( + 'https://www.tiktok.com/player/v1/6718335390845095173?autoplay=0&controls=1' + ); + }); + + it('extracts generic metadata from local HTML and tolerates a missing image', async () => { + const metadata = await extractMetadataFromHtml( + ` + + + + `, + 'https://example.com/artikel' + ); + expect(metadata).toMatchObject({ + title: 'Lokale Vorschau', + description: 'Ein kurzer Anreißer', + imageUrl: '', + siteName: 'Testseite' + }); + }); + + it('rejects malformed and private metadata targets before a network request', async () => { + await expect(fetchPresentationMetadata('keine-url')).rejects.toThrow('FETCH_INVALID_URL'); + await expect(fetchPresentationMetadata('http://127.0.0.1/private')).rejects.toThrow( + 'FETCH_BLOCKED' + ); + await expect(fetchPresentationMetadata('http://[::1]/private')).rejects.toThrow( + 'FETCH_BLOCKED' + ); + }); +}); diff --git a/src/lib/server/presentation.ts b/src/lib/server/presentation.ts new file mode 100644 index 0000000..a586ad3 --- /dev/null +++ b/src/lib/server/presentation.ts @@ -0,0 +1,274 @@ +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; +import http from 'node:http'; +import https from 'node:https'; +import metascraper from 'metascraper'; +import description from 'metascraper-description'; +import image from 'metascraper-image'; +import publisher from 'metascraper-publisher'; +import title from 'metascraper-title'; + +export type PresentationMode = 'text' | 'link' | 'video'; +export type VideoProvider = 'youtube' | 'tiktok'; + +export type PresentationMetadata = { + title: string; + description: string; + imageUrl: string; + siteName: string; + provider: VideoProvider | ''; + providerId: string; +}; + +export type PresentationSource = { + label: string; + url: string; + timecodeSeconds: number | null; +}; + +export type PresentationContent = { + mode: PresentationMode; + title: string; + html: string; + source: PresentationSource | null; + previewTitle: string; + description: string; + imageUrl: string; + siteName: string; + provider: VideoProvider | ''; + providerId: string; + embedUrl: string; +}; + +const scrape = metascraper([title(), description({ truncateLength: 500 }), image(), publisher()]); +const MAX_HTML_BYTES = 1024 * 1024; +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; +const REQUEST_TIMEOUT_MS = 5_000; +const MAX_REDIRECTS = 3; + +function isPrivateIpv4(address: string) { + const parts = address.split('.').map(Number); + return ( + parts[0] === 0 || + parts[0] === 10 || + parts[0] === 127 || + (parts[0] === 169 && parts[1] === 254) || + (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) || + parts[0] >= 224 + ); +} + +function isPrivateAddress(address: string) { + if (isIP(address) === 4) return isPrivateIpv4(address); + const normalized = address.toLowerCase().split('%')[0]; + if (normalized === '::' || normalized === '::1') return true; + if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe8')) { + return true; + } + if ( + normalized.startsWith('fe9') || + normalized.startsWith('fea') || + normalized.startsWith('feb') + ) { + return true; + } + const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1]; + return mapped ? isPrivateIpv4(mapped) : false; +} + +async function publicAddress(url: URL) { + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) { + throw new Error('FETCH_BLOCKED'); + } + if (url.port && url.port !== (url.protocol === 'https:' ? '443' : '80')) { + throw new Error('FETCH_BLOCKED'); + } + const hostname = url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname; + if (hostname === 'localhost' || hostname.endsWith('.localhost')) { + throw new Error('FETCH_BLOCKED'); + } + const addresses = isIP(hostname) + ? [{ address: hostname, family: isIP(hostname) }] + : await lookup(hostname, { all: true, verbatim: true }).catch(() => { + throw new Error('FETCH_UNAVAILABLE'); + }); + if (!addresses.length || addresses.some((entry) => isPrivateAddress(entry.address))) { + throw new Error('FETCH_BLOCKED'); + } + return addresses[0]; +} + +async function requestPublic( + input: string, + accept: string, + maximumBytes: number, + redirects = 0 +): Promise<{ url: string; contentType: string; data: Buffer }> { + let url: URL; + try { + url = new URL(input); + } catch { + throw new Error('FETCH_INVALID_URL'); + } + const target = await publicAddress(url); + const client = url.protocol === 'https:' ? https : http; + return new Promise((resolve, reject) => { + const request = client.get( + url, + { + headers: { + Accept: accept, + 'User-Agent': 'Sendewerk/0.5.1 (+https://git.hnz.io/hnzio/sendewerk)' + }, + lookup: (_hostname, _options, callback) => + callback(null, target.address, target.family as 4 | 6) + }, + (response) => { + const status = response.statusCode ?? 0; + if (status >= 300 && status < 400 && response.headers.location) { + response.resume(); + if (redirects >= MAX_REDIRECTS) return reject(new Error('FETCH_REDIRECTS')); + const next = new URL(response.headers.location, url).href; + requestPublic(next, accept, maximumBytes, redirects + 1).then(resolve, reject); + return; + } + if (status < 200 || status >= 300) { + response.resume(); + return reject(new Error('FETCH_UNAVAILABLE')); + } + const chunks: Buffer[] = []; + let size = 0; + response.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > maximumBytes) { + request.destroy(new Error('FETCH_TOO_LARGE')); + return; + } + chunks.push(chunk); + }); + response.on('end', () => + resolve({ + url: url.href, + contentType: String(response.headers['content-type'] ?? ''), + data: Buffer.concat(chunks) + }) + ); + } + ); + request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy(new Error('FETCH_TIMEOUT'))); + request.on('error', reject); + }); +} + +export function detectVideoProvider(input: string): { + provider: VideoProvider; + id: string; +} | null { + let url: URL; + try { + url = new URL(input); + } catch { + return null; + } + if (url.protocol !== 'https:' || url.username || url.password || url.port) return null; + const host = url.hostname.toLowerCase(); + let youtubeId = ''; + if (host === 'youtu.be') youtubeId = url.pathname.split('/').filter(Boolean)[0] ?? ''; + if (host === 'youtube.com' || host === 'www.youtube.com' || host === 'm.youtube.com') { + if (url.pathname === '/watch') youtubeId = url.searchParams.get('v') ?? ''; + else if (/^\/(shorts|embed|live)\//.test(url.pathname)) { + youtubeId = url.pathname.split('/').filter(Boolean)[1] ?? ''; + } + } + if (/^[A-Za-z0-9_-]{11}$/.test(youtubeId)) { + return { provider: 'youtube', id: youtubeId }; + } + if (host === 'tiktok.com' || host === 'www.tiktok.com' || host === 'm.tiktok.com') { + const id = url.pathname.match(/\/video\/(\d+)/)?.[1] ?? ''; + if (/^\d+$/.test(id)) return { provider: 'tiktok', id }; + } + return null; +} + +export function buildEmbedUrl( + provider: VideoProvider | '', + providerId: string, + timecodeSeconds: number | null +) { + if (provider === 'youtube' && /^[A-Za-z0-9_-]{11}$/.test(providerId)) { + const url = new URL(`https://www.youtube-nocookie.com/embed/${providerId}`); + if (timecodeSeconds && timecodeSeconds > 0) + url.searchParams.set('start', String(timecodeSeconds)); + return url.href; + } + if (provider === 'tiktok' && /^\d+$/.test(providerId)) { + const url = new URL(`https://www.tiktok.com/player/v1/${providerId}`); + url.searchParams.set('autoplay', '0'); + url.searchParams.set('controls', '1'); + return url.href; + } + return ''; +} + +export async function extractMetadataFromHtml( + html: string, + url: string +): Promise { + const metadata = await scrape({ html, url }); + return { + title: String(metadata.title ?? '').slice(0, 300), + description: String(metadata.description ?? '').slice(0, 1_000), + imageUrl: String(metadata.image ?? '').slice(0, 2_000), + siteName: String(metadata.publisher ?? new URL(url).hostname).slice(0, 200), + provider: '', + providerId: '' + }; +} + +async function providerMetadata(url: string, provider: VideoProvider) { + const endpoint = + provider === 'youtube' + ? `https://www.youtube.com/oembed?format=json&url=${encodeURIComponent(url)}` + : `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`; + const response = await requestPublic(endpoint, 'application/json', 256 * 1024); + if (!response.contentType.toLowerCase().includes('json')) throw new Error('FETCH_INVALID_TYPE'); + const value = JSON.parse(response.data.toString('utf8')) as Record; + return { + title: String(value.title ?? '').slice(0, 300), + description: '', + imageUrl: String(value.thumbnail_url ?? '').slice(0, 2_000), + siteName: String(value.provider_name ?? (provider === 'youtube' ? 'YouTube' : 'TikTok')).slice( + 0, + 200 + ) + }; +} + +export async function fetchPresentationMetadata(url: string): Promise { + const video = detectVideoProvider(url); + if (video) { + const metadata = await providerMetadata(url, video.provider); + return { ...metadata, provider: video.provider, providerId: video.id }; + } + const response = await requestPublic(url, 'text/html,application/xhtml+xml', MAX_HTML_BYTES); + const contentType = response.contentType.toLowerCase(); + if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) { + throw new Error('FETCH_INVALID_TYPE'); + } + return extractMetadataFromHtml(response.data.toString('utf8'), response.url); +} + +export async function fetchPresentationImage(url: string) { + const response = await requestPublic( + url, + 'image/jpeg,image/png,image/webp,image/gif', + MAX_IMAGE_BYTES + ); + const mimeType = response.contentType.split(';')[0].trim().toLowerCase(); + if (!['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(mimeType)) { + throw new Error('FETCH_INVALID_TYPE'); + } + return { data: response.data, mimeType }; +} diff --git a/src/lib/server/shows.spec.ts b/src/lib/server/shows.spec.ts index 67a542c..da644ec 100644 --- a/src/lib/server/shows.spec.ts +++ b/src/lib/server/shows.spec.ts @@ -9,10 +9,13 @@ import { addPieceUsage, addShowSection, createShow, + duplicatePieceUsage, + getPieceUsageContexts, getShow, listShowTemplates, movePieceUsage, moveShowSection, + refreshPieceUsageFromOriginal, removePieceUsage, removeShowSection, updatePieceUsage, @@ -46,9 +49,16 @@ function editedUsage(usage: PieceUsage, overrides: Partial = {}) { moderation: edited.moderation, transition: edited.transition, presentation: edited.presentation, + presentationMode: edited.presentationMode, + presentationSourceLabel: edited.presentationSourceLabel, + presentationSourceUrl: edited.presentationSourceUrl, + presentationSourceTimecodeSeconds: edited.presentationSourceTimecodeSeconds, + presentationTitle: edited.presentationTitle, + presentationDescription: edited.presentationDescription, + presentationImageUrl: edited.presentationImageUrl, + presentationSiteName: edited.presentationSiteName, ownNotes: edited.ownNotes, - durationSeconds: edited.durationSeconds, - status: edited.status + durationSeconds: edited.durationSeconds }; } @@ -153,6 +163,245 @@ describe('show builder data layer', () => { }); }); + it('derives sent history and current planning only from qualifying usages by original id', () => { + const completed = createShow({ + title: 'Abgeschlossene Sendung', + startsAtUtc: '2027-01-10T19:00:00Z', + templateId: null + }); + const completedSection = addShowSection(completed.id, completed.revision, { + name: 'Ablauf', + categoryId: null, + explicitDurationSeconds: null + }); + const done = addPieceUsage(completed.id, completedSection.revision, { + pieceId: 'piece-console', + sectionId: completedSection.id + }); + const skipped = addPieceUsage(completed.id, done.revision, { + pieceId: 'piece-effects', + sectionId: completedSection.id + }); + const reserve = addPieceUsage(completed.id, skipped.revision, { + pieceId: 'piece-pixels', + sectionId: null + }); + getDatabase() + .prepare("UPDATE piece_usages SET status = 'done' WHERE id IN (?, ?)") + .run(done.id, reserve.id); + getDatabase() + .prepare("UPDATE piece_usages SET status = 'skipped' WHERE id = ?") + .run(skipped.id); + getDatabase().prepare("UPDATE shows SET status = 'completed' WHERE id = ?").run(completed.id); + + const active = createShow({ title: 'Aktuelle Planung', startsAtUtc: null, templateId: null }); + const activeSection = addShowSection(active.id, active.revision, { + name: 'Plan', + categoryId: null, + explicitDurationSeconds: null + }); + const planned = addPieceUsage(active.id, activeSection.revision, { + pieceId: 'piece-console', + sectionId: activeSection.id + }); + const plannedReserve = addPieceUsage(active.id, planned.revision, { + pieceId: 'piece-effects', + sectionId: null + }); + getDatabase().prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(planned.id); + + let contexts = getPieceUsageContexts( + ['piece-console', 'piece-effects', 'piece-pixels'], + active.id + ); + expect(contexts['piece-console']).toMatchObject({ + isPlannedInCurrentShow: true, + sentHistory: [ + { + usageId: done.id, + showId: completed.id, + showTitle: 'Abgeschlossene Sendung', + usedAtUtc: '2027-01-10T19:00:00.000Z' + } + ] + }); + expect(contexts['piece-effects']).toEqual({ + isPlannedInActiveShow: true, + isPlannedInCurrentShow: true, + sentHistory: [] + }); + expect(contexts['piece-pixels']).toEqual({ + isPlannedInActiveShow: true, + isPlannedInCurrentShow: false, + sentHistory: [] + }); + + removePieceUsage(active.id, plannedReserve.id, plannedReserve.revision); + contexts = getPieceUsageContexts(['piece-effects'], active.id); + expect(contexts['piece-effects'].isPlannedInCurrentShow).toBe(false); + }); + + it('duplicates an adjusted usage directly after its source without live measurements', () => { + const created = createShow({ + title: 'Einplanungsduplikat', + startsAtUtc: null, + templateId: null + }); + const section = addShowSection(created.id, created.revision, { + name: 'Ablauf', + categoryId: null, + explicitDurationSeconds: null + }); + const usage = addPieceUsage(created.id, section.revision, { + pieceId: 'piece-console', + sectionId: section.id + }); + const source = getShow(created.id)!.sections[0].usages[0]; + const adjusted = updatePieceUsage( + created.id, + usage.id, + usage.revision, + editedUsage(source, { + title: 'Angepasste Einplanung', + durationSeconds: 777, + intro: 'Eigene Einleitung', + research: 'Eigene Recherche', + moderation: 'Eigene Moderation', + transition: 'Eigene Überleitung', + presentation: 'Eigene Präsentation', + ownNotes: 'Eigene Notizen' + }) + ); + getDatabase().prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(usage.id); + getDatabase() + .prepare( + `INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc, ended_at_utc) + VALUES ('source-run', ?, ?, '2027-01-01T10:00:00.000Z', '2027-01-01T10:10:00.000Z')` + ) + .run(created.id, usage.id); + const pieceCountBefore = ( + getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number } + ).count; + + const duplicate = duplicatePieceUsage(created.id, usage.id, adjusted.revision); + const usages = getShow(created.id)!.sections[0].usages; + expect(usages.map((item) => item.id)).toEqual([usage.id, duplicate.id]); + expect(usages[1]).toMatchObject({ + originalPieceId: 'piece-console', + status: 'open', + durationSeconds: 777, + title: 'Angepasste Einplanung', + intro: 'Eigene Einleitung', + research: 'Eigene Recherche', + moderation: 'Eigene Moderation', + transition: 'Eigene Überleitung', + presentation: 'Eigene Präsentation', + ownNotes: 'Eigene Notizen' + }); + expect( + getDatabase() + .prepare('SELECT count(*) AS count FROM live_usage_runs WHERE usage_id = ?') + .get(duplicate.id) + ).toEqual({ count: 0 }); + expect( + (getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number }) + .count + ).toBe(pieceCountBefore); + + getDatabase() + .prepare( + `UPDATE pieces SET title = 'Aktuelles Original', duration_seconds = 420, + intro = 'Neue Einleitung', research_text = 'Neue Recherche', + moderation_notes = 'Neue Moderation', transition_text = 'Neue Überleitung', + presentation_text = 'Neue Präsentation' WHERE id = 'piece-console'` + ) + .run(); + const refreshed = refreshPieceUsageFromOriginal(created.id, usage.id, duplicate.revision); + const refreshedUsages = getShow(created.id)!.sections[0].usages; + expect(refreshedUsages[0]).toMatchObject({ + title: 'Aktuelles Original', + durationSeconds: 420, + intro: 'Neue Einleitung', + research: 'Neue Recherche', + moderation: 'Neue Moderation', + transition: 'Neue Überleitung', + presentation: 'Neue Präsentation', + ownNotes: 'Eigene Notizen', + status: 'done' + }); + expect(refreshedUsages[1]).toMatchObject({ + title: 'Angepasste Einplanung', + durationSeconds: 777, + status: 'open' + }); + expect( + getDatabase() + .prepare('SELECT count(*) AS count FROM live_usage_runs WHERE usage_id = ?') + .get(usage.id) + ).toEqual({ count: 1 }); + expect(() => duplicatePieceUsage(created.id, usage.id, adjusted.revision)).toThrow('CONFLICT'); + expect(getShow(created.id)?.revision).toBe(refreshed.revision); + getDatabase() + .prepare('UPDATE piece_usages SET original_piece_id = NULL WHERE id = ?') + .run(duplicate.id); + expect(() => + refreshPieceUsageFromOriginal(created.id, duplicate.id, refreshed.revision) + ).toThrow('ORIGINAL_NOT_FOUND'); + expect(getShow(created.id)?.revision).toBe(refreshed.revision); + expect(getShow(created.id)!.sections[0].usages).toHaveLength(2); + }); + + it('snapshots presentation settings until an explicit refresh and copies them on duplication', () => { + getDatabase() + .prepare( + `UPDATE piece_sources SET label = 'YouTube', url = ?, timecode_seconds = 75 + WHERE piece_id = 'piece-console' AND position = 0` + ) + .run('https://youtu.be/dQw4w9WgXcQ'); + getDatabase() + .prepare( + `UPDATE pieces SET presentation_mode = 'video', presentation_source_position = 0, + presentation_title = 'Gespeicherte Vorschau', presentation_description = 'Erster Stand', + presentation_site_name = 'YouTube', presentation_provider = 'youtube', + presentation_provider_id = 'dQw4w9WgXcQ' WHERE id = 'piece-console'` + ) + .run(); + const show = createShow({ title: 'Snapshot-Test', startsAtUtc: null, templateId: null }); + const section = addShowSection(show.id, show.revision, { + name: 'Videos', + 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({ + presentationMode: 'video', + presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ', + presentationSourceTimecodeSeconds: 75, + presentationTitle: 'Gespeicherte Vorschau' + }); + + getDatabase() + .prepare( + `UPDATE pieces SET presentation_title = 'Neuer Originaltitel' WHERE id = 'piece-console'` + ) + .run(); + expect(getShow(show.id)!.sections[0].usages[0].presentationTitle).toBe('Gespeicherte Vorschau'); + const refreshed = refreshPieceUsageFromOriginal(show.id, usage.id, added.revision); + usage = getShow(show.id)!.sections[0].usages[0]; + expect(usage.presentationTitle).toBe('Neuer Originaltitel'); + duplicatePieceUsage(show.id, usage.id, refreshed.revision); + expect(getShow(show.id)!.sections[0].usages[1]).toMatchObject({ + presentationMode: 'video', + presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ', + presentationTitle: 'Neuer Originaltitel', + status: 'open' + }); + }); + 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, { diff --git a/src/lib/server/shows.ts b/src/lib/server/shows.ts index 4776bff..89d5c46 100644 --- a/src/lib/server/shows.ts +++ b/src/lib/server/shows.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import type Database from 'better-sqlite3'; import { getDatabase } from './database.js'; +import { detectVideoProvider, type PresentationMode } from './presentation.js'; export const SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const; export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const; @@ -46,9 +47,32 @@ export type PieceUsage = { moderation: string; transition: string; presentation: string; + presentationMode: PresentationMode; + presentationSourceLabel: string; + presentationSourceUrl: string; + presentationSourceTimecodeSeconds: number | null; + presentationTitle: string; + presentationDescription: string; + presentationImageUrl: string; + presentationSiteName: string; + presentationProvider: string; + presentationProviderId: string; ownNotes: string; }; +export type SentPieceUsage = { + usageId: string; + showId: string; + showTitle: string; + usedAtUtc: string; +}; + +export type PieceUsageContext = { + sentHistory: SentPieceUsage[]; + isPlannedInActiveShow: boolean; + isPlannedInCurrentShow: boolean; +}; + export type ShowSection = { id: string; name: string; @@ -110,9 +134,16 @@ export type PieceUsageUpdate = { moderation: string; transition: string; presentation: string; + presentationMode: PresentationMode; + presentationSourceLabel: string; + presentationSourceUrl: string; + presentationSourceTimecodeSeconds: number | null; + presentationTitle: string; + presentationDescription: string; + presentationImageUrl: string; + presentationSiteName: string; ownNotes: string; durationSeconds: number; - status: UsageStatus; }; export type MovePieceUsageInput = { @@ -159,6 +190,16 @@ type UsageRow = { moderation_snapshot: string; transition_snapshot: string; presentation_snapshot: string; + presentation_mode_snapshot: PresentationMode; + presentation_source_label_snapshot: string; + presentation_source_url_snapshot: string; + presentation_source_timecode_snapshot: number | null; + presentation_title_meta_snapshot: string; + presentation_description_snapshot: string; + presentation_image_url_snapshot: string; + presentation_site_name_snapshot: string; + presentation_provider_snapshot: string; + presentation_provider_id_snapshot: string; own_notes: string; }; @@ -191,6 +232,50 @@ function validTimestamp(value: string | null): string | null { return date.toISOString(); } +function validPresentation( + mode: PresentationMode, + sourceLabel: string, + sourceUrl: string, + timecodeSeconds: number | null, + title: string, + description: string, + imageUrl: string, + siteName: string +) { + if (!['text', 'link', 'video'].includes(mode)) throw new Error('INVALID_PRESENTATION'); + const cleanUrl = cleanText(sourceUrl, 2_000, 'presentation_source'); + const cleanImageUrl = cleanText(imageUrl, 2_000, 'presentation_image'); + for (const value of [cleanUrl, cleanImageUrl]) { + if (!value) continue; + try { + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol)) throw new Error(); + } catch { + throw new Error('INVALID_PRESENTATION'); + } + } + if (mode !== 'text' && !cleanUrl) throw new Error('INVALID_PRESENTATION'); + if ( + timecodeSeconds !== null && + (!Number.isInteger(timecodeSeconds) || timecodeSeconds < 0 || timecodeSeconds > 86_400) + ) { + throw new Error('INVALID_PRESENTATION'); + } + const detected = detectVideoProvider(cleanUrl); + return { + mode, + sourceLabel: cleanText(sourceLabel, 200, 'presentation_source'), + sourceUrl: cleanUrl, + timecodeSeconds, + title: cleanText(title, 300, 'presentation_title'), + description: cleanText(description, 1_000, 'presentation_description'), + imageUrl: cleanImageUrl, + siteName: cleanText(siteName, 200, 'presentation_site'), + provider: detected?.provider ?? '', + providerId: detected?.id ?? '' + }; +} + function requireRevision(expectedRevision: number): void { if (!Number.isInteger(expectedRevision) || expectedRevision < 1) { throw new Error('INVALID_REVISION'); @@ -296,6 +381,16 @@ function usageFromRow(row: UsageRow): PieceUsage { moderation: row.moderation_snapshot, transition: row.transition_snapshot, presentation: row.presentation_snapshot, + presentationMode: row.presentation_mode_snapshot, + presentationSourceLabel: row.presentation_source_label_snapshot, + presentationSourceUrl: row.presentation_source_url_snapshot, + presentationSourceTimecodeSeconds: row.presentation_source_timecode_snapshot, + presentationTitle: row.presentation_title_meta_snapshot, + presentationDescription: row.presentation_description_snapshot, + presentationImageUrl: row.presentation_image_url_snapshot, + presentationSiteName: row.presentation_site_name_snapshot, + presentationProvider: row.presentation_provider_snapshot, + presentationProviderId: row.presentation_provider_id_snapshot, ownNotes: row.own_notes }; } @@ -384,7 +479,12 @@ export function getShow(id: string): Show | null { .prepare( `SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status, u.duration_seconds, u.title_snapshot, u.intro_snapshot, u.research_snapshot, - u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot, u.own_notes + u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot, + u.presentation_mode_snapshot, u.presentation_source_label_snapshot, + u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot, + u.presentation_title_meta_snapshot, u.presentation_description_snapshot, + u.presentation_image_url_snapshot, u.presentation_site_name_snapshot, + u.presentation_provider_snapshot, u.presentation_provider_id_snapshot, u.own_notes FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id WHERE s.show_id = ? ORDER BY s.position, u.position` ) @@ -430,6 +530,80 @@ export function getShow(id: string): Show | null { }; } +export function getPieceUsageContexts( + pieceIds: string[], + currentShowId?: string +): Record { + const uniquePieceIds = [...new Set(pieceIds.filter(Boolean))]; + const contexts = Object.fromEntries( + uniquePieceIds.map((pieceId) => [ + pieceId, + { + sentHistory: [], + isPlannedInActiveShow: false, + isPlannedInCurrentShow: false + } satisfies PieceUsageContext + ]) + ) as Record; + if (!uniquePieceIds.length) return contexts; + + const database = getDatabase(); + const history = database + .prepare( + `SELECT u.id AS usage_id, u.original_piece_id, sh.id AS show_id, + sh.title AS show_title, COALESCE(sh.starts_at_utc, sh.updated_at_utc) AS used_at_utc + FROM piece_usages u + JOIN show_sections s ON s.id = u.show_section_id + JOIN shows sh ON sh.id = s.show_id + WHERE u.original_piece_id IS NOT NULL AND u.status = 'done' + AND sh.status = 'completed' AND s.is_reserve_bank = 0 + ORDER BY used_at_utc DESC, u.id DESC` + ) + .all() as { + usage_id: string; + original_piece_id: string; + show_id: string; + show_title: string; + used_at_utc: string; + }[]; + for (const usage of history) { + contexts[usage.original_piece_id]?.sentHistory.push({ + usageId: usage.usage_id, + showId: usage.show_id, + showTitle: usage.show_title, + usedAtUtc: usage.used_at_utc + }); + } + const plannedInActiveShows = database + .prepare( + `SELECT DISTINCT u.original_piece_id FROM piece_usages u + JOIN show_sections s ON s.id = u.show_section_id + JOIN shows sh ON sh.id = s.show_id + WHERE sh.status != 'completed' AND u.original_piece_id IS NOT NULL` + ) + .all() as { original_piece_id: string }[]; + for (const usage of plannedInActiveShows) { + const context = contexts[usage.original_piece_id]; + if (context) context.isPlannedInActiveShow = true; + } + + if (currentShowId) { + const planned = database + .prepare( + `SELECT DISTINCT u.original_piece_id FROM piece_usages u + JOIN show_sections s ON s.id = u.show_section_id + WHERE s.show_id = ? AND u.original_piece_id IS NOT NULL` + ) + .all(currentShowId) as { original_piece_id: string }[]; + for (const usage of planned) { + const context = contexts[usage.original_piece_id]; + if (context) context.isPlannedInCurrentShow = true; + } + } + + return contexts; +} + export function createShow(input: CreateShowInput): CreatedMutationResult { const database = getDatabase(); const id = randomUUID(); @@ -631,8 +805,15 @@ export function addPieceUsage( sectionBelongsToShow(database, showId, sectionId, input.sectionId === null); const piece = database .prepare( - `SELECT title, duration_seconds, intro, research_text, moderation_notes, - transition_text, presentation_text, status FROM pieces WHERE id = ?` + `SELECT p.title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes, + p.transition_text, p.presentation_text, p.status, p.presentation_mode, + COALESCE(ps.label, '') AS presentation_source_label, + COALESCE(ps.url, '') AS presentation_source_url, + ps.timecode_seconds AS presentation_source_timecode, + p.presentation_title, p.presentation_description, p.presentation_image_url, + p.presentation_site_name, p.presentation_provider, p.presentation_provider_id + FROM pieces p LEFT JOIN piece_sources ps ON ps.piece_id = p.id + AND ps.position = p.presentation_source_position WHERE p.id = ?` ) .get(input.pieceId) as | { @@ -643,6 +824,16 @@ export function addPieceUsage( moderation_notes: string; transition_text: string; presentation_text: string; + presentation_mode: PresentationMode; + presentation_source_label: string; + presentation_source_url: string; + presentation_source_timecode: number | null; + presentation_title: string; + presentation_description: string; + presentation_image_url: string; + presentation_site_name: string; + presentation_provider: string; + presentation_provider_id: string; status: string; } | undefined; @@ -659,8 +850,13 @@ export function addPieceUsage( .prepare( `INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, duration_seconds, title_snapshot, intro_snapshot, research_snapshot, - moderation_snapshot, transition_snapshot, presentation_snapshot, own_notes, is_reserve) - VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, '', ?)` + moderation_snapshot, transition_snapshot, presentation_snapshot, + presentation_mode_snapshot, presentation_source_label_snapshot, + presentation_source_url_snapshot, presentation_source_timecode_snapshot, + presentation_title_meta_snapshot, presentation_description_snapshot, + presentation_image_url_snapshot, presentation_site_name_snapshot, + presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) + VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)` ) .run( id, @@ -674,6 +870,16 @@ export function addPieceUsage( piece.moderation_notes, piece.transition_text, piece.presentation_text, + piece.presentation_mode, + piece.presentation_source_label, + piece.presentation_source_url, + piece.presentation_source_timecode, + piece.presentation_title, + piece.presentation_description, + piece.presentation_image_url, + piece.presentation_site_name, + piece.presentation_provider, + piece.presentation_provider_id, input.sectionId === null ? 1 : 0 ); ids.splice(position, 0, id); @@ -682,6 +888,160 @@ export function addPieceUsage( })(); } +export function duplicatePieceUsage( + showId: string, + usageId: string, + expectedRevision: number +): CreatedMutationResult { + const database = getDatabase(); + return database.transaction(() => { + const result = bumpShow(database, showId, expectedRevision); + const sourceSection = usageSection(database, showId, usageId); + const source = database + .prepare( + `SELECT original_piece_id, status, duration_seconds, title_snapshot, intro_snapshot, + research_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot, + presentation_mode_snapshot, presentation_source_label_snapshot, + presentation_source_url_snapshot, presentation_source_timecode_snapshot, + presentation_title_meta_snapshot, presentation_description_snapshot, + presentation_image_url_snapshot, presentation_site_name_snapshot, + presentation_provider_snapshot, presentation_provider_id_snapshot, + own_notes, is_reserve FROM piece_usages WHERE id = ?` + ) + .get(usageId) as Omit & { + is_reserve: number; + }; + const ids = ( + database + .prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position') + .all(sourceSection.sectionId) as { id: string }[] + ).map((row) => row.id); + const sourcePosition = ids.indexOf(usageId); + if (sourcePosition < 0) throw new Error('NOT_FOUND'); + const id = randomUUID(); + database + .prepare( + `INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, + duration_seconds, title_snapshot, intro_snapshot, research_snapshot, + moderation_snapshot, transition_snapshot, presentation_snapshot, + presentation_mode_snapshot, presentation_source_label_snapshot, + presentation_source_url_snapshot, presentation_source_timecode_snapshot, + presentation_title_meta_snapshot, presentation_description_snapshot, + presentation_image_url_snapshot, presentation_site_name_snapshot, + presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) + VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + id, + sourceSection.sectionId, + source.original_piece_id, + ids.length, + source.duration_seconds, + source.title_snapshot, + source.intro_snapshot, + source.research_snapshot, + source.moderation_snapshot, + source.transition_snapshot, + source.presentation_snapshot, + source.presentation_mode_snapshot, + source.presentation_source_label_snapshot, + source.presentation_source_url_snapshot, + source.presentation_source_timecode_snapshot, + source.presentation_title_meta_snapshot, + source.presentation_description_snapshot, + source.presentation_image_url_snapshot, + source.presentation_site_name_snapshot, + source.presentation_provider_snapshot, + source.presentation_provider_id_snapshot, + source.own_notes, + source.is_reserve + ); + ids.splice(sourcePosition + 1, 0, id); + normalizePositions(database, 'piece_usages', ids); + return { ...result, id }; + })(); +} + +export function refreshPieceUsageFromOriginal( + showId: string, + usageId: string, + expectedRevision: number +): MutationResult { + const database = getDatabase(); + return database.transaction(() => { + const result = bumpShow(database, showId, expectedRevision); + usageSection(database, showId, usageId); + const source = database + .prepare( + `SELECT p.title, p.duration_seconds, p.intro, p.research_text, + p.moderation_notes, p.transition_text, p.presentation_text, p.presentation_mode, + COALESCE(ps.label, '') AS presentation_source_label, + COALESCE(ps.url, '') AS presentation_source_url, + ps.timecode_seconds AS presentation_source_timecode, + p.presentation_title, p.presentation_description, p.presentation_image_url, + p.presentation_site_name, p.presentation_provider, p.presentation_provider_id + FROM piece_usages u JOIN pieces p ON p.id = u.original_piece_id + LEFT JOIN piece_sources ps ON ps.piece_id = p.id + AND ps.position = p.presentation_source_position + WHERE u.id = ?` + ) + .get(usageId) as + | { + title: string; + duration_seconds: number; + intro: string; + research_text: string; + moderation_notes: string; + transition_text: string; + presentation_text: string; + presentation_mode: PresentationMode; + presentation_source_label: string; + presentation_source_url: string; + presentation_source_timecode: number | null; + presentation_title: string; + presentation_description: string; + presentation_image_url: string; + presentation_site_name: string; + presentation_provider: string; + presentation_provider_id: string; + } + | undefined; + if (!source) throw new Error('ORIGINAL_NOT_FOUND'); + database + .prepare( + `UPDATE piece_usages SET title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?, + research_snapshot = ?, moderation_snapshot = ?, transition_snapshot = ?, + presentation_snapshot = ?, presentation_mode_snapshot = ?, + presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?, + presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?, + presentation_description_snapshot = ?, presentation_image_url_snapshot = ?, + presentation_site_name_snapshot = ?, presentation_provider_snapshot = ?, + presentation_provider_id_snapshot = ? WHERE id = ?` + ) + .run( + source.title, + source.duration_seconds, + source.intro, + source.research_text, + source.moderation_notes, + source.transition_text, + source.presentation_text, + source.presentation_mode, + source.presentation_source_label, + source.presentation_source_url, + source.presentation_source_timecode, + source.presentation_title, + source.presentation_description, + source.presentation_image_url, + source.presentation_site_name, + source.presentation_provider, + source.presentation_provider_id, + usageId + ); + return result; + })(); +} + export function updatePieceUsage( showId: string, usageId: string, @@ -690,7 +1050,6 @@ export function updatePieceUsage( ): MutationResult { const title = cleanText(input.title, 200, 'title', true); const duration = validDuration(input.durationSeconds)!; - if (!USAGE_STATUSES.includes(input.status)) throw new Error('INVALID_STATUS'); const values = { intro: cleanText(input.intro, 50_000, 'intro'), research: cleanText(input.research, 50_000, 'research'), @@ -699,6 +1058,16 @@ export function updatePieceUsage( presentation: cleanText(input.presentation, 50_000, 'presentation'), ownNotes: cleanText(input.ownNotes, 50_000, 'notes') }; + const presentation = validPresentation( + input.presentationMode, + input.presentationSourceLabel, + input.presentationSourceUrl, + input.presentationSourceTimecodeSeconds, + input.presentationTitle, + input.presentationDescription, + input.presentationImageUrl, + input.presentationSiteName + ); const database = getDatabase(); return database.transaction(() => { const result = bumpShow(database, showId, expectedRevision); @@ -707,7 +1076,12 @@ export function updatePieceUsage( .prepare( `UPDATE piece_usages SET title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?, moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?, - own_notes = ?, duration_seconds = ?, status = ? WHERE id = ?` + presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?, + presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?, + presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?, + presentation_image_url_snapshot = ?, presentation_site_name_snapshot = ?, + presentation_provider_snapshot = ?, presentation_provider_id_snapshot = ?, + own_notes = ?, duration_seconds = ? WHERE id = ?` ) .run( title, @@ -716,9 +1090,18 @@ export function updatePieceUsage( values.moderation, values.transition, values.presentation, + presentation.mode, + presentation.sourceLabel, + presentation.sourceUrl, + presentation.timecodeSeconds, + presentation.title, + presentation.description, + presentation.imageUrl, + presentation.siteName, + presentation.provider, + presentation.providerId, values.ownNotes, duration, - input.status, usageId ); return result; diff --git a/src/routes/fundus/+page.server.ts b/src/routes/fundus/+page.server.ts index 1c90748..9d94ccb 100644 --- a/src/routes/fundus/+page.server.ts +++ b/src/routes/fundus/+page.server.ts @@ -10,6 +10,7 @@ import { setPieceArchived, type PieceTemplateId } from '#lib/server/content.js'; +import { getPieceUsageContexts } from '#lib/server/shows.js'; export const load: PageServerLoad = ({ locals, url }) => { requireUser(locals.user); @@ -18,21 +19,25 @@ export const load: PageServerLoad = ({ locals, url }) => { formatId: url.searchParams.get('format') ?? '', topicId: url.searchParams.get('topic') ?? '', categoryId: url.searchParams.get('category') ?? '', - status: url.searchParams.get('status') ?? '' + status: url.searchParams.get('status') ?? '', + includeUsed: url.searchParams.get('includeUsed') === '1', + includePlanned: url.searchParams.get('includePlanned') === '1' }; + const pieces = listPieces({ + statuses: + filters.status === 'ready' + ? ['ready'] + : filters.status === 'preparing' + ? ['preparing'] + : ['preparing', 'ready'], + query: filters.query, + formatId: filters.formatId, + topicId: filters.topicId, + categoryId: filters.categoryId + }); + const usageContexts = getPieceUsageContexts(pieces.map((piece) => piece.id)); return { - pieces: listPieces({ - statuses: - filters.status === 'ready' - ? ['ready'] - : filters.status === 'preparing' - ? ['preparing'] - : ['preparing', 'ready'], - query: filters.query, - formatId: filters.formatId, - topicId: filters.topicId, - categoryId: filters.categoryId - }), + pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })), taxonomy: getTaxonomy(), templates: pieceTemplates, filters diff --git a/src/routes/fundus/+page.svelte b/src/routes/fundus/+page.svelte index 2a01571..b226f8b 100644 --- a/src/routes/fundus/+page.svelte +++ b/src/routes/fundus/+page.svelte @@ -1,9 +1,48 @@ + + + Präsentation testen | Sendewerk + + + +
+
Isolierte Testansicht
+ +
diff --git a/src/routes/fundus/[id]/praesentation/preview-image/+server.ts b/src/routes/fundus/[id]/praesentation/preview-image/+server.ts new file mode 100644 index 0000000..2fc2d1f --- /dev/null +++ b/src/routes/fundus/[id]/praesentation/preview-image/+server.ts @@ -0,0 +1,26 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireUser } from '#lib/server/auth.js'; +import { getPiece } from '#lib/server/content.js'; +import { fetchPresentationImage } from '#lib/server/presentation.js'; + +export const GET: RequestHandler = async ({ locals, params }) => { + requireUser(locals.user); + const piece = getPiece(params.id); + if (!piece?.presentationImageUrl) error(404, 'Vorschaubild nicht verfügbar'); + try { + const image = await fetchPresentationImage(piece.presentationImageUrl); + return new Response(new Uint8Array(image.data), { + headers: { + 'Content-Type': image.mimeType, + 'Content-Length': String(image.data.byteLength), + 'Cache-Control': 'private, max-age=300', + 'X-Content-Type-Options': 'nosniff', + 'Cross-Origin-Resource-Policy': 'same-origin', + 'Content-Security-Policy': "default-src 'none'; sandbox" + } + }); + } catch { + error(404, 'Vorschaubild nicht verfügbar'); + } +}; diff --git a/src/routes/heinzlive/[token]/+page.svelte b/src/routes/heinzlive/[token]/+page.svelte index b85c81f..6895458 100644 --- a/src/routes/heinzlive/[token]/+page.svelte +++ b/src/routes/heinzlive/[token]/+page.svelte @@ -1,6 +1,7 @@