import { randomUUID } from 'node:crypto'; 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 { detectVideoProvider, fetchPresentationMetadata, type PresentationMode } from './presentation.js'; export const PIECE_STATUSES = ['inbox', 'preparing', 'ready', 'archived'] as const; export type PieceStatus = (typeof PIECE_STATUSES)[number]; export type EditablePieceStatus = Exclude; export type PreparationEffort = 'low' | 'medium' | 'high' | null; export type TaxonomyKind = 'category' | 'format' | 'topic'; export type PieceSourceInput = { label: string; url: string; timecodeSeconds: number | null; }; export type PieceToolInput = { toolId: string; workUrl: string; note: string; }; export type PieceTool = PieceToolInput & { id: string; name: string; startUrl: string; iconName: string; isActive: boolean; }; export type PieceInput = { title: string; outputTitle: string; summary: string; formatId: string | null; status: EditablePieceStatus; preparationEffort: PreparationEffort; durationSeconds: number; intro: string; researchText: string; moderationNotes: string; interactionText?: string; transitionText: string; presentationText: string; presentationMode: PresentationMode; presentationSourcePosition: number | null; presentationTitle: string; presentationDescription: string; presentationImageUrl: string; presentationSiteName: string; presentationProvider: string; presentationProviderId: string; presentationTitleManual: boolean; presentationDescriptionManual: boolean; presentationImageManual: boolean; presentationMetadataFetchedAtUtc: string | null; presentationMediaId?: string | null; topicIds: string[]; categoryIds: string[]; sources: PieceSourceInput[]; tools?: PieceToolInput[]; }; export type PieceListItem = { id: string; title: string; summary: string; status: PieceStatus; revision: number; durationSeconds: number; formatId: string | null; format: string; categories: { id: string; name: string; color: string }[]; topics: { id: string; name: string; iconName: string }[]; sourceCount: number; mediaId: string | null; isFavorite: boolean; preparationEffort: PreparationEffort; }; export type PieceEditor = Omit< PieceInput, 'status' | 'tools' | 'presentationMediaId' | 'interactionText' > & { id: string; status: PieceStatus; revision: number; createdAtUtc: string; updatedAtUtc: string; media: PieceMedia[]; presentationMediaId: string | null; isFavorite: boolean; interactionText: string; tools: PieceTool[]; }; type ValidatedPieceInput = PieceInput; export type PieceMedia = { id: string; originalName: string; mimeType: string; byteSize: number; altText: string; position: number; }; export type Taxonomy = { categories: { id: string; name: string; color: string }[]; formats: { id: string; name: string }[]; topics: { id: string; name: string; iconName: string }[]; }; export type PieceTemplate = { id: string; name: string; description: string; revision: number; iconName: string; }; const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; const ALLOWED_IMAGES = new Map([ ['image/jpeg', 'jpg'], ['image/png', 'png'], ['image/webp', 'webp'], ['image/gif', 'gif'] ]); function cleanText(value: unknown, max: number, field: string): string { const text = String(value ?? '').trim(); if (text.length > max) throw new Error(`INVALID_${field.toUpperCase()}`); return text; } 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); return url.protocol === 'http:' || url.protocol === 'https:'; } catch { return false; } } function cleanPieceTools(values: PieceToolInput[] = []): PieceToolInput[] { if (values.length > 20) throw new Error('INVALID_TOOLS'); const ids = uniqueIds(values.map((value) => value.toolId)); if (ids.length !== values.length) throw new Error('INVALID_TOOLS'); if (ids.length) { const placeholders = ids.map(() => '?').join(', '); const row = getDatabase() .prepare(`SELECT count(*) AS count FROM tools WHERE id IN (${placeholders})`) .get(...ids) as { count: number }; if (row.count !== ids.length) throw new Error('INVALID_TOOLS'); } return values.map((value) => { const workUrl = cleanText(value.workUrl, 2_000, 'tools'); if (workUrl) { if (!validUrl(workUrl)) throw new Error('INVALID_TOOLS'); const parsed = new URL(workUrl); if (parsed.username || parsed.password) throw new Error('INVALID_TOOLS'); } return { toolId: value.toolId, workUrl, note: cleanText(value.note, 2_000, 'tools') }; }); } function requireTaxonomyIds(table: 'formats' | 'topics' | 'section_categories', ids: string[]) { if (!ids.length) return; const placeholders = ids.map(() => '?').join(', '); const count = getDatabase() .prepare(`SELECT count(*) AS count FROM ${table} WHERE id IN (${placeholders})`) .get(...ids) as { count: number }; if (count.count !== ids.length) throw new Error('INVALID_TAXONOMY'); } function validatePieceInput(input: PieceInput): ValidatedPieceInput { const title = cleanText(input.title, 200, 'title'); if (!title) throw new Error('INVALID_TITLE'); if (!['inbox', 'preparing', 'ready'].includes(input.status)) throw new Error('INVALID_STATUS'); if (![null, 'low', 'medium', 'high'].includes(input.preparationEffort)) { throw new Error('INVALID_PREPARATION_EFFORT'); } if ( !Number.isInteger(input.durationSeconds) || input.durationSeconds < 0 || input.durationSeconds > 86_400 ) { throw new Error('INVALID_DURATION'); } const topicIds = uniqueIds(input.topicIds); const categoryIds = uniqueIds(input.categoryIds); if (topicIds.length > 20 || categoryIds.length > 20) throw new Error('INVALID_TAXONOMY'); if (input.formatId) requireTaxonomyIds('formats', [input.formatId]); requireTaxonomyIds('topics', topicIds); requireTaxonomyIds('section_categories', categoryIds); if (input.sources.length > 20) throw new Error('INVALID_SOURCES'); const sources = input.sources.map((source) => { const label = cleanText(source.label, 200, 'source'); const url = cleanText(source.url, 2_000, 'source'); if (!label || !validUrl(url)) throw new Error('INVALID_SOURCES'); if ( source.timecodeSeconds !== null && (!Number.isInteger(source.timecodeSeconds) || source.timecodeSeconds < 0 || source.timecodeSeconds > 86_400) ) { throw new Error('INVALID_SOURCES'); } return { label, url, timecodeSeconds: source.timecodeSeconds }; }); if (!['text', 'image', '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 (['link', 'video'].includes(input.presentationMode) && 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); const presentationMediaId = input.presentationMediaId === undefined ? undefined : cleanText(input.presentationMediaId, 100, 'presentation_media') || null; if (input.presentationMode === 'image' && !presentationMediaId) { throw new Error('INVALID_PRESENTATION'); } return { title, outputTitle: cleanText(input.outputTitle, 300, 'output_title'), summary: cleanText(input.summary, 2_000, 'summary'), formatId: input.formatId || null, status: input.status, preparationEffort: input.preparationEffort, durationSeconds: input.durationSeconds, intro: cleanText(input.intro, 50_000, 'intro'), researchText: cleanText(input.researchText, 50_000, 'research'), moderationNotes: cleanText(input.moderationNotes, 50_000, 'moderation'), interactionText: input.interactionText === undefined ? undefined : cleanText(input.interactionText, 50_000, 'interaction'), transitionText: cleanText(input.transitionText, 50_000, 'transition'), presentationText: cleanText(input.presentationText, 50_000, 'presentation'), presentationMode: input.presentationMode, 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, presentationMediaId, topicIds, categoryIds, sources, tools: cleanPieceTools(input.tools) }; } export function getTaxonomy(): Taxonomy { const database = getDatabase(); return { categories: database .prepare('SELECT id, name, color FROM section_categories ORDER BY sort_order, name') .all() as Taxonomy['categories'], formats: database .prepare('SELECT id, name FROM formats ORDER BY name') .all() as Taxonomy['formats'], topics: database .prepare('SELECT id, name, icon_name AS iconName FROM topics ORDER BY name') .all() as Taxonomy['topics'] }; } type PieceListRow = { id: string; title: string; output_title: string; summary: string; status: PieceStatus; revision: number; duration_seconds: number; format_id: string | null; format: string; source_count: number; media_id: string | null; is_favorite: number; preparation_effort: PreparationEffort; }; export function listPieces(filters: { statuses: PieceStatus[]; query?: string; formatId?: string; topicId?: string; categoryId?: string; deleted?: boolean; }): PieceListItem[] { if (!filters.statuses.length) return []; 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) { const escapedQuery = query .replaceAll('\\', '\\\\') .replaceAll('%', '\\%') .replaceAll('_', '\\_'); conditions.push(`( lower(p.title) LIKE ? ESCAPE '\\' OR lower(p.summary) LIKE ? ESCAPE '\\' OR EXISTS (SELECT 1 FROM piece_sources ps WHERE ps.piece_id = p.id AND (lower(ps.label) LIKE ? ESCAPE '\\' OR lower(ps.url) LIKE ? ESCAPE '\\')) OR EXISTS (SELECT 1 FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id WHERE pt.piece_id = p.id AND lower(t.name) LIKE ? ESCAPE '\\') )`); parameters.push(...Array(5).fill(`%${escapedQuery}%`)); } if (filters.formatId) { conditions.push('p.format_id = ?'); parameters.push(filters.formatId); } if (filters.topicId) { conditions.push( 'EXISTS (SELECT 1 FROM piece_topics pt WHERE pt.piece_id = p.id AND pt.topic_id = ?)' ); parameters.push(filters.topicId); } if (filters.categoryId) { conditions.push( 'EXISTS (SELECT 1 FROM piece_category_suggestions pc WHERE pc.piece_id = p.id AND pc.category_id = ?)' ); parameters.push(filters.categoryId); } const database = getDatabase(); const rows = database .prepare( `SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds, p.is_favorite, p.preparation_effort, p.format_id, COALESCE(f.name, 'Ohne Format') AS format, (SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count, (SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id AND pm.removed_at_utc IS NULL ORDER BY pm.position LIMIT 1) AS media_id FROM pieces p LEFT JOIN formats f ON f.id = p.format_id WHERE ${conditions.join(' AND ')} ORDER BY p.is_favorite DESC, CASE p.status WHEN 'ready' THEN 0 WHEN 'preparing' THEN 1 WHEN 'inbox' THEN 2 ELSE 3 END, p.updated_at_utc DESC, p.title` ) .all(...parameters) as PieceListRow[]; const categoryStatement = database.prepare( `SELECT c.id, c.name, c.color FROM piece_category_suggestions pc JOIN section_categories c ON c.id = pc.category_id WHERE pc.piece_id = ? ORDER BY c.sort_order, c.name` ); const topicStatement = database.prepare( `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) => ({ id: row.id, title: row.title, summary: row.summary, status: row.status, revision: row.revision, durationSeconds: row.duration_seconds, formatId: row.format_id, format: row.format, categories: categoryStatement.all(row.id) as PieceListItem['categories'], topics: topicStatement.all(row.id) as PieceListItem['topics'], sourceCount: row.source_count, mediaId: row.media_id, isFavorite: row.is_favorite === 1, preparationEffort: row.preparation_effort })); } type PieceRow = { id: string; title: string; output_title: string; summary: string; format_id: string | null; status: PieceStatus; duration_seconds: number; intro: string; research_text: string; moderation_notes: string; interaction_text: 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; is_favorite: number; presentation_media_id: string | null; preparation_effort: PreparationEffort; }; export function getPiece(id: string): PieceEditor | null { const database = getDatabase(); const row = database .prepare( `SELECT id, title, output_title, summary, format_id, status, preparation_effort, duration_seconds, intro, research_text, moderation_notes, interaction_text, transition_text, presentation_text, CASE WHEN presentation_image_only = 1 THEN 'image' ELSE presentation_mode END AS presentation_mode, presentation_source_position, presentation_title, presentation_description, presentation_image_url, presentation_site_name, presentation_provider, presentation_provider_id, presentation_title_manual, presentation_description_manual, 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 = ? AND deleted_at_utc IS NULL` ) .get(id) as PieceRow | undefined; if (!row) return null; return { id: row.id, title: row.title, outputTitle: row.output_title, summary: row.summary, formatId: row.format_id, status: row.status, preparationEffort: row.preparation_effort, durationSeconds: row.duration_seconds, intro: row.intro, researchText: row.research_text, moderationNotes: row.moderation_notes, interactionText: row.interaction_text, transitionText: row.transition_text, presentationText: row.presentation_text, presentationMode: row.presentation_mode, 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, presentationMediaId: row.presentation_media_id, revision: row.revision, createdAtUtc: row.created_at_utc, updatedAtUtc: row.updated_at_utc, isFavorite: row.is_favorite === 1, topicIds: ( database.prepare('SELECT topic_id FROM piece_topics WHERE piece_id = ?').all(id) as { topic_id: string; }[] ).map((entry) => entry.topic_id), categoryIds: ( database .prepare('SELECT category_id FROM piece_category_suggestions WHERE piece_id = ?') .all(id) as { category_id: string }[] ).map((entry) => entry.category_id), sources: ( database .prepare( 'SELECT label, url, timecode_seconds FROM piece_sources WHERE piece_id = ? ORDER BY position' ) .all(id) as { label: string; url: string; timecode_seconds: number | null }[] ).map((source) => ({ label: source.label, url: source.url, timecodeSeconds: source.timecode_seconds })), media: ( database .prepare( `SELECT id, original_name, mime_type, byte_size, alt_text, position FROM piece_media WHERE piece_id = ? AND removed_at_utc IS NULL ORDER BY position` ) .all(id) as { id: string; original_name: string; mime_type: string; byte_size: number; alt_text: string; position: number; }[] ).map((media) => ({ id: media.id, originalName: media.original_name, mimeType: media.mime_type, byteSize: media.byte_size, altText: media.alt_text, position: media.position })), tools: ( database .prepare( `SELECT pt.id, pt.tool_id, pt.work_url, pt.note, t.name, t.start_url, t.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` ) .all(id) as { id: string; tool_id: string; work_url: string; note: string; name: string; start_url: string; icon_name: string; is_active: number; }[] ).map((tool) => ({ id: tool.id, toolId: tool.tool_id, workUrl: tool.work_url, note: tool.note, name: tool.name, startUrl: tool.start_url, iconName: tool.icon_name, isActive: tool.is_active === 1 })) }; } function replaceRelations(pieceId: string, input: PieceInput) { const database = getDatabase(); database.prepare('DELETE FROM piece_topics WHERE piece_id = ?').run(pieceId); const addTopic = database.prepare('INSERT INTO piece_topics (piece_id, topic_id) VALUES (?, ?)'); for (const id of input.topicIds) addTopic.run(pieceId, id); database.prepare('DELETE FROM piece_category_suggestions WHERE piece_id = ?').run(pieceId); const addCategory = database.prepare( 'INSERT INTO piece_category_suggestions (piece_id, category_id) VALUES (?, ?)' ); for (const id of input.categoryIds) addCategory.run(pieceId, id); database.prepare('DELETE FROM piece_sources WHERE piece_id = ?').run(pieceId); const addSource = database.prepare( `INSERT INTO piece_sources (id, piece_id, label, url, timecode_seconds, position) VALUES (?, ?, ?, ?, ?, ?)` ); input.sources.forEach((source, position) => addSource.run(randomUUID(), pieceId, source.label, source.url, source.timecodeSeconds, position) ); database.prepare('DELETE FROM piece_tools WHERE piece_id = ?').run(pieceId); const addTool = database.prepare( `INSERT INTO piece_tools (id, piece_id, tool_id, work_url, note, position) VALUES (?, ?, ?, ?, ?, ?)` ); (input.tools ?? []).forEach((tool, position) => addTool.run(randomUUID(), pieceId, tool.toolId, tool.workUrl, tool.note, position) ); } 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 = ? AND deleted_at_utc IS NULL') .run(favorite ? 1 : 0, id); if (result.changes !== 1) throw new Error('NOT_FOUND'); } export function savePiece(id: string, expectedRevision: number, rawInput: PieceInput) { if (!Number.isInteger(expectedRevision) || expectedRevision < 1) throw new Error('INVALID_REVISION'); const input = validatePieceInput(rawInput); const database = getDatabase(); const updatedAtUtc = new Date().toISOString(); const save = database.transaction(() => { const result = database .prepare( `UPDATE pieces SET title = ?, output_title = ?, summary = ?, format_id = ?, status = ?, preparation_effort = ?, duration_seconds = ?, intro = ?, research_text = ?, moderation_notes = ?, interaction_text = COALESCE(?, interaction_text), transition_text = ?, presentation_text = ?, presentation_mode = ?, presentation_image_only = ?, presentation_source_position = ?, presentation_title = ?, presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?, presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?, presentation_description_manual = ?, presentation_image_manual = ?, presentation_metadata_fetched_at_utc = ?, updated_at_utc = ?, revision = revision + 1, archived_from_status = NULL WHERE id = ? AND revision = ? AND status != 'archived' AND deleted_at_utc IS NULL` ) .run( input.title, input.outputTitle, input.summary, input.formatId, input.status, input.preparationEffort, input.durationSeconds, input.intro, input.researchText, input.moderationNotes, input.interactionText ?? null, input.transitionText, input.presentationText, input.presentationMode === 'image' ? 'text' : input.presentationMode, input.presentationMode === 'image' ? 1 : 0, 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 ); if (result.changes !== 1) { 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 }; } function blankPieceInput(): ValidatedPieceInput { return { title: 'Neues Piece', outputTitle: '', summary: '', formatId: null, status: 'preparing', preparationEffort: null, durationSeconds: 0, intro: '', researchText: '', moderationNotes: '', interactionText: '', transitionText: '', presentationText: '', presentationMode: 'text', presentationSourcePosition: null, presentationTitle: '', presentationDescription: '', presentationImageUrl: '', presentationSiteName: '', presentationProvider: '', presentationProviderId: '', presentationTitleManual: false, presentationDescriptionManual: false, presentationImageManual: false, presentationMetadataFetchedAtUtc: null, presentationMediaId: null, topicIds: [], categoryIds: [], sources: [] }; } function templateInput(templateId: string): ValidatedPieceInput { if (templateId === 'blank') return blankPieceInput(); const row = getDatabase() .prepare('SELECT snapshot_json FROM piece_templates WHERE id = ?') .get(templateId) as { snapshot_json: string } | undefined; if (!row) throw new Error('INVALID_TEMPLATE'); try { const snapshot = JSON.parse(row.snapshot_json) as Partial; delete snapshot.presentationMediaId; return validatePieceInput({ ...blankPieceInput(), ...snapshot, status: 'preparing' }); } catch { throw new Error('INVALID_TEMPLATE'); } } export function listPieceTemplates(): PieceTemplate[] { const database = getDatabase(); const rows = database .prepare('SELECT id, name, description, revision FROM piece_templates ORDER BY name') .all() as Omit[]; 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, iconName: 'newspaper' }, ...rows.flatMap((template) => { try { return [{ ...template, iconName: iconForTemplate(template.id) }]; } catch { return []; } }) ]; } export function createPiece(template = 'blank'): string { const input = templateInput(template); const id = randomUUID(); const now = new Date().toISOString(); const database = getDatabase(); database.transaction(() => { database .prepare( `INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro, research_text, moderation_notes, interaction_text, transition_text, presentation_text, created_at_utc, updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` ) .run( id, input.title, input.outputTitle, input.summary, input.formatId, input.status, input.durationSeconds, input.intro, input.researchText, input.moderationNotes, input.interactionText ?? '', input.transitionText, input.presentationText, now, now ); replaceRelations(id, input); })(); return id; } export function savePieceAsTemplate( pieceId: string, nameValue: string, descriptionValue: string, templateId?: string, expectedRevision?: number ) { const piece = getPiece(pieceId); if (!piece) throw new Error('NOT_FOUND'); const name = cleanText(nameValue, 120, 'template_name'); if (!name) throw new Error('INVALID_TEMPLATE_NAME'); const description = cleanText(descriptionValue, 500, 'template_description'); const snapshot: Partial = structuredClone(piece); delete snapshot.id; delete snapshot.revision; delete snapshot.createdAtUtc; delete snapshot.updatedAtUtc; delete snapshot.media; delete snapshot.presentationMediaId; if (snapshot.presentationMode === 'image') snapshot.presentationMode = 'text'; snapshot.status = 'preparing'; const snapshotJson = JSON.stringify(snapshot); const database = getDatabase(); const now = new Date().toISOString(); if (!templateId) { const id = randomUUID(); database .prepare( `INSERT INTO piece_templates (id, name, description, snapshot_json, revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, ?, 1, ?, ?)` ) .run(id, name, description, snapshotJson, now, now); return { id, revision: 1 }; } if (!Number.isInteger(expectedRevision) || Number(expectedRevision) < 1) { throw new Error('INVALID_REVISION'); } const result = database .prepare( `UPDATE piece_templates SET name = ?, description = ?, snapshot_json = ?, revision = revision + 1, updated_at_utc = ? WHERE id = ? AND revision = ?` ) .run(name, description, snapshotJson, now, templateId, expectedRevision); if (result.changes !== 1) { const exists = database.prepare('SELECT 1 FROM piece_templates WHERE id = ?').get(templateId); throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND'); } return { id: templateId, revision: Number(expectedRevision) + 1 }; } export function deletePieceTemplate(templateId: string, expectedRevision: number) { const result = getDatabase() .prepare('DELETE FROM piece_templates WHERE id = ? AND revision = ?') .run(templateId, expectedRevision); if (result.changes !== 1) throw new Error('CONFLICT'); } export function captureInbox(titleValue: string, urlValue: string): string { let title = cleanText(titleValue, 200, 'title'); const url = cleanText(urlValue, 2_000, 'source'); if (url && !validUrl(url)) throw new Error('INVALID_SOURCES'); if (!title && !url) throw new Error('INVALID_TITLE'); if (!title) title = new URL(url).hostname; const id = createPiece(); const piece = getPiece(id)!; savePiece(id, piece.revision, { ...piece, title, status: 'inbox', sources: url ? [{ label: 'Schnellaufnahme', url, timecodeSeconds: null }] : [] }); return id; } export function setPieceArchived(id: string, expectedRevision: number, archived: boolean) { const database = getDatabase(); const now = new Date().toISOString(); const result = 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' 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' AND deleted_at_utc IS NULL` ) .run(now, id, expectedRevision); if (result.changes !== 1) { 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'); } } export async function duplicatePiece(sourceId: string): Promise { const source = getPiece(sourceId); if (!source) throw new Error('NOT_FOUND'); const id = randomUUID(); const now = new Date().toISOString(); const uploadDirectory = getServerPaths().uploads; await mkdir(uploadDirectory, { recursive: true, mode: 0o700 }); const database = getDatabase(); const storedMedia = database .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; original_name: string; mime_type: string; byte_size: number; alt_text: string; position: number; is_presentation: number; }[]; const copied: { id: string; source: (typeof storedMedia)[number]; storageName: string }[] = []; try { for (const media of storedMedia) { const extension = ALLOWED_IMAGES.get(media.mime_type)!; const storageName = `${randomUUID()}.${extension}`; await copyFile( path.join(uploadDirectory, media.storage_name), path.join(uploadDirectory, storageName) ); copied.push({ id: randomUUID(), source: media, storageName }); } database.transaction(() => { database .prepare( `INSERT INTO pieces (id, title, output_title, summary, format_id, status, preparation_effort, duration_seconds, intro, research_text, moderation_notes, interaction_text, transition_text, presentation_text, presentation_mode, presentation_image_only, presentation_source_position, presentation_title, presentation_description, presentation_image_url, presentation_site_name, presentation_provider, presentation_provider_id, presentation_title_manual, presentation_description_manual, presentation_image_manual, presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision) VALUES ( ?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1 )` ) .run( id, `Kopie von ${source.title}`.slice(0, 200), source.outputTitle, source.summary, source.formatId, source.preparationEffort, source.durationSeconds, source.intro, source.researchText, source.moderationNotes, source.interactionText, source.transitionText, source.presentationText, source.presentationMode === 'image' ? 'text' : source.presentationMode, source.presentationMode === 'image' ? 1 : 0, 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 ); replaceRelations(id, { ...source, status: 'preparing' }); const addMedia = database.prepare( `INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type, byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ); for (const media of copied) { addMedia.run( media.id, id, media.storageName, media.source.original_name, media.source.mime_type, media.source.byte_size, media.source.alt_text, media.source.position, 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) { await Promise.all( copied.map((media) => unlink(path.join(uploadDirectory, media.storageName)).catch(() => {})) ); throw error; } } 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' AND p.deleted_at_utc IS NULL` ) .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' AND deleted_at_utc IS NULL` ) .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, file: File, 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()); const detected = await fileTypeFromBuffer(buffer); const extension = detected ? ALLOWED_IMAGES.get(detected.mime) : undefined; if (!detected || !extension) throw new Error('INVALID_IMAGE_TYPE'); const altText = cleanText(altTextValue, 300, 'alt'); const storageName = `${randomUUID()}.${extension}`; const uploadDirectory = getServerPaths().uploads; await mkdir(uploadDirectory, { recursive: true, mode: 0o700 }); 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' AND deleted_at_utc IS NULL` ) .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 = ? 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( mediaId, pieceId, storageName, path.basename(file.name).slice(0, 200) || `bild.${extension}`, detected.mime, buffer.byteLength, altText, 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 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 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; return { data: await readFile(path.join(getServerPaths().uploads, row.storage_name)), mimeType: row.mime_type, byteSize: row.byte_size, altText: row.alt_text }; } 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(); const duplicate = database .prepare( `SELECT 1 FROM ${kind === 'category' ? 'section_categories' : kind === 'format' ? 'formats' : 'topics'} WHERE lower(name) = lower(?)` ) .get(name); if (duplicate) throw new Error('TAXONOMY_EXISTS'); const id = randomUUID(); if (kind === 'category') { 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') .get() as { next: number; }; 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 { 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) { const table = kind === 'category' ? 'section_categories' : kind === 'format' ? 'formats' : 'topics'; if ( ['format-talk', 'format-find', 'format-quiz', 'cat-jc', 'cat-reaction', 'cat-game'].includes(id) ) { throw new Error('TAXONOMY_IN_USE'); } const database = getDatabase(); const usage = kind === 'format' ? ( database.prepare('SELECT count(*) AS count FROM pieces WHERE format_id = ?').get(id) as { count: number; } ).count : kind === 'topic' ? ( database .prepare('SELECT count(*) AS count FROM piece_topics WHERE topic_id = ?') .get(id) as { count: number; } ).count : ( database .prepare( `SELECT (SELECT count(*) FROM piece_category_suggestions WHERE category_id = ?) + (SELECT count(*) FROM show_sections WHERE category_id = ?) AS count` ) .get(id, id) as { count: number } ).count; if (usage > 0) throw new Error('TAXONOMY_IN_USE'); const result = database.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id); if (result.changes !== 1) throw new Error('NOT_FOUND'); }