Release 0.5.8

This commit is contained in:
hnzio committed 2026-10-23 07:47:27 +02:00
1 parent 22ae3a68d6
commit 2851c7d936
33 files changed
+1263 -272

No files matched your search

+127 -71
View File
@@ -23,6 +23,7 @@ export type PieceSourceInput = {
export type PieceInput = {
title: string;
outputTitle: string;
summary: string;
formatId: string | null;
status: EditablePieceStatus;
@@ -88,30 +89,12 @@ export type Taxonomy = {
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'];
export type PieceTemplate = {
id: string;
name: string;
description: string;
revision: number;
};
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const ALLOWED_IMAGES = new Map([
@@ -202,6 +185,7 @@ function validatePieceInput(input: PieceInput): PieceInput {
sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url);
return {
title,
outputTitle: cleanText(input.outputTitle, 300, 'output_title'),
summary: cleanText(input.summary, 2_000, 'summary'),
formatId: input.formatId || null,
status: input.status,
@@ -251,6 +235,7 @@ export function getTaxonomy(): Taxonomy {
type PieceListRow = {
id: string;
title: string;
output_title: string;
summary: string;
status: PieceStatus;
revision: number;
@@ -341,6 +326,7 @@ export function listPieces(filters: {
type PieceRow = {
id: string;
title: string;
output_title: string;
summary: string;
format_id: string | null;
status: PieceStatus;
@@ -371,7 +357,7 @@ 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,
`SELECT id, title, output_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,
@@ -385,6 +371,7 @@ export function getPiece(id: string): PieceEditor | null {
return {
id: row.id,
title: row.title,
outputTitle: row.output_title,
summary: row.summary,
formatId: row.format_id,
status: row.status,
@@ -484,7 +471,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
const save = database.transaction(() => {
const result = database
.prepare(
`UPDATE pieces SET title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?,
`UPDATE pieces SET title = ?, output_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 = ?,
@@ -496,6 +483,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
)
.run(
input.title,
input.outputTitle,
input.summary,
input.formatId,
input.status,
@@ -531,50 +519,19 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
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,
function blankPieceInput(): PieceInput {
return {
title: 'Neues Piece',
outputTitle: '',
summary: '',
formatId: defaults.formatId ?? null,
formatId: null,
status: 'preparing',
durationSeconds: 0,
intro: '',
researchText: '',
moderationNotes: defaults.moderationNotes ?? '',
moderationNotes: '',
transitionText: '',
presentationText: defaults.presentationText ?? '',
presentationText: '',
presentationMode: 'text',
presentationSourcePosition: null,
presentationTitle: '',
@@ -588,20 +545,63 @@ export function createPiece(template: PieceTemplateId = 'blank'): string {
presentationImageManual: false,
presentationMetadataFetchedAtUtc: null,
topicIds: [],
categoryIds: defaults.categoryIds ?? [],
categoryIds: [],
sources: []
});
};
}
function templateInput(templateId: string): PieceInput {
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>;
return validatePieceInput({ ...blankPieceInput(), ...snapshot, status: 'preparing' });
} catch {
throw new Error('INVALID_TEMPLATE');
}
}
export function listPieceTemplates(): PieceTemplate[] {
const rows = getDatabase()
.prepare('SELECT id, name, description, revision FROM piece_templates ORDER BY name')
.all() as PieceTemplate[];
return [
{
id: 'blank',
name: 'Leer starten',
description: 'Ein freies Piece ohne Vorgaben.',
revision: 0
},
...rows.filter((template) => {
try {
templateInput(template.id);
return true;
} catch {
return false;
}
})
];
}
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, summary, format_id, status, duration_seconds, intro,
`INSERT INTO pieces (id, title, output_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)`
updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
id,
input.title,
input.outputTitle,
input.summary,
input.formatId,
input.status,
@@ -619,13 +619,68 @@ export function createPiece(template: PieceTemplateId = 'blank'): string {
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;
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('blank');
const id = createPiece();
const piece = getPiece(id)!;
savePiece(id, piece.revision, {
...piece,
@@ -692,18 +747,19 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
database.transaction(() => {
database
.prepare(
`INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro,
`INSERT INTO pieces (id, title, output_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)`
VALUES (?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
id,
`Kopie von ${source.title}`.slice(0, 200),
source.outputTitle,
source.summary,
source.formatId,
source.durationSeconds,