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

+42
View File
@@ -12,9 +12,11 @@ import {
getMediaFile,
getPiece,
getTaxonomy,
listPieceTemplates,
listPieces,
loadPiecePresentationMetadata,
savePiece,
savePieceAsTemplate,
setPieceArchived,
uploadPieceImage,
type PieceInput
@@ -42,6 +44,7 @@ afterEach(() => {
function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
return {
title: 'Getestetes Piece',
outputTitle: '',
summary: 'Eine persistente Kurzbeschreibung.',
formatId: 'format-talk',
status: 'ready',
@@ -73,6 +76,45 @@ function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
}
describe('content management', () => {
it('persists independently versioned templates and copies their complete snapshot', () => {
const sourceId = createPiece('talk');
const source = getPiece(sourceId)!;
savePiece(
sourceId,
source.revision,
completeInput({
title: 'Interner Vorlagentitel',
outputTitle: 'Titel für die Ausgabe',
presentationText: 'Unveränderlicher Vorlagentext'
})
);
const created = savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Redaktioneller Stand');
const template = listPieceTemplates().find((entry) => entry.id === created.id)!;
expect(template).toMatchObject({ name: 'Eigene Vorlage', revision: 1 });
const changed = getPiece(sourceId)!;
savePiece(sourceId, changed.revision, {
...changed,
status: 'ready',
presentationText: 'Spätere Änderung'
});
const copyId = createPiece(created.id);
expect(getPiece(copyId)).toMatchObject({
title: 'Interner Vorlagentitel',
outputTitle: 'Titel für die Ausgabe',
presentationText: 'Unveränderlicher Vorlagentext',
sources: [
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
]
});
const updated = savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Neuer Stand', created.id, 1);
expect(updated.revision).toBe(2);
expect(() =>
savePieceAsTemplate(sourceId, 'Eigene Vorlage', 'Veraltet', created.id, 1)
).toThrow('CONFLICT');
});
it('persists complete pieces and rejects stale revisions atomically', () => {
const id = createPiece('talk');
const initial = getPiece(id)!;
+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,
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close();
expect(counts).toEqual({
migrations: 6,
migrations: 8,
pieces: 6,
shows: 2,
usages: 5,
+63 -3
View File
@@ -6,13 +6,18 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { closeDatabase, getDatabase } from './database.js';
import {
completeCurrent,
clearPresentationChannel,
getPresentationChannelView,
getLiveView,
getPresentationMedia,
getPresentationView,
goBack,
issuePresentationAccess,
moveOpenUsage,
presentationChannelAccess,
presentUsage,
rotatePresentationChannelKey,
selectUsage,
skipCurrent,
startOrResumeLive
} from './live.js';
@@ -35,6 +40,57 @@ afterEach(() => {
});
describe('live mode', () => {
it('selects any rundown usage by stable id, including the first and completed entries', () => {
let live = getLiveView('show-october');
expect(() =>
selectUsage('show-october', 'usage-effects', 'select-before-start', live.liveRevision)
).toThrow('NOT_STARTED');
startOrResumeLive('show-october', 'start-selection', live.liveRevision);
live = getLiveView('show-october');
selectUsage('show-october', 'usage-effects', 'select-second', live.liveRevision);
live = getLiveView('show-october');
expect(live.current?.id).toBe('usage-effects');
selectUsage('show-october', 'usage-console', 'select-first', live.liveRevision);
live = getLiveView('show-october');
expect(live.current?.id).toBe('usage-console');
completeCurrent('show-october', 'complete-first', live.liveRevision);
live = getLiveView('show-october');
expect(
live.sections
.flatMap((section) => section.usages)
.find((usage) => usage.id === 'usage-console')?.status
).toBe('done');
selectUsage('show-october', 'usage-console', 'return-to-completed', live.liveRevision);
expect(getLiveView('show-october').current).toMatchObject({
id: 'usage-console',
status: 'current'
});
});
it('keeps one stable output channel until its key is explicitly rotated', () => {
const key = issuePresentationAccess('show-october');
expect(issuePresentationAccess('show-october')).toBe(key);
expect(presentationChannelAccess('default', key)).toBe(true);
expect(getPresentationChannelView('default')).toEqual({
revision: 1_000_000_000,
content: null
});
const replacement = rotatePresentationChannelKey('show-october');
expect(replacement).not.toBe(key);
expect(presentationChannelAccess('default', key)).toBe(false);
expect(presentationChannelAccess('default', replacement)).toBe(true);
startOrResumeLive('show-october', 'start-channel', 0);
presentUsage('show-october', 'usage-console', 'present-channel', 1);
expect(getPresentationChannelView('default')?.content).not.toBeNull();
clearPresentationChannel();
expect(getPresentationChannelView('default')?.content).toBeNull();
issuePresentationAccess('show-october');
expect(getPresentationChannelView('default')?.content).toBeNull();
});
it('restores live state, records actual intervals and applies commands only once', () => {
const started = new Date('2026-10-22T18:00:00.000Z');
const initial = getLiveView('show-october', started);
@@ -115,7 +171,8 @@ describe('live mode', () => {
const output = getPresentationView(token)!;
expect(output.content).toMatchObject({
title: 'Die kleine Konsole mit dem großen Dreh',
images: [{ id: 'presentation-image', altText: 'Konsole auf dem Tisch' }]
images: [],
sources: []
});
const serialized = JSON.stringify(output);
expect(serialized).toContain('Weniger Leistung, mehr Idee.');
@@ -125,11 +182,14 @@ describe('live mode', () => {
expect(serialized).not.toContain('presentation.png');
const media = await getPresentationMedia(token, 'presentation-image');
expect(media?.data.toString()).toBe('test-image');
expect(media).toBeNull();
expect(await getPresentationMedia(token, 'unknown-image')).toBeNull();
const replacementToken = issuePresentationAccess('show-october');
expect(getPresentationView(token)).toBeNull();
expect(replacementToken).toBe(token);
expect(getPresentationView(token)?.content?.title).toBe(
'Die kleine Konsole mit dem großen Dreh'
);
expect(getPresentationView(replacementToken)?.content?.title).toBe(
'Die kleine Konsole mit dem großen Dreh'
);
+134 -56
View File
@@ -1,6 +1,5 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { getMediaFile } from './content.js';
import { getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js';
import { buildEmbedUrl, fetchPresentationImage, type PresentationContent } from './presentation.js';
@@ -414,6 +413,34 @@ export function goBack(
);
}
export function selectUsage(
showId: string,
usageId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'select',
(database, state, now) => {
if (!state.started_at_utc) throw new Error('NOT_STARTED');
usageBelongsToShow(database, showId, usageId);
if (state.current_usage_id === usageId) return;
if (state.current_usage_id) {
closeActiveRun(database, showId, now);
database
.prepare("UPDATE piece_usages SET status = 'open' WHERE id = ?")
.run(state.current_usage_id);
}
activateUsage(database, showId, usageId, now);
},
nowValue
);
}
export function presentUsage(
showId: string,
usageId: string | null,
@@ -480,25 +507,90 @@ export function issuePresentationAccess(showId: string) {
const database = getDatabase();
const now = nowIso();
ensureLiveState(database, showId, now);
const token = randomBytes(32).toString('base64url');
database.transaction(() => {
database
.prepare(
'UPDATE presentation_access_tokens SET revoked_at_utc = ? WHERE show_id = ? AND revoked_at_utc IS NULL'
)
.run(now, showId);
database
.prepare(
`INSERT INTO presentation_access_tokens (id, show_id, token_hash, created_at_utc)
VALUES (?, ?, ?, ?)`
)
.run(randomUUID(), showId, hashToken(token), now);
return database.transaction(() => {
let channel = database
.prepare("SELECT * FROM presentation_channels WHERE id = 'default'")
.get() as { access_key: string; show_id: string | null } | undefined;
if (!channel) {
const accessKey = randomBytes(32).toString('base64url');
clearPresentationState(database, showId);
database
.prepare(
`INSERT INTO presentation_channels
(id, name, access_key, key_hash, show_id, revision, created_at_utc, updated_at_utc)
VALUES ('default', 'Standardausgabe', ?, ?, ?, 1, ?, ?)`
)
.run(accessKey, hashToken(accessKey), showId, now, now);
channel = { access_key: accessKey, show_id: showId };
} else if (channel.show_id !== showId) {
clearPresentationState(database, showId);
database
.prepare(
`UPDATE presentation_channels SET show_id = ?, revision = revision + 1,
updated_at_utc = ? WHERE id = 'default'`
)
.run(showId, now);
}
return channel.access_key;
})();
return token;
}
function clearPresentationState(database: Database.Database, showId: string) {
database
.prepare(
`UPDATE show_live_states SET presentation_usage_id = NULL,
presentation_revision = presentation_revision + 1, updated_at_utc = ?
WHERE show_id = ? AND presentation_usage_id IS NOT NULL`
)
.run(nowIso(), showId);
}
export function clearPresentationChannel() {
const now = nowIso();
getDatabase().transaction(() => {
const database = getDatabase();
const channel = database
.prepare("SELECT show_id FROM presentation_channels WHERE id = 'default'")
.get() as { show_id: string | null } | undefined;
if (!channel) throw new Error('NOT_FOUND');
if (channel.show_id) clearPresentationState(database, channel.show_id);
database
.prepare(
`UPDATE presentation_channels SET show_id = NULL, revision = revision + 1,
updated_at_utc = ? WHERE id = 'default'`
)
.run(now);
})();
}
export function rotatePresentationChannelKey(showId: string) {
issuePresentationAccess(showId);
const accessKey = randomBytes(32).toString('base64url');
const now = nowIso();
getDatabase()
.prepare(
`UPDATE presentation_channels SET access_key = ?, key_hash = ?, revision = revision + 1,
updated_at_utc = ? WHERE id = 'default'`
)
.run(accessKey, hashToken(accessKey), now);
return accessKey;
}
export function presentationChannelAccess(channelId: string, accessKey: string) {
if (!channelId || !accessKey || accessKey.length > 200) return false;
return Boolean(
getDatabase()
.prepare('SELECT 1 FROM presentation_channels WHERE id = ? AND key_hash = ?')
.get(channelId, hashToken(accessKey))
);
}
function presentationShowId(database: Database.Database, token: string) {
if (!token || token.length > 200) return null;
const channel = database
.prepare('SELECT show_id FROM presentation_channels WHERE key_hash = ?')
.get(hashToken(token)) as { show_id: string | null } | undefined;
if (channel) return channel.show_id;
const row = database
.prepare(
`SELECT show_id FROM presentation_access_tokens
@@ -508,15 +600,30 @@ function presentationShowId(database: Database.Database, token: string) {
return row?.show_id ?? null;
}
export function getPresentationChannelView(channelId: string): PresentationView | null {
const channel = getDatabase()
.prepare('SELECT show_id, revision FROM presentation_channels WHERE id = ?')
.get(channelId) as { show_id: string | null; revision: number } | undefined;
if (!channel) return null;
if (!channel.show_id) return { revision: channel.revision * 1_000_000_000, content: null };
const view = getPresentationViewForShow(channel.show_id);
return { ...view, revision: channel.revision * 1_000_000_000 + view.revision };
}
export function getPresentationView(token: string): PresentationView | null {
const database = getDatabase();
const showId = presentationShowId(database, token);
if (!showId) return null;
return getPresentationViewForShow(showId);
}
function getPresentationViewForShow(showId: string): PresentationView {
const database = getDatabase();
const state = ensureLiveState(database, showId, nowIso());
if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null };
const usage = database
.prepare(
`SELECT u.title_snapshot, u.presentation_snapshot, u.original_piece_id,
`SELECT u.title_snapshot, u.output_title_snapshot, u.presentation_snapshot, u.original_piece_id,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
@@ -528,6 +635,7 @@ export function getPresentationView(token: string): PresentationView | null {
.get(state.presentation_usage_id, showId) as
| {
title_snapshot: string;
output_title_snapshot: string;
presentation_snapshot: string;
original_piece_id: string | null;
presentation_mode_snapshot: PresentationContent['mode'];
@@ -543,33 +651,11 @@ export function getPresentationView(token: string): PresentationView | null {
}
| undefined;
if (!usage) return { revision: state.presentation_revision, content: null };
const sources =
usage.presentation_mode_snapshot === 'text' && usage.original_piece_id
? (database
.prepare(
`SELECT label, url, timecode_seconds AS timecodeSeconds
FROM piece_sources WHERE piece_id = ? ORDER BY position`
)
.all(usage.original_piece_id) as {
label: string;
url: string;
timecodeSeconds: number | null;
}[])
: [];
const images =
usage.presentation_mode_snapshot === 'text' && usage.original_piece_id
? (database
.prepare(
`SELECT id, alt_text AS altText FROM piece_media
WHERE piece_id = ? ORDER BY position`
)
.all(usage.original_piece_id) as { id: string; altText: string }[])
: [];
return {
revision: state.presentation_revision,
content: {
mode: usage.presentation_mode_snapshot,
title: usage.title_snapshot,
title: usage.output_title_snapshot || usage.title_snapshot,
html: renderMarkdown(usage.presentation_snapshot),
source: usage.presentation_source_url_snapshot
? {
@@ -589,8 +675,8 @@ export function getPresentationView(token: string): PresentationView | null {
usage.presentation_provider_id_snapshot,
usage.presentation_source_timecode_snapshot
),
sources,
images
sources: [],
images: []
}
};
}
@@ -605,19 +691,11 @@ export async function getPresentationPreviewImage(token: string) {
}
}
export async function getPresentationMedia(token: string, mediaId: string) {
const database = getDatabase();
const showId = presentationShowId(database, token);
if (!showId) return null;
const allowed = database
.prepare(
`SELECT 1 FROM show_live_states l
JOIN piece_usages u ON u.id = l.presentation_usage_id
JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media m ON m.piece_id = u.original_piece_id
WHERE l.show_id = ? AND s.show_id = ? AND m.id = ?`
)
.get(showId, showId, mediaId);
if (!allowed) return null;
return getMediaFile(mediaId);
export async function getPresentationMedia(
token: string,
mediaId: string
): Promise<{ data: Buffer; mimeType: string; byteSize: number; altText: string } | null> {
void token;
void mediaId;
return null;
}
+63 -1
View File
@@ -9,6 +9,7 @@ import {
addPieceUsage,
addShowSection,
createShow,
createAndAddPieceUsage,
duplicatePieceUsage,
getPieceUsageContexts,
getShow,
@@ -44,6 +45,7 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
const edited = { ...usage, ...overrides };
return {
title: edited.title,
outputTitle: edited.outputTitle,
intro: edited.intro,
research: edited.research,
moderation: edited.moderation,
@@ -63,6 +65,64 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
}
describe('show builder data layer', () => {
it('creates a fundus original and usage atomically and idempotently', () => {
const created = createShow({ title: 'Direkte Planung', startsAtUtc: null, templateId: null });
const section = addShowSection(created.id, created.revision, {
name: 'Ablauf',
categoryId: null,
explicitDurationSeconds: null
});
const before = (
getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number }
).count;
const first = createAndAddPieceUsage(created.id, section.revision, 'new-piece-command', {
title: 'Direkt geplantes Piece',
templateId: 'talk',
sectionId: section.id
});
const repeated = createAndAddPieceUsage(created.id, section.revision, 'new-piece-command', {
title: 'Darf nicht doppelt entstehen',
templateId: 'blank',
sectionId: section.id
});
expect(repeated).toEqual({ ...first, duplicate: true });
expect(getShow(created.id)?.sections[0].usages).toHaveLength(1);
expect(getPiece(first.pieceId)).toMatchObject({
title: 'Direkt geplantes Piece',
status: 'ready',
formatId: 'format-talk'
});
expect(
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
.count
).toBe(before + 1);
expect(() =>
createAndAddPieceUsage(created.id, first.revision, 'failing-command', {
title: 'Rollback',
templateId: 'blank',
sectionId: 'missing'
})
).toThrow('NOT_FOUND');
expect(
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
.count
).toBe(before + 1);
const removed = removePieceUsage(created.id, first.usageId, first.revision);
const afterRemoval = createAndAddPieceUsage(created.id, removed.revision, 'new-piece-command', {
title: 'Darf auch nach dem Entfernen nicht neu entstehen',
templateId: 'blank',
sectionId: section.id
});
expect(afterRemoval).toEqual({ ...first, duplicate: true });
expect(getShow(created.id)?.sections[0].usages).toHaveLength(0);
expect(
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
.count
).toBe(before + 1);
});
it('creates blank and templated shows with independent sections and reserve banks', () => {
const templatesBefore = listShowTemplates();
expect(
@@ -360,7 +420,8 @@ describe('show builder data layer', () => {
.run('https://youtu.be/dQw4w9WgXcQ');
getDatabase()
.prepare(
`UPDATE pieces SET presentation_mode = 'video', presentation_source_position = 0,
`UPDATE pieces SET output_title = 'Öffentlicher Ausgabetitel',
presentation_mode = 'video', presentation_source_position = 0,
presentation_title = 'Gespeicherte Vorschau', presentation_description = 'Erster Stand',
presentation_site_name = 'YouTube', presentation_provider = 'youtube',
presentation_provider_id = 'dQw4w9WgXcQ' WHERE id = 'piece-console'`
@@ -378,6 +439,7 @@ describe('show builder data layer', () => {
});
let usage = getShow(show.id)!.sections[0].usages[0];
expect(usage).toMatchObject({
outputTitle: 'Öffentlicher Ausgabetitel',
presentationMode: 'video',
presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ',
presentationSourceTimecodeSeconds: 75,
+76 -10
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { createPiece } from './content.js';
import { getDatabase } from './database.js';
import { detectVideoProvider, type PresentationMode } from './presentation.js';
@@ -42,6 +43,7 @@ export type PieceUsage = {
status: UsageStatus;
durationSeconds: number;
title: string;
outputTitle: string;
intro: string;
research: string;
moderation: string;
@@ -129,6 +131,7 @@ export type PieceUsageInput = {
export type PieceUsageUpdate = {
title: string;
outputTitle: string;
intro: string;
research: string;
moderation: string;
@@ -185,6 +188,7 @@ type UsageRow = {
status: UsageStatus;
duration_seconds: number;
title_snapshot: string;
output_title_snapshot: string;
intro_snapshot: string;
research_snapshot: string;
moderation_snapshot: string;
@@ -376,6 +380,7 @@ function usageFromRow(row: UsageRow): PieceUsage {
status: row.status,
durationSeconds: row.duration_seconds,
title: row.title_snapshot,
outputTitle: row.output_title_snapshot,
intro: row.intro_snapshot,
research: row.research_snapshot,
moderation: row.moderation_snapshot,
@@ -478,7 +483,7 @@ export function getShow(id: string): Show | null {
const usageRows = database
.prepare(
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
u.duration_seconds, u.title_snapshot, u.intro_snapshot, u.research_snapshot,
u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot,
u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
@@ -805,7 +810,7 @@ export function addPieceUsage(
sectionBelongsToShow(database, showId, sectionId, input.sectionId === null);
const piece = database
.prepare(
`SELECT p.title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes,
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes,
p.transition_text, p.presentation_text, p.status, p.presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url,
@@ -818,6 +823,7 @@ export function addPieceUsage(
.get(input.pieceId) as
| {
title: string;
output_title: string;
duration_seconds: number;
intro: string;
research_text: string;
@@ -849,14 +855,14 @@ export function addPieceUsage(
database
.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot,
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
)
.run(
id,
@@ -865,6 +871,7 @@ export function addPieceUsage(
ids.length,
piece.duration_seconds,
piece.title,
piece.output_title,
piece.intro,
piece.research_text,
piece.moderation_notes,
@@ -888,6 +895,60 @@ export function addPieceUsage(
})();
}
export function createAndAddPieceUsage(
showId: string,
expectedRevision: number,
commandId: string,
input: Omit<PieceUsageInput, 'pieceId'> & { title: string; templateId: string }
) {
if (!commandId || commandId.length > 200) throw new Error('INVALID_COMMAND');
const title = cleanText(input.title, 200, 'title', true);
const database = getDatabase();
return database.transaction(() => {
const duplicate = database
.prepare(
`SELECT piece_id, usage_id, result_revision FROM planning_commands
WHERE show_id = ? AND command_id = ?`
)
.get(showId, commandId) as
{ piece_id: string; usage_id: string; result_revision: number } | undefined;
if (duplicate) {
return {
pieceId: duplicate.piece_id,
usageId: duplicate.usage_id,
revision: duplicate.result_revision,
duplicate: true
};
}
const pieceId = createPiece(input.templateId || 'blank');
database
.prepare(
`UPDATE pieces SET title = ?, status = 'ready', revision = revision + 1,
updated_at_utc = ? WHERE id = ?`
)
.run(title, new Date().toISOString(), pieceId);
const usage = addPieceUsage(showId, expectedRevision, {
pieceId,
sectionId: input.sectionId,
position: input.position
});
database
.prepare(
`INSERT INTO planning_commands
(show_id, command_id, piece_id, usage_id, result_revision, created_at_utc)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(showId, commandId, pieceId, usage.id, usage.revision, new Date().toISOString());
return {
pieceId,
usageId: usage.id,
revision: usage.revision,
duplicate: false
};
})();
}
export function duplicatePieceUsage(
showId: string,
usageId: string,
@@ -899,7 +960,7 @@ export function duplicatePieceUsage(
const sourceSection = usageSection(database, showId, usageId);
const source = database
.prepare(
`SELECT original_piece_id, status, duration_seconds, title_snapshot, intro_snapshot,
`SELECT original_piece_id, status, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot,
research_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
@@ -922,14 +983,14 @@ export function duplicatePieceUsage(
database
.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot,
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
@@ -938,6 +999,7 @@ export function duplicatePieceUsage(
ids.length,
source.duration_seconds,
source.title_snapshot,
source.output_title_snapshot,
source.intro_snapshot,
source.research_snapshot,
source.moderation_snapshot,
@@ -973,7 +1035,7 @@ export function refreshPieceUsageFromOriginal(
usageSection(database, showId, usageId);
const source = database
.prepare(
`SELECT p.title, p.duration_seconds, p.intro, p.research_text,
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text,
p.moderation_notes, p.transition_text, p.presentation_text, p.presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url,
@@ -988,6 +1050,7 @@ export function refreshPieceUsageFromOriginal(
.get(usageId) as
| {
title: string;
output_title: string;
duration_seconds: number;
intro: string;
research_text: string;
@@ -1009,7 +1072,7 @@ export function refreshPieceUsageFromOriginal(
if (!source) throw new Error('ORIGINAL_NOT_FOUND');
database
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
research_snapshot = ?, moderation_snapshot = ?, transition_snapshot = ?,
presentation_snapshot = ?, presentation_mode_snapshot = ?,
presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?,
@@ -1020,6 +1083,7 @@ export function refreshPieceUsageFromOriginal(
)
.run(
source.title,
source.output_title,
source.duration_seconds,
source.intro,
source.research_text,
@@ -1049,6 +1113,7 @@ export function updatePieceUsage(
input: PieceUsageUpdate
): MutationResult {
const title = cleanText(input.title, 200, 'title', true);
const outputTitle = cleanText(input.outputTitle, 300, 'output_title');
const duration = validDuration(input.durationSeconds)!;
const values = {
intro: cleanText(input.intro, 50_000, 'intro'),
@@ -1074,7 +1139,7 @@ export function updatePieceUsage(
usageSection(database, showId, usageId);
database
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?,
presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?,
presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?,
@@ -1085,6 +1150,7 @@ export function updatePieceUsage(
)
.run(
title,
outputTitle,
values.intro,
values.research,
values.moderation,