Release 0.3.0
This commit is contained in:
1 parent
08e8875cb5
commit
7590be66a5
31 files changed
+3270
-151
No files matched your search
@@ -0,0 +1,169 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createPiece,
|
||||
createTaxonomy,
|
||||
deletePieceImage,
|
||||
deleteTaxonomy,
|
||||
duplicatePiece,
|
||||
getMediaFile,
|
||||
getPiece,
|
||||
getTaxonomy,
|
||||
listPieces,
|
||||
savePiece,
|
||||
setPieceArchived,
|
||||
uploadPieceImage,
|
||||
type PieceInput
|
||||
} from './content.js';
|
||||
import { closeDatabase } from './database.js';
|
||||
import { renderMarkdown } from './markdown.js';
|
||||
|
||||
let directory: string;
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-content-test-'));
|
||||
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
|
||||
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
|
||||
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
|
||||
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
delete process.env.SENDEWERK_DB_PATH;
|
||||
delete process.env.SENDEWERK_UPLOAD_DIR;
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
|
||||
return {
|
||||
title: 'Getestetes Piece',
|
||||
summary: 'Eine persistente Kurzbeschreibung.',
|
||||
formatId: 'format-talk',
|
||||
status: 'ready',
|
||||
durationSeconds: 420,
|
||||
intro: '**Starker Einstieg**',
|
||||
researchText: 'Private Recherche',
|
||||
moderationNotes: '- Punkt eins',
|
||||
transitionText: 'Weiter geht es.',
|
||||
presentationText: '## Sichtbare Aussage',
|
||||
topicIds: ['topic-gaming', 'topic-internet'],
|
||||
categoryIds: ['cat-jc', 'cat-reaction'],
|
||||
sources: [
|
||||
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
|
||||
],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('content management', () => {
|
||||
it('persists complete pieces and rejects stale revisions atomically', () => {
|
||||
const id = createPiece('talk');
|
||||
const initial = getPiece(id)!;
|
||||
const saved = savePiece(id, initial.revision, completeInput());
|
||||
const piece = getPiece(id)!;
|
||||
|
||||
expect(saved.revision).toBe(2);
|
||||
expect(piece).toMatchObject({
|
||||
title: 'Getestetes Piece',
|
||||
status: 'ready',
|
||||
researchText: 'Private Recherche',
|
||||
topicIds: ['topic-gaming', 'topic-internet'],
|
||||
categoryIds: ['cat-jc', 'cat-reaction']
|
||||
});
|
||||
expect(piece.sources).toEqual([
|
||||
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
|
||||
]);
|
||||
expect(() =>
|
||||
savePiece(id, initial.revision, completeInput({ title: 'Veralteter Titel', topicIds: [] }))
|
||||
).toThrow('CONFLICT');
|
||||
expect(getPiece(id)).toMatchObject({
|
||||
title: 'Getestetes Piece',
|
||||
topicIds: ['topic-gaming', 'topic-internet']
|
||||
});
|
||||
});
|
||||
|
||||
it('searches independent taxonomies and archives, restores and duplicates pieces', async () => {
|
||||
createTaxonomy('topic', 'Technikgeschichte');
|
||||
const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!;
|
||||
const sourceId = createPiece('find');
|
||||
const source = getPiece(sourceId)!;
|
||||
savePiece(
|
||||
sourceId,
|
||||
source.revision,
|
||||
completeInput({
|
||||
title: 'Seltene Hardware',
|
||||
formatId: 'format-find',
|
||||
topicIds: [topic.id],
|
||||
categoryIds: ['cat-reaction']
|
||||
})
|
||||
);
|
||||
|
||||
expect(
|
||||
listPieces({ statuses: ['ready'], query: 'dokumentation', topicId: topic.id })
|
||||
).toHaveLength(1);
|
||||
expect(listPieces({ statuses: ['ready'], query: '%' })).toHaveLength(0);
|
||||
expect(() => deleteTaxonomy('topic', topic.id)).toThrow('TAXONOMY_IN_USE');
|
||||
createTaxonomy('topic', 'Unbenutzt');
|
||||
const unused = getTaxonomy().topics.find((entry) => entry.name === 'Unbenutzt')!;
|
||||
deleteTaxonomy('topic', unused.id);
|
||||
expect(getTaxonomy().topics.some((entry) => entry.id === unused.id)).toBe(false);
|
||||
expect(() => deleteTaxonomy('format', 'format-find')).toThrow('TAXONOMY_IN_USE');
|
||||
const duplicateId = await duplicatePiece(sourceId);
|
||||
expect(getPiece(duplicateId)).toMatchObject({
|
||||
title: 'Kopie von Seltene Hardware',
|
||||
status: 'preparing',
|
||||
topicIds: [topic.id]
|
||||
});
|
||||
const current = getPiece(sourceId)!;
|
||||
setPieceArchived(sourceId, current.revision, true);
|
||||
expect(listPieces({ statuses: ['ready'] }).some((piece) => piece.id === sourceId)).toBe(false);
|
||||
const archived = listPieces({ statuses: ['archived'] }).find((piece) => piece.id === sourceId)!;
|
||||
setPieceArchived(sourceId, archived.revision, false);
|
||||
expect(getPiece(sourceId)?.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('sanitizes dangerous markdown while preserving useful formatting', () => {
|
||||
const html = renderMarkdown(
|
||||
'# Verbotene Ebene\n\n**sicher** <script>globalThis.compromised = true</script> [Link](javascript:alert(1))'
|
||||
);
|
||||
expect(html).toContain('<strong>sicher</strong>');
|
||||
expect(html).not.toContain('<script');
|
||||
expect(html).not.toContain('javascript:');
|
||||
expect(html).not.toContain('<h1');
|
||||
});
|
||||
|
||||
it('accepts signed raster images, rejects disguised files and keeps storage private', async () => {
|
||||
const id = createPiece();
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
);
|
||||
await uploadPieceImage(
|
||||
id,
|
||||
1,
|
||||
new File([png], '../bild.png', { type: 'text/html' }),
|
||||
'Ein heller Pixel'
|
||||
);
|
||||
const piece = getPiece(id)!;
|
||||
expect(piece.media).toHaveLength(1);
|
||||
expect(piece.media[0]).toMatchObject({ mimeType: 'image/png', altText: 'Ein heller Pixel' });
|
||||
const media = await getMediaFile(piece.media[0].id);
|
||||
expect(media?.data.equals(png)).toBe(true);
|
||||
expect(existsSync(path.join(directory, 'uploads', '../bild.png'))).toBe(false);
|
||||
|
||||
await expect(
|
||||
uploadPieceImage(
|
||||
id,
|
||||
piece.revision,
|
||||
new File(['<script>alert(1)</script>'], 'angriff.png', { type: 'image/png' }),
|
||||
''
|
||||
)
|
||||
).rejects.toThrow('INVALID_IMAGE_TYPE');
|
||||
await deletePieceImage(id, piece.media[0].id, piece.revision);
|
||||
expect(getPiece(id)?.media).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,803 @@
|
||||
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';
|
||||
|
||||
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;
|
||||
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 };
|
||||
});
|
||||
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'),
|
||||
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;
|
||||
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, 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,
|
||||
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 = ?,
|
||||
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,
|
||||
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 ?? '',
|
||||
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, 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,
|
||||
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 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');
|
||||
}
|
||||
@@ -52,7 +52,7 @@ describe('database setup', () => {
|
||||
.get() as { migrations: number; pieces: number; shows: number; usages: number };
|
||||
database.close();
|
||||
|
||||
expect(counts).toEqual({ migrations: 2, pieces: 4, shows: 1, usages: 3 });
|
||||
expect(counts).toEqual({ migrations: 3, pieces: 6, shows: 1, usages: 3 });
|
||||
expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite'));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { marked } from 'marked';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
export function renderMarkdown(markdown: string): string {
|
||||
const rendered = marked.parse(markdown, {
|
||||
async: false,
|
||||
breaks: true,
|
||||
gfm: true
|
||||
}) as string;
|
||||
return sanitizeHtml(rendered, {
|
||||
allowedTags: [
|
||||
'p',
|
||||
'br',
|
||||
'strong',
|
||||
'em',
|
||||
'del',
|
||||
'blockquote',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'code',
|
||||
'pre',
|
||||
'a',
|
||||
'hr'
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ['href', 'title', 'target', 'rel']
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto'],
|
||||
allowProtocolRelative: false,
|
||||
enforceHtmlBoundary: true,
|
||||
transformTags: {
|
||||
a: (_tagName, attributes) => ({
|
||||
tagName: 'a',
|
||||
attribs: { ...attributes, target: '_blank', rel: 'noopener noreferrer' }
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export function getPieces(): PreviewPiece[] {
|
||||
(SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count
|
||||
FROM pieces p
|
||||
LEFT JOIN formats f ON f.id = p.format_id
|
||||
WHERE p.status != 'archived'
|
||||
WHERE p.status IN ('preparing', 'ready')
|
||||
ORDER BY CASE p.status WHEN 'ready' THEN 0 ELSE 1 END, p.title`
|
||||
)
|
||||
.all() as PieceRow[];
|
||||
|
||||
Reference in new issue
Block a user