Release 0.5.10

This commit is contained in:
hnzio committed 2026-10-23 21:33:15 +02:00
1 parent c3033479b5
commit 4fe61971e4
52 files changed
+38565 -466

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
let { name, label, size = '1em' }: { name: string; label?: string; size?: string } = $props();
</script>
<svg
width={size}
height={size}
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : 'true'}
class="app-icon"
>
<use href={`/icons/classic-regular.svg#${name || 'circle'}`} />
</svg>
<style>
.app-icon {
display: inline-block;
flex: none;
fill: currentColor;
vertical-align: -0.125em;
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
import AppIcon from './AppIcon.svelte';
type Item = {
id: string;
name: string;
iconName?: string;
description?: string;
meta?: string;
};
let {
items,
selectedIds,
label,
fieldName,
onselectionchange
}: {
items: Item[];
selectedIds: string[];
label: string;
fieldName?: string;
onselectionchange: (ids: string[]) => void;
} = $props();
let query = $state('');
let menu: HTMLDetailsElement;
let filtered = $derived(
items.filter((item) =>
`${item.name} ${item.description ?? ''} ${item.meta ?? ''}`
.toLocaleLowerCase('de')
.includes(query.trim().toLocaleLowerCase('de'))
)
);
let selected = $derived(items.filter((item) => selectedIds.includes(item.id)));
function toggle(id: string) {
onselectionchange(
selectedIds.includes(id)
? selectedIds.filter((selectedId) => selectedId !== id)
: [...selectedIds, id]
);
}
</script>
<div class="choice-picker">
{#if fieldName}
{#each selectedIds as id (id)}<input type="hidden" name={fieldName} value={id} />{/each}
{/if}
{#if selected.length}
<div class="choice-picker-selected" aria-label={`${label}: ausgewählt`}>
{#each selected as item (item.id)}
<span
>{#if item.iconName}<AppIcon name={item.iconName} />{/if}{item.name}</span
>
{/each}
</div>
{/if}
<details bind:this={menu} class="choice-picker-menu">
<summary>{selected.length ? `${label} ändern` : `${label} auswählen`}</summary>
<div class="choice-picker-popover">
<label class="choice-picker-search">
<span class="sr-only">{label} durchsuchen</span>
<AppIcon name="magnifying-glass" />
<input type="search" placeholder={`${label} durchsuchen`} bind:value={query} />
</label>
<div class="choice-picker-options">
{#each filtered as item (item.id)}
<label class:active={selectedIds.includes(item.id)}>
<input
type="checkbox"
checked={selectedIds.includes(item.id)}
onchange={() => toggle(item.id)}
/>
{#if item.iconName}<AppIcon name={item.iconName} size="1.2em" />{/if}
<span
><strong>{item.name}</strong>{#if item.description}<small>{item.description}</small
>{/if}</span
>
{#if item.meta}<small>{item.meta}</small>{/if}
</label>
{:else}<p>Keine Treffer.</p>{/each}
</div>
<button
class="choice-picker-close text-button"
type="button"
onclick={() => (menu.open = false)}>Auswahl schließen</button
>
</div>
</details>
</div>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import { classicRegularIcons } from '#lib/icons/classic-regular.js';
import AppIcon from './AppIcon.svelte';
let { name = $bindable(), inputName = 'iconName' }: { name: string; inputName?: string } =
$props();
let query = $state('');
let menu: HTMLDetailsElement;
let filtered = $derived(
classicRegularIcons.filter((icon) =>
`${icon.name} ${icon.label} ${icon.terms.join(' ')}`
.toLocaleLowerCase('de')
.includes(query.trim().toLocaleLowerCase('de'))
)
);
let visible = $derived(filtered.slice(0, 120));
</script>
<div class="icon-picker">
<input type="hidden" name={inputName} value={name} />
<details bind:this={menu}>
<summary><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
<div class="icon-picker-popover">
<label
><span class="sr-only">Icons durchsuchen</span><input
type="search"
placeholder="Icon suchen"
bind:value={query}
/></label
>
<div class="icon-picker-grid">
{#each visible as icon (icon.name)}
<button
type="button"
class:active={name === icon.name}
title={icon.label}
onclick={() => {
name = icon.name;
menu.open = false;
}}><AppIcon name={icon.name} /><span>{icon.label}</span></button
>
{/each}
</div>
{#if filtered.length > visible.length}<p class="icon-picker-hint">
{visible.length} von {filtered.length} Icons sichtbar. Suche verwenden, um weitere Icons zu
finden.
</p>{/if}
</div>
</details>
</div>
@@ -0,0 +1,70 @@
<script lang="ts">
import { tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
let {
value = $bindable(),
form,
name = 'title',
label = 'Titel',
oncommit
}: {
value: string;
form: string;
name?: string;
label?: string;
oncommit?: () => void;
} = $props();
let editing = $state(false);
let original = $state('');
let input = $state<HTMLInputElement>();
let trigger = $state<HTMLButtonElement>();
async function edit() {
original = value;
editing = true;
await tick();
input?.focus();
input?.select();
}
async function finish(commit: boolean) {
if (!editing) return;
if (!commit) value = original;
editing = false;
if (commit && value !== original) oncommit?.();
await tick();
trigger?.focus();
}
</script>
<div class="inline-title-editor">
<input type="hidden" {form} {name} {value} />
{#if editing}
<label>
<span class="sr-only">{label}</span>
<input
bind:this={input}
bind:value
maxlength="200"
required
onkeydown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void finish(true);
} else if (event.key === 'Escape') {
event.preventDefault();
void finish(false);
}
}}
onblur={() => void finish(true)}
/>
</label>
{:else}
<button bind:this={trigger} type="button" class="inline-title-trigger" onclick={edit}>
<span>{value}</span><AppIcon name="pen" size="0.9em" />
<span class="sr-only">{label} bearbeiten</span>
</button>
{/if}
</div>
@@ -1,5 +1,6 @@
<script lang="ts">
import { tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
let {
name,
@@ -60,12 +61,15 @@
<button type="button" aria-label="Kursiv" title="Kursiv" onclick={() => replaceSelection('_')}
><em>I</em></button
>
<button type="button" aria-label="Liste" title="Liste" onclick={addList}>• Liste</button>
<button type="button" aria-label="Liste" title="Liste" onclick={addList}
><AppIcon name="list-check" /> Liste</button
>
<button
type="button"
aria-label="Link"
title="Link"
onclick={() => replaceSelection('[', '](https://)', 'Linktext')}>↗ Link</button
onclick={() => replaceSelection('[', '](https://)', 'Linktext')}
><AppIcon name="link" /> Link</button
>
</span>
</div>
+13 -26
View File
@@ -1,34 +1,21 @@
<script lang="ts">
import AppIcon from './AppIcon.svelte';
let { name }: { name: string } = $props();
const aliases: Record<string, string> = {
library: 'folder-open',
shows: 'calendar-days',
archive: 'box-archive',
settings: 'gear',
edit: 'pen'
};
let iconName = $derived(aliases[name] ?? name);
</script>
<svg viewBox="0 0 24 24" aria-hidden="true">
{#if name === 'inbox'}
<path d="M4 5.5h16v13H4zM4 14h4l1.5 2h5l1.5-2h4" />
{:else if name === 'library'}
<path d="M5 4.5h4v15H5zM10.5 4.5h4v15h-4zM16 5.5l3-1 3.5 14-3 1z" />
{:else if name === 'shows'}
<path d="M4 6.5h16v13H4zM8 3.5v6M16 3.5v6M4 10h16" />
{:else if name === 'archive'}
<path d="M4 7h16v13H4zM3 4h18v4H3zM9 12h6" />
{:else if name === 'settings'}
<circle cx="12" cy="12" r="3" />
<path
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4"
/>
{:else if name === 'edit'}
<path d="M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4" />
{/if}
</svg>
<AppIcon name={iconName} size="1.25rem" />
<style>
svg {
width: 1.25rem;
height: 1.25rem;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.7;
:global(.app-icon) {
display: block;
}
</style>
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { tick, type Snippet } from 'svelte';
import AppIcon from './AppIcon.svelte';
let {
labelledby,
@@ -53,7 +54,7 @@
class="dialog-close"
type="button"
aria-label="Editor schließen"
onclick={onrequestclose}>×</button
onclick={onrequestclose}><AppIcon name="xmark" /></button
>
</header>
<nav class="piece-dialog-tabs" aria-label="Editorbereiche">
+6 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import AppIcon from './AppIcon.svelte';
type Preference = 'system' | 'light' | 'dark';
const preferences: Preference[] = ['system', 'light', 'dark'];
@@ -42,8 +43,10 @@
onclick={cycle}
aria-label={`Darstellung: ${labels[preference]}`}
>
<span class="theme-symbol" aria-hidden="true">
{preference === 'system' ? '◐' : preference === 'light' ? '○' : '●'}
</span>
<span class="theme-symbol" aria-hidden="true"
><AppIcon
name={preference === 'system' ? 'display' : preference === 'light' ? 'sun' : 'moon'}
/></span
>
<span>{labels[preference]}</span>
</button>
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import { tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
import ChoicePicker from './ChoicePicker.svelte';
import IconPicker from './IconPicker.svelte';
type Format = { id: string; name: string };
type Tool = {
id: string;
name: string;
startUrl: string;
description: string;
iconName: string;
isActive: boolean;
formatIds: string[];
};
let { formats }: { formats: Format[] } = $props();
let dialog: HTMLDialogElement;
let nameInput: HTMLInputElement;
let tool = $state<Tool | null>(null);
let name = $state('');
let startUrl = $state('');
let description = $state('');
let iconName = $state('wrench');
let isActive = $state(true);
let formatIds = $state<string[]>([]);
export async function showModal(selectedTool: Tool | null = null) {
tool = selectedTool;
name = selectedTool?.name ?? '';
startUrl = selectedTool?.startUrl ?? '';
description = selectedTool?.description ?? '';
iconName = selectedTool?.iconName || 'wrench';
isActive = selectedTool?.isActive ?? true;
formatIds = [...(selectedTool?.formatIds ?? [])];
dialog.showModal();
await tick();
nameInput.focus();
}
function close() {
dialog.close();
}
</script>
<dialog
bind:this={dialog}
class="management-dialog tool-editor-dialog"
aria-labelledby="tool-dialog-title"
onclick={(event) => event.target === dialog && close()}
>
<header>
<div class="management-dialog-title">
<span class="management-dialog-icon"><AppIcon name={iconName || 'wrench'} /></span>
<div>
<p class="eyebrow">{tool ? 'Werkzeug bearbeiten' : 'Werkzeug hinzufügen'}</p>
<h2 id="tool-dialog-title">{tool?.name || 'Neues Werkzeug'}</h2>
</div>
</div>
<button class="dialog-close" type="button" aria-label="Werkzeugdialog schließen" onclick={close}
><AppIcon name="xmark" /></button
>
</header>
<form
class="management-dialog-form"
method="POST"
action={tool ? '?/updateTool' : '?/createTool'}
>
{#if tool}<input type="hidden" name="toolId" value={tool.id} />{/if}
<div class="management-dialog-fields">
<label
>Name<input
bind:this={nameInput}
bind:value={name}
name="name"
required
maxlength="120"
/></label
>
<label
>Start-URL<input
bind:value={startUrl}
name="startUrl"
type="url"
maxlength="2000"
placeholder="https://…"
/></label
>
<label class="wide-field"
>Beschreibung<textarea bind:value={description} name="description" rows="3" maxlength="2000"
></textarea></label
>
</div>
<div class="management-dialog-selections">
<div>
<span class="field-label">Icon</span>
<IconPicker bind:name={iconName} />
</div>
<div>
<span class="field-label">Passende Formate</span>
<ChoicePicker
items={formats}
selectedIds={formatIds}
label="Formate"
fieldName="formatIds"
onselectionchange={(ids) => (formatIds = ids)}
/>
</div>
<label class="toggle-row"
><input type="checkbox" name="isActive" value="1" bind:checked={isActive} /><span
><strong>Aktiv</strong><small
>Deaktivierte Werkzeuge bleiben in bestehenden Pieces erhalten.</small
></span
></label
>
</div>
<footer>
<button class="secondary-button" type="button" onclick={close}>Abbrechen</button>
<button class="primary-button" type="submit"
>{tool ? 'Änderungen speichern' : 'Werkzeug anlegen'}</button
>
</footer>
</form>
</dialog>
+44 -13
View File
@@ -2,6 +2,7 @@
import { enhance, type SubmitFunction } from '$app/forms';
import { untrack } from 'svelte';
import MarkdownEditorField from './MarkdownEditorField.svelte';
import InlineTitleEditor from './InlineTitleEditor.svelte';
import NavIcon from './NavIcon.svelte';
import PieceEditorDialog from './PieceEditorDialog.svelte';
import type { PieceUsage } from '#lib/server/shows.js';
@@ -29,6 +30,9 @@
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
);
let dirty = $state(false);
let selectedMedia = $derived(
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
);
function durationInMinutes(seconds: number) {
return Number((seconds / 60).toFixed(2));
@@ -67,18 +71,7 @@
>
{#snippet header()}
<p class="eyebrow">Sendungsfassung</p>
<label class="piece-dialog-title">
<span class="sr-only">Titel</span>
<textarea
form={formId}
name="title"
required
rows="1"
maxlength="200"
aria-label="Titel"
bind:value={draft.title}
oninput={() => (dirty = true)}></textarea>
</label>
<InlineTitleEditor form={formId} bind:value={draft.title} oncommit={() => (dirty = true)} />
<h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
{/snippet}
@@ -250,6 +243,7 @@
value={draft.presentationSourceTimecodeSeconds ?? ''}
/>
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
{#if draft.presentationMode !== 'text'}
<div class="presentation-settings">
<div class="presentation-source-summary wide-field">
@@ -297,6 +291,33 @@
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
{/if}
</div>
{#if draft.media.length}<fieldset class="usage-media-picker">
<legend>Hochgeladenes Präsentationsbild</legend>
<label
><input
type="radio"
checked={!draft.presentationMediaId}
onchange={() => {
draft.presentationMediaId = null;
dirty = true;
}}
/> Kein Bild</label
>
<div class="media-grid">
{#each draft.media as media (media.id)}<label class="media-presentation-choice"
><input
type="radio"
checked={draft.presentationMediaId === media.id}
onchange={() => {
draft.presentationMediaId = media.id;
dirty = true;
}}
/><img src={`/media/${media.originalMediaId}`} alt={media.altText} /><span
>Auswählen</span
></label
>{/each}
</div>
</fieldset>{/if}
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<p class="output-kicker">Vorschau · {draft.presentationMode}</p>
<h4>{draft.outputTitle || draft.title}</h4>
@@ -304,8 +325,18 @@
<p class="preview-markdown-source">
{draft.presentation || 'Noch kein öffentlicher Text.'}
</p>
{#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{/if}
{:else}
{#if draft.presentationImageUrl}<img src={draft.presentationImageUrl} alt="" />{/if}
{#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{:else if draft.presentationImageUrl}<img
src={draft.presentationImageUrl}
alt=""
/>{/if}
<p>
{draft.presentationDescription ||
draft.presentationSourceUrl ||
File diff suppressed because it is too large. Load diff
+97 -4
View File
@@ -18,11 +18,13 @@ import {
savePiece,
savePieceAsTemplate,
setPieceArchived,
setPieceDeleted,
setPieceFavorite,
uploadPieceImage,
updateTaxonomy,
type PieceInput
} from './content.js';
import { closeDatabase } from './database.js';
import { closeDatabase, getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js';
let directory: string;
@@ -198,8 +200,17 @@ describe('content management', () => {
});
it('searches independent taxonomies and archives, restores and duplicates pieces', async () => {
createTaxonomy('topic', 'Technikgeschichte');
createTaxonomy('topic', 'Technikgeschichte', 'microchip');
const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!;
expect(topic.iconName).toBe('microchip');
updateTaxonomy('topic', topic.id, 'Computergeschichte', 'microchip');
expect(getTaxonomy().topics.find((entry) => entry.id === topic.id)).toMatchObject({
name: 'Computergeschichte',
iconName: 'microchip'
});
expect(() => updateTaxonomy('topic', topic.id, 'Computergeschichte', '../unsafe')).toThrow(
'INVALID_ICON'
);
const sourceId = createPiece('find');
const source = getPiece(sourceId)!;
savePiece(
@@ -253,15 +264,17 @@ describe('content management', () => {
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
await uploadPieceImage(
const uploaded = await uploadPieceImage(
id,
1,
new File([png], '../bild.png', { type: 'text/html' }),
'Ein heller Pixel'
'Ein heller Pixel',
true
);
const piece = getPiece(id)!;
expect(piece.media).toHaveLength(1);
expect(piece.media[0]).toMatchObject({ mimeType: 'image/png', altText: 'Ein heller Pixel' });
expect(piece.presentationMediaId).toBe(uploaded.id);
const media = await getMediaFile(piece.media[0].id);
expect(media?.data.equals(png)).toBe(true);
expect(existsSync(path.join(directory, 'uploads', '../bild.png'))).toBe(false);
@@ -274,7 +287,87 @@ describe('content management', () => {
''
)
).rejects.toThrow('INVALID_IMAGE_TYPE');
const stored = getDatabase()
.prepare('SELECT storage_name FROM piece_media WHERE id = ?')
.get(piece.media[0].id) as { storage_name: string };
await deletePieceImage(id, piece.media[0].id, piece.revision);
expect(getPiece(id)?.media).toHaveLength(0);
expect(getPiece(id)?.presentationMediaId).toBeNull();
expect(await getMediaFile(piece.media[0].id)).toBeNull();
expect(existsSync(path.join(directory, 'uploads', stored.storage_name))).toBe(true);
});
it('selects presentation media atomically, copies active media and omits media from templates', async () => {
const id = createPiece();
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
const selected = await uploadPieceImage(
id,
1,
new File([png], 'auswahl.png', { type: 'image/png' }),
'Auswahl',
true
);
const second = await uploadPieceImage(
id,
selected.revision,
new File([png], 'entfernt.png', { type: 'image/png' }),
'Entfernt'
);
await deletePieceImage(id, second.id, second.revision);
const source = getPiece(id)!;
expect(() =>
savePiece(id, source.revision, {
...source,
title: 'Darf nicht gespeichert werden',
status: 'preparing',
presentationMediaId: 'fremdes-medium'
})
).toThrow('presentation media does not belong to piece');
expect(getPiece(id)).toMatchObject({
title: 'Neues Piece',
revision: source.revision,
presentationMediaId: selected.id
});
const duplicateId = await duplicatePiece(id);
const duplicate = getPiece(duplicateId)!;
expect(duplicate.media).toHaveLength(1);
expect(duplicate.presentationMediaId).toBe(duplicate.media[0].id);
expect(duplicate.presentationMediaId).not.toBe(selected.id);
const template = savePieceAsTemplate(id, 'Ohne Medienreferenz', 'Nur Inhaltsdaten');
const snapshot = getDatabase()
.prepare('SELECT snapshot_json FROM piece_templates WHERE id = ?')
.get(template.id) as { snapshot_json: string };
expect(JSON.parse(snapshot.snapshot_json)).not.toHaveProperty('presentationMediaId');
expect(getPiece(createPiece(template.id))?.presentationMediaId).toBeNull();
});
it('soft-deletes pieces behind revisions and keeps their original media private', async () => {
const id = createPiece();
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
const uploaded = await uploadPieceImage(
id,
1,
new File([png], 'privat.png', { type: 'image/png' }),
'Privat'
);
const deleted = setPieceDeleted(id, uploaded.revision, true);
expect(getPiece(id)).toBeNull();
expect(listPieces({ statuses: ['preparing'] }).some((piece) => piece.id === id)).toBe(false);
expect(await getMediaFile(uploaded.id)).toBeNull();
expect(() => setPieceFavorite(id, true)).toThrow('NOT_FOUND');
expect(() => savePiece(id, deleted.revision, completeInput())).toThrow('NOT_FOUND');
expect(() => setPieceDeleted(id, uploaded.revision, false)).toThrow('CONFLICT');
const restored = setPieceDeleted(id, deleted.revision, false);
expect(getPiece(id)).toMatchObject({ id, revision: restored.revision });
expect((await getMediaFile(uploaded.id))?.data.equals(png)).toBe(true);
});
});
+199 -78
View File
@@ -1,7 +1,8 @@
import { randomUUID } from 'node:crypto';
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { copyFile, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileTypeFromBuffer } from 'file-type';
import { classicRegularIconNames } from '#lib/icons/classic-regular.js';
import { getDatabase } from './database.js';
import { getServerPaths } from './config.js';
import {
@@ -31,6 +32,7 @@ export type PieceTool = PieceToolInput & {
id: string;
name: string;
startUrl: string;
iconName: string;
isActive: boolean;
};
@@ -58,6 +60,7 @@ export type PieceInput = {
presentationDescriptionManual: boolean;
presentationImageManual: boolean;
presentationMetadataFetchedAtUtc: string | null;
presentationMediaId?: string | null;
topicIds: string[];
categoryIds: string[];
sources: PieceSourceInput[];
@@ -74,19 +77,20 @@ export type PieceListItem = {
formatId: string | null;
format: string;
categories: { id: string; name: string; color: string }[];
topics: { id: string; name: string }[];
topics: { id: string; name: string; iconName: string }[];
sourceCount: number;
mediaId: string | null;
isFavorite: boolean;
};
export type PieceEditor = Omit<PieceInput, 'status' | 'tools'> & {
export type PieceEditor = Omit<PieceInput, 'status' | 'tools' | 'presentationMediaId'> & {
id: string;
status: PieceStatus;
revision: number;
createdAtUtc: string;
updatedAtUtc: string;
media: PieceMedia[];
presentationMediaId: string | null;
isFavorite: boolean;
tools: PieceTool[];
};
@@ -103,7 +107,7 @@ export type PieceMedia = {
export type Taxonomy = {
categories: { id: string; name: string; color: string }[];
formats: { id: string; name: string }[];
topics: { id: string; name: string }[];
topics: { id: string; name: string; iconName: string }[];
};
export type PieceTemplate = {
@@ -111,6 +115,7 @@ export type PieceTemplate = {
name: string;
description: string;
revision: number;
iconName: string;
};
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
@@ -131,6 +136,13 @@ function uniqueIds(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
function cleanIconName(value: unknown): string {
const iconName = cleanText(value, 80, 'icon');
if (!iconName) return '';
if (!classicRegularIconNames.has(iconName)) throw new Error('INVALID_ICON');
return iconName;
}
function validUrl(value: string): boolean {
try {
const url = new URL(value);
@@ -226,6 +238,10 @@ function validatePieceInput(input: PieceInput): PieceInput {
}
const detected =
sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url);
const presentationMediaId =
input.presentationMediaId === undefined
? undefined
: cleanText(input.presentationMediaId, 100, 'presentation_media') || null;
return {
title,
outputTitle: cleanText(input.outputTitle, 300, 'output_title'),
@@ -254,6 +270,7 @@ function validatePieceInput(input: PieceInput): PieceInput {
presentationDescriptionManual: Boolean(input.presentationDescriptionManual),
presentationImageManual: Boolean(input.presentationImageManual),
presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null,
presentationMediaId,
topicIds,
categoryIds,
sources,
@@ -271,7 +288,7 @@ export function getTaxonomy(): Taxonomy {
.prepare('SELECT id, name FROM formats ORDER BY name')
.all() as Taxonomy['formats'],
topics: database
.prepare('SELECT id, name FROM topics ORDER BY name')
.prepare('SELECT id, name, icon_name AS iconName FROM topics ORDER BY name')
.all() as Taxonomy['topics']
};
}
@@ -297,9 +314,13 @@ export function listPieces(filters: {
formatId?: string;
topicId?: string;
categoryId?: string;
deleted?: boolean;
}): PieceListItem[] {
if (!filters.statuses.length) return [];
const conditions = [`p.status IN (${filters.statuses.map(() => '?').join(', ')})`];
const conditions = [
`p.status IN (${filters.statuses.map(() => '?').join(', ')})`,
filters.deleted ? 'p.deleted_at_utc IS NOT NULL' : 'p.deleted_at_utc IS NULL'
];
const parameters: unknown[] = [...filters.statuses];
const query = filters.query?.trim().toLowerCase();
if (query) {
@@ -336,7 +357,7 @@ export function listPieces(filters: {
`SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds, p.is_favorite,
p.format_id, COALESCE(f.name, 'Ohne Format') AS format,
(SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count,
(SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id ORDER BY pm.position LIMIT 1) AS media_id
(SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id AND pm.removed_at_utc IS NULL ORDER BY pm.position LIMIT 1) AS media_id
FROM pieces p LEFT JOIN formats f ON f.id = p.format_id
WHERE ${conditions.join(' AND ')}
ORDER BY p.is_favorite DESC,
@@ -350,7 +371,7 @@ export function listPieces(filters: {
WHERE pc.piece_id = ? ORDER BY c.sort_order, c.name`
);
const topicStatement = database.prepare(
`SELECT t.id, t.name FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id
`SELECT t.id, t.name, t.icon_name AS iconName FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id
WHERE pt.piece_id = ? ORDER BY t.name`
);
return rows.map((row) => ({
@@ -399,6 +420,7 @@ type PieceRow = {
created_at_utc: string;
updated_at_utc: string;
is_favorite: number;
presentation_media_id: string | null;
};
export function getPiece(id: string): PieceEditor | null {
@@ -411,8 +433,11 @@ export function getPiece(id: string): PieceEditor | null {
presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual, presentation_description_manual,
presentation_image_manual, presentation_metadata_fetched_at_utc,
(SELECT ppm.media_id FROM piece_presentation_media ppm
JOIN piece_media pm ON pm.id = ppm.media_id AND pm.removed_at_utc IS NULL
WHERE ppm.piece_id = pieces.id) AS presentation_media_id,
revision, created_at_utc, updated_at_utc, is_favorite
FROM pieces WHERE id = ?`
FROM pieces WHERE id = ? AND deleted_at_utc IS NULL`
)
.get(id) as PieceRow | undefined;
if (!row) return null;
@@ -441,6 +466,7 @@ export function getPiece(id: string): PieceEditor | null {
presentationDescriptionManual: row.presentation_description_manual === 1,
presentationImageManual: row.presentation_image_manual === 1,
presentationMetadataFetchedAtUtc: row.presentation_metadata_fetched_at_utc,
presentationMediaId: row.presentation_media_id,
revision: row.revision,
createdAtUtc: row.created_at_utc,
updatedAtUtc: row.updated_at_utc,
@@ -470,7 +496,7 @@ export function getPiece(id: string): PieceEditor | null {
database
.prepare(
`SELECT id, original_name, mime_type, byte_size, alt_text, position
FROM piece_media WHERE piece_id = ? ORDER BY position`
FROM piece_media WHERE piece_id = ? AND removed_at_utc IS NULL ORDER BY position`
)
.all(id) as {
id: string;
@@ -491,7 +517,7 @@ export function getPiece(id: string): PieceEditor | null {
tools: (
database
.prepare(
`SELECT pt.id, pt.tool_id, pt.work_url, pt.note, t.name, t.start_url, t.is_active
`SELECT pt.id, pt.tool_id, pt.work_url, pt.note, t.name, t.start_url, t.icon_name, t.is_active
FROM piece_tools pt JOIN tools t ON t.id = pt.tool_id
WHERE pt.piece_id = ? ORDER BY pt.position`
)
@@ -502,6 +528,7 @@ export function getPiece(id: string): PieceEditor | null {
note: string;
name: string;
start_url: string;
icon_name: string;
is_active: number;
}[]
).map((tool) => ({
@@ -511,6 +538,7 @@ export function getPiece(id: string): PieceEditor | null {
note: tool.note,
name: tool.name,
startUrl: tool.start_url,
iconName: tool.icon_name,
isActive: tool.is_active === 1
}))
};
@@ -544,9 +572,20 @@ function replaceRelations(pieceId: string, input: PieceInput) {
);
}
function replacePresentationMedia(pieceId: string, mediaId: string | null | undefined) {
if (mediaId === undefined) return;
const database = getDatabase();
database.prepare('DELETE FROM piece_presentation_media WHERE piece_id = ?').run(pieceId);
if (mediaId) {
database
.prepare('INSERT INTO piece_presentation_media (piece_id, media_id) VALUES (?, ?)')
.run(pieceId, mediaId);
}
}
export function setPieceFavorite(id: string, favorite: boolean) {
const result = getDatabase()
.prepare('UPDATE pieces SET is_favorite = ? WHERE id = ?')
.prepare('UPDATE pieces SET is_favorite = ? WHERE id = ? AND deleted_at_utc IS NULL')
.run(favorite ? 1 : 0, id);
if (result.changes !== 1) throw new Error('NOT_FOUND');
}
@@ -568,7 +607,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
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'`
WHERE id = ? AND revision = ? AND status != 'archived' AND deleted_at_utc IS NULL`
)
.run(
input.title,
@@ -599,10 +638,13 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
expectedRevision
);
if (result.changes !== 1) {
const exists = database.prepare('SELECT 1 FROM pieces WHERE id = ?').get(id);
const exists = database
.prepare('SELECT 1 FROM pieces WHERE id = ? AND deleted_at_utc IS NULL')
.get(id);
throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND');
}
replaceRelations(id, input);
replacePresentationMedia(id, input.presentationMediaId);
});
save();
return { revision: expectedRevision + 1, updatedAtUtc };
@@ -633,6 +675,7 @@ function blankPieceInput(): PieceInput {
presentationDescriptionManual: false,
presentationImageManual: false,
presentationMetadataFetchedAtUtc: null,
presentationMediaId: null,
topicIds: [],
categoryIds: [],
sources: []
@@ -647,6 +690,7 @@ function templateInput(templateId: string): PieceInput {
if (!row) throw new Error('INVALID_TEMPLATE');
try {
const snapshot = JSON.parse(row.snapshot_json) as Partial<PieceInput>;
delete snapshot.presentationMediaId;
return validatePieceInput({ ...blankPieceInput(), ...snapshot, status: 'preparing' });
} catch {
throw new Error('INVALID_TEMPLATE');
@@ -654,22 +698,33 @@ function templateInput(templateId: string): PieceInput {
}
export function listPieceTemplates(): PieceTemplate[] {
const rows = getDatabase()
const database = getDatabase();
const rows = database
.prepare('SELECT id, name, description, revision FROM piece_templates ORDER BY name')
.all() as PieceTemplate[];
.all() as Omit<PieceTemplate, 'iconName'>[];
const iconForTemplate = (templateId: string) => {
const input = templateInput(templateId);
if (!input.topicIds.length) return 'newspaper';
return (
(
database.prepare('SELECT icon_name FROM topics WHERE id = ?').get(input.topicIds[0]) as
{ icon_name: string } | undefined
)?.icon_name || 'newspaper'
);
};
return [
{
id: 'blank',
name: 'Leer starten',
description: 'Ein freies Piece ohne Vorgaben.',
revision: 0
revision: 0,
iconName: 'newspaper'
},
...rows.filter((template) => {
...rows.flatMap((template) => {
try {
templateInput(template.id);
return true;
return [{ ...template, iconName: iconForTemplate(template.id) }];
} catch {
return false;
return [];
}
})
];
@@ -726,6 +781,7 @@ export function savePieceAsTemplate(
delete snapshot.createdAtUtc;
delete snapshot.updatedAtUtc;
delete snapshot.media;
delete snapshot.presentationMediaId;
snapshot.status = 'preparing';
const snapshotJson = JSON.stringify(snapshot);
const database = getDatabase();
@@ -787,18 +843,22 @@ export function setPieceArchived(id: string, expectedRevision: number, archived:
? database
.prepare(
`UPDATE pieces SET archived_from_status = status, status = 'archived', revision = revision + 1,
updated_at_utc = ? WHERE id = ? AND revision = ? AND status != 'archived'`
updated_at_utc = ? WHERE id = ? AND revision = ? AND status != 'archived'
AND deleted_at_utc IS NULL`
)
.run(now, id, expectedRevision)
: database
.prepare(
`UPDATE pieces SET status = COALESCE(archived_from_status, 'preparing'), archived_from_status = NULL,
revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status = 'archived'`
WHERE id = ? AND revision = ? AND status = 'archived'
AND deleted_at_utc IS NULL`
)
.run(now, id, expectedRevision);
if (result.changes !== 1) {
const exists = database.prepare('SELECT 1 FROM pieces WHERE id = ?').get(id);
const exists = database
.prepare('SELECT 1 FROM pieces WHERE id = ? AND deleted_at_utc IS NULL')
.get(id);
throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND');
}
}
@@ -812,7 +872,11 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
await mkdir(uploadDirectory, { recursive: true, mode: 0o700 });
const database = getDatabase();
const storedMedia = database
.prepare('SELECT * FROM piece_media WHERE piece_id = ? ORDER BY position')
.prepare(
`SELECT pm.*, ppm.media_id IS NOT NULL AS is_presentation
FROM piece_media pm LEFT JOIN piece_presentation_media ppm ON ppm.media_id = pm.id
WHERE pm.piece_id = ? AND pm.removed_at_utc IS NULL ORDER BY pm.position`
)
.all(sourceId) as {
id: string;
storage_name: string;
@@ -821,8 +885,9 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
byte_size: number;
alt_text: string;
position: number;
is_presentation: number;
}[];
const copied: { source: (typeof storedMedia)[number]; storageName: string }[] = [];
const copied: { id: string; source: (typeof storedMedia)[number]; storageName: string }[] = [];
try {
for (const media of storedMedia) {
const extension = ALLOWED_IMAGES.get(media.mime_type)!;
@@ -831,7 +896,7 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
path.join(uploadDirectory, media.storage_name),
path.join(uploadDirectory, storageName)
);
copied.push({ source: media, storageName });
copied.push({ id: randomUUID(), source: media, storageName });
}
database.transaction(() => {
database
@@ -879,7 +944,7 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
);
for (const media of copied) {
addMedia.run(
randomUUID(),
media.id,
id,
media.storageName,
media.source.original_name,
@@ -890,6 +955,12 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
now
);
}
const presentation = copied.find((media) => media.source.is_presentation === 1);
if (presentation) {
database
.prepare('INSERT INTO piece_presentation_media (piece_id, media_id) VALUES (?, ?)')
.run(id, presentation.id);
}
})();
return id;
} catch (error) {
@@ -916,7 +987,7 @@ export async function loadPiecePresentationMetadata(
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'`
WHERE p.id = ? AND p.status != 'archived' AND p.deleted_at_utc IS NULL`
)
.get(id) as
| {
@@ -942,7 +1013,7 @@ export async function loadPiecePresentationMetadata(
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'`
WHERE id = ? AND revision = ? AND status != 'archived' AND deleted_at_utc IS NULL`
)
.run(
metadata.title,
@@ -964,7 +1035,8 @@ export async function uploadPieceImage(
pieceId: string,
expectedRevision: number,
file: File,
altTextValue: string
altTextValue: string,
selectForPresentation = false
) {
if (!file.size || file.size > MAX_UPLOAD_BYTES) throw new Error('INVALID_IMAGE_SIZE');
const buffer = Buffer.from(await file.arrayBuffer());
@@ -978,85 +1050,86 @@ export async function uploadPieceImage(
const filePath = path.join(uploadDirectory, storageName);
await writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 });
const database = getDatabase();
const mediaId = randomUUID();
const now = new Date().toISOString();
try {
database.transaction(() => {
const updated = database
.prepare(
`UPDATE pieces SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'archived'`
WHERE id = ? AND revision = ? AND status != 'archived' AND deleted_at_utc IS NULL`
)
.run(new Date().toISOString(), pieceId, expectedRevision);
.run(now, pieceId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
const count = database
.prepare('SELECT count(*) AS count FROM piece_media WHERE piece_id = ?')
.prepare(
`SELECT count(*) AS count
FROM piece_media WHERE piece_id = ? AND removed_at_utc IS NULL`
)
.get(pieceId) as { count: number };
if (count.count >= 8) throw new Error('TOO_MANY_IMAGES');
const position = (
database
.prepare(
'SELECT COALESCE(max(position), -1) + 1 AS next FROM piece_media WHERE piece_id = ?'
)
.get(pieceId) as { next: number }
).next;
database
.prepare(
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
randomUUID(),
mediaId,
pieceId,
storageName,
path.basename(file.name).slice(0, 200) || `bild.${extension}`,
detected.mime,
buffer.byteLength,
altText,
count.count,
new Date().toISOString()
position,
now
);
if (selectForPresentation) replacePresentationMedia(pieceId, mediaId);
})();
} catch (error) {
await unlink(filePath).catch(() => {});
throw error;
}
return { id: mediaId, revision: expectedRevision + 1, updatedAtUtc: now };
}
export async function deletePieceImage(pieceId: string, mediaId: string, expectedRevision: number) {
const database = getDatabase();
const media = database
.prepare('SELECT storage_name FROM piece_media WHERE id = ? AND piece_id = ?')
.get(mediaId, pieceId) as { storage_name: string } | undefined;
if (!media) throw new Error('NOT_FOUND');
const originalPath = path.join(getServerPaths().uploads, media.storage_name);
const quarantinePath = `${originalPath}.deleting-${randomUUID()}`;
await rename(originalPath, quarantinePath);
try {
database.transaction(() => {
const updated = database
.prepare(
`UPDATE pieces SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'archived'`
)
.run(new Date().toISOString(), pieceId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
database.prepare('DELETE FROM piece_media WHERE id = ?').run(mediaId);
})();
} catch (error) {
await rename(quarantinePath, originalPath).catch((restoreError) => {
console.error(
'Bilddatei konnte nach fehlgeschlagener Löschung nicht wiederhergestellt werden.',
restoreError
);
});
throw error;
}
try {
await unlink(quarantinePath);
} catch (error) {
console.error(
'Gelöschte Bilddatei konnte nicht aus dem Quarantänepfad entfernt werden.',
error
);
throw new Error('IMAGE_CLEANUP_FAILED', { cause: error });
}
const now = new Date().toISOString();
database.transaction(() => {
const updated = database
.prepare(
`UPDATE pieces SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'archived' AND deleted_at_utc IS NULL`
)
.run(now, pieceId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
const removed = database
.prepare(
`UPDATE piece_media SET removed_at_utc = ?
WHERE id = ? AND piece_id = ? AND removed_at_utc IS NULL`
)
.run(now, mediaId, pieceId);
if (removed.changes !== 1) throw new Error('NOT_FOUND');
database.prepare('DELETE FROM piece_presentation_media WHERE media_id = ?').run(mediaId);
})();
return { revision: expectedRevision + 1, updatedAtUtc: now };
}
export async function getMediaFile(id: string) {
const row = getDatabase()
.prepare('SELECT storage_name, mime_type, byte_size, alt_text FROM piece_media WHERE id = ?')
.prepare(
`SELECT pm.storage_name, pm.mime_type, pm.byte_size, pm.alt_text
FROM piece_media pm JOIN pieces p ON p.id = pm.piece_id
WHERE pm.id = ? AND pm.removed_at_utc IS NULL AND p.deleted_at_utc IS NULL`
)
.get(id) as
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
if (!row) return null;
@@ -1068,7 +1141,12 @@ export async function getMediaFile(id: string) {
};
}
export function createTaxonomy(kind: TaxonomyKind, nameValue: string, colorValue?: string) {
export function createTaxonomy(
kind: TaxonomyKind,
nameValue: string,
colorOrIconValue?: string,
iconNameValue?: string
) {
const name = cleanText(nameValue, 80, 'taxonomy');
if (!name) throw new Error('INVALID_TAXONOMY');
const database = getDatabase();
@@ -1081,7 +1159,7 @@ export function createTaxonomy(kind: TaxonomyKind, nameValue: string, colorValue
if (duplicate) throw new Error('TAXONOMY_EXISTS');
const id = randomUUID();
if (kind === 'category') {
const color = String(colorValue ?? '');
const color = String(colorOrIconValue ?? '');
if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new Error('INVALID_COLOR');
const order = database
.prepare('SELECT COALESCE(max(sort_order), -1) + 1 AS next FROM section_categories')
@@ -1091,10 +1169,53 @@ export function createTaxonomy(kind: TaxonomyKind, nameValue: string, colorValue
database
.prepare('INSERT INTO section_categories (id, name, color, sort_order) VALUES (?, ?, ?, ?)')
.run(id, name, color, order.next);
} else if (kind === 'topic') {
database
.prepare('INSERT INTO topics (id, name, icon_name) VALUES (?, ?, ?)')
.run(id, name, cleanIconName(iconNameValue ?? colorOrIconValue));
} else {
const table = kind === 'format' ? 'formats' : 'topics';
database.prepare(`INSERT INTO ${table} (id, name) VALUES (?, ?)`).run(id, name);
database.prepare('INSERT INTO formats (id, name) VALUES (?, ?)').run(id, name);
}
return id;
}
export function updateTaxonomy(
kind: TaxonomyKind,
id: string,
nameValue: string,
iconNameValue?: string
) {
const name = cleanText(nameValue, 80, 'taxonomy');
if (!name) throw new Error('INVALID_TAXONOMY');
const table =
kind === 'category' ? 'section_categories' : kind === 'format' ? 'formats' : 'topics';
const database = getDatabase();
const duplicate = database
.prepare(`SELECT 1 FROM ${table} WHERE lower(name) = lower(?) AND id != ?`)
.get(name, id);
if (duplicate) throw new Error('TAXONOMY_EXISTS');
const result =
kind === 'topic'
? database
.prepare('UPDATE topics SET name = ?, icon_name = ? WHERE id = ?')
.run(name, cleanIconName(iconNameValue), id)
: database.prepare(`UPDATE ${table} SET name = ? WHERE id = ?`).run(name, id);
if (result.changes !== 1) throw new Error('NOT_FOUND');
}
export function setPieceDeleted(id: string, expectedRevision: number, deleted: boolean) {
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
throw new Error('INVALID_REVISION');
}
const now = new Date().toISOString();
const result = getDatabase()
.prepare(
`UPDATE pieces SET deleted_at_utc = ?, updated_at_utc = ?, revision = revision + 1
WHERE id = ? AND revision = ? AND deleted_at_utc IS ${deleted ? 'NULL' : 'NOT NULL'}`
)
.run(deleted ? now : null, now, id, expectedRevision);
if (result.changes !== 1) throw new Error('CONFLICT');
return { revision: expectedRevision + 1, updatedAtUtc: now };
}
export function deleteTaxonomy(kind: TaxonomyKind, id: string) {
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close();
expect(counts).toEqual({
migrations: 9,
migrations: 11,
pieces: 6,
shows: 2,
usages: 5,
+40 -7
View File
@@ -1,5 +1,8 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import type Database from 'better-sqlite3';
import { getServerPaths } from './config.js';
import { getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js';
import { buildEmbedUrl, fetchPresentationImage, type PresentationContent } from './presentation.js';
@@ -726,7 +729,7 @@ function getPresentationViewForShow(showId: string): PresentationView {
if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null };
const usage = database
.prepare(
`SELECT u.title_snapshot, u.output_title_snapshot, u.presentation_snapshot, u.original_piece_id,
`SELECT u.title_snapshot, u.output_title_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,
@@ -740,7 +743,6 @@ function getPresentationViewForShow(showId: string): PresentationView {
title_snapshot: string;
output_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;
@@ -784,7 +786,14 @@ function getPresentationViewForShow(showId: string): PresentationView {
usage.presentation_source_timecode_snapshot
),
sources: [],
images: []
images: (
database
.prepare(
`SELECT id, alt_text FROM usage_media_snapshots
WHERE usage_id = ? AND is_presentation = 1`
)
.all(state.presentation_usage_id) as { id: string; alt_text: string }[]
).map((media) => ({ id: media.id, altText: media.alt_text }))
}
};
}
@@ -801,9 +810,33 @@ export async function getPresentationPreviewImage(token: string) {
export async function getPresentationMedia(
token: string,
mediaId: string
snapshotId: string
): Promise<{ data: Buffer; mimeType: string; byteSize: number; altText: string } | null> {
void token;
void mediaId;
return null;
const database = getDatabase();
const showId = presentationShowId(database, token);
if (!showId || !snapshotId) return null;
const media = database
.prepare(
`SELECT pm.storage_name, pm.mime_type, pm.byte_size, ums.alt_text
FROM show_live_states state
JOIN usage_media_snapshots ums ON ums.usage_id = state.presentation_usage_id
JOIN piece_usages u ON u.id = ums.usage_id
JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media pm ON pm.id = ums.original_media_id
WHERE state.show_id = ? AND s.show_id = state.show_id
AND s.is_reserve_bank = 0 AND ums.id = ? AND ums.is_presentation = 1`
)
.get(showId, snapshotId) as
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
if (!media) return null;
try {
return {
data: await readFile(path.join(getServerPaths().uploads, media.storage_name)),
mimeType: media.mime_type,
byteSize: media.byte_size,
altText: media.alt_text
};
} catch {
return null;
}
}
+5 -2
View File
@@ -86,7 +86,7 @@ export function getPieces(): PreviewPiece[] {
(SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count
FROM pieces p
LEFT JOIN formats f ON f.id = p.format_id
WHERE p.status IN ('preparing', 'ready')
WHERE p.status IN ('preparing', 'ready') AND p.deleted_at_utc IS NULL
ORDER BY CASE p.status WHEN 'ready' THEN 0 ELSE 1 END, p.title`
)
.all() as PieceRow[];
@@ -175,9 +175,12 @@ export function getTaxonomy() {
id: string;
name: string;
}[],
topics: database.prepare('SELECT id, name FROM topics ORDER BY name').all() as {
topics: database
.prepare('SELECT id, name, icon_name AS iconName FROM topics ORDER BY name')
.all() as {
id: string;
name: string;
iconName: string;
}[]
};
}
+112 -3
View File
@@ -59,6 +59,7 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
presentationDescription: edited.presentationDescription,
presentationImageUrl: edited.presentationImageUrl,
presentationSiteName: edited.presentationSiteName,
presentationMediaId: edited.presentationMediaId,
ownNotes: edited.ownNotes,
durationSeconds: edited.durationSeconds
};
@@ -492,7 +493,7 @@ describe('show builder data layer', () => {
expect(usage).toMatchObject({
sources: [{ label: 'Herstellerseite', url: 'https://example.com/console' }],
tools: [{ name: 'Tierlist', workUrl: 'https://example.com/board', note: 'Erster Stand' }],
topics: ['Gaming']
topics: [{ name: 'Gaming', iconName: '' }]
});
database
@@ -518,7 +519,7 @@ describe('show builder data layer', () => {
name: 'Tierlist',
workUrl: 'https://example.com/board'
});
expect(usage.topics).toEqual(['Gaming']);
expect(usage.topics).toEqual([{ name: 'Gaming', iconName: '' }]);
refreshPieceUsageFromOriginal(show.id, usage.id, added.revision);
usage = getShow(show.id)!.sections[0].usages[0];
@@ -527,7 +528,115 @@ describe('show builder data layer', () => {
name: 'Neuer Katalogname',
workUrl: 'https://example.com/new'
});
expect(usage.topics).toEqual(['Netzkultur']);
expect(usage.topics).toEqual([{ name: 'Netzkultur', iconName: '' }]);
});
it('snapshots active media, their presentation choice and topic icons independently', () => {
const database = getDatabase();
database.prepare("UPDATE topics SET icon_name = 'gamepad' WHERE id = 'topic-gaming'").run();
const insertMedia = database.prepare(
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
byte_size, alt_text, position, created_at_utc, removed_at_utc)
VALUES (?, 'piece-console', ?, ?, 'image/png', 10, ?, ?, ?, ?)`
);
insertMedia.run(
'media-first',
'first.png',
'first-original.png',
'Erstes Bild',
0,
'2026-10-23T00:00:00.000Z',
null
);
insertMedia.run(
'media-second',
'second.png',
'second-original.png',
'Zweites Bild',
1,
'2026-10-23T00:00:00.000Z',
null
);
insertMedia.run(
'media-removed',
'removed.png',
'removed-original.png',
'Entferntes Bild',
2,
'2026-10-23T00:00:00.000Z',
'2026-10-23T01:00:00.000Z'
);
database
.prepare(
"INSERT INTO piece_presentation_media (piece_id, media_id) VALUES ('piece-console', 'media-first')"
)
.run();
const show = createShow({ title: 'Medien', startsAtUtc: null, templateId: null });
const section = addShowSection(show.id, show.revision, {
name: 'Bilder',
categoryId: null,
explicitDurationSeconds: null
});
const added = addPieceUsage(show.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
let usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.topics).toEqual([{ name: 'Gaming', iconName: 'gamepad' }]);
expect(usage.media).toMatchObject([
{ originalMediaId: 'media-first', altText: 'Erstes Bild', position: 0, isPresentation: true },
{
originalMediaId: 'media-second',
altText: 'Zweites Bild',
position: 1,
isPresentation: false
}
]);
expect(usage.presentationMediaId).toBe(usage.media[0].id);
const selected = updatePieceUsage(show.id, usage.id, added.revision, {
...editedUsage(usage),
presentationMediaId: usage.media[1].id
});
usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.media.map((item) => item.isPresentation)).toEqual([false, true]);
expect(() =>
updatePieceUsage(show.id, usage.id, selected.revision, {
...editedUsage(usage),
presentationMediaId: 'media-first'
})
).toThrow('INVALID_PRESENTATION_MEDIA');
expect(getShow(show.id)?.revision).toBe(selected.revision);
const duplicate = duplicatePieceUsage(show.id, usage.id, selected.revision);
const duplicateUsage = getShow(show.id)!.sections[0].usages[1];
expect(duplicateUsage.media.map((item) => item.originalMediaId)).toEqual([
'media-first',
'media-second'
]);
expect(duplicateUsage.presentationMediaId).toBe(duplicateUsage.media[1].id);
expect(duplicateUsage.media.map((item) => item.id)).not.toEqual(
usage.media.map((item) => item.id)
);
database
.prepare("UPDATE piece_media SET removed_at_utc = ? WHERE id = 'media-first'")
.run('2026-10-23T02:00:00.000Z');
database
.prepare("UPDATE piece_media SET alt_text = 'Neuer Alternativtext' WHERE id = 'media-second'")
.run();
database.prepare("DELETE FROM piece_presentation_media WHERE piece_id = 'piece-console'").run();
database
.prepare(
"INSERT INTO piece_presentation_media (piece_id, media_id) VALUES ('piece-console', 'media-second')"
)
.run();
refreshPieceUsageFromOriginal(show.id, usage.id, duplicate.revision);
usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.media).toMatchObject([
{ originalMediaId: 'media-second', altText: 'Neuer Alternativtext', isPresentation: true }
]);
expect(duplicateUsage.media).toHaveLength(2);
});
it('persists normalized section and usage reordering, moving and removal', () => {
+164 -11
View File
@@ -69,7 +69,19 @@ export type PieceUsage = {
workUrl: string;
note: string;
}[];
topics: string[];
topics: UsageTopic[];
media: PieceUsageMedia[];
presentationMediaId: string | null;
};
export type UsageTopic = { name: string; iconName: string };
export type PieceUsageMedia = {
id: string;
originalMediaId: string;
altText: string;
position: number;
isPresentation: boolean;
};
export type SentPieceUsage = {
@@ -158,6 +170,7 @@ export type PieceUsageUpdate = {
ownNotes: string;
durationSeconds: number;
tools?: { id: string; workUrl: string; note: string }[];
presentationMediaId?: string | null;
};
export type MovePieceUsageInput = {
@@ -363,6 +376,43 @@ function usageSection(
return row;
}
function requireUsagesRemovable(
database: Database.Database,
showId: string,
usageIds: string[]
): void {
if (!usageIds.length) return;
const placeholders = usageIds.map(() => '?').join(', ');
const state = database
.prepare(
`SELECT current_usage_id, presentation_usage_id FROM show_live_states WHERE show_id = ?`
)
.get(showId) as
{ current_usage_id: string | null; presentation_usage_id: string | null } | undefined;
if (state?.current_usage_id && usageIds.includes(state.current_usage_id)) {
throw new Error('USAGE_IS_CURRENT');
}
if (state?.presentation_usage_id && usageIds.includes(state.presentation_usage_id)) {
throw new Error('USAGE_IS_PRESENTED');
}
if (
database
.prepare(`SELECT 1 FROM live_usage_runs WHERE usage_id IN (${placeholders}) LIMIT 1`)
.get(...usageIds)
) {
throw new Error('USAGE_HAS_LIVE_RUNS');
}
if (
database
.prepare(
`SELECT 1 FROM piece_usages WHERE id IN (${placeholders}) AND status != 'open' LIMIT 1`
)
.get(...usageIds)
) {
throw new Error('USAGE_NOT_OPEN');
}
}
function normalizePositions(
database: Database.Database,
table: 'show_sections' | 'piece_usages',
@@ -450,9 +500,39 @@ function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage {
})),
topics: (
database
.prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position')
.all(row.id) as { name: string }[]
).map((topic) => topic.name)
.prepare(
'SELECT name, icon_name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position'
)
.all(row.id) as { name: string; icon_name: string }[]
).map((topic) => ({ name: topic.name, iconName: topic.icon_name })),
media: (
database
.prepare(
`SELECT id, original_media_id, alt_text, position, is_presentation
FROM usage_media_snapshots WHERE usage_id = ? ORDER BY position`
)
.all(row.id) as {
id: string;
original_media_id: string;
alt_text: string;
position: number;
is_presentation: number;
}[]
).map((media) => ({
id: media.id,
originalMediaId: media.original_media_id,
altText: media.alt_text,
position: media.position,
isPresentation: media.is_presentation === 1
})),
presentationMediaId:
(
database
.prepare(
'SELECT id FROM usage_media_snapshots WHERE usage_id = ? AND is_presentation = 1'
)
.get(row.id) as { id: string } | undefined
)?.id ?? null
};
}
@@ -460,6 +540,7 @@ function snapshotPieceRelations(database: Database.Database, usageId: string, pi
database.prepare('DELETE FROM usage_source_snapshots WHERE usage_id = ?').run(usageId);
database.prepare('DELETE FROM usage_tool_snapshots WHERE usage_id = ?').run(usageId);
database.prepare('DELETE FROM usage_topic_snapshots WHERE usage_id = ?').run(usageId);
database.prepare('DELETE FROM usage_media_snapshots WHERE usage_id = ?').run(usageId);
const sources = database
.prepare(
`SELECT label, url, timecode_seconds, position FROM piece_sources
@@ -518,14 +599,44 @@ function snapshotPieceRelations(database: Database.Database, usageId: string, pi
}
const topics = database
.prepare(
`SELECT t.name FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id
`SELECT t.name, t.icon_name FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id
WHERE pt.piece_id = ? ORDER BY t.name, t.id`
)
.all(pieceId) as { name: string }[];
.all(pieceId) as { name: string; icon_name: string }[];
const addTopic = database.prepare(
'INSERT INTO usage_topic_snapshots (usage_id, name, position) VALUES (?, ?, ?)'
'INSERT INTO usage_topic_snapshots (usage_id, name, icon_name, position) VALUES (?, ?, ?, ?)'
);
topics.forEach((topic, position) => addTopic.run(usageId, topic.name, position));
topics.forEach((topic, position) => addTopic.run(usageId, topic.name, topic.icon_name, position));
const media = database
.prepare(
`SELECT pm.id, pm.alt_text, pm.position,
CASE WHEN ppm.media_id IS NULL THEN 0 ELSE 1 END AS is_presentation
FROM piece_media pm
LEFT JOIN piece_presentation_media ppm
ON ppm.piece_id = pm.piece_id AND ppm.media_id = pm.id
WHERE pm.piece_id = ? AND pm.removed_at_utc IS NULL ORDER BY pm.position`
)
.all(pieceId) as {
id: string;
alt_text: string;
position: number;
is_presentation: number;
}[];
const addMedia = database.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES (?, ?, ?, ?, ?, ?)`
);
for (const item of media) {
addMedia.run(
randomUUID(),
usageId,
item.id,
item.alt_text,
item.position,
item.is_presentation
);
}
}
function copyUsageRelations(database: Database.Database, sourceUsageId: string, usageId: string) {
@@ -584,8 +695,16 @@ function copyUsageRelations(database: Database.Database, sourceUsageId: string,
);
database
.prepare(
`INSERT INTO usage_topic_snapshots (usage_id, name, position)
SELECT ?, name, position FROM usage_topic_snapshots WHERE usage_id = ?`
`INSERT INTO usage_topic_snapshots (usage_id, name, icon_name, position)
SELECT ?, name, icon_name, position FROM usage_topic_snapshots WHERE usage_id = ?`
)
.run(usageId, sourceUsageId);
database
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
SELECT lower(hex(randomblob(16))), ?, original_media_id, alt_text, position, is_presentation
FROM usage_media_snapshots WHERE usage_id = ?`
)
.run(usageId, sourceUsageId);
}
@@ -949,6 +1068,12 @@ export function removeShowSection(
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
sectionBelongsToShow(database, showId, sectionId);
const usageIds = (
database.prepare('SELECT id FROM piece_usages WHERE show_section_id = ?').all(sectionId) as {
id: string;
}[]
).map((usage) => usage.id);
requireUsagesRemovable(database, showId, usageIds);
database.prepare('DELETE FROM show_sections WHERE id = ?').run(sectionId);
const ids = (
database
@@ -1034,6 +1159,13 @@ export function addPieceUsage(
}
| undefined;
if (!piece) throw new Error('NOT_FOUND');
if (
!database
.prepare('SELECT 1 FROM pieces WHERE id = ? AND deleted_at_utc IS NULL')
.get(input.pieceId)
) {
throw new Error('NOT_FOUND');
}
if (piece.status !== 'ready') throw new Error('PIECE_NOT_READY');
const ids = (
database
@@ -1237,7 +1369,7 @@ export function refreshPieceUsageFromOriginal(
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 = ?`
WHERE u.id = ? AND p.deleted_at_utc IS NULL`
)
.get(usageId) as
| {
@@ -1400,6 +1532,26 @@ export function updatePieceUsage(
updateTool.run(workUrl, cleanText(tool.note, 2_000, 'tools'), tool.id, usageId);
}
}
if (input.presentationMediaId !== undefined) {
if (
input.presentationMediaId !== null &&
!database
.prepare('SELECT 1 FROM usage_media_snapshots WHERE id = ? AND usage_id = ?')
.get(input.presentationMediaId, usageId)
) {
throw new Error('INVALID_PRESENTATION_MEDIA');
}
database
.prepare('UPDATE usage_media_snapshots SET is_presentation = 0 WHERE usage_id = ?')
.run(usageId);
if (input.presentationMediaId !== null) {
database
.prepare(
'UPDATE usage_media_snapshots SET is_presentation = 1 WHERE id = ? AND usage_id = ?'
)
.run(input.presentationMediaId, usageId);
}
}
return result;
})();
}
@@ -1413,6 +1565,7 @@ export function removePieceUsage(
return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision);
const source = usageSection(database, showId, usageId);
requireUsagesRemovable(database, showId, [usageId]);
database.prepare('DELETE FROM piece_usages WHERE id = ?').run(usageId);
const ids = (
database
+15 -1
View File
@@ -29,6 +29,7 @@ describe('tool catalog', () => {
name: 'Tierlist',
startUrl: 'https://example.com/tierlist',
description: 'Gemeinsames Ranking',
iconName: 'ranking-star',
isActive: true,
formatIds: ['format-quiz']
});
@@ -38,6 +39,7 @@ describe('tool catalog', () => {
name: 'Tierlist',
startUrl: 'https://example.com/tierlist',
description: 'Gemeinsames Ranking',
iconName: 'ranking-star',
isActive: true,
formatIds: ['format-quiz']
}
@@ -46,19 +48,31 @@ describe('tool catalog', () => {
name: 'Tierlist',
startUrl: '',
description: '',
iconName: 'list-ol',
isActive: false,
formatIds: []
});
expect(listTools()).toEqual([]);
expect(listTools(true)[0]).toMatchObject({ id, isActive: false });
expect(listTools(true)[0]).toMatchObject({ id, iconName: 'list-ol', isActive: false });
expect(() =>
createTool({
name: 'Unsicher',
startUrl: 'javascript:alert(1)',
description: '',
iconName: 'wrench',
isActive: true,
formatIds: []
})
).toThrow('INVALID_TOOL_URL');
expect(() =>
createTool({
name: 'Unbekanntes Icon',
startUrl: '',
description: '',
iconName: 'kein-font-awesome-icon',
isActive: true,
formatIds: []
})
).toThrow('INVALID_ICON');
});
});
+24 -5
View File
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto';
import { classicRegularIconNames } from '#lib/icons/classic-regular.js';
import { getDatabase } from './database.js';
export type Tool = {
@@ -6,6 +7,7 @@ export type Tool = {
name: string;
startUrl: string;
description: string;
iconName: string;
isActive: boolean;
formatIds: string[];
};
@@ -44,6 +46,11 @@ function validated(input: ToolInput): ToolInput {
name: clean(input.name, 120, true),
startUrl: validUrl(clean(input.startUrl, 2_000)),
description: clean(input.description, 2_000),
iconName: (() => {
const iconName = clean(input.iconName, 120, true);
if (!classicRegularIconNames.has(iconName)) throw new Error('INVALID_ICON');
return iconName;
})(),
isActive: Boolean(input.isActive),
formatIds
};
@@ -53,7 +60,7 @@ export function listTools(includeInactive = false): Tool[] {
const database = getDatabase();
const rows = database
.prepare(
`SELECT id, name, start_url, description, is_active FROM tools
`SELECT id, name, start_url, description, icon_name, is_active FROM tools
${includeInactive ? '' : 'WHERE is_active = 1'} ORDER BY is_active DESC, name`
)
.all() as {
@@ -61,6 +68,7 @@ export function listTools(includeInactive = false): Tool[] {
name: string;
start_url: string;
description: string;
icon_name: string;
is_active: number;
}[];
const formats = database.prepare(
@@ -71,6 +79,7 @@ export function listTools(includeInactive = false): Tool[] {
name: row.name,
startUrl: row.start_url,
description: row.description,
iconName: row.icon_name,
isActive: row.is_active === 1,
formatIds: (formats.all(row.id) as { format_id: string }[]).map((item) => item.format_id)
}));
@@ -92,10 +101,19 @@ export function createTool(rawInput: ToolInput) {
database.transaction(() => {
database
.prepare(
`INSERT INTO tools (id, name, start_url, description, is_active, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO tools (id, name, start_url, description, icon_name, is_active, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(id, input.name, input.startUrl, input.description, input.isActive ? 1 : 0, now, now);
.run(
id,
input.name,
input.startUrl,
input.description,
input.iconName,
input.isActive ? 1 : 0,
now,
now
);
replaceFormats(id, input.formatIds);
})();
} catch (cause) {
@@ -114,13 +132,14 @@ export function updateTool(id: string, rawInput: ToolInput) {
database.transaction(() => {
const result = database
.prepare(
`UPDATE tools SET name = ?, start_url = ?, description = ?, is_active = ?, updated_at_utc = ?
`UPDATE tools SET name = ?, start_url = ?, description = ?, icon_name = ?, is_active = ?, updated_at_utc = ?
WHERE id = ?`
)
.run(
input.name,
input.startUrl,
input.description,
input.iconName,
input.isActive ? 1 : 0,
new Date().toISOString(),
id