Release 0.5.1
This commit is contained in:
1 parent
5a9d7735d4
commit
fb24d171ae
36 files changed
+5099
-263
No files matched your search
@@ -16,6 +16,8 @@
|
||||
<path
|
||||
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4"
|
||||
/>
|
||||
{:else if name === 'edit'}
|
||||
<path d="M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4" />
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { SentPieceUsage } from '#lib/server/shows.js';
|
||||
|
||||
let { history }: { history: SentPieceUsage[] } = $props();
|
||||
|
||||
function berlinDate(value: string) {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
dateStyle: 'medium'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if history.length}
|
||||
<div class="piece-usage-history">
|
||||
<p>
|
||||
<strong>Gesendet am {berlinDate(history[0].usedAtUtc)}</strong>
|
||||
in <a href={resolve('/sendungen/[id]', { id: history[0].showId })}>{history[0].showTitle}</a>
|
||||
{#if history.length > 1}<span>+ {history.length - 1} weitere</span>{/if}
|
||||
</p>
|
||||
{#if history.length > 1}
|
||||
<details>
|
||||
<summary>Vollständige Verwendungshistorie</summary>
|
||||
<ul>
|
||||
{#each history as usage (usage.usageId)}
|
||||
<li>
|
||||
<time datetime={usage.usedAtUtc}>{berlinDate(usage.usedAtUtc)}</time>
|
||||
<a href={resolve('/sendungen/[id]', { id: usage.showId })}>{usage.showTitle}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { PresentationContent } from '#lib/server/presentation.js';
|
||||
|
||||
let {
|
||||
content,
|
||||
imageSrc = ''
|
||||
}: {
|
||||
content: PresentationContent & {
|
||||
sources?: { label: string; url: string }[];
|
||||
images?: { id: string; altText: string }[];
|
||||
};
|
||||
imageSrc?: string;
|
||||
} = $props();
|
||||
let frame = $state<HTMLIFrameElement>();
|
||||
let playerUnavailable = $state(false);
|
||||
let imageUnavailable = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
const receive = (event: MessageEvent) => {
|
||||
if (content.provider !== 'tiktok' || event.origin !== 'https://www.tiktok.com') return;
|
||||
if (event.source !== frame?.contentWindow || typeof event.data !== 'object' || !event.data)
|
||||
return;
|
||||
const message = event.data as { type?: string; value?: unknown };
|
||||
if (message.type === 'onPlayerReady' && content.source?.timecodeSeconds) {
|
||||
frame.contentWindow?.postMessage(
|
||||
{
|
||||
type: 'seekTo',
|
||||
value: content.source.timecodeSeconds,
|
||||
'x-tiktok-player': true
|
||||
},
|
||||
'https://www.tiktok.com'
|
||||
);
|
||||
}
|
||||
if (message.type === 'onPlayerError') playerUnavailable = true;
|
||||
};
|
||||
addEventListener('message', receive);
|
||||
return () => removeEventListener('message', receive);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if content.mode === 'text'}
|
||||
<article class="output-content output-text">
|
||||
<p class="output-kicker">heinzlive</p>
|
||||
<h1>{content.title}</h1>
|
||||
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
<div class="output-copy">{@html content.html}</div>
|
||||
{#if content.images?.length}<div class="output-images">
|
||||
{#each content.images as image (image.id)}
|
||||
<img src={`${imageSrc}/${image.id}`} alt={image.altText} />
|
||||
{/each}
|
||||
</div>{/if}
|
||||
{#if content.sources?.length}<footer class="output-sources">
|
||||
<span>Quellen</span>
|
||||
{#each content.sources as source, index (`${index}-${source.url}`)}
|
||||
<!-- The URL is a server-validated external HTTP(S) source. -->
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={source.url} target="_blank" rel="noopener noreferrer">{source.label}</a>
|
||||
{/each}
|
||||
</footer>{/if}
|
||||
</article>
|
||||
{:else if content.mode === 'link'}
|
||||
<article class="output-content output-link-card">
|
||||
{#if imageSrc && !imageUnavailable}<div class="output-link-image">
|
||||
<img src={imageSrc} alt="" onerror={() => (imageUnavailable = true)} />
|
||||
</div>{/if}
|
||||
<div class="output-link-copy">
|
||||
<p class="output-kicker">{content.siteName || content.source?.label || 'Webseite'}</p>
|
||||
<h1>{content.previewTitle || content.title}</h1>
|
||||
{#if content.description}<p>{content.description}</p>{/if}
|
||||
<!-- The URL is a server-validated external HTTP(S) source. -->
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
{#if content.source}<a href={content.source.url} target="_blank" rel="noopener noreferrer"
|
||||
>Original öffnen</a
|
||||
>{/if}
|
||||
</div>
|
||||
</article>
|
||||
{:else}
|
||||
<article class:portrait={content.provider === 'tiktok'} class="output-content output-video">
|
||||
<header>
|
||||
<p class="output-kicker">{content.siteName || content.source?.label || 'Video'}</p>
|
||||
<h1>{content.previewTitle || content.title}</h1>
|
||||
</header>
|
||||
{#if content.embedUrl && !playerUnavailable}
|
||||
<div class="output-player">
|
||||
<iframe
|
||||
bind:this={frame}
|
||||
title={content.previewTitle || content.title}
|
||||
src={content.embedUrl}
|
||||
allow="encrypted-media; fullscreen; picture-in-picture"
|
||||
allowfullscreen
|
||||
onerror={() => (playerUnavailable = true)}
|
||||
></iframe>
|
||||
</div>
|
||||
{:else}
|
||||
<section class="output-fallback">
|
||||
<strong>Video kann nicht eingebettet werden</strong>
|
||||
<p>Der Anbieter oder diese URL ist für die eingebettete Wiedergabe nicht verfügbar.</p>
|
||||
<!-- The URL is a server-validated external HTTP(S) source. -->
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
{#if content.source}<a href={content.source.url} target="_blank" rel="noopener noreferrer"
|
||||
>Original öffnen</a
|
||||
>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{#if content.source}<footer class="output-video-source">
|
||||
<!-- The URL is a server-validated external HTTP(S) source. -->
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={content.source.url} target="_blank" rel="noopener noreferrer">Original öffnen</a>
|
||||
</footer>{/if}
|
||||
</article>
|
||||
{/if}
|
||||
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
import { enhance, type SubmitFunction } from '$app/forms';
|
||||
import { untrack } from 'svelte';
|
||||
import NavIcon from './NavIcon.svelte';
|
||||
import type { PieceUsage } from '#lib/server/shows.js';
|
||||
|
||||
let {
|
||||
usage,
|
||||
revision,
|
||||
sectionId,
|
||||
sections,
|
||||
error,
|
||||
enhanceCommand
|
||||
}: {
|
||||
usage: PieceUsage;
|
||||
revision: number;
|
||||
sectionId: string;
|
||||
sections: { id: string; name: string }[];
|
||||
error?: string;
|
||||
enhanceCommand: SubmitFunction;
|
||||
} = $props();
|
||||
let dialog: HTMLDialogElement;
|
||||
let presentationMode = $state(untrack(() => usage.presentationMode));
|
||||
|
||||
function durationInMinutes(seconds: number) {
|
||||
return Number((seconds / 60).toFixed(2));
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="usage-edit-button"
|
||||
type="button"
|
||||
aria-label={`${usage.title} bearbeiten und verschieben`}
|
||||
title="Bearbeiten und verschieben"
|
||||
onclick={() => dialog.showModal()}
|
||||
>
|
||||
<NavIcon name="edit" />
|
||||
</button>
|
||||
|
||||
<dialog
|
||||
bind:this={dialog}
|
||||
class="usage-editor-dialog"
|
||||
aria-labelledby={`usage-editor-title-${usage.id}`}
|
||||
onclick={(event) => event.target === dialog && dialog.close()}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">Sendungsspezifische Kopie</p>
|
||||
<h2 id={`usage-editor-title-${usage.id}`}>{usage.title}</h2>
|
||||
</div>
|
||||
<form method="dialog">
|
||||
<button class="dialog-close" type="submit" aria-label="Editor schließen">×</button>
|
||||
</form>
|
||||
</header>
|
||||
<div class="usage-dialog-scroll">
|
||||
{#if error}<div class="form-message error usage-dialog-message" role="alert">{error}</div>{/if}
|
||||
<form
|
||||
use:enhance={enhanceCommand}
|
||||
method="POST"
|
||||
action="?/updateUsage"
|
||||
class="usage-dialog-editor"
|
||||
>
|
||||
<input type="hidden" name="revision" value={revision} />
|
||||
<input type="hidden" name="usageId" value={usage.id} />
|
||||
<section class="editor-section">
|
||||
<div class="section-title">
|
||||
<p class="eyebrow">Grundlagen</p>
|
||||
<h3>Titel, Dauer und Notizen</h3>
|
||||
</div>
|
||||
<div class="editor-fields two-columns">
|
||||
<label class="wide-field"
|
||||
>Titel<input name="title" required maxlength="200" value={usage.title} /></label
|
||||
>
|
||||
<label
|
||||
>Dauer in Minuten<input
|
||||
name="durationMinutes"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1440"
|
||||
step="any"
|
||||
value={durationInMinutes(usage.durationSeconds)}
|
||||
/></label
|
||||
>
|
||||
<label class="wide-field"
|
||||
>Eigene Notizen<textarea name="ownNotes" rows="3" maxlength="50000"
|
||||
>{usage.ownNotes}</textarea
|
||||
></label
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="editor-section markdown-section">
|
||||
<div class="section-title">
|
||||
<p class="eyebrow">Redaktion</p>
|
||||
<h3>Getrennte Arbeitsbereiche</h3>
|
||||
</div>
|
||||
<div class="markdown-grid">
|
||||
<label
|
||||
><span>Einleitung</span><textarea name="intro" rows="7" maxlength="50000"
|
||||
>{usage.intro}</textarea
|
||||
></label
|
||||
>
|
||||
<label class="private-field"
|
||||
><span>Private Recherche</span><textarea name="research" rows="7" maxlength="50000"
|
||||
>{usage.research}</textarea
|
||||
></label
|
||||
>
|
||||
<label
|
||||
><span>Moderationsstichpunkte</span><textarea
|
||||
name="moderation"
|
||||
rows="7"
|
||||
maxlength="50000">{usage.moderation}</textarea
|
||||
></label
|
||||
>
|
||||
<label
|
||||
><span>Überleitung</span><textarea name="transition" rows="7" maxlength="50000"
|
||||
>{usage.transition}</textarea
|
||||
></label
|
||||
>
|
||||
<label class="wide-field"
|
||||
><span>Präsentationstext</span><textarea name="presentation" rows="7" maxlength="50000"
|
||||
>{usage.presentation}</textarea
|
||||
></label
|
||||
>
|
||||
</div>
|
||||
<div class="presentation-settings">
|
||||
<label
|
||||
>Darstellung<select name="presentationMode" bind:value={presentationMode}>
|
||||
<option value="text">Text</option>
|
||||
<option value="link">Linkvorschau</option>
|
||||
<option value="video">Video</option>
|
||||
</select></label
|
||||
>
|
||||
{#if presentationMode !== 'text'}
|
||||
<input
|
||||
type="hidden"
|
||||
name="presentationSourceLabel"
|
||||
value={usage.presentationSourceLabel}
|
||||
/>
|
||||
<input type="hidden" name="presentationSourceUrl" value={usage.presentationSourceUrl} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="presentationSourceTimecodeSeconds"
|
||||
value={usage.presentationSourceTimecodeSeconds ?? ''}
|
||||
/>
|
||||
<input type="hidden" name="presentationSiteName" value={usage.presentationSiteName} />
|
||||
<div class="presentation-source-summary wide-field">
|
||||
<span>Ausgewählte Quelle</span>
|
||||
<strong
|
||||
>{usage.presentationSourceLabel ||
|
||||
usage.presentationSourceUrl ||
|
||||
'Keine Quelle'}</strong
|
||||
>
|
||||
{#if usage.presentationSourceUrl}<small>{usage.presentationSourceUrl}</small>{/if}
|
||||
</div>
|
||||
<label
|
||||
>Titel<input
|
||||
name="presentationTitle"
|
||||
maxlength="300"
|
||||
value={usage.presentationTitle}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Bild-URL<input
|
||||
name="presentationImageUrl"
|
||||
type="url"
|
||||
maxlength="2000"
|
||||
value={usage.presentationImageUrl}
|
||||
/></label
|
||||
>
|
||||
<label class="wide-field"
|
||||
>Anreißer<textarea name="presentationDescription" rows="3" maxlength="1000"
|
||||
>{usage.presentationDescription}</textarea
|
||||
></label
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button class="primary-button" type="submit">Verwendung speichern</button>
|
||||
</form>
|
||||
|
||||
<section class="usage-dialog-actions" aria-label="Weitere Aktionen">
|
||||
<div>
|
||||
<form use:enhance={enhanceCommand} method="POST" action="?/refreshUsage">
|
||||
<input type="hidden" name="revision" value={revision} />
|
||||
<input type="hidden" name="usageId" value={usage.id} />
|
||||
<button class="secondary-button" type="submit" disabled={!usage.originalPieceId}
|
||||
>Aus Fundus aktualisieren</button
|
||||
>
|
||||
</form>
|
||||
<small
|
||||
>Übernimmt Titel, Dauer und Redaktionstexte. Eigene Notizen und Live-Daten bleiben.</small
|
||||
>
|
||||
</div>
|
||||
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
|
||||
<input type="hidden" name="revision" value={revision} />
|
||||
<input type="hidden" name="usageId" value={usage.id} />
|
||||
<button class="secondary-button" type="submit">Duplizieren</button>
|
||||
</form>
|
||||
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
|
||||
<input type="hidden" name="revision" value={revision} />
|
||||
<input type="hidden" name="usageId" value={usage.id} />
|
||||
<input type="hidden" name="position" value="9999" />
|
||||
<label
|
||||
>Ziel<select name="sectionId">
|
||||
<option value="">Reservebank</option>
|
||||
{#each sections as target (target.id)}
|
||||
<option value={target.id} selected={target.id === sectionId}>{target.name}</option>
|
||||
{/each}
|
||||
</select></label
|
||||
>
|
||||
<button class="secondary-button" type="submit">Verschieben</button>
|
||||
</form>
|
||||
<form
|
||||
use:enhance={enhanceCommand}
|
||||
method="POST"
|
||||
action="?/removeUsage"
|
||||
onsubmit={(event) =>
|
||||
!confirm('Verwendung aus der Sendung entfernen?') && event.preventDefault()}
|
||||
>
|
||||
<input type="hidden" name="revision" value={revision} />
|
||||
<input type="hidden" name="usageId" value={usage.id} />
|
||||
<button class="text-button danger-text" type="submit">Entfernen</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getPiece,
|
||||
getTaxonomy,
|
||||
listPieces,
|
||||
loadPiecePresentationMetadata,
|
||||
savePiece,
|
||||
setPieceArchived,
|
||||
uploadPieceImage,
|
||||
@@ -50,6 +51,18 @@ function completeInput(overrides: Partial<PieceInput> = {}): 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')!;
|
||||
|
||||
+190
-3
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('database setup', () => {
|
||||
database.close();
|
||||
|
||||
expect(counts).toEqual({
|
||||
migrations: 5,
|
||||
migrations: 6,
|
||||
pieces: 6,
|
||||
shows: 2,
|
||||
usages: 5,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
+80
-20
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
`<!doctype html><html><head>
|
||||
<meta property="og:title" content="Lokale Vorschau">
|
||||
<meta property="og:description" content="Ein kurzer Anreißer">
|
||||
<meta property="og:site_name" content="Testseite">
|
||||
</head></html>`,
|
||||
'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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<PresentationMetadata> {
|
||||
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<string, unknown>;
|
||||
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<PresentationMetadata> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<PieceUsage> = {}) {
|
||||
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, {
|
||||
|
||||
+392
-9
@@ -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<string, PieceUsageContext> {
|
||||
const uniquePieceIds = [...new Set(pieceIds.filter(Boolean))];
|
||||
const contexts = Object.fromEntries(
|
||||
uniquePieceIds.map((pieceId) => [
|
||||
pieceId,
|
||||
{
|
||||
sentHistory: [],
|
||||
isPlannedInActiveShow: false,
|
||||
isPlannedInCurrentShow: false
|
||||
} satisfies PieceUsageContext
|
||||
])
|
||||
) as Record<string, PieceUsageContext>;
|
||||
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<UsageRow, 'id' | 'show_section_id' | 'position'> & {
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user