Release 0.5.9
This commit is contained in:
1 parent
2851c7d936
commit
c3033479b5
32 files changed
+1598
-50
No files matched your search
@@ -41,7 +41,7 @@
|
||||
|
||||
{#if content.mode === 'text'}
|
||||
<article class="output-content output-text">
|
||||
<p class="output-kicker">heinzlive</p>
|
||||
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
|
||||
<h1>{content.title}</h1>
|
||||
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
@@ -66,6 +66,7 @@
|
||||
<img src={imageSrc} alt="" onerror={() => (imageUnavailable = true)} />
|
||||
</div>{/if}
|
||||
<div class="output-link-copy">
|
||||
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
|
||||
<p class="output-kicker">{content.siteName || content.source?.label || 'Webseite'}</p>
|
||||
<h1>{content.previewTitle || content.title}</h1>
|
||||
{#if content.description}<p>{content.description}</p>{/if}
|
||||
@@ -79,6 +80,7 @@
|
||||
{:else}
|
||||
<article class:portrait={content.provider === 'tiktok'} class="output-content output-video">
|
||||
<header>
|
||||
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
|
||||
<p class="output-kicker">{content.siteName || content.source?.label || 'Video'}</p>
|
||||
<h1>{content.previewTitle || content.title}</h1>
|
||||
</header>
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
let formId = $derived(`usage-editor-form-${usage.id}`);
|
||||
let editor: { showModal: () => void; close: () => void };
|
||||
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
|
||||
let draft = $state(untrack(() => ({ ...usage })));
|
||||
let draft = $state(
|
||||
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
|
||||
);
|
||||
let dirty = $state(false);
|
||||
|
||||
function durationInMinutes(seconds: number) {
|
||||
@@ -163,13 +165,40 @@
|
||||
/>
|
||||
</div>
|
||||
<section class="piece-source-summary">
|
||||
<p class="eyebrow">Snapshot-Quelle</p>
|
||||
{#if draft.presentationSourceUrl}
|
||||
<strong>{draft.presentationSourceLabel || draft.presentationSourceUrl}</strong>
|
||||
<small>{draft.presentationSourceUrl}</small>
|
||||
{:else}
|
||||
<p>Für diese Verwendung wurde keine Präsentationsquelle kopiert.</p>
|
||||
{/if}
|
||||
<p class="eyebrow">Snapshot-Quellen</p>
|
||||
{#if draft.sources.length}
|
||||
{#each draft.sources as source (source.id)}<div>
|
||||
<strong>{source.label}</strong><small>{source.url}</small>
|
||||
</div>{/each}
|
||||
{:else}<p>Für diese Verwendung wurden keine Quellen kopiert.</p>{/if}
|
||||
</section>
|
||||
<section class="piece-source-editor">
|
||||
<div class="piece-subsection-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Snapshot</p>
|
||||
<h3>Werkzeuge</h3>
|
||||
</div>
|
||||
<small>Name und Start-URL bleiben Teil der Sendungsfassung.</small>
|
||||
</div>
|
||||
{#if draft.tools.length}<div class="source-list">
|
||||
{#each draft.tools as tool (tool.id)}<div class="source-row tool-assignment-row">
|
||||
<input type="hidden" name="usageToolId" value={tool.id} />
|
||||
<div class="tool-assignment-name">
|
||||
<strong>{tool.name}</strong>{#if tool.startUrl}<small>{tool.startUrl}</small>{/if}
|
||||
</div>
|
||||
<label
|
||||
>Arbeitslink<input
|
||||
name="toolWorkUrl"
|
||||
type="url"
|
||||
maxlength="2000"
|
||||
placeholder={tool.startUrl || 'https://…'}
|
||||
bind:value={tool.workUrl}
|
||||
/></label
|
||||
>
|
||||
<label>Notiz<input name="toolNote" maxlength="2000" bind:value={tool.note} /></label
|
||||
>
|
||||
</div>{/each}
|
||||
</div>{:else}<p class="context-empty">Keine Werkzeuge in dieser Sendungsfassung.</p>{/if}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
savePiece,
|
||||
savePieceAsTemplate,
|
||||
setPieceArchived,
|
||||
setPieceFavorite,
|
||||
uploadPieceImage,
|
||||
type PieceInput
|
||||
} from './content.js';
|
||||
@@ -76,6 +77,18 @@ function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
|
||||
}
|
||||
|
||||
describe('content management', () => {
|
||||
it('sorts global favorites first without changing their revision', () => {
|
||||
const before = getPiece('piece-console')!;
|
||||
setPieceFavorite('piece-console', true);
|
||||
const pieces = listPieces({ statuses: ['ready'] });
|
||||
expect(pieces[0]).toMatchObject({ id: 'piece-console', isFavorite: true });
|
||||
expect(getPiece('piece-console')).toMatchObject({
|
||||
isFavorite: true,
|
||||
revision: before.revision,
|
||||
status: before.status
|
||||
});
|
||||
});
|
||||
|
||||
it('persists independently versioned templates and copies their complete snapshot', () => {
|
||||
const sourceId = createPiece('talk');
|
||||
const source = getPiece(sourceId)!;
|
||||
|
||||
@@ -21,6 +21,19 @@ export type PieceSourceInput = {
|
||||
timecodeSeconds: number | null;
|
||||
};
|
||||
|
||||
export type PieceToolInput = {
|
||||
toolId: string;
|
||||
workUrl: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export type PieceTool = PieceToolInput & {
|
||||
id: string;
|
||||
name: string;
|
||||
startUrl: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type PieceInput = {
|
||||
title: string;
|
||||
outputTitle: string;
|
||||
@@ -48,6 +61,7 @@ export type PieceInput = {
|
||||
topicIds: string[];
|
||||
categoryIds: string[];
|
||||
sources: PieceSourceInput[];
|
||||
tools?: PieceToolInput[];
|
||||
};
|
||||
|
||||
export type PieceListItem = {
|
||||
@@ -63,15 +77,18 @@ export type PieceListItem = {
|
||||
topics: { id: string; name: string }[];
|
||||
sourceCount: number;
|
||||
mediaId: string | null;
|
||||
isFavorite: boolean;
|
||||
};
|
||||
|
||||
export type PieceEditor = Omit<PieceInput, 'status'> & {
|
||||
export type PieceEditor = Omit<PieceInput, 'status' | 'tools'> & {
|
||||
id: string;
|
||||
status: PieceStatus;
|
||||
revision: number;
|
||||
createdAtUtc: string;
|
||||
updatedAtUtc: string;
|
||||
media: PieceMedia[];
|
||||
isFavorite: boolean;
|
||||
tools: PieceTool[];
|
||||
};
|
||||
|
||||
export type PieceMedia = {
|
||||
@@ -123,6 +140,32 @@ function validUrl(value: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanPieceTools(values: PieceToolInput[] = []): PieceToolInput[] {
|
||||
if (values.length > 20) throw new Error('INVALID_TOOLS');
|
||||
const ids = uniqueIds(values.map((value) => value.toolId));
|
||||
if (ids.length !== values.length) throw new Error('INVALID_TOOLS');
|
||||
if (ids.length) {
|
||||
const placeholders = ids.map(() => '?').join(', ');
|
||||
const row = getDatabase()
|
||||
.prepare(`SELECT count(*) AS count FROM tools WHERE id IN (${placeholders})`)
|
||||
.get(...ids) as { count: number };
|
||||
if (row.count !== ids.length) throw new Error('INVALID_TOOLS');
|
||||
}
|
||||
return values.map((value) => {
|
||||
const workUrl = cleanText(value.workUrl, 2_000, 'tools');
|
||||
if (workUrl) {
|
||||
if (!validUrl(workUrl)) throw new Error('INVALID_TOOLS');
|
||||
const parsed = new URL(workUrl);
|
||||
if (parsed.username || parsed.password) throw new Error('INVALID_TOOLS');
|
||||
}
|
||||
return {
|
||||
toolId: value.toolId,
|
||||
workUrl,
|
||||
note: cleanText(value.note, 2_000, 'tools')
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function requireTaxonomyIds(table: 'formats' | 'topics' | 'section_categories', ids: string[]) {
|
||||
if (!ids.length) return;
|
||||
const placeholders = ids.map(() => '?').join(', ');
|
||||
@@ -213,7 +256,8 @@ function validatePieceInput(input: PieceInput): PieceInput {
|
||||
presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null,
|
||||
topicIds,
|
||||
categoryIds,
|
||||
sources
|
||||
sources,
|
||||
tools: cleanPieceTools(input.tools)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -244,6 +288,7 @@ type PieceListRow = {
|
||||
format: string;
|
||||
source_count: number;
|
||||
media_id: string | null;
|
||||
is_favorite: number;
|
||||
};
|
||||
|
||||
export function listPieces(filters: {
|
||||
@@ -288,13 +333,14 @@ export function listPieces(filters: {
|
||||
const database = getDatabase();
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds,
|
||||
`SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds, p.is_favorite,
|
||||
p.format_id, COALESCE(f.name, 'Ohne Format') AS format,
|
||||
(SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count,
|
||||
(SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id ORDER BY pm.position LIMIT 1) AS media_id
|
||||
FROM pieces p LEFT JOIN formats f ON f.id = p.format_id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY CASE p.status WHEN 'ready' THEN 0 WHEN 'preparing' THEN 1 WHEN 'inbox' THEN 2 ELSE 3 END,
|
||||
ORDER BY p.is_favorite DESC,
|
||||
CASE p.status WHEN 'ready' THEN 0 WHEN 'preparing' THEN 1 WHEN 'inbox' THEN 2 ELSE 3 END,
|
||||
p.updated_at_utc DESC, p.title`
|
||||
)
|
||||
.all(...parameters) as PieceListRow[];
|
||||
@@ -319,7 +365,8 @@ export function listPieces(filters: {
|
||||
categories: categoryStatement.all(row.id) as PieceListItem['categories'],
|
||||
topics: topicStatement.all(row.id) as PieceListItem['topics'],
|
||||
sourceCount: row.source_count,
|
||||
mediaId: row.media_id
|
||||
mediaId: row.media_id,
|
||||
isFavorite: row.is_favorite === 1
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -351,6 +398,7 @@ type PieceRow = {
|
||||
revision: number;
|
||||
created_at_utc: string;
|
||||
updated_at_utc: string;
|
||||
is_favorite: number;
|
||||
};
|
||||
|
||||
export function getPiece(id: string): PieceEditor | null {
|
||||
@@ -363,7 +411,7 @@ export function getPiece(id: string): PieceEditor | null {
|
||||
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,
|
||||
revision, created_at_utc, updated_at_utc
|
||||
revision, created_at_utc, updated_at_utc, is_favorite
|
||||
FROM pieces WHERE id = ?`
|
||||
)
|
||||
.get(id) as PieceRow | undefined;
|
||||
@@ -396,6 +444,7 @@ export function getPiece(id: string): PieceEditor | null {
|
||||
revision: row.revision,
|
||||
createdAtUtc: row.created_at_utc,
|
||||
updatedAtUtc: row.updated_at_utc,
|
||||
isFavorite: row.is_favorite === 1,
|
||||
topicIds: (
|
||||
database.prepare('SELECT topic_id FROM piece_topics WHERE piece_id = ?').all(id) as {
|
||||
topic_id: string;
|
||||
@@ -438,6 +487,31 @@ export function getPiece(id: string): PieceEditor | null {
|
||||
byteSize: media.byte_size,
|
||||
altText: media.alt_text,
|
||||
position: media.position
|
||||
})),
|
||||
tools: (
|
||||
database
|
||||
.prepare(
|
||||
`SELECT pt.id, pt.tool_id, pt.work_url, pt.note, t.name, t.start_url, t.is_active
|
||||
FROM piece_tools pt JOIN tools t ON t.id = pt.tool_id
|
||||
WHERE pt.piece_id = ? ORDER BY pt.position`
|
||||
)
|
||||
.all(id) as {
|
||||
id: string;
|
||||
tool_id: string;
|
||||
work_url: string;
|
||||
note: string;
|
||||
name: string;
|
||||
start_url: string;
|
||||
is_active: number;
|
||||
}[]
|
||||
).map((tool) => ({
|
||||
id: tool.id,
|
||||
toolId: tool.tool_id,
|
||||
workUrl: tool.work_url,
|
||||
note: tool.note,
|
||||
name: tool.name,
|
||||
startUrl: tool.start_url,
|
||||
isActive: tool.is_active === 1
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -460,6 +534,21 @@ function replaceRelations(pieceId: string, input: PieceInput) {
|
||||
input.sources.forEach((source, position) =>
|
||||
addSource.run(randomUUID(), pieceId, source.label, source.url, source.timecodeSeconds, position)
|
||||
);
|
||||
database.prepare('DELETE FROM piece_tools WHERE piece_id = ?').run(pieceId);
|
||||
const addTool = database.prepare(
|
||||
`INSERT INTO piece_tools (id, piece_id, tool_id, work_url, note, position)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
(input.tools ?? []).forEach((tool, position) =>
|
||||
addTool.run(randomUUID(), pieceId, tool.toolId, tool.workUrl, tool.note, position)
|
||||
);
|
||||
}
|
||||
|
||||
export function setPieceFavorite(id: string, favorite: boolean) {
|
||||
const result = getDatabase()
|
||||
.prepare('UPDATE pieces SET is_favorite = ? WHERE id = ?')
|
||||
.run(favorite ? 1 : 0, id);
|
||||
if (result.changes !== 1) throw new Error('NOT_FOUND');
|
||||
}
|
||||
|
||||
export function savePiece(id: string, expectedRevision: number, rawInput: PieceInput) {
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('database setup', () => {
|
||||
database.close();
|
||||
|
||||
expect(counts).toEqual({
|
||||
migrations: 8,
|
||||
migrations: 9,
|
||||
pieces: 6,
|
||||
shows: 2,
|
||||
usages: 5,
|
||||
|
||||
@@ -40,6 +40,48 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('live mode', () => {
|
||||
it('persists a frozen section countdown across usage changes and reloads', () => {
|
||||
getDatabase()
|
||||
.prepare(
|
||||
"UPDATE show_sections SET explicit_duration_seconds = 300 WHERE id = 'section-stories'"
|
||||
)
|
||||
.run();
|
||||
const start = new Date('2026-10-23T18:00:00.000Z');
|
||||
startOrResumeLive('show-october', 'timer-start', 0, start);
|
||||
let live = getLiveView('show-october', new Date('2026-10-23T18:01:40.000Z'));
|
||||
expect(live.sectionTimer).toMatchObject({
|
||||
sectionId: 'section-stories',
|
||||
plannedDurationSeconds: 300,
|
||||
elapsedSeconds: 100
|
||||
});
|
||||
getDatabase()
|
||||
.prepare(
|
||||
"UPDATE show_sections SET explicit_duration_seconds = 600 WHERE id = 'section-stories'"
|
||||
)
|
||||
.run();
|
||||
completeCurrent(
|
||||
'show-october',
|
||||
'timer-next-piece',
|
||||
live.liveRevision,
|
||||
new Date('2026-10-23T18:02:00.000Z')
|
||||
);
|
||||
live = getLiveView('show-october', new Date('2026-10-23T18:04:00.000Z'));
|
||||
expect(live.current?.id).toBe('usage-effects');
|
||||
expect(live.sectionTimer).toMatchObject({
|
||||
plannedDurationSeconds: 300,
|
||||
elapsedSeconds: 240
|
||||
});
|
||||
completeCurrent(
|
||||
'show-october',
|
||||
'timer-finish',
|
||||
live.liveRevision,
|
||||
new Date('2026-10-23T18:06:00.000Z')
|
||||
);
|
||||
expect(
|
||||
getLiveView('show-october', new Date('2026-10-23T18:07:00.000Z')).sectionTimer
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('selects any rundown usage by stable id, including the first and completed entries', () => {
|
||||
let live = getLiveView('show-october');
|
||||
expect(() =>
|
||||
@@ -161,6 +203,14 @@ describe('live mode', () => {
|
||||
0,
|
||||
'2026-10-22T18:00:00.000Z'
|
||||
);
|
||||
getDatabase()
|
||||
.prepare(
|
||||
`INSERT INTO usage_tool_snapshots
|
||||
(id, usage_id, original_tool_id, name, start_url, work_url, note, position)
|
||||
VALUES ('private-live-tool', 'usage-console', NULL, 'Privates Werkzeug', '',
|
||||
'https://private.example.test/board', 'PRIVATE_TOOL_NOTE', 0)`
|
||||
)
|
||||
.run();
|
||||
|
||||
const token = issuePresentationAccess('show-october');
|
||||
expect(getPresentationView(token)).toEqual({ revision: 0, content: null });
|
||||
@@ -171,6 +221,7 @@ describe('live mode', () => {
|
||||
const output = getPresentationView(token)!;
|
||||
expect(output.content).toMatchObject({
|
||||
title: 'Die kleine Konsole mit dem großen Dreh',
|
||||
topics: ['Gaming'],
|
||||
images: [],
|
||||
sources: []
|
||||
});
|
||||
@@ -180,6 +231,9 @@ describe('live mode', () => {
|
||||
expect(serialized).not.toContain('Moderationsstichpunkte');
|
||||
expect(serialized).not.toContain('private-original-name');
|
||||
expect(serialized).not.toContain('presentation.png');
|
||||
expect(serialized).not.toContain('https://example.com/console');
|
||||
expect(serialized).not.toContain('https://private.example.test/board');
|
||||
expect(serialized).not.toContain('PRIVATE_TOOL_NOTE');
|
||||
|
||||
const media = await getPresentationMedia(token, 'presentation-image');
|
||||
expect(media).toBeNull();
|
||||
|
||||
+110
-2
@@ -62,12 +62,20 @@ export type LiveView = {
|
||||
next: LiveUsage | null;
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
sectionTimer: null | {
|
||||
sectionId: string;
|
||||
sectionName: string;
|
||||
plannedDurationSeconds: number;
|
||||
elapsedSeconds: number;
|
||||
runningSinceUtc: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type PresentationView = {
|
||||
revision: number;
|
||||
content: null | {
|
||||
mode: PresentationContent['mode'];
|
||||
topics: string[];
|
||||
title: string;
|
||||
html: string;
|
||||
source: PresentationContent['source'];
|
||||
@@ -141,6 +149,59 @@ function closeActiveRun(database: Database.Database, showId: string, now: string
|
||||
.run(now, showId);
|
||||
}
|
||||
|
||||
function closeSectionTimer(database: Database.Database, showId: string, now: string) {
|
||||
const timer = database
|
||||
.prepare('SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL')
|
||||
.get(showId) as { id: string } | undefined;
|
||||
if (!timer) return;
|
||||
database
|
||||
.prepare(
|
||||
'UPDATE live_section_timer_runs SET ended_at_utc = ? WHERE timer_id = ? AND ended_at_utc IS NULL'
|
||||
)
|
||||
.run(now, timer.id);
|
||||
database
|
||||
.prepare('UPDATE live_section_timers SET ended_at_utc = ? WHERE id = ?')
|
||||
.run(now, timer.id);
|
||||
}
|
||||
|
||||
function syncSectionTimer(
|
||||
database: Database.Database,
|
||||
showId: string,
|
||||
usageId: string,
|
||||
now: string
|
||||
) {
|
||||
const section = database
|
||||
.prepare(
|
||||
`SELECT s.id, s.explicit_duration_seconds FROM piece_usages u
|
||||
JOIN show_sections s ON s.id = u.show_section_id
|
||||
WHERE u.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0`
|
||||
)
|
||||
.get(usageId, showId) as { id: string; explicit_duration_seconds: number | null } | undefined;
|
||||
if (!section) throw new Error('NOT_FOUND');
|
||||
const active = database
|
||||
.prepare(
|
||||
'SELECT id, section_id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL'
|
||||
)
|
||||
.get(showId) as { id: string; section_id: string } | undefined;
|
||||
if (active?.section_id === section.id) return;
|
||||
if (active) closeSectionTimer(database, showId, now);
|
||||
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) return;
|
||||
const timerId = randomUUID();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_section_timers
|
||||
(id, show_id, section_id, planned_duration_seconds, started_at_utc)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(timerId, showId, section.id, section.explicit_duration_seconds, now);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
|
||||
VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), timerId, now);
|
||||
}
|
||||
|
||||
function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) {
|
||||
usageBelongsToShow(database, showId, usageId);
|
||||
database
|
||||
@@ -164,6 +225,7 @@ function activateUsage(database: Database.Database, showId: string, usageId: str
|
||||
'UPDATE show_live_states SET current_usage_id = ?, ended_at_utc = NULL WHERE show_id = ?'
|
||||
)
|
||||
.run(usageId, showId);
|
||||
syncSectionTimer(database, showId, usageId, now);
|
||||
}
|
||||
|
||||
function executeCommand(
|
||||
@@ -268,6 +330,44 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
|
||||
const next =
|
||||
usages.slice(currentIndex + 1).find((usage) => usage.status === 'open') ??
|
||||
(current ? null : (usages.find((usage) => usage.status === 'open') ?? null));
|
||||
const timer = database
|
||||
.prepare(
|
||||
`SELECT t.id, t.section_id, s.name AS section_name, t.planned_duration_seconds
|
||||
FROM live_section_timers t JOIN show_sections s ON s.id = t.section_id
|
||||
WHERE t.show_id = ? AND t.ended_at_utc IS NULL`
|
||||
)
|
||||
.get(showId) as
|
||||
| { id: string; section_id: string; section_name: string; planned_duration_seconds: number }
|
||||
| undefined;
|
||||
let sectionTimer: LiveView['sectionTimer'] = null;
|
||||
if (timer) {
|
||||
const runs = database
|
||||
.prepare(
|
||||
`SELECT started_at_utc, ended_at_utc FROM live_section_timer_runs
|
||||
WHERE timer_id = ? ORDER BY started_at_utc`
|
||||
)
|
||||
.all(timer.id) as { started_at_utc: string; ended_at_utc: string | null }[];
|
||||
const running = runs.find((run) => run.ended_at_utc === null) ?? null;
|
||||
const elapsedSeconds = runs.reduce(
|
||||
(total, run) =>
|
||||
total +
|
||||
Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(new Date(run.ended_at_utc ?? now).getTime() - new Date(run.started_at_utc).getTime()) /
|
||||
1_000
|
||||
)
|
||||
),
|
||||
0
|
||||
);
|
||||
sectionTimer = {
|
||||
sectionId: timer.section_id,
|
||||
sectionName: timer.section_name,
|
||||
plannedDurationSeconds: timer.planned_duration_seconds,
|
||||
elapsedSeconds,
|
||||
runningSinceUtc: running?.started_at_utc ?? null
|
||||
};
|
||||
}
|
||||
return {
|
||||
show: {
|
||||
id: show.id,
|
||||
@@ -287,7 +387,8 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
|
||||
next,
|
||||
completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped')
|
||||
.length,
|
||||
totalCount: usages.length
|
||||
totalCount: usages.length,
|
||||
sectionTimer
|
||||
};
|
||||
}
|
||||
|
||||
@@ -353,10 +454,12 @@ function advanceCurrent(
|
||||
.run(status, state.current_usage_id);
|
||||
const next = ordered.slice(currentIndex + 1).find((usage) => usage.status === 'open');
|
||||
if (next) activateUsage(database, showId, next.id, now);
|
||||
else
|
||||
else {
|
||||
closeSectionTimer(database, showId, now);
|
||||
database
|
||||
.prepare('UPDATE show_live_states SET current_usage_id = NULL WHERE show_id = ?')
|
||||
.run(showId);
|
||||
}
|
||||
},
|
||||
nowValue
|
||||
);
|
||||
@@ -655,6 +758,11 @@ function getPresentationViewForShow(showId: string): PresentationView {
|
||||
revision: state.presentation_revision,
|
||||
content: {
|
||||
mode: usage.presentation_mode_snapshot,
|
||||
topics: (
|
||||
database
|
||||
.prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position')
|
||||
.all(state.presentation_usage_id) as { name: string }[]
|
||||
).map((topic) => topic.name),
|
||||
title: usage.output_title_snapshot || usage.title_snapshot,
|
||||
html: renderMarkdown(usage.presentation_snapshot),
|
||||
source: usage.presentation_source_url_snapshot
|
||||
|
||||
@@ -28,6 +28,7 @@ export type PresentationSource = {
|
||||
|
||||
export type PresentationContent = {
|
||||
mode: PresentationMode;
|
||||
topics: string[];
|
||||
title: string;
|
||||
html: string;
|
||||
source: PresentationSource | null;
|
||||
|
||||
@@ -464,6 +464,72 @@ describe('show builder data layer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps private source, tool and topic snapshots stable until an explicit refresh', () => {
|
||||
const database = getDatabase();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO tools (id, name, start_url, description, is_active, created_at_utc, updated_at_utc)
|
||||
VALUES ('tool-tierlist', 'Tierlist', 'https://example.com/start', '', 1, ?, ?)`
|
||||
)
|
||||
.run('2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z');
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO piece_tools (id, piece_id, tool_id, work_url, note, position)
|
||||
VALUES ('piece-tool-console', 'piece-console', 'tool-tierlist', 'https://example.com/board', 'Erster Stand', 0)`
|
||||
)
|
||||
.run();
|
||||
const show = createShow({ title: 'Relationen', startsAtUtc: null, templateId: null });
|
||||
const section = addShowSection(show.id, show.revision, {
|
||||
name: 'Block',
|
||||
categoryId: null,
|
||||
explicitDurationSeconds: null
|
||||
});
|
||||
const added = addPieceUsage(show.id, section.revision, {
|
||||
pieceId: 'piece-console',
|
||||
sectionId: section.id
|
||||
});
|
||||
let usage = getShow(show.id)!.sections[0].usages[0];
|
||||
expect(usage).toMatchObject({
|
||||
sources: [{ label: 'Herstellerseite', url: 'https://example.com/console' }],
|
||||
tools: [{ name: 'Tierlist', workUrl: 'https://example.com/board', note: 'Erster Stand' }],
|
||||
topics: ['Gaming']
|
||||
});
|
||||
|
||||
database
|
||||
.prepare("UPDATE piece_sources SET label = 'Neue Quelle' WHERE piece_id = 'piece-console'")
|
||||
.run();
|
||||
database
|
||||
.prepare("UPDATE tools SET name = 'Neuer Katalogname' WHERE id = 'tool-tierlist'")
|
||||
.run();
|
||||
database
|
||||
.prepare(
|
||||
"UPDATE piece_tools SET work_url = 'https://example.com/new' WHERE id = 'piece-tool-console'"
|
||||
)
|
||||
.run();
|
||||
database.prepare("DELETE FROM piece_topics WHERE piece_id = 'piece-console'").run();
|
||||
database
|
||||
.prepare(
|
||||
"INSERT INTO piece_topics (piece_id, topic_id) VALUES ('piece-console', 'topic-internet')"
|
||||
)
|
||||
.run();
|
||||
usage = getShow(show.id)!.sections[0].usages[0];
|
||||
expect(usage.sources[0].label).toBe('Herstellerseite');
|
||||
expect(usage.tools[0]).toMatchObject({
|
||||
name: 'Tierlist',
|
||||
workUrl: 'https://example.com/board'
|
||||
});
|
||||
expect(usage.topics).toEqual(['Gaming']);
|
||||
|
||||
refreshPieceUsageFromOriginal(show.id, usage.id, added.revision);
|
||||
usage = getShow(show.id)!.sections[0].usages[0];
|
||||
expect(usage.sources[0].label).toBe('Neue Quelle');
|
||||
expect(usage.tools[0]).toMatchObject({
|
||||
name: 'Neuer Katalogname',
|
||||
workUrl: 'https://example.com/new'
|
||||
});
|
||||
expect(usage.topics).toEqual(['Netzkultur']);
|
||||
});
|
||||
|
||||
it('persists normalized section and usage reordering, moving and removal', () => {
|
||||
const created = createShow({ title: 'Sortierung', startsAtUtc: null, templateId: null });
|
||||
const firstSection = addShowSection(created.id, created.revision, {
|
||||
|
||||
+233
-3
@@ -60,6 +60,16 @@ export type PieceUsage = {
|
||||
presentationProvider: string;
|
||||
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: string[];
|
||||
};
|
||||
|
||||
export type SentPieceUsage = {
|
||||
@@ -147,6 +157,7 @@ export type PieceUsageUpdate = {
|
||||
presentationSiteName: string;
|
||||
ownNotes: string;
|
||||
durationSeconds: number;
|
||||
tools?: { id: string; workUrl: string; note: string }[];
|
||||
};
|
||||
|
||||
export type MovePieceUsageInput = {
|
||||
@@ -372,7 +383,7 @@ function normalizePositions(
|
||||
ids.forEach((id, position) => update.run(position, id));
|
||||
}
|
||||
|
||||
function usageFromRow(row: UsageRow): PieceUsage {
|
||||
function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage {
|
||||
return {
|
||||
id: row.id,
|
||||
originalPieceId: row.original_piece_id,
|
||||
@@ -396,10 +407,189 @@ function usageFromRow(row: UsageRow): PieceUsage {
|
||||
presentationSiteName: row.presentation_site_name_snapshot,
|
||||
presentationProvider: row.presentation_provider_snapshot,
|
||||
presentationProviderId: row.presentation_provider_id_snapshot,
|
||||
ownNotes: row.own_notes
|
||||
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 FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position')
|
||||
.all(row.id) as { name: string }[]
|
||||
).map((topic) => topic.name)
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
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 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 }[];
|
||||
const addTopic = database.prepare(
|
||||
'INSERT INTO usage_topic_snapshots (usage_id, name, position) VALUES (?, ?, ?)'
|
||||
);
|
||||
topics.forEach((topic, position) => addTopic.run(usageId, topic.name, position));
|
||||
}
|
||||
|
||||
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, position)
|
||||
SELECT ?, name, position FROM usage_topic_snapshots WHERE usage_id = ?`
|
||||
)
|
||||
.run(usageId, sourceUsageId);
|
||||
}
|
||||
|
||||
export function listShows(): ShowListItem[] {
|
||||
return (
|
||||
getDatabase()
|
||||
@@ -497,7 +687,7 @@ export function getShow(id: string): Show | null {
|
||||
const usagesBySection = new Map<string, PieceUsage[]>();
|
||||
for (const row of usageRows) {
|
||||
const usages = usagesBySection.get(row.show_section_id) ?? [];
|
||||
usages.push(usageFromRow(row));
|
||||
usages.push(usageFromRow(database, row));
|
||||
usagesBySection.set(row.show_section_id, usages);
|
||||
}
|
||||
const sections = sectionRows
|
||||
@@ -889,6 +1079,7 @@ export function addPieceUsage(
|
||||
piece.presentation_provider_id,
|
||||
input.sectionId === null ? 1 : 0
|
||||
);
|
||||
snapshotPieceRelations(database, id, input.pieceId);
|
||||
ids.splice(position, 0, id);
|
||||
normalizePositions(database, 'piece_usages', ids);
|
||||
return { ...result, id };
|
||||
@@ -1018,6 +1209,7 @@ export function duplicatePieceUsage(
|
||||
source.own_notes,
|
||||
source.is_reserve
|
||||
);
|
||||
copyUsageRelations(database, usageId, id);
|
||||
ids.splice(sourcePosition + 1, 0, id);
|
||||
normalizePositions(database, 'piece_usages', ids);
|
||||
return { ...result, id };
|
||||
@@ -1102,6 +1294,15 @@ export function refreshPieceUsageFromOriginal(
|
||||
source.presentation_provider_id,
|
||||
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;
|
||||
})();
|
||||
}
|
||||
@@ -1170,6 +1371,35 @@ export function updatePieceUsage(
|
||||
duration,
|
||||
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);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { closeDatabase } from './database.js';
|
||||
import { createTool, listTools, updateTool } from './tools.js';
|
||||
|
||||
let directory: string;
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-tools-test-'));
|
||||
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
|
||||
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
|
||||
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
|
||||
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
delete process.env.SENDEWERK_DB_PATH;
|
||||
delete process.env.SENDEWERK_UPLOAD_DIR;
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('tool catalog', () => {
|
||||
it('validates, classifies and deactivates tools without deleting them', () => {
|
||||
const id = createTool({
|
||||
name: 'Tierlist',
|
||||
startUrl: 'https://example.com/tierlist',
|
||||
description: 'Gemeinsames Ranking',
|
||||
isActive: true,
|
||||
formatIds: ['format-quiz']
|
||||
});
|
||||
expect(listTools()).toEqual([
|
||||
{
|
||||
id,
|
||||
name: 'Tierlist',
|
||||
startUrl: 'https://example.com/tierlist',
|
||||
description: 'Gemeinsames Ranking',
|
||||
isActive: true,
|
||||
formatIds: ['format-quiz']
|
||||
}
|
||||
]);
|
||||
updateTool(id, {
|
||||
name: 'Tierlist',
|
||||
startUrl: '',
|
||||
description: '',
|
||||
isActive: false,
|
||||
formatIds: []
|
||||
});
|
||||
expect(listTools()).toEqual([]);
|
||||
expect(listTools(true)[0]).toMatchObject({ id, isActive: false });
|
||||
expect(() =>
|
||||
createTool({
|
||||
name: 'Unsicher',
|
||||
startUrl: 'javascript:alert(1)',
|
||||
description: '',
|
||||
isActive: true,
|
||||
formatIds: []
|
||||
})
|
||||
).toThrow('INVALID_TOOL_URL');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDatabase } from './database.js';
|
||||
|
||||
export type Tool = {
|
||||
id: string;
|
||||
name: string;
|
||||
startUrl: string;
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
formatIds: string[];
|
||||
};
|
||||
|
||||
export type ToolInput = Omit<Tool, 'id'>;
|
||||
|
||||
function clean(value: unknown, maximum: number, required = false) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (text.length > maximum || (required && !text)) throw new Error('INVALID_TOOL');
|
||||
return text;
|
||||
}
|
||||
|
||||
function validUrl(value: string) {
|
||||
if (!value) return '';
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
|
||||
throw new Error();
|
||||
return url.href;
|
||||
} catch {
|
||||
throw new Error('INVALID_TOOL_URL');
|
||||
}
|
||||
}
|
||||
|
||||
function validated(input: ToolInput): ToolInput {
|
||||
const formatIds = [...new Set(input.formatIds.filter(Boolean))];
|
||||
if (formatIds.length > 30) throw new Error('INVALID_TOOL');
|
||||
if (formatIds.length) {
|
||||
const placeholders = formatIds.map(() => '?').join(', ');
|
||||
const row = getDatabase()
|
||||
.prepare(`SELECT count(*) AS count FROM formats WHERE id IN (${placeholders})`)
|
||||
.get(...formatIds) as { count: number };
|
||||
if (row.count !== formatIds.length) throw new Error('INVALID_TOOL_FORMAT');
|
||||
}
|
||||
return {
|
||||
name: clean(input.name, 120, true),
|
||||
startUrl: validUrl(clean(input.startUrl, 2_000)),
|
||||
description: clean(input.description, 2_000),
|
||||
isActive: Boolean(input.isActive),
|
||||
formatIds
|
||||
};
|
||||
}
|
||||
|
||||
export function listTools(includeInactive = false): Tool[] {
|
||||
const database = getDatabase();
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT id, name, start_url, description, is_active FROM tools
|
||||
${includeInactive ? '' : 'WHERE is_active = 1'} ORDER BY is_active DESC, name`
|
||||
)
|
||||
.all() as {
|
||||
id: string;
|
||||
name: string;
|
||||
start_url: string;
|
||||
description: string;
|
||||
is_active: number;
|
||||
}[];
|
||||
const formats = database.prepare(
|
||||
'SELECT format_id FROM tool_formats WHERE tool_id = ? ORDER BY format_id'
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
startUrl: row.start_url,
|
||||
description: row.description,
|
||||
isActive: row.is_active === 1,
|
||||
formatIds: (formats.all(row.id) as { format_id: string }[]).map((item) => item.format_id)
|
||||
}));
|
||||
}
|
||||
|
||||
function replaceFormats(toolId: string, formatIds: string[]) {
|
||||
const database = getDatabase();
|
||||
database.prepare('DELETE FROM tool_formats WHERE tool_id = ?').run(toolId);
|
||||
const insert = database.prepare('INSERT INTO tool_formats (tool_id, format_id) VALUES (?, ?)');
|
||||
for (const formatId of formatIds) insert.run(toolId, formatId);
|
||||
}
|
||||
|
||||
export function createTool(rawInput: ToolInput) {
|
||||
const input = validated(rawInput);
|
||||
const database = getDatabase();
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
try {
|
||||
database.transaction(() => {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO tools (id, name, start_url, description, is_active, created_at_utc, updated_at_utc)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(id, input.name, input.startUrl, input.description, input.isActive ? 1 : 0, now, now);
|
||||
replaceFormats(id, input.formatIds);
|
||||
})();
|
||||
} catch (cause) {
|
||||
if (cause instanceof Error && cause.message.includes('UNIQUE')) {
|
||||
throw new Error('TOOL_EXISTS', { cause });
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function updateTool(id: string, rawInput: ToolInput) {
|
||||
const input = validated(rawInput);
|
||||
const database = getDatabase();
|
||||
try {
|
||||
database.transaction(() => {
|
||||
const result = database
|
||||
.prepare(
|
||||
`UPDATE tools SET name = ?, start_url = ?, description = ?, is_active = ?, updated_at_utc = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
input.name,
|
||||
input.startUrl,
|
||||
input.description,
|
||||
input.isActive ? 1 : 0,
|
||||
new Date().toISOString(),
|
||||
id
|
||||
);
|
||||
if (result.changes !== 1) throw new Error('NOT_FOUND');
|
||||
replaceFormats(id, input.formatIds);
|
||||
})();
|
||||
} catch (cause) {
|
||||
if (cause instanceof Error && cause.message.includes('UNIQUE')) {
|
||||
throw new Error('TOOL_EXISTS', { cause });
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user