1300 lines
44 KiB
TypeScript
1300 lines
44 KiB
TypeScript
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<PieceStatus, 'archived'>;
|
|
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<PieceInput>;
|
|
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<PieceTemplate, 'iconName'>[];
|
|
const iconForTemplate = (templateId: string) => {
|
|
const input = templateInput(templateId);
|
|
if (!input.topicIds.length) return 'newspaper';
|
|
return (
|
|
(
|
|
database.prepare('SELECT icon_name FROM topics WHERE id = ?').get(input.topicIds[0]) as
|
|
{ icon_name: string } | undefined
|
|
)?.icon_name || 'newspaper'
|
|
);
|
|
};
|
|
return [
|
|
{
|
|
id: 'blank',
|
|
name: 'Leer starten',
|
|
description: 'Ein freies Piece ohne Vorgaben.',
|
|
revision: 0,
|
|
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<typeof piece> = 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<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 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');
|
|
}
|