import { randomUUID } from 'node:crypto'; import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileTypeFromBuffer } from 'file-type'; import { getDatabase } from './database.js'; import { getServerPaths } from './config.js'; import { detectVideoProvider, fetchPresentationMetadata, type PresentationMode } from './presentation.js'; export const PIECE_STATUSES = ['inbox', 'preparing', 'ready', 'archived'] as const; export type PieceStatus = (typeof PIECE_STATUSES)[number]; export type EditablePieceStatus = Exclude; export type TaxonomyKind = 'category' | 'format' | 'topic'; export type PieceSourceInput = { label: string; url: string; timecodeSeconds: number | null; }; export type PieceInput = { title: string; summary: string; formatId: string | null; status: EditablePieceStatus; durationSeconds: number; intro: string; researchText: string; moderationNotes: string; transitionText: string; presentationText: string; presentationMode: PresentationMode; presentationSourcePosition: number | null; presentationTitle: string; presentationDescription: string; presentationImageUrl: string; presentationSiteName: string; presentationProvider: string; presentationProviderId: string; presentationTitleManual: boolean; presentationDescriptionManual: boolean; presentationImageManual: boolean; presentationMetadataFetchedAtUtc: string | null; topicIds: string[]; categoryIds: string[]; sources: PieceSourceInput[]; }; 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 }[]; sourceCount: number; mediaId: string | null; }; export type PieceEditor = Omit & { id: string; status: PieceStatus; revision: number; createdAtUtc: string; updatedAtUtc: string; media: PieceMedia[]; }; 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 }[]; }; export const pieceTemplates = [ { id: 'blank', name: 'Leer starten', description: 'Ein freies Piece ohne Vorgaben.' }, { id: 'talk', name: 'Gespräch', description: 'Leitfragen, Recherche und eine klare Überleitung.' }, { id: 'find', name: 'Reaction / Fundstück', description: 'Quelle, Einordnung und präsentierbare Kernaussage.' }, { id: 'interaction', name: 'Interaktion', description: 'Quiz, Publikumsfrage oder kleines Spiel.' } ] as const; export type PieceTemplateId = (typeof pieceTemplates)[number]['id']; 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 validUrl(value: string): boolean { try { const url = new URL(value); return url.protocol === 'http:' || url.protocol === 'https:'; } catch { return false; } } 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): PieceInput { 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 ( !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', 'link', 'video'].includes(input.presentationMode)) { throw new Error('INVALID_PRESENTATION'); } const sourcePosition = input.presentationSourcePosition; if ( sourcePosition !== null && (!Number.isInteger(sourcePosition) || sourcePosition < 0 || sourcePosition >= sources.length) ) { throw new Error('INVALID_PRESENTATION'); } if (input.presentationMode !== 'text' && sourcePosition === null) { throw new Error('INVALID_PRESENTATION'); } const presentationImageUrl = cleanText(input.presentationImageUrl, 2_000, 'presentation_image'); if (presentationImageUrl && !validUrl(presentationImageUrl)) { throw new Error('INVALID_PRESENTATION'); } const detected = sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url); return { title, summary: cleanText(input.summary, 2_000, 'summary'), formatId: input.formatId || null, status: input.status, durationSeconds: input.durationSeconds, intro: cleanText(input.intro, 50_000, 'intro'), researchText: cleanText(input.researchText, 50_000, 'research'), moderationNotes: cleanText(input.moderationNotes, 50_000, 'moderation'), transitionText: cleanText(input.transitionText, 50_000, 'transition'), presentationText: cleanText(input.presentationText, 50_000, 'presentation'), presentationMode: input.presentationMode, presentationSourcePosition: sourcePosition, presentationTitle: cleanText(input.presentationTitle, 300, 'presentation_title'), presentationDescription: cleanText( input.presentationDescription, 1_000, 'presentation_description' ), presentationImageUrl, presentationSiteName: cleanText(input.presentationSiteName, 200, 'presentation_site'), presentationProvider: detected?.provider ?? '', presentationProviderId: detected?.id ?? '', presentationTitleManual: Boolean(input.presentationTitleManual), presentationDescriptionManual: Boolean(input.presentationDescriptionManual), presentationImageManual: Boolean(input.presentationImageManual), presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null, topicIds, categoryIds, sources }; } 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 FROM topics ORDER BY name') .all() as Taxonomy['topics'] }; } type PieceListRow = { id: string; title: string; summary: string; status: PieceStatus; revision: number; duration_seconds: number; format_id: string | null; format: string; source_count: number; media_id: string | null; }; export function listPieces(filters: { statuses: PieceStatus[]; query?: string; formatId?: string; topicId?: string; categoryId?: string; }): PieceListItem[] { if (!filters.statuses.length) return []; const conditions = [`p.status IN (${filters.statuses.map(() => '?').join(', ')})`]; 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.format_id, COALESCE(f.name, 'Ohne Format') AS format, (SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count, (SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id ORDER BY pm.position LIMIT 1) AS media_id FROM pieces p LEFT JOIN formats f ON f.id = p.format_id WHERE ${conditions.join(' AND ')} ORDER BY CASE p.status WHEN 'ready' THEN 0 WHEN 'preparing' THEN 1 WHEN 'inbox' THEN 2 ELSE 3 END, 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 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 })); } type PieceRow = { id: string; title: string; summary: string; format_id: string | null; status: PieceStatus; duration_seconds: number; intro: string; research_text: string; moderation_notes: string; transition_text: string; presentation_text: string; presentation_mode: PresentationMode; presentation_source_position: number | null; presentation_title: string; presentation_description: string; presentation_image_url: string; presentation_site_name: string; presentation_provider: string; presentation_provider_id: string; presentation_title_manual: number; presentation_description_manual: number; presentation_image_manual: number; presentation_metadata_fetched_at_utc: string | null; revision: number; created_at_utc: string; updated_at_utc: string; }; export function getPiece(id: string): PieceEditor | null { const database = getDatabase(); const row = database .prepare( `SELECT id, title, summary, format_id, status, duration_seconds, intro, research_text, moderation_notes, transition_text, presentation_text, presentation_mode, presentation_source_position, presentation_title, presentation_description, presentation_image_url, presentation_site_name, presentation_provider, presentation_provider_id, presentation_title_manual, presentation_description_manual, presentation_image_manual, presentation_metadata_fetched_at_utc, revision, created_at_utc, updated_at_utc FROM pieces WHERE id = ?` ) .get(id) as PieceRow | undefined; if (!row) return null; return { id: row.id, title: row.title, summary: row.summary, formatId: row.format_id, status: row.status, durationSeconds: row.duration_seconds, intro: row.intro, researchText: row.research_text, moderationNotes: row.moderation_notes, transitionText: row.transition_text, presentationText: row.presentation_text, presentationMode: row.presentation_mode, presentationSourcePosition: row.presentation_source_position, presentationTitle: row.presentation_title, presentationDescription: row.presentation_description, presentationImageUrl: row.presentation_image_url, presentationSiteName: row.presentation_site_name, presentationProvider: row.presentation_provider, presentationProviderId: row.presentation_provider_id, presentationTitleManual: row.presentation_title_manual === 1, presentationDescriptionManual: row.presentation_description_manual === 1, presentationImageManual: row.presentation_image_manual === 1, presentationMetadataFetchedAtUtc: row.presentation_metadata_fetched_at_utc, revision: row.revision, createdAtUtc: row.created_at_utc, updatedAtUtc: row.updated_at_utc, 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 = ? 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 })) }; } 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) ); } 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 = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?, intro = ?, research_text = ?, moderation_notes = ?, transition_text = ?, presentation_text = ?, presentation_mode = ?, presentation_source_position = ?, presentation_title = ?, presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?, presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?, presentation_description_manual = ?, presentation_image_manual = ?, presentation_metadata_fetched_at_utc = ?, updated_at_utc = ?, revision = revision + 1, archived_from_status = NULL WHERE id = ? AND revision = ? AND status != 'archived'` ) .run( input.title, input.summary, input.formatId, input.status, input.durationSeconds, input.intro, input.researchText, input.moderationNotes, input.transitionText, input.presentationText, input.presentationMode, input.presentationSourcePosition, input.presentationTitle, input.presentationDescription, input.presentationImageUrl, input.presentationSiteName, input.presentationProvider, input.presentationProviderId, input.presentationTitleManual ? 1 : 0, input.presentationDescriptionManual ? 1 : 0, input.presentationImageManual ? 1 : 0, input.presentationMetadataFetchedAtUtc, updatedAtUtc, id, expectedRevision ); if (result.changes !== 1) { const exists = database.prepare('SELECT 1 FROM pieces WHERE id = ?').get(id); throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND'); } replaceRelations(id, input); }); save(); return { revision: expectedRevision + 1, updatedAtUtc }; } function templateDefaults(template: PieceTemplateId): Partial & { title: string } { if (template === 'talk') { return { title: 'Unbenanntes Gespräch', formatId: 'format-talk', categoryIds: ['cat-jc'], moderationNotes: '## Leitfragen\n\n- ' }; } if (template === 'find') { return { title: 'Neues Fundstück', formatId: 'format-find', categoryIds: ['cat-reaction'], presentationText: '## Kernaussage\n\n' }; } if (template === 'interaction') { return { title: 'Neue Interaktion', formatId: 'format-quiz', categoryIds: ['cat-game'], moderationNotes: '## Ablauf\n\n1. ' }; } return { title: 'Neues Piece' }; } export function createPiece(template: PieceTemplateId = 'blank'): string { if (!pieceTemplates.some((entry) => entry.id === template)) throw new Error('INVALID_TEMPLATE'); const defaults = templateDefaults(template); const id = randomUUID(); const now = new Date().toISOString(); const input = validatePieceInput({ title: defaults.title, summary: '', formatId: defaults.formatId ?? null, status: 'preparing', durationSeconds: 0, intro: '', researchText: '', moderationNotes: defaults.moderationNotes ?? '', transitionText: '', presentationText: defaults.presentationText ?? '', presentationMode: 'text', presentationSourcePosition: null, presentationTitle: '', presentationDescription: '', presentationImageUrl: '', presentationSiteName: '', presentationProvider: '', presentationProviderId: '', presentationTitleManual: false, presentationDescriptionManual: false, presentationImageManual: false, presentationMetadataFetchedAtUtc: null, topicIds: [], categoryIds: defaults.categoryIds ?? [], sources: [] }); const database = getDatabase(); database.transaction(() => { database .prepare( `INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro, research_text, moderation_notes, transition_text, presentation_text, created_at_utc, updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` ) .run( id, input.title, input.summary, input.formatId, input.status, input.durationSeconds, input.intro, input.researchText, input.moderationNotes, input.transitionText, input.presentationText, now, now ); replaceRelations(id, input); })(); return id; } 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('blank'); 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'` ) .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'` ) .run(now, id, expectedRevision); if (result.changes !== 1) { const exists = database.prepare('SELECT 1 FROM pieces WHERE id = ?').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 * FROM piece_media WHERE piece_id = ? ORDER BY position') .all(sourceId) as { id: string; storage_name: string; original_name: string; mime_type: string; byte_size: number; alt_text: string; position: number; }[]; const copied: { 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({ source: media, storageName }); } database.transaction(() => { database .prepare( `INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro, research_text, moderation_notes, transition_text, presentation_text, presentation_mode, presentation_source_position, presentation_title, presentation_description, presentation_image_url, presentation_site_name, presentation_provider, presentation_provider_id, presentation_title_manual, presentation_description_manual, presentation_image_manual, presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision) VALUES (?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` ) .run( id, `Kopie von ${source.title}`.slice(0, 200), source.summary, source.formatId, source.durationSeconds, source.intro, source.researchText, source.moderationNotes, source.transitionText, source.presentationText, source.presentationMode, source.presentationSourcePosition, source.presentationTitle, source.presentationDescription, source.presentationImageUrl, source.presentationSiteName, source.presentationProvider, source.presentationProviderId, source.presentationTitleManual ? 1 : 0, source.presentationDescriptionManual ? 1 : 0, source.presentationImageManual ? 1 : 0, source.presentationMetadataFetchedAtUtc, now, now ); 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( randomUUID(), id, media.storageName, media.source.original_name, media.source.mime_type, media.source.byte_size, media.source.alt_text, media.source.position, now ); } })(); 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'` ) .get(id) as | { revision: number; presentation_mode: PresentationMode; presentation_source_position: number | null; presentation_title_manual: number; presentation_description_manual: number; presentation_image_manual: number; url: string | null; } | undefined; if (!piece) throw new Error('NOT_FOUND'); if (piece.revision !== expectedRevision) throw new Error('CONFLICT'); if (piece.presentation_mode === 'text' || !piece.url) throw new Error('INVALID_PRESENTATION'); const metadata = await metadataLoader(piece.url); const now = new Date().toISOString(); const result = database .prepare( `UPDATE pieces SET presentation_title = CASE WHEN presentation_title_manual = 1 THEN presentation_title ELSE ? END, presentation_description = CASE WHEN presentation_description_manual = 1 THEN presentation_description ELSE ? END, presentation_image_url = CASE WHEN presentation_image_manual = 1 THEN presentation_image_url ELSE ? END, presentation_site_name = ?, presentation_provider = ?, presentation_provider_id = ?, presentation_metadata_fetched_at_utc = ?, updated_at_utc = ?, revision = revision + 1 WHERE id = ? AND revision = ? AND status != 'archived'` ) .run( metadata.title, metadata.description, metadata.imageUrl, metadata.siteName, metadata.provider, metadata.providerId, now, now, id, expectedRevision ); if (result.changes !== 1) throw new Error('CONFLICT'); return { revision: expectedRevision + 1, updatedAtUtc: now }; } export async function uploadPieceImage( pieceId: string, expectedRevision: number, file: File, altTextValue: string ) { 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(); 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'); const count = database .prepare('SELECT count(*) AS count FROM piece_media WHERE piece_id = ?') .get(pieceId) as { count: number }; if (count.count >= 8) throw new Error('TOO_MANY_IMAGES'); 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(), pieceId, storageName, path.basename(file.name).slice(0, 200) || `bild.${extension}`, detected.mime, buffer.byteLength, altText, count.count, new Date().toISOString() ); })(); } catch (error) { await unlink(filePath).catch(() => {}); throw error; } } 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 }); } } export async function getMediaFile(id: string) { const row = getDatabase() .prepare('SELECT storage_name, mime_type, byte_size, alt_text FROM piece_media WHERE id = ?') .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, colorValue?: 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(colorValue ?? ''); 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 { const table = kind === 'format' ? 'formats' : 'topics'; database.prepare(`INSERT INTO ${table} (id, name) VALUES (?, ?)`).run(id, name); } } 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'); }