2009 lines
66 KiB
TypeScript
2009 lines
66 KiB
TypeScript
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, type VideoProvider } from './presentation.js';
|
|
|
|
export const SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const;
|
|
export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const;
|
|
|
|
export type ShowStatus = (typeof SHOW_STATUSES)[number];
|
|
export type EditableShowStatus = Exclude<ShowStatus, 'completed'>;
|
|
export type UsageStatus = (typeof USAGE_STATUSES)[number];
|
|
export type UsagePreparationStatus = 'preparing' | 'ready';
|
|
export type PreparationEffort = 'low' | 'medium' | 'high' | null;
|
|
|
|
export type ShowListItem = {
|
|
id: string;
|
|
title: string;
|
|
startsAtUtc: string | null;
|
|
status: ShowStatus;
|
|
revision: number;
|
|
totalDurationSeconds: number;
|
|
sectionCount: number;
|
|
};
|
|
|
|
export type PreparationUsage = {
|
|
usageId: string;
|
|
showId: string;
|
|
showTitle: string;
|
|
showStartsAtUtc: string | null;
|
|
sectionName: string;
|
|
title: string;
|
|
preparationEffort: PreparationEffort;
|
|
topics: UsageTopic[];
|
|
};
|
|
|
|
export type ShowTemplateSection = {
|
|
id: string;
|
|
name: string;
|
|
categoryId: string | null;
|
|
position: number;
|
|
explicitDurationSeconds: number | null;
|
|
twitchTags: string[];
|
|
};
|
|
|
|
export type ShowTemplate = {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
sections: ShowTemplateSection[];
|
|
};
|
|
|
|
export type PieceUsage = {
|
|
id: string;
|
|
originalPieceId: string | null;
|
|
position: number;
|
|
status: UsageStatus;
|
|
preparationStatus: UsagePreparationStatus;
|
|
preparationEffort: PreparationEffort;
|
|
durationSeconds: number;
|
|
title: string;
|
|
outputTitle: string;
|
|
intro: string;
|
|
research: string;
|
|
moderation: string;
|
|
interaction: string;
|
|
transition: string;
|
|
presentation: string;
|
|
presentationMode: PresentationMode;
|
|
presentationSourceLabel: string;
|
|
presentationSourceUrl: string;
|
|
presentationSourceTimecodeSeconds: number | null;
|
|
presentationTitle: string;
|
|
presentationDescription: string;
|
|
presentationImageUrl: string;
|
|
presentationSiteName: string;
|
|
presentationProvider: VideoProvider | '';
|
|
presentationProviderId: string;
|
|
ownNotes: string;
|
|
sources: { id: string; label: string; url: string; timecodeSeconds: number | null }[];
|
|
tools: {
|
|
id: string;
|
|
originalToolId: string | null;
|
|
name: string;
|
|
startUrl: string;
|
|
workUrl: string;
|
|
note: string;
|
|
}[];
|
|
topics: UsageTopic[];
|
|
media: PieceUsageMedia[];
|
|
presentationMediaId: string | null;
|
|
};
|
|
|
|
export type UsageTopic = { name: string; iconName: string };
|
|
|
|
export type PieceUsageMedia = {
|
|
id: string;
|
|
originalMediaId: string;
|
|
altText: string;
|
|
position: number;
|
|
isPresentation: boolean;
|
|
};
|
|
|
|
export type SentPieceUsage = {
|
|
usageId: string;
|
|
showId: string;
|
|
showTitle: string;
|
|
usedAtUtc: string;
|
|
};
|
|
|
|
export type PieceUsageContext = {
|
|
sentHistory: SentPieceUsage[];
|
|
isPlannedInActiveShow: boolean;
|
|
isPlannedInCurrentShow: boolean;
|
|
};
|
|
|
|
export type ShowSection = {
|
|
id: string;
|
|
name: string;
|
|
categoryId: string | null;
|
|
category: string | null;
|
|
categoryColor: string | null;
|
|
position: number;
|
|
explicitDurationSeconds: number | null;
|
|
twitchTags: string[];
|
|
durationSeconds: number;
|
|
usages: PieceUsage[];
|
|
};
|
|
|
|
export type Show = {
|
|
id: string;
|
|
title: string;
|
|
startsAtUtc: string | null;
|
|
twitchTitle: string;
|
|
announcementText: string;
|
|
status: ShowStatus;
|
|
revision: number;
|
|
createdAtUtc: string;
|
|
updatedAtUtc: string;
|
|
completedAtUtc: string | null;
|
|
sections: ShowSection[];
|
|
reserve: PieceUsage[];
|
|
totalDurationSeconds: number;
|
|
};
|
|
|
|
export type CreateShowInput = {
|
|
title: string | null;
|
|
startsAtUtc: string | null;
|
|
templateId: string | null;
|
|
};
|
|
|
|
export type ShowMetadataInput = {
|
|
title: string;
|
|
startsAtUtc: string | null;
|
|
twitchTitle: string;
|
|
announcementText: string;
|
|
status: EditableShowStatus;
|
|
};
|
|
|
|
export type ShowSectionInput = {
|
|
name: string;
|
|
categoryId: string | null;
|
|
explicitDurationSeconds: number | null;
|
|
twitchTags?: string[];
|
|
position?: number;
|
|
};
|
|
|
|
export type PieceUsageInput = {
|
|
pieceId: string;
|
|
sectionId: string | null;
|
|
position?: number;
|
|
};
|
|
|
|
export type PieceUsageUpdate = {
|
|
title: string;
|
|
outputTitle: string;
|
|
intro: string;
|
|
research: string;
|
|
moderation: string;
|
|
interaction?: string;
|
|
transition: string;
|
|
presentation: string;
|
|
presentationMode: PresentationMode;
|
|
presentationSourceLabel: string;
|
|
presentationSourceUrl: string;
|
|
presentationSourceTimecodeSeconds: number | null;
|
|
presentationTitle: string;
|
|
presentationDescription: string;
|
|
presentationImageUrl: string;
|
|
presentationSiteName: string;
|
|
ownNotes: string;
|
|
durationSeconds: number;
|
|
preparationStatus?: UsagePreparationStatus;
|
|
preparationEffort?: PreparationEffort;
|
|
tools?: { id: string; workUrl: string; note: string }[];
|
|
presentationMediaId?: string | null;
|
|
};
|
|
|
|
export type PieceUsageMetadataUpdate = Pick<
|
|
PieceUsageUpdate,
|
|
| 'title'
|
|
| 'outputTitle'
|
|
| 'durationSeconds'
|
|
| 'ownNotes'
|
|
| 'preparationStatus'
|
|
| 'preparationEffort'
|
|
>;
|
|
|
|
export type MovePieceUsageInput = {
|
|
sectionId: string | null;
|
|
position: number;
|
|
};
|
|
|
|
export type MutationResult = { revision: number; updatedAtUtc: string };
|
|
export type CreatedMutationResult = MutationResult & { id: string };
|
|
|
|
type ShowRow = {
|
|
id: string;
|
|
title: string;
|
|
starts_at_utc: string | null;
|
|
twitch_title: string;
|
|
announcement_text: string;
|
|
status: ShowStatus;
|
|
revision: number;
|
|
created_at_utc: string;
|
|
updated_at_utc: string;
|
|
completed_at_utc: string | null;
|
|
};
|
|
|
|
type SectionRow = {
|
|
id: string;
|
|
name: string;
|
|
category_id: string | null;
|
|
category: string | null;
|
|
category_color: string | null;
|
|
category_name_snapshot: string | null;
|
|
category_color_snapshot: string | null;
|
|
position: number;
|
|
explicit_duration_seconds: number | null;
|
|
twitch_tags: string;
|
|
is_reserve_bank: number;
|
|
};
|
|
|
|
type UsageRow = {
|
|
id: string;
|
|
show_section_id: string;
|
|
original_piece_id: string | null;
|
|
position: number;
|
|
status: UsageStatus;
|
|
preparation_status_snapshot: UsagePreparationStatus;
|
|
preparation_effort_snapshot: PreparationEffort;
|
|
duration_seconds: number;
|
|
title_snapshot: string;
|
|
output_title_snapshot: string;
|
|
intro_snapshot: string;
|
|
research_snapshot: string;
|
|
moderation_snapshot: string;
|
|
interaction_snapshot: string;
|
|
transition_snapshot: string;
|
|
presentation_snapshot: string;
|
|
presentation_mode_snapshot: PresentationMode;
|
|
presentation_image_only_snapshot: number;
|
|
presentation_source_label_snapshot: string;
|
|
presentation_source_url_snapshot: string;
|
|
presentation_source_timecode_snapshot: number | null;
|
|
presentation_title_meta_snapshot: string;
|
|
presentation_description_snapshot: string;
|
|
presentation_image_url_snapshot: string;
|
|
presentation_site_name_snapshot: string;
|
|
presentation_provider_snapshot: VideoProvider | '';
|
|
presentation_provider_id_snapshot: string;
|
|
own_notes: string;
|
|
};
|
|
|
|
function cleanText(value: unknown, maximum: number, field: string, required = false): string {
|
|
const text = String(value ?? '').trim();
|
|
if (text.length > maximum || (required && !text)) {
|
|
throw new Error(`INVALID_${field.toUpperCase()}`);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function validDuration(value: number | null): number | null {
|
|
if (value === null) return null;
|
|
if (!Number.isInteger(value) || value < 0 || value > 86_400) {
|
|
throw new Error('INVALID_DURATION');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function validTwitchTags(values: string[]) {
|
|
const tags = [...new Set(values.map((tag) => tag.trim()).filter(Boolean))];
|
|
if (tags.length > 10 || tags.some((tag) => tag.length > 25 || tag.includes(','))) {
|
|
throw new Error('INVALID_TWITCH_TAGS');
|
|
}
|
|
return tags;
|
|
}
|
|
|
|
function validPosition(value: number, maximum: number): number {
|
|
if (!Number.isInteger(value) || value < 0) throw new Error('INVALID_POSITION');
|
|
return Math.min(value, maximum);
|
|
}
|
|
|
|
function validTimestamp(value: string | null): string | null {
|
|
if (value === null) return null;
|
|
if (!value.endsWith('Z')) throw new Error('INVALID_TIMESTAMP');
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.valueOf())) throw new Error('INVALID_TIMESTAMP');
|
|
return date.toISOString();
|
|
}
|
|
|
|
function validPresentation(
|
|
mode: PresentationMode,
|
|
sourceLabel: string,
|
|
sourceUrl: string,
|
|
timecodeSeconds: number | null,
|
|
title: string,
|
|
description: string,
|
|
imageUrl: string,
|
|
siteName: string
|
|
) {
|
|
if (!['text', 'image', 'link', 'video'].includes(mode)) throw new Error('INVALID_PRESENTATION');
|
|
const cleanUrl = cleanText(sourceUrl, 2_000, 'presentation_source');
|
|
const cleanImageUrl = cleanText(imageUrl, 2_000, 'presentation_image');
|
|
for (const value of [cleanUrl, cleanImageUrl]) {
|
|
if (!value) continue;
|
|
try {
|
|
const url = new URL(value);
|
|
if (!['http:', 'https:'].includes(url.protocol)) throw new Error();
|
|
} catch {
|
|
throw new Error('INVALID_PRESENTATION');
|
|
}
|
|
}
|
|
if ((mode === 'link' || mode === 'video') && !cleanUrl) throw new Error('INVALID_PRESENTATION');
|
|
if (
|
|
timecodeSeconds !== null &&
|
|
(!Number.isInteger(timecodeSeconds) || timecodeSeconds < 0 || timecodeSeconds > 86_400)
|
|
) {
|
|
throw new Error('INVALID_PRESENTATION');
|
|
}
|
|
const detected = detectVideoProvider(cleanUrl);
|
|
return {
|
|
mode,
|
|
sourceLabel: cleanText(sourceLabel, 200, 'presentation_source'),
|
|
sourceUrl: cleanUrl,
|
|
timecodeSeconds,
|
|
title: cleanText(title, 300, 'presentation_title'),
|
|
description: cleanText(description, 1_000, 'presentation_description'),
|
|
imageUrl: cleanImageUrl,
|
|
siteName: cleanText(siteName, 200, 'presentation_site'),
|
|
provider: detected?.provider ?? '',
|
|
providerId: detected?.id ?? ''
|
|
};
|
|
}
|
|
|
|
function requireRevision(expectedRevision: number): void {
|
|
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
|
|
throw new Error('INVALID_REVISION');
|
|
}
|
|
}
|
|
|
|
function requireCategory(database: Database.Database, categoryId: string | null): void {
|
|
if (!categoryId) return;
|
|
if (!database.prepare('SELECT 1 FROM section_categories WHERE id = ?').get(categoryId)) {
|
|
throw new Error('INVALID_CATEGORY');
|
|
}
|
|
}
|
|
|
|
function bumpShow(
|
|
database: Database.Database,
|
|
showId: string,
|
|
expectedRevision: number
|
|
): MutationResult {
|
|
requireRevision(expectedRevision);
|
|
const updatedAtUtc = new Date().toISOString();
|
|
const result = database
|
|
.prepare(
|
|
`UPDATE shows SET revision = revision + 1, updated_at_utc = ?
|
|
WHERE id = ? AND revision = ? AND status != 'completed'`
|
|
)
|
|
.run(updatedAtUtc, showId, expectedRevision);
|
|
if (result.changes !== 1) {
|
|
const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as
|
|
{ status: ShowStatus } | undefined;
|
|
if (!show) throw new Error('NOT_FOUND');
|
|
if (show.status === 'completed') throw new Error('SHOW_COMPLETED');
|
|
throw new Error('CONFLICT');
|
|
}
|
|
return { revision: expectedRevision + 1, updatedAtUtc };
|
|
}
|
|
|
|
function reserveSectionId(database: Database.Database, showId: string): string {
|
|
const row = database
|
|
.prepare('SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 1')
|
|
.get(showId) as { id: string } | undefined;
|
|
if (!row) throw new Error('INVALID_RESERVE_BANK');
|
|
return row.id;
|
|
}
|
|
|
|
function sectionBelongsToShow(
|
|
database: Database.Database,
|
|
showId: string,
|
|
sectionId: string,
|
|
allowReserve = false
|
|
): void {
|
|
const row = database
|
|
.prepare('SELECT is_reserve_bank FROM show_sections WHERE id = ? AND show_id = ?')
|
|
.get(sectionId, showId) as { is_reserve_bank: number } | undefined;
|
|
if (!row || (!allowReserve && row.is_reserve_bank === 1)) throw new Error('NOT_FOUND');
|
|
}
|
|
|
|
function usageSection(
|
|
database: Database.Database,
|
|
showId: string,
|
|
usageId: string
|
|
): { sectionId: string } {
|
|
const row = database
|
|
.prepare(
|
|
`SELECT u.show_section_id AS sectionId FROM piece_usages u
|
|
JOIN show_sections s ON s.id = u.show_section_id
|
|
WHERE u.id = ? AND s.show_id = ?`
|
|
)
|
|
.get(usageId, showId) as { sectionId: string } | undefined;
|
|
if (!row) throw new Error('NOT_FOUND');
|
|
return row;
|
|
}
|
|
|
|
function requireUsagesRemovable(
|
|
database: Database.Database,
|
|
showId: string,
|
|
usageIds: string[]
|
|
): void {
|
|
if (!usageIds.length) return;
|
|
const placeholders = usageIds.map(() => '?').join(', ');
|
|
const state = database
|
|
.prepare(
|
|
`SELECT current_usage_id, presentation_usage_id FROM show_live_states WHERE show_id = ?`
|
|
)
|
|
.get(showId) as
|
|
{ current_usage_id: string | null; presentation_usage_id: string | null } | undefined;
|
|
if (state?.current_usage_id && usageIds.includes(state.current_usage_id)) {
|
|
throw new Error('USAGE_IS_CURRENT');
|
|
}
|
|
if (state?.presentation_usage_id && usageIds.includes(state.presentation_usage_id)) {
|
|
throw new Error('USAGE_IS_PRESENTED');
|
|
}
|
|
if (
|
|
database
|
|
.prepare(`SELECT 1 FROM live_usage_runs WHERE usage_id IN (${placeholders}) LIMIT 1`)
|
|
.get(...usageIds)
|
|
) {
|
|
throw new Error('USAGE_HAS_LIVE_RUNS');
|
|
}
|
|
if (
|
|
database
|
|
.prepare(
|
|
`SELECT 1 FROM piece_usages WHERE id IN (${placeholders}) AND status != 'open' LIMIT 1`
|
|
)
|
|
.get(...usageIds)
|
|
) {
|
|
throw new Error('USAGE_NOT_OPEN');
|
|
}
|
|
}
|
|
|
|
function normalizePositions(
|
|
database: Database.Database,
|
|
table: 'show_sections' | 'piece_usages',
|
|
ids: string[]
|
|
): void {
|
|
if (!ids.length) return;
|
|
const placeholders = ids.map(() => '?').join(', ');
|
|
const maximum = database
|
|
.prepare(
|
|
`SELECT COALESCE(max(position), -1) AS maximum FROM ${table} WHERE id IN (${placeholders})`
|
|
)
|
|
.get(...ids) as { maximum: number };
|
|
const offset = maximum.maximum + ids.length + 1;
|
|
database
|
|
.prepare(`UPDATE ${table} SET position = position + ? WHERE id IN (${placeholders})`)
|
|
.run(offset, ...ids);
|
|
const update = database.prepare(`UPDATE ${table} SET position = ? WHERE id = ?`);
|
|
ids.forEach((id, position) => update.run(position, id));
|
|
}
|
|
|
|
function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage {
|
|
return {
|
|
id: row.id,
|
|
originalPieceId: row.original_piece_id,
|
|
position: row.position,
|
|
status: row.status,
|
|
preparationStatus: row.preparation_status_snapshot,
|
|
preparationEffort: row.preparation_effort_snapshot,
|
|
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,
|
|
interaction: row.interaction_snapshot,
|
|
transition: row.transition_snapshot,
|
|
presentation: row.presentation_snapshot,
|
|
presentationMode: row.presentation_mode_snapshot,
|
|
presentationSourceLabel: row.presentation_source_label_snapshot,
|
|
presentationSourceUrl: row.presentation_source_url_snapshot,
|
|
presentationSourceTimecodeSeconds: row.presentation_source_timecode_snapshot,
|
|
presentationTitle: row.presentation_title_meta_snapshot,
|
|
presentationDescription: row.presentation_description_snapshot,
|
|
presentationImageUrl: row.presentation_image_url_snapshot,
|
|
presentationSiteName: row.presentation_site_name_snapshot,
|
|
presentationProvider: row.presentation_provider_snapshot,
|
|
presentationProviderId: row.presentation_provider_id_snapshot,
|
|
ownNotes: row.own_notes,
|
|
sources: (
|
|
database
|
|
.prepare(
|
|
`SELECT id, label, url, timecode_seconds FROM usage_source_snapshots
|
|
WHERE usage_id = ? ORDER BY position`
|
|
)
|
|
.all(row.id) as {
|
|
id: string;
|
|
label: string;
|
|
url: string;
|
|
timecode_seconds: number | null;
|
|
}[]
|
|
).map((source) => ({
|
|
id: source.id,
|
|
label: source.label,
|
|
url: source.url,
|
|
timecodeSeconds: source.timecode_seconds
|
|
})),
|
|
tools: (
|
|
database
|
|
.prepare(
|
|
`SELECT id, original_tool_id, name, start_url, work_url, note
|
|
FROM usage_tool_snapshots WHERE usage_id = ? ORDER BY position`
|
|
)
|
|
.all(row.id) as {
|
|
id: string;
|
|
original_tool_id: string | null;
|
|
name: string;
|
|
start_url: string;
|
|
work_url: string;
|
|
note: string;
|
|
}[]
|
|
).map((tool) => ({
|
|
id: tool.id,
|
|
originalToolId: tool.original_tool_id,
|
|
name: tool.name,
|
|
startUrl: tool.start_url,
|
|
workUrl: tool.work_url,
|
|
note: tool.note
|
|
})),
|
|
topics: (
|
|
database
|
|
.prepare(
|
|
'SELECT name, icon_name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position'
|
|
)
|
|
.all(row.id) as { name: string; icon_name: string }[]
|
|
).map((topic) => ({ name: topic.name, iconName: topic.icon_name })),
|
|
media: (
|
|
database
|
|
.prepare(
|
|
`SELECT id, original_media_id, alt_text, position, is_presentation
|
|
FROM usage_media_snapshots WHERE usage_id = ? ORDER BY position`
|
|
)
|
|
.all(row.id) as {
|
|
id: string;
|
|
original_media_id: string;
|
|
alt_text: string;
|
|
position: number;
|
|
is_presentation: number;
|
|
}[]
|
|
).map((media) => ({
|
|
id: media.id,
|
|
originalMediaId: media.original_media_id,
|
|
altText: media.alt_text,
|
|
position: media.position,
|
|
isPresentation: media.is_presentation === 1
|
|
})),
|
|
presentationMediaId:
|
|
(
|
|
database
|
|
.prepare(
|
|
'SELECT id FROM usage_media_snapshots WHERE usage_id = ? AND is_presentation = 1'
|
|
)
|
|
.get(row.id) as { id: string } | undefined
|
|
)?.id ?? null
|
|
};
|
|
}
|
|
|
|
function snapshotPieceRelations(database: Database.Database, usageId: string, pieceId: string) {
|
|
database.prepare('DELETE FROM usage_source_snapshots WHERE usage_id = ?').run(usageId);
|
|
database.prepare('DELETE FROM usage_tool_snapshots WHERE usage_id = ?').run(usageId);
|
|
database.prepare('DELETE FROM usage_topic_snapshots WHERE usage_id = ?').run(usageId);
|
|
database.prepare('DELETE FROM usage_media_snapshots WHERE usage_id = ?').run(usageId);
|
|
const sources = database
|
|
.prepare(
|
|
`SELECT label, url, timecode_seconds, position FROM piece_sources
|
|
WHERE piece_id = ? ORDER BY position`
|
|
)
|
|
.all(pieceId) as {
|
|
label: string;
|
|
url: string;
|
|
timecode_seconds: number | null;
|
|
position: number;
|
|
}[];
|
|
const addSource = database.prepare(
|
|
`INSERT INTO usage_source_snapshots (id, usage_id, label, url, timecode_seconds, position)
|
|
VALUES (?, ?, ?, ?, ?, ?)`
|
|
);
|
|
for (const source of sources) {
|
|
addSource.run(
|
|
randomUUID(),
|
|
usageId,
|
|
source.label,
|
|
source.url,
|
|
source.timecode_seconds,
|
|
source.position
|
|
);
|
|
}
|
|
const tools = database
|
|
.prepare(
|
|
`SELECT pt.tool_id, t.name, t.start_url, pt.work_url, pt.note, pt.position
|
|
FROM piece_tools pt JOIN tools t ON t.id = pt.tool_id
|
|
WHERE pt.piece_id = ? ORDER BY pt.position`
|
|
)
|
|
.all(pieceId) as {
|
|
tool_id: string;
|
|
name: string;
|
|
start_url: string;
|
|
work_url: string;
|
|
note: string;
|
|
position: number;
|
|
}[];
|
|
const addTool = database.prepare(
|
|
`INSERT INTO usage_tool_snapshots
|
|
(id, usage_id, original_tool_id, name, start_url, work_url, note, position)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
);
|
|
for (const tool of tools) {
|
|
addTool.run(
|
|
randomUUID(),
|
|
usageId,
|
|
tool.tool_id,
|
|
tool.name,
|
|
tool.start_url,
|
|
tool.work_url,
|
|
tool.note,
|
|
tool.position
|
|
);
|
|
}
|
|
const topics = database
|
|
.prepare(
|
|
`SELECT t.name, t.icon_name FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id
|
|
WHERE pt.piece_id = ? ORDER BY t.name, t.id`
|
|
)
|
|
.all(pieceId) as { name: string; icon_name: string }[];
|
|
const addTopic = database.prepare(
|
|
'INSERT INTO usage_topic_snapshots (usage_id, name, icon_name, position) VALUES (?, ?, ?, ?)'
|
|
);
|
|
topics.forEach((topic, position) => addTopic.run(usageId, topic.name, topic.icon_name, position));
|
|
const media = database
|
|
.prepare(
|
|
`SELECT pm.id, pm.alt_text, pm.position,
|
|
CASE WHEN ppm.media_id IS NULL THEN 0 ELSE 1 END AS is_presentation
|
|
FROM piece_media pm
|
|
LEFT JOIN piece_presentation_media ppm
|
|
ON ppm.piece_id = pm.piece_id AND ppm.media_id = pm.id
|
|
WHERE pm.piece_id = ? AND pm.removed_at_utc IS NULL ORDER BY pm.position`
|
|
)
|
|
.all(pieceId) as {
|
|
id: string;
|
|
alt_text: string;
|
|
position: number;
|
|
is_presentation: number;
|
|
}[];
|
|
const addMedia = database.prepare(
|
|
`INSERT INTO usage_media_snapshots
|
|
(id, usage_id, original_media_id, alt_text, position, is_presentation)
|
|
VALUES (?, ?, ?, ?, ?, ?)`
|
|
);
|
|
for (const item of media) {
|
|
addMedia.run(
|
|
randomUUID(),
|
|
usageId,
|
|
item.id,
|
|
item.alt_text,
|
|
item.position,
|
|
item.is_presentation
|
|
);
|
|
}
|
|
}
|
|
|
|
function copyUsageRelations(database: Database.Database, sourceUsageId: string, usageId: string) {
|
|
const sources = database
|
|
.prepare(
|
|
`SELECT label, url, timecode_seconds, position FROM usage_source_snapshots
|
|
WHERE usage_id = ? ORDER BY position`
|
|
)
|
|
.all(sourceUsageId) as {
|
|
label: string;
|
|
url: string;
|
|
timecode_seconds: number | null;
|
|
position: number;
|
|
}[];
|
|
const addSource = database.prepare(
|
|
`INSERT INTO usage_source_snapshots (id, usage_id, label, url, timecode_seconds, position)
|
|
VALUES (?, ?, ?, ?, ?, ?)`
|
|
);
|
|
for (const source of sources)
|
|
addSource.run(
|
|
randomUUID(),
|
|
usageId,
|
|
source.label,
|
|
source.url,
|
|
source.timecode_seconds,
|
|
source.position
|
|
);
|
|
const tools = database
|
|
.prepare(
|
|
`SELECT original_tool_id, name, start_url, work_url, note, position
|
|
FROM usage_tool_snapshots WHERE usage_id = ? ORDER BY position`
|
|
)
|
|
.all(sourceUsageId) as {
|
|
original_tool_id: string | null;
|
|
name: string;
|
|
start_url: string;
|
|
work_url: string;
|
|
note: string;
|
|
position: number;
|
|
}[];
|
|
const addTool = database.prepare(
|
|
`INSERT INTO usage_tool_snapshots
|
|
(id, usage_id, original_tool_id, name, start_url, work_url, note, position)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
);
|
|
for (const tool of tools)
|
|
addTool.run(
|
|
randomUUID(),
|
|
usageId,
|
|
tool.original_tool_id,
|
|
tool.name,
|
|
tool.start_url,
|
|
tool.work_url,
|
|
tool.note,
|
|
tool.position
|
|
);
|
|
database
|
|
.prepare(
|
|
`INSERT INTO usage_topic_snapshots (usage_id, name, icon_name, position)
|
|
SELECT ?, name, icon_name, position FROM usage_topic_snapshots WHERE usage_id = ?`
|
|
)
|
|
.run(usageId, sourceUsageId);
|
|
database
|
|
.prepare(
|
|
`INSERT INTO usage_media_snapshots
|
|
(id, usage_id, original_media_id, alt_text, position, is_presentation)
|
|
SELECT lower(hex(randomblob(16))), ?, original_media_id, alt_text, position, is_presentation
|
|
FROM usage_media_snapshots WHERE usage_id = ?`
|
|
)
|
|
.run(usageId, sourceUsageId);
|
|
}
|
|
|
|
export function listShows(statuses?: ShowStatus[]): ShowListItem[] {
|
|
const requestedStatuses = statuses?.filter((status) => SHOW_STATUSES.includes(status));
|
|
const where = requestedStatuses?.length
|
|
? `WHERE sh.status IN (${requestedStatuses.map(() => '?').join(', ')})`
|
|
: '';
|
|
return (
|
|
getDatabase()
|
|
.prepare(
|
|
`SELECT sh.id, sh.title, sh.starts_at_utc, sh.status, sh.revision,
|
|
count(DISTINCT CASE WHEN s.is_reserve_bank = 0 THEN s.id END) AS section_count,
|
|
COALESCE(sum(CASE WHEN s.is_reserve_bank = 0 THEN
|
|
COALESCE(s.explicit_duration_seconds,
|
|
(SELECT sum(u.duration_seconds) FROM piece_usages u WHERE u.show_section_id = s.id))
|
|
ELSE 0 END), 0) AS total_duration_seconds
|
|
FROM shows sh LEFT JOIN show_sections s ON s.show_id = sh.id
|
|
${where}
|
|
GROUP BY sh.id
|
|
ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc DESC, sh.created_at_utc DESC`
|
|
)
|
|
.all(...(requestedStatuses ?? [])) as {
|
|
id: string;
|
|
title: string;
|
|
starts_at_utc: string | null;
|
|
status: ShowStatus;
|
|
revision: number;
|
|
section_count: number;
|
|
total_duration_seconds: number;
|
|
}[]
|
|
).map((row) => ({
|
|
id: row.id,
|
|
title: row.title,
|
|
startsAtUtc: row.starts_at_utc,
|
|
status: row.status,
|
|
revision: row.revision,
|
|
totalDurationSeconds: row.total_duration_seconds,
|
|
sectionCount: row.section_count
|
|
}));
|
|
}
|
|
|
|
export function listPreparationUsages(): PreparationUsage[] {
|
|
const database = getDatabase();
|
|
const rows = database
|
|
.prepare(
|
|
`SELECT u.id AS usage_id, sh.id AS show_id, sh.title AS show_title,
|
|
sh.starts_at_utc AS show_starts_at_utc, s.name AS section_name,
|
|
u.title_snapshot, u.preparation_effort_snapshot
|
|
FROM piece_usages u
|
|
JOIN show_sections s ON s.id = u.show_section_id
|
|
JOIN shows sh ON sh.id = s.show_id
|
|
WHERE sh.status IN ('draft', 'ready', 'live')
|
|
AND s.is_reserve_bank = 0 AND u.preparation_status_snapshot = 'preparing'
|
|
ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc,
|
|
CASE u.preparation_effort_snapshot
|
|
WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END,
|
|
s.position, u.position`
|
|
)
|
|
.all() as {
|
|
usage_id: string;
|
|
show_id: string;
|
|
show_title: string;
|
|
show_starts_at_utc: string | null;
|
|
section_name: string;
|
|
title_snapshot: string;
|
|
preparation_effort_snapshot: PreparationUsage['preparationEffort'];
|
|
}[];
|
|
const topics = database.prepare(
|
|
'SELECT name, icon_name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position'
|
|
);
|
|
return rows.map((row) => ({
|
|
usageId: row.usage_id,
|
|
showId: row.show_id,
|
|
showTitle: row.show_title,
|
|
showStartsAtUtc: row.show_starts_at_utc,
|
|
sectionName: row.section_name,
|
|
title: row.title_snapshot,
|
|
preparationEffort: row.preparation_effort_snapshot,
|
|
topics: (topics.all(row.usage_id) as { name: string; icon_name: string }[]).map((topic) => ({
|
|
name: topic.name,
|
|
iconName: topic.icon_name
|
|
}))
|
|
}));
|
|
}
|
|
|
|
export function listShowTemplates(): ShowTemplate[] {
|
|
const database = getDatabase();
|
|
const templates = database
|
|
.prepare('SELECT id, name, description FROM show_templates ORDER BY name')
|
|
.all() as Omit<ShowTemplate, 'sections'>[];
|
|
const sectionStatement = database.prepare(
|
|
`SELECT id, name, category_id, position, explicit_duration_seconds, twitch_tags
|
|
FROM show_template_sections WHERE template_id = ? ORDER BY position`
|
|
);
|
|
return templates.map((template) => ({
|
|
...template,
|
|
sections: (
|
|
sectionStatement.all(template.id) as {
|
|
id: string;
|
|
name: string;
|
|
category_id: string | null;
|
|
position: number;
|
|
explicit_duration_seconds: number | null;
|
|
twitch_tags: string;
|
|
}[]
|
|
).map((section) => ({
|
|
id: section.id,
|
|
name: section.name,
|
|
categoryId: section.category_id,
|
|
position: section.position,
|
|
explicitDurationSeconds: section.explicit_duration_seconds,
|
|
twitchTags: section.twitch_tags ? section.twitch_tags.split(',') : []
|
|
}))
|
|
}));
|
|
}
|
|
|
|
export function getShow(id: string): Show | null {
|
|
const database = getDatabase();
|
|
const show = database
|
|
.prepare(
|
|
`SELECT id, title, starts_at_utc, twitch_title, announcement_text, status, revision,
|
|
created_at_utc, updated_at_utc, completed_at_utc FROM shows WHERE id = ?`
|
|
)
|
|
.get(id) as ShowRow | undefined;
|
|
if (!show) return null;
|
|
const sectionRows = database
|
|
.prepare(
|
|
`SELECT s.id, s.name, s.category_id, c.name AS category, c.color AS category_color,
|
|
s.category_name_snapshot, s.category_color_snapshot,
|
|
s.position, s.explicit_duration_seconds, s.twitch_tags, s.is_reserve_bank
|
|
FROM show_sections s LEFT JOIN section_categories c ON c.id = s.category_id
|
|
WHERE s.show_id = ? ORDER BY s.position`
|
|
)
|
|
.all(id) as SectionRow[];
|
|
const usageRows = database
|
|
.prepare(
|
|
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
|
|
u.preparation_status_snapshot, u.preparation_effort_snapshot,
|
|
u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot,
|
|
u.moderation_snapshot, u.interaction_snapshot, u.transition_snapshot, u.presentation_snapshot,
|
|
CASE WHEN u.presentation_image_only_snapshot = 1 THEN 'image' ELSE u.presentation_mode_snapshot END AS presentation_mode_snapshot,
|
|
u.presentation_image_only_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,
|
|
u.presentation_image_url_snapshot, u.presentation_site_name_snapshot,
|
|
u.presentation_provider_snapshot, u.presentation_provider_id_snapshot, u.own_notes
|
|
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
|
|
WHERE s.show_id = ? ORDER BY s.position, u.position`
|
|
)
|
|
.all(id) as UsageRow[];
|
|
const usagesBySection = new Map<string, PieceUsage[]>();
|
|
for (const row of usageRows) {
|
|
const usages = usagesBySection.get(row.show_section_id) ?? [];
|
|
usages.push(usageFromRow(database, row));
|
|
usagesBySection.set(row.show_section_id, usages);
|
|
}
|
|
const sections = sectionRows
|
|
.filter((row) => row.is_reserve_bank === 0)
|
|
.map((row) => {
|
|
const usages = usagesBySection.get(row.id) ?? [];
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
categoryId: row.category_id,
|
|
category:
|
|
show.status === 'completed'
|
|
? row.category_name_snapshot
|
|
: (row.category_name_snapshot ?? row.category),
|
|
categoryColor:
|
|
show.status === 'completed'
|
|
? row.category_color_snapshot
|
|
: (row.category_color_snapshot ?? row.category_color),
|
|
position: row.position,
|
|
explicitDurationSeconds: row.explicit_duration_seconds,
|
|
twitchTags: row.twitch_tags ? row.twitch_tags.split(',') : [],
|
|
durationSeconds:
|
|
row.explicit_duration_seconds ??
|
|
usages.reduce((total, usage) => total + usage.durationSeconds, 0),
|
|
usages
|
|
};
|
|
});
|
|
const reserveRow = sectionRows.find((row) => row.is_reserve_bank === 1);
|
|
return {
|
|
id: show.id,
|
|
title: show.title,
|
|
startsAtUtc: show.starts_at_utc,
|
|
twitchTitle: show.twitch_title,
|
|
announcementText: show.announcement_text,
|
|
status: show.status,
|
|
revision: show.revision,
|
|
createdAtUtc: show.created_at_utc,
|
|
updatedAtUtc: show.updated_at_utc,
|
|
completedAtUtc: show.completed_at_utc,
|
|
sections,
|
|
reserve: reserveRow ? (usagesBySection.get(reserveRow.id) ?? []) : [],
|
|
totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0)
|
|
};
|
|
}
|
|
|
|
export function getPieceUsageContexts(
|
|
pieceIds: string[],
|
|
currentShowId?: string
|
|
): Record<string, PieceUsageContext> {
|
|
const uniquePieceIds = [...new Set(pieceIds.filter(Boolean))];
|
|
const contexts = Object.fromEntries(
|
|
uniquePieceIds.map((pieceId) => [
|
|
pieceId,
|
|
{
|
|
sentHistory: [],
|
|
isPlannedInActiveShow: false,
|
|
isPlannedInCurrentShow: false
|
|
} satisfies PieceUsageContext
|
|
])
|
|
) as Record<string, PieceUsageContext>;
|
|
if (!uniquePieceIds.length) return contexts;
|
|
|
|
const database = getDatabase();
|
|
const history = database
|
|
.prepare(
|
|
`SELECT usage_id, original_piece_id, show_id,
|
|
show_title_snapshot AS show_title, used_at_utc
|
|
FROM piece_usage_history ORDER BY used_at_utc DESC, usage_id DESC`
|
|
)
|
|
.all() as {
|
|
usage_id: string;
|
|
original_piece_id: string;
|
|
show_id: string;
|
|
show_title: string;
|
|
used_at_utc: string;
|
|
}[];
|
|
for (const usage of history) {
|
|
contexts[usage.original_piece_id]?.sentHistory.push({
|
|
usageId: usage.usage_id,
|
|
showId: usage.show_id,
|
|
showTitle: usage.show_title,
|
|
usedAtUtc: usage.used_at_utc
|
|
});
|
|
}
|
|
const plannedInActiveShows = database
|
|
.prepare(
|
|
`SELECT DISTINCT u.original_piece_id FROM piece_usages u
|
|
JOIN show_sections s ON s.id = u.show_section_id
|
|
JOIN shows sh ON sh.id = s.show_id
|
|
WHERE sh.status != 'completed' AND u.original_piece_id IS NOT NULL`
|
|
)
|
|
.all() as { original_piece_id: string }[];
|
|
for (const usage of plannedInActiveShows) {
|
|
const context = contexts[usage.original_piece_id];
|
|
if (context) context.isPlannedInActiveShow = true;
|
|
}
|
|
|
|
if (currentShowId) {
|
|
const planned = database
|
|
.prepare(
|
|
`SELECT DISTINCT u.original_piece_id FROM piece_usages u
|
|
JOIN show_sections s ON s.id = u.show_section_id
|
|
WHERE s.show_id = ? AND u.original_piece_id IS NOT NULL`
|
|
)
|
|
.all(currentShowId) as { original_piece_id: string }[];
|
|
for (const usage of planned) {
|
|
const context = contexts[usage.original_piece_id];
|
|
if (context) context.isPlannedInCurrentShow = true;
|
|
}
|
|
}
|
|
|
|
return contexts;
|
|
}
|
|
|
|
export function createShow(input: CreateShowInput): CreatedMutationResult {
|
|
const database = getDatabase();
|
|
const id = randomUUID();
|
|
const title = cleanText(input.title ?? 'Neuer Stream', 200, 'title', true);
|
|
const startsAtUtc = validTimestamp(input.startsAtUtc);
|
|
const now = new Date().toISOString();
|
|
const create = database.transaction(() => {
|
|
if (
|
|
input.templateId &&
|
|
!database.prepare('SELECT 1 FROM show_templates WHERE id = ?').get(input.templateId)
|
|
) {
|
|
throw new Error('INVALID_TEMPLATE');
|
|
}
|
|
database
|
|
.prepare(
|
|
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text,
|
|
status, revision, created_at_utc, updated_at_utc)
|
|
VALUES (?, ?, ?, '', '', 'draft', 1, ?, ?)`
|
|
)
|
|
.run(id, title, startsAtUtc, now, now);
|
|
const templateSections = input.templateId
|
|
? (database
|
|
.prepare(
|
|
`SELECT name, category_id, position, explicit_duration_seconds, twitch_tags
|
|
FROM show_template_sections WHERE template_id = ? ORDER BY position`
|
|
)
|
|
.all(input.templateId) as {
|
|
name: string;
|
|
category_id: string | null;
|
|
position: number;
|
|
explicit_duration_seconds: number | null;
|
|
twitch_tags: string;
|
|
}[])
|
|
: [];
|
|
const insertSection = database.prepare(
|
|
`INSERT INTO show_sections (id, show_id, name, category_id, position,
|
|
explicit_duration_seconds, twitch_tags, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
);
|
|
for (const section of templateSections) {
|
|
insertSection.run(
|
|
randomUUID(),
|
|
id,
|
|
section.name,
|
|
section.category_id,
|
|
section.position,
|
|
section.explicit_duration_seconds,
|
|
section.twitch_tags,
|
|
0
|
|
);
|
|
}
|
|
insertSection.run(randomUUID(), id, 'Reservebank', null, templateSections.length, null, '', 1);
|
|
});
|
|
create();
|
|
return { id, revision: 1, updatedAtUtc: now };
|
|
}
|
|
|
|
export function duplicateCompletedShow(
|
|
sourceShowId: string,
|
|
commandId: string
|
|
): CreatedMutationResult {
|
|
if (!commandId || commandId.length > 200) throw new Error('INVALID_COMMAND');
|
|
const database = getDatabase();
|
|
return database.transaction(() => {
|
|
const duplicate = database
|
|
.prepare(
|
|
`SELECT created_show_id FROM show_duplication_commands
|
|
WHERE source_show_id = ? AND command_id = ?`
|
|
)
|
|
.get(sourceShowId, commandId) as { created_show_id: string } | undefined;
|
|
if (duplicate) {
|
|
const show = database
|
|
.prepare('SELECT revision, updated_at_utc FROM shows WHERE id = ?')
|
|
.get(duplicate.created_show_id) as { revision: number; updated_at_utc: string };
|
|
return {
|
|
id: duplicate.created_show_id,
|
|
revision: show.revision,
|
|
updatedAtUtc: show.updated_at_utc
|
|
};
|
|
}
|
|
const source = database
|
|
.prepare(
|
|
`SELECT sh.title, sh.twitch_title, sh.announcement_text
|
|
FROM shows sh JOIN show_finalizations f ON f.show_id = sh.id WHERE sh.id = ?`
|
|
)
|
|
.get(sourceShowId) as
|
|
{ title: string; twitch_title: string; announcement_text: string } | undefined;
|
|
if (!source) throw new Error('NOT_FOUND');
|
|
const id = randomUUID();
|
|
const now = new Date().toISOString();
|
|
const title = `${source.title} (Kopie)`.slice(0, 200);
|
|
database
|
|
.prepare(
|
|
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text,
|
|
status, revision, created_at_utc, updated_at_utc)
|
|
VALUES (?, ?, NULL, ?, ?, 'draft', 1, ?, ?)`
|
|
)
|
|
.run(id, title, source.twitch_title, source.announcement_text, now, now);
|
|
|
|
const sections = database
|
|
.prepare(
|
|
`SELECT id, name, category_id, category_name_snapshot, category_color_snapshot,
|
|
position, explicit_duration_seconds, twitch_tags, is_reserve_bank
|
|
FROM show_sections WHERE show_id = ? ORDER BY position`
|
|
)
|
|
.all(sourceShowId) as {
|
|
id: string;
|
|
name: string;
|
|
category_id: string | null;
|
|
category_name_snapshot: string | null;
|
|
category_color_snapshot: string | null;
|
|
position: number;
|
|
explicit_duration_seconds: number | null;
|
|
twitch_tags: string;
|
|
is_reserve_bank: number;
|
|
}[];
|
|
const insertSection = database.prepare(
|
|
`INSERT INTO show_sections (id, show_id, name, category_id, category_name_snapshot,
|
|
category_color_snapshot, position, explicit_duration_seconds, twitch_tags, is_reserve_bank)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
);
|
|
const usages = database.prepare(
|
|
'SELECT * FROM piece_usages WHERE show_section_id = ? ORDER BY position'
|
|
);
|
|
const insertUsage = database.prepare(
|
|
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
|
|
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
|
|
moderation_snapshot, interaction_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, presentation_image_only_snapshot,
|
|
preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
|
|
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
);
|
|
for (const section of sections) {
|
|
const sectionId = randomUUID();
|
|
insertSection.run(
|
|
sectionId,
|
|
id,
|
|
section.name,
|
|
section.category_id,
|
|
section.category_name_snapshot,
|
|
section.category_color_snapshot,
|
|
section.position,
|
|
section.explicit_duration_seconds,
|
|
section.twitch_tags,
|
|
section.is_reserve_bank
|
|
);
|
|
for (const usage of usages.all(section.id) as (UsageRow & { is_reserve: number })[]) {
|
|
const usageId = randomUUID();
|
|
insertUsage.run(
|
|
usageId,
|
|
sectionId,
|
|
usage.original_piece_id,
|
|
usage.position,
|
|
usage.duration_seconds,
|
|
usage.title_snapshot,
|
|
usage.output_title_snapshot,
|
|
usage.intro_snapshot,
|
|
usage.research_snapshot,
|
|
usage.moderation_snapshot,
|
|
usage.interaction_snapshot,
|
|
usage.transition_snapshot,
|
|
usage.presentation_snapshot,
|
|
usage.presentation_mode_snapshot,
|
|
usage.presentation_source_label_snapshot,
|
|
usage.presentation_source_url_snapshot,
|
|
usage.presentation_source_timecode_snapshot,
|
|
usage.presentation_title_meta_snapshot,
|
|
usage.presentation_description_snapshot,
|
|
usage.presentation_image_url_snapshot,
|
|
usage.presentation_site_name_snapshot,
|
|
usage.presentation_provider_snapshot,
|
|
usage.presentation_provider_id_snapshot,
|
|
usage.presentation_image_only_snapshot,
|
|
usage.preparation_status_snapshot,
|
|
usage.preparation_effort_snapshot,
|
|
usage.own_notes,
|
|
usage.is_reserve
|
|
);
|
|
copyUsageRelations(database, usage.id, usageId);
|
|
}
|
|
}
|
|
database
|
|
.prepare(
|
|
`INSERT INTO show_duplication_commands
|
|
(source_show_id, command_id, created_show_id, created_at_utc) VALUES (?, ?, ?, ?)`
|
|
)
|
|
.run(sourceShowId, commandId, id, now);
|
|
return { id, revision: 1, updatedAtUtc: now };
|
|
})();
|
|
}
|
|
|
|
export function saveShowMetadata(
|
|
showId: string,
|
|
expectedRevision: number,
|
|
input: ShowMetadataInput
|
|
): MutationResult {
|
|
const title = cleanText(input.title, 200, 'title', true);
|
|
const startsAtUtc = validTimestamp(input.startsAtUtc);
|
|
const twitchTitle = cleanText(input.twitchTitle, 200, 'twitch_title');
|
|
const announcementText = cleanText(input.announcementText, 20_000, 'announcement');
|
|
if (!['draft', 'ready', 'live'].includes(input.status)) throw new Error('INVALID_STATUS');
|
|
const database = getDatabase();
|
|
return database.transaction(() => {
|
|
const result = bumpShow(database, showId, expectedRevision);
|
|
database
|
|
.prepare(
|
|
`UPDATE shows SET title = ?, starts_at_utc = ?, twitch_title = ?,
|
|
announcement_text = ?, status = ? WHERE id = ?`
|
|
)
|
|
.run(title, startsAtUtc, twitchTitle, announcementText, input.status, showId);
|
|
return result;
|
|
})();
|
|
}
|
|
|
|
export function addShowSection(
|
|
showId: string,
|
|
expectedRevision: number,
|
|
input: ShowSectionInput
|
|
): CreatedMutationResult {
|
|
const name = cleanText(input.name, 200, 'section_name', true);
|
|
const duration = validDuration(input.explicitDurationSeconds);
|
|
const twitchTags = validTwitchTags(input.twitchTags ?? []);
|
|
const database = getDatabase();
|
|
requireCategory(database, input.categoryId);
|
|
return database.transaction(() => {
|
|
const result = bumpShow(database, showId, expectedRevision);
|
|
const visibleIds = (
|
|
database
|
|
.prepare(
|
|
`SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 0 ORDER BY position`
|
|
)
|
|
.all(showId) as { id: string }[]
|
|
).map((row) => row.id);
|
|
const position = validPosition(input.position ?? visibleIds.length, visibleIds.length);
|
|
const id = randomUUID();
|
|
const allIds = (
|
|
database
|
|
.prepare('SELECT id FROM show_sections WHERE show_id = ? ORDER BY position')
|
|
.all(showId) as {
|
|
id: string;
|
|
}[]
|
|
).map((row) => row.id);
|
|
const temporaryPosition = allIds.length;
|
|
database
|
|
.prepare(
|
|
`INSERT INTO show_sections (id, show_id, name, category_id, position,
|
|
explicit_duration_seconds, twitch_tags, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?, 0)`
|
|
)
|
|
.run(id, showId, name, input.categoryId, temporaryPosition, duration, twitchTags.join(','));
|
|
visibleIds.splice(position, 0, id);
|
|
const reserveId = reserveSectionId(database, showId);
|
|
normalizePositions(database, 'show_sections', [...visibleIds, reserveId]);
|
|
return { ...result, id };
|
|
})();
|
|
}
|
|
|
|
export function updateShowSection(
|
|
showId: string,
|
|
sectionId: string,
|
|
expectedRevision: number,
|
|
input: Omit<ShowSectionInput, 'position'>
|
|
): MutationResult {
|
|
const name = cleanText(input.name, 200, 'section_name', true);
|
|
const duration = validDuration(input.explicitDurationSeconds);
|
|
const twitchTags = validTwitchTags(input.twitchTags ?? []);
|
|
const database = getDatabase();
|
|
requireCategory(database, input.categoryId);
|
|
return database.transaction(() => {
|
|
const result = bumpShow(database, showId, expectedRevision);
|
|
sectionBelongsToShow(database, showId, sectionId);
|
|
database
|
|
.prepare(
|
|
`UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ?, twitch_tags = ?,
|
|
category_name_snapshot = NULL, category_color_snapshot = NULL
|
|
WHERE id = ?`
|
|
)
|
|
.run(name, input.categoryId, duration, twitchTags.join(','), sectionId);
|
|
return result;
|
|
})();
|
|
}
|
|
|
|
export function removeShowSection(
|
|
showId: string,
|
|
sectionId: string,
|
|
expectedRevision: number
|
|
): MutationResult {
|
|
const database = getDatabase();
|
|
return database.transaction(() => {
|
|
const result = bumpShow(database, showId, expectedRevision);
|
|
sectionBelongsToShow(database, showId, sectionId);
|
|
const usageIds = (
|
|
database.prepare('SELECT id FROM piece_usages WHERE show_section_id = ?').all(sectionId) as {
|
|
id: string;
|
|
}[]
|
|
).map((usage) => usage.id);
|
|
requireUsagesRemovable(database, showId, usageIds);
|
|
database.prepare('DELETE FROM show_sections WHERE id = ?').run(sectionId);
|
|
const ids = (
|
|
database
|
|
.prepare('SELECT id FROM show_sections WHERE show_id = ? ORDER BY position')
|
|
.all(showId) as {
|
|
id: string;
|
|
}[]
|
|
).map((row) => row.id);
|
|
normalizePositions(database, 'show_sections', ids);
|
|
return result;
|
|
})();
|
|
}
|
|
|
|
export function moveShowSection(
|
|
showId: string,
|
|
sectionId: string,
|
|
targetPosition: number,
|
|
expectedRevision: number
|
|
): MutationResult {
|
|
const database = getDatabase();
|
|
return database.transaction(() => {
|
|
const result = bumpShow(database, showId, expectedRevision);
|
|
sectionBelongsToShow(database, showId, sectionId);
|
|
const ids = (
|
|
database
|
|
.prepare(
|
|
`SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 0 ORDER BY position`
|
|
)
|
|
.all(showId) as { id: string }[]
|
|
).map((row) => row.id);
|
|
const current = ids.indexOf(sectionId);
|
|
ids.splice(current, 1);
|
|
ids.splice(validPosition(targetPosition, ids.length), 0, sectionId);
|
|
ids.push(reserveSectionId(database, showId));
|
|
normalizePositions(database, 'show_sections', ids);
|
|
return result;
|
|
})();
|
|
}
|
|
|
|
export function addPieceUsage(
|
|
showId: string,
|
|
expectedRevision: number,
|
|
input: PieceUsageInput
|
|
): CreatedMutationResult {
|
|
const database = getDatabase();
|
|
return database.transaction(() => {
|
|
const result = bumpShow(database, showId, expectedRevision);
|
|
const sectionId = input.sectionId ?? reserveSectionId(database, showId);
|
|
sectionBelongsToShow(database, showId, sectionId, input.sectionId === null);
|
|
const piece = database
|
|
.prepare(
|
|
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes,
|
|
p.interaction_text, p.transition_text, p.presentation_text, p.status,
|
|
CASE WHEN p.presentation_image_only = 1 THEN 'image' ELSE p.presentation_mode END AS presentation_mode,
|
|
COALESCE(ps.label, '') AS presentation_source_label,
|
|
COALESCE(ps.url, '') AS presentation_source_url,
|
|
ps.timecode_seconds AS presentation_source_timecode,
|
|
p.presentation_title, p.presentation_description, p.presentation_image_url,
|
|
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id,
|
|
p.preparation_effort
|
|
FROM pieces p LEFT JOIN piece_sources ps ON ps.piece_id = p.id
|
|
AND ps.position = p.presentation_source_position WHERE p.id = ?`
|
|
)
|
|
.get(input.pieceId) as
|
|
| {
|
|
title: string;
|
|
output_title: string;
|
|
duration_seconds: number;
|
|
intro: string;
|
|
research_text: string;
|
|
moderation_notes: string;
|
|
interaction_text: string;
|
|
transition_text: string;
|
|
presentation_text: string;
|
|
presentation_mode: PresentationMode;
|
|
presentation_source_label: string;
|
|
presentation_source_url: string;
|
|
presentation_source_timecode: number | null;
|
|
presentation_title: string;
|
|
presentation_description: string;
|
|
presentation_image_url: string;
|
|
presentation_site_name: string;
|
|
presentation_provider: string;
|
|
presentation_provider_id: string;
|
|
status: string;
|
|
preparation_effort: PreparationEffort;
|
|
}
|
|
| undefined;
|
|
if (!piece) throw new Error('NOT_FOUND');
|
|
if (
|
|
!database
|
|
.prepare('SELECT 1 FROM pieces WHERE id = ? AND deleted_at_utc IS NULL')
|
|
.get(input.pieceId)
|
|
) {
|
|
throw new Error('NOT_FOUND');
|
|
}
|
|
if (!['preparing', 'ready'].includes(piece.status)) throw new Error('PIECE_NOT_READY');
|
|
const ids = (
|
|
database
|
|
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
|
|
.all(sectionId) as { id: string }[]
|
|
).map((row) => row.id);
|
|
const position = validPosition(input.position ?? ids.length, ids.length);
|
|
const id = randomUUID();
|
|
database
|
|
.prepare(
|
|
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
|
|
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
|
|
moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
|
|
presentation_mode_snapshot, presentation_image_only_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,
|
|
preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
|
|
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
|
|
)
|
|
.run(
|
|
id,
|
|
sectionId,
|
|
input.pieceId,
|
|
ids.length,
|
|
piece.duration_seconds,
|
|
piece.title,
|
|
piece.output_title,
|
|
piece.intro,
|
|
piece.research_text,
|
|
piece.moderation_notes,
|
|
piece.interaction_text,
|
|
piece.transition_text,
|
|
piece.presentation_text,
|
|
piece.presentation_mode === 'image' ? 'text' : piece.presentation_mode,
|
|
piece.presentation_mode === 'image' ? 1 : 0,
|
|
piece.presentation_source_label,
|
|
piece.presentation_source_url,
|
|
piece.presentation_source_timecode,
|
|
piece.presentation_title,
|
|
piece.presentation_description,
|
|
piece.presentation_image_url,
|
|
piece.presentation_site_name,
|
|
piece.presentation_provider,
|
|
piece.presentation_provider_id,
|
|
piece.status,
|
|
piece.preparation_effort,
|
|
input.sectionId === null ? 1 : 0
|
|
);
|
|
snapshotPieceRelations(database, id, input.pieceId);
|
|
ids.splice(position, 0, id);
|
|
normalizePositions(database, 'piece_usages', ids);
|
|
return { ...result, id };
|
|
})();
|
|
}
|
|
|
|
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,
|
|
expectedRevision: number
|
|
): CreatedMutationResult {
|
|
const database = getDatabase();
|
|
return database.transaction(() => {
|
|
const result = bumpShow(database, showId, expectedRevision);
|
|
const sourceSection = usageSection(database, showId, usageId);
|
|
const source = database
|
|
.prepare(
|
|
`SELECT original_piece_id, status, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot,
|
|
research_snapshot, moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
|
|
presentation_mode_snapshot, presentation_image_only_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,
|
|
preparation_status_snapshot, preparation_effort_snapshot,
|
|
own_notes, is_reserve FROM piece_usages WHERE id = ?`
|
|
)
|
|
.get(usageId) as Omit<UsageRow, 'id' | 'show_section_id' | 'position'> & {
|
|
is_reserve: number;
|
|
};
|
|
const ids = (
|
|
database
|
|
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
|
|
.all(sourceSection.sectionId) as { id: string }[]
|
|
).map((row) => row.id);
|
|
const sourcePosition = ids.indexOf(usageId);
|
|
if (sourcePosition < 0) throw new Error('NOT_FOUND');
|
|
const id = randomUUID();
|
|
database
|
|
.prepare(
|
|
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
|
|
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
|
|
moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
|
|
presentation_mode_snapshot, presentation_image_only_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,
|
|
preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
|
|
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
id,
|
|
sourceSection.sectionId,
|
|
source.original_piece_id,
|
|
ids.length,
|
|
source.duration_seconds,
|
|
source.title_snapshot,
|
|
source.output_title_snapshot,
|
|
source.intro_snapshot,
|
|
source.research_snapshot,
|
|
source.moderation_snapshot,
|
|
source.interaction_snapshot,
|
|
source.transition_snapshot,
|
|
source.presentation_snapshot,
|
|
source.presentation_mode_snapshot,
|
|
source.presentation_image_only_snapshot,
|
|
source.presentation_source_label_snapshot,
|
|
source.presentation_source_url_snapshot,
|
|
source.presentation_source_timecode_snapshot,
|
|
source.presentation_title_meta_snapshot,
|
|
source.presentation_description_snapshot,
|
|
source.presentation_image_url_snapshot,
|
|
source.presentation_site_name_snapshot,
|
|
source.presentation_provider_snapshot,
|
|
source.presentation_provider_id_snapshot,
|
|
source.preparation_status_snapshot,
|
|
source.preparation_effort_snapshot,
|
|
source.own_notes,
|
|
source.is_reserve
|
|
);
|
|
copyUsageRelations(database, usageId, id);
|
|
ids.splice(sourcePosition + 1, 0, id);
|
|
normalizePositions(database, 'piece_usages', ids);
|
|
return { ...result, id };
|
|
})();
|
|
}
|
|
|
|
export function refreshPieceUsageFromOriginal(
|
|
showId: string,
|
|
usageId: string,
|
|
expectedRevision: number
|
|
): MutationResult {
|
|
const database = getDatabase();
|
|
return database.transaction(() => {
|
|
const result = bumpShow(database, showId, expectedRevision);
|
|
usageSection(database, showId, usageId);
|
|
const source = database
|
|
.prepare(
|
|
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text,
|
|
p.moderation_notes, p.interaction_text, p.transition_text, p.presentation_text,
|
|
CASE WHEN p.presentation_image_only = 1 THEN 'image' ELSE p.presentation_mode END AS presentation_mode,
|
|
COALESCE(ps.label, '') AS presentation_source_label,
|
|
COALESCE(ps.url, '') AS presentation_source_url,
|
|
ps.timecode_seconds AS presentation_source_timecode,
|
|
p.presentation_title, p.presentation_description, p.presentation_image_url,
|
|
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id,
|
|
p.status, p.preparation_effort
|
|
FROM piece_usages u JOIN pieces p ON p.id = u.original_piece_id
|
|
LEFT JOIN piece_sources ps ON ps.piece_id = p.id
|
|
AND ps.position = p.presentation_source_position
|
|
WHERE u.id = ? AND p.deleted_at_utc IS NULL`
|
|
)
|
|
.get(usageId) as
|
|
| {
|
|
title: string;
|
|
output_title: string;
|
|
duration_seconds: number;
|
|
intro: string;
|
|
research_text: string;
|
|
moderation_notes: string;
|
|
interaction_text: string;
|
|
transition_text: string;
|
|
presentation_text: string;
|
|
presentation_mode: PresentationMode;
|
|
presentation_source_label: string;
|
|
presentation_source_url: string;
|
|
presentation_source_timecode: number | null;
|
|
presentation_title: string;
|
|
presentation_description: string;
|
|
presentation_image_url: string;
|
|
presentation_site_name: string;
|
|
presentation_provider: string;
|
|
presentation_provider_id: string;
|
|
status: string;
|
|
preparation_effort: PreparationEffort;
|
|
}
|
|
| undefined;
|
|
if (!source) throw new Error('ORIGINAL_NOT_FOUND');
|
|
if (!['preparing', 'ready'].includes(source.status)) throw new Error('PIECE_NOT_READY');
|
|
database
|
|
.prepare(
|
|
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
|
|
research_snapshot = ?, moderation_snapshot = ?, interaction_snapshot = ?, transition_snapshot = ?,
|
|
presentation_snapshot = ?, presentation_mode_snapshot = ?, presentation_image_only_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 = ?, preparation_status_snapshot = ?,
|
|
preparation_effort_snapshot = ? WHERE id = ?`
|
|
)
|
|
.run(
|
|
source.title,
|
|
source.output_title,
|
|
source.duration_seconds,
|
|
source.intro,
|
|
source.research_text,
|
|
source.moderation_notes,
|
|
source.interaction_text,
|
|
source.transition_text,
|
|
source.presentation_text,
|
|
source.presentation_mode === 'image' ? 'text' : source.presentation_mode,
|
|
source.presentation_mode === 'image' ? 1 : 0,
|
|
source.presentation_source_label,
|
|
source.presentation_source_url,
|
|
source.presentation_source_timecode,
|
|
source.presentation_title,
|
|
source.presentation_description,
|
|
source.presentation_image_url,
|
|
source.presentation_site_name,
|
|
source.presentation_provider,
|
|
source.presentation_provider_id,
|
|
source.status,
|
|
source.preparation_effort,
|
|
usageId
|
|
);
|
|
snapshotPieceRelations(
|
|
database,
|
|
usageId,
|
|
(
|
|
database
|
|
.prepare('SELECT original_piece_id FROM piece_usages WHERE id = ?')
|
|
.get(usageId) as { original_piece_id: string }
|
|
).original_piece_id
|
|
);
|
|
return result;
|
|
})();
|
|
}
|
|
|
|
export function updatePieceUsage(
|
|
showId: string,
|
|
usageId: string,
|
|
expectedRevision: number,
|
|
input: PieceUsageUpdate
|
|
): MutationResult {
|
|
const title = cleanText(input.title, 200, 'title', true);
|
|
const outputTitle = cleanText(input.outputTitle, 300, 'output_title');
|
|
const duration = validDuration(input.durationSeconds)!;
|
|
if (
|
|
input.preparationStatus !== undefined &&
|
|
!['preparing', 'ready'].includes(input.preparationStatus)
|
|
) {
|
|
throw new Error('INVALID_PREPARATION_STATUS');
|
|
}
|
|
if (
|
|
input.preparationEffort !== undefined &&
|
|
![null, 'low', 'medium', 'high'].includes(input.preparationEffort)
|
|
) {
|
|
throw new Error('INVALID_PREPARATION_EFFORT');
|
|
}
|
|
const values = {
|
|
intro: cleanText(input.intro, 50_000, 'intro'),
|
|
research: cleanText(input.research, 50_000, 'research'),
|
|
moderation: cleanText(input.moderation, 50_000, 'moderation'),
|
|
interaction:
|
|
input.interaction === undefined ? null : cleanText(input.interaction, 50_000, 'interaction'),
|
|
transition: cleanText(input.transition, 50_000, 'transition'),
|
|
presentation: cleanText(input.presentation, 50_000, 'presentation'),
|
|
ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
|
|
};
|
|
const submittedPresentation = validPresentation(
|
|
input.presentationMode,
|
|
input.presentationSourceLabel,
|
|
input.presentationSourceUrl,
|
|
input.presentationSourceTimecodeSeconds,
|
|
input.presentationTitle,
|
|
input.presentationDescription,
|
|
input.presentationImageUrl,
|
|
input.presentationSiteName
|
|
);
|
|
const database = getDatabase();
|
|
return database.transaction(() => {
|
|
const result = bumpShow(database, showId, expectedRevision);
|
|
usageSection(database, showId, usageId);
|
|
const currentPreparation = database
|
|
.prepare(
|
|
`SELECT preparation_status_snapshot, preparation_effort_snapshot
|
|
FROM piece_usages WHERE id = ?`
|
|
)
|
|
.get(usageId) as {
|
|
preparation_status_snapshot: UsagePreparationStatus;
|
|
preparation_effort_snapshot: PreparationEffort;
|
|
};
|
|
let presentation = submittedPresentation;
|
|
if (presentation.mode === 'link' || presentation.mode === 'video') {
|
|
const source = database
|
|
.prepare(
|
|
`SELECT label, url, timecode_seconds FROM usage_source_snapshots
|
|
WHERE usage_id = ? AND url = ?`
|
|
)
|
|
.get(usageId, presentation.sourceUrl) as
|
|
{ label: string; url: string; timecode_seconds: number | null } | undefined;
|
|
if (!source) throw new Error('INVALID_PRESENTATION');
|
|
presentation = validPresentation(
|
|
presentation.mode,
|
|
source.label,
|
|
source.url,
|
|
source.timecode_seconds,
|
|
presentation.title,
|
|
presentation.description,
|
|
presentation.imageUrl,
|
|
presentation.siteName
|
|
);
|
|
}
|
|
const selectedMediaId =
|
|
input.presentationMediaId === undefined
|
|
? ((
|
|
database
|
|
.prepare(
|
|
'SELECT id FROM usage_media_snapshots WHERE usage_id = ? AND is_presentation = 1'
|
|
)
|
|
.get(usageId) as { id: string } | undefined
|
|
)?.id ?? null)
|
|
: input.presentationMediaId;
|
|
if (
|
|
selectedMediaId !== null &&
|
|
!database
|
|
.prepare('SELECT 1 FROM usage_media_snapshots WHERE id = ? AND usage_id = ?')
|
|
.get(selectedMediaId, usageId)
|
|
) {
|
|
throw new Error('INVALID_PRESENTATION_MEDIA');
|
|
}
|
|
if (presentation.mode === 'image' && !selectedMediaId) {
|
|
throw new Error('INVALID_PRESENTATION_MEDIA');
|
|
}
|
|
database
|
|
.prepare(
|
|
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
|
|
moderation_snapshot = ?, interaction_snapshot = COALESCE(?, interaction_snapshot), transition_snapshot = ?, presentation_snapshot = ?,
|
|
presentation_mode_snapshot = ?, presentation_image_only_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 = ?, duration_seconds = ?, preparation_status_snapshot = ?,
|
|
preparation_effort_snapshot = ? WHERE id = ?`
|
|
)
|
|
.run(
|
|
title,
|
|
outputTitle,
|
|
values.intro,
|
|
values.research,
|
|
values.moderation,
|
|
values.interaction,
|
|
values.transition,
|
|
values.presentation,
|
|
presentation.mode === 'image' ? 'text' : presentation.mode,
|
|
presentation.mode === 'image' ? 1 : 0,
|
|
presentation.sourceLabel,
|
|
presentation.sourceUrl,
|
|
presentation.timecodeSeconds,
|
|
presentation.title,
|
|
presentation.description,
|
|
presentation.imageUrl,
|
|
presentation.siteName,
|
|
presentation.provider,
|
|
presentation.providerId,
|
|
values.ownNotes,
|
|
duration,
|
|
input.preparationStatus ?? currentPreparation.preparation_status_snapshot,
|
|
input.preparationEffort === undefined
|
|
? currentPreparation.preparation_effort_snapshot
|
|
: input.preparationEffort,
|
|
usageId
|
|
);
|
|
if (input.tools) {
|
|
const existing = database
|
|
.prepare('SELECT id FROM usage_tool_snapshots WHERE usage_id = ?')
|
|
.all(usageId) as { id: string }[];
|
|
const existingIds = new Set(existing.map((tool) => tool.id));
|
|
if (
|
|
input.tools.length !== existing.length ||
|
|
input.tools.some((tool) => !existingIds.has(tool.id))
|
|
) {
|
|
throw new Error('INVALID_TOOLS');
|
|
}
|
|
const updateTool = database.prepare(
|
|
'UPDATE usage_tool_snapshots SET work_url = ?, note = ? WHERE id = ? AND usage_id = ?'
|
|
);
|
|
for (const tool of input.tools) {
|
|
const workUrl = cleanText(tool.workUrl, 2_000, 'tools');
|
|
if (workUrl) {
|
|
try {
|
|
const url = new URL(workUrl);
|
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
throw new Error();
|
|
}
|
|
} catch {
|
|
throw new Error('INVALID_TOOLS');
|
|
}
|
|
}
|
|
updateTool.run(workUrl, cleanText(tool.note, 2_000, 'tools'), tool.id, usageId);
|
|
}
|
|
}
|
|
if (input.presentationMediaId !== undefined) {
|
|
database
|
|
.prepare('UPDATE usage_media_snapshots SET is_presentation = 0 WHERE usage_id = ?')
|
|
.run(usageId);
|
|
if (selectedMediaId !== null) {
|
|
database
|
|
.prepare(
|
|
'UPDATE usage_media_snapshots SET is_presentation = 1 WHERE id = ? AND usage_id = ?'
|
|
)
|
|
.run(selectedMediaId, usageId);
|
|
}
|
|
}
|
|
return result;
|
|
})();
|
|
}
|
|
|
|
export function updatePieceUsageMetadata(
|
|
showId: string,
|
|
usageId: string,
|
|
expectedRevision: number,
|
|
input: PieceUsageMetadataUpdate
|
|
): MutationResult {
|
|
const show = getShow(showId);
|
|
const usage = [
|
|
...(show?.sections.flatMap((section) => section.usages) ?? []),
|
|
...(show?.reserve ?? [])
|
|
].find((item) => item.id === usageId);
|
|
if (!usage) throw new Error('NOT_FOUND');
|
|
return updatePieceUsage(showId, usageId, expectedRevision, {
|
|
...input,
|
|
intro: usage.intro,
|
|
research: usage.research,
|
|
moderation: usage.moderation,
|
|
interaction: usage.interaction,
|
|
transition: usage.transition,
|
|
presentation: usage.presentation,
|
|
presentationMode: usage.presentationMode,
|
|
presentationSourceLabel: usage.presentationSourceLabel,
|
|
presentationSourceUrl: usage.presentationSourceUrl,
|
|
presentationSourceTimecodeSeconds: usage.presentationSourceTimecodeSeconds,
|
|
presentationTitle: usage.presentationTitle,
|
|
presentationDescription: usage.presentationDescription,
|
|
presentationImageUrl: usage.presentationImageUrl,
|
|
presentationSiteName: usage.presentationSiteName,
|
|
presentationMediaId: usage.presentationMediaId,
|
|
tools: usage.tools.map((tool) => ({
|
|
id: tool.id,
|
|
workUrl: tool.workUrl,
|
|
note: tool.note
|
|
}))
|
|
});
|
|
}
|
|
|
|
export function removePieceUsage(
|
|
showId: string,
|
|
usageId: string,
|
|
expectedRevision: number
|
|
): MutationResult {
|
|
const database = getDatabase();
|
|
return database.transaction(() => {
|
|
const result = bumpShow(database, showId, expectedRevision);
|
|
const source = usageSection(database, showId, usageId);
|
|
requireUsagesRemovable(database, showId, [usageId]);
|
|
database.prepare('DELETE FROM piece_usages WHERE id = ?').run(usageId);
|
|
const ids = (
|
|
database
|
|
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
|
|
.all(source.sectionId) as { id: string }[]
|
|
).map((row) => row.id);
|
|
normalizePositions(database, 'piece_usages', ids);
|
|
return result;
|
|
})();
|
|
}
|
|
|
|
export function movePieceUsage(
|
|
showId: string,
|
|
usageId: string,
|
|
expectedRevision: number,
|
|
input: MovePieceUsageInput
|
|
): MutationResult {
|
|
const database = getDatabase();
|
|
return database.transaction(() => {
|
|
const result = bumpShow(database, showId, expectedRevision);
|
|
const source = usageSection(database, showId, usageId);
|
|
const targetSectionId = input.sectionId ?? reserveSectionId(database, showId);
|
|
sectionBelongsToShow(database, showId, targetSectionId, input.sectionId === null);
|
|
const sourceIds = (
|
|
database
|
|
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
|
|
.all(source.sectionId) as { id: string }[]
|
|
).map((row) => row.id);
|
|
if (source.sectionId === targetSectionId) {
|
|
sourceIds.splice(sourceIds.indexOf(usageId), 1);
|
|
sourceIds.splice(validPosition(input.position, sourceIds.length), 0, usageId);
|
|
normalizePositions(database, 'piece_usages', sourceIds);
|
|
} else {
|
|
const targetIds = (
|
|
database
|
|
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
|
|
.all(targetSectionId) as { id: string }[]
|
|
).map((row) => row.id);
|
|
const remainingSourceIds = sourceIds.filter((id) => id !== usageId);
|
|
normalizePositions(database, 'piece_usages', sourceIds);
|
|
normalizePositions(database, 'piece_usages', targetIds);
|
|
const temporaryPosition = (
|
|
database
|
|
.prepare(
|
|
'SELECT COALESCE(max(position), -1) + 1 AS position FROM piece_usages WHERE show_section_id = ?'
|
|
)
|
|
.get(targetSectionId) as { position: number }
|
|
).position;
|
|
database
|
|
.prepare(
|
|
`UPDATE piece_usages SET show_section_id = ?, position = ?, is_reserve = ? WHERE id = ?`
|
|
)
|
|
.run(targetSectionId, temporaryPosition, input.sectionId === null ? 1 : 0, usageId);
|
|
targetIds.splice(validPosition(input.position, targetIds.length), 0, usageId);
|
|
normalizePositions(database, 'piece_usages', remainingSourceIds);
|
|
normalizePositions(database, 'piece_usages', targetIds);
|
|
}
|
|
return result;
|
|
})();
|
|
}
|