991 lines
33 KiB
TypeScript
991 lines
33 KiB
TypeScript
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<PieceStatus, 'archived'>;
|
|
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<PieceInput, 'status'> & {
|
|
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<PieceInput> & { 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 {
|
|
const title = cleanText(titleValue, 200, 'title');
|
|
if (!title) throw new Error('INVALID_TITLE');
|
|
const url = cleanText(urlValue, 2_000, 'source');
|
|
if (url && !validUrl(url)) throw new Error('INVALID_SOURCES');
|
|
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<string> {
|
|
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');
|
|
}
|