Release 0.5.8
This commit is contained in:
1 parent
22ae3a68d6
commit
2851c7d936
33 files changed
+1263
-272
No files matched your search
+127
-71
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user