Release 0.5.10

This commit is contained in:
hnzio committed 2026-10-23 21:33:15 +02:00
1 parent c3033479b5
commit 4fe61971e4
52 files changed
+38565 -466

No files matched your search

+199 -78
View File
@@ -1,7 +1,8 @@
import { randomUUID } from 'node:crypto';
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { copyFile, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileTypeFromBuffer } from 'file-type';
import { classicRegularIconNames } from '#lib/icons/classic-regular.js';
import { getDatabase } from './database.js';
import { getServerPaths } from './config.js';
import {
@@ -31,6 +32,7 @@ export type PieceTool = PieceToolInput & {
id: string;
name: string;
startUrl: string;
iconName: string;
isActive: boolean;
};
@@ -58,6 +60,7 @@ export type PieceInput = {
presentationDescriptionManual: boolean;
presentationImageManual: boolean;
presentationMetadataFetchedAtUtc: string | null;
presentationMediaId?: string | null;
topicIds: string[];
categoryIds: string[];
sources: PieceSourceInput[];
@@ -74,19 +77,20 @@ export type PieceListItem = {
formatId: string | null;
format: string;
categories: { id: string; name: string; color: string }[];
topics: { id: string; name: string }[];
topics: { id: string; name: string; iconName: string }[];
sourceCount: number;
mediaId: string | null;
isFavorite: boolean;
};
export type PieceEditor = Omit<PieceInput, 'status' | 'tools'> & {
export type PieceEditor = Omit<PieceInput, 'status' | 'tools' | 'presentationMediaId'> & {
id: string;
status: PieceStatus;
revision: number;
createdAtUtc: string;
updatedAtUtc: string;
media: PieceMedia[];
presentationMediaId: string | null;
isFavorite: boolean;
tools: PieceTool[];
};
@@ -103,7 +107,7 @@ export type PieceMedia = {
export type Taxonomy = {
categories: { id: string; name: string; color: string }[];
formats: { id: string; name: string }[];
topics: { id: string; name: string }[];
topics: { id: string; name: string; iconName: string }[];
};
export type PieceTemplate = {
@@ -111,6 +115,7 @@ export type PieceTemplate = {
name: string;
description: string;
revision: number;
iconName: string;
};
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
@@ -131,6 +136,13 @@ function uniqueIds(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
function cleanIconName(value: unknown): string {
const iconName = cleanText(value, 80, 'icon');
if (!iconName) return '';
if (!classicRegularIconNames.has(iconName)) throw new Error('INVALID_ICON');
return iconName;
}
function validUrl(value: string): boolean {
try {
const url = new URL(value);
@@ -226,6 +238,10 @@ function validatePieceInput(input: PieceInput): PieceInput {
}
const detected =
sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url);
const presentationMediaId =
input.presentationMediaId === undefined
? undefined
: cleanText(input.presentationMediaId, 100, 'presentation_media') || null;
return {
title,
outputTitle: cleanText(input.outputTitle, 300, 'output_title'),
@@ -254,6 +270,7 @@ function validatePieceInput(input: PieceInput): PieceInput {
presentationDescriptionManual: Boolean(input.presentationDescriptionManual),
presentationImageManual: Boolean(input.presentationImageManual),
presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null,
presentationMediaId,
topicIds,
categoryIds,
sources,
@@ -271,7 +288,7 @@ export function getTaxonomy(): Taxonomy {
.prepare('SELECT id, name FROM formats ORDER BY name')
.all() as Taxonomy['formats'],
topics: database
.prepare('SELECT id, name FROM topics ORDER BY name')
.prepare('SELECT id, name, icon_name AS iconName FROM topics ORDER BY name')
.all() as Taxonomy['topics']
};
}
@@ -297,9 +314,13 @@ export function listPieces(filters: {
formatId?: string;
topicId?: string;
categoryId?: string;
deleted?: boolean;
}): PieceListItem[] {
if (!filters.statuses.length) return [];
const conditions = [`p.status IN (${filters.statuses.map(() => '?').join(', ')})`];
const conditions = [
`p.status IN (${filters.statuses.map(() => '?').join(', ')})`,
filters.deleted ? 'p.deleted_at_utc IS NOT NULL' : 'p.deleted_at_utc IS NULL'
];
const parameters: unknown[] = [...filters.statuses];
const query = filters.query?.trim().toLowerCase();
if (query) {
@@ -336,7 +357,7 @@ export function listPieces(filters: {
`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
(SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id AND pm.removed_at_utc IS NULL 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 p.is_favorite DESC,
@@ -350,7 +371,7 @@ export function listPieces(filters: {
WHERE pc.piece_id = ? ORDER BY c.sort_order, c.name`
);
const topicStatement = database.prepare(
`SELECT t.id, t.name FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id
`SELECT t.id, t.name, t.icon_name AS iconName FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id
WHERE pt.piece_id = ? ORDER BY t.name`
);
return rows.map((row) => ({
@@ -399,6 +420,7 @@ type PieceRow = {
created_at_utc: string;
updated_at_utc: string;
is_favorite: number;
presentation_media_id: string | null;
};
export function getPiece(id: string): PieceEditor | null {
@@ -411,8 +433,11 @@ 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,
(SELECT ppm.media_id FROM piece_presentation_media ppm
JOIN piece_media pm ON pm.id = ppm.media_id AND pm.removed_at_utc IS NULL
WHERE ppm.piece_id = pieces.id) AS presentation_media_id,
revision, created_at_utc, updated_at_utc, is_favorite
FROM pieces WHERE id = ?`
FROM pieces WHERE id = ? AND deleted_at_utc IS NULL`
)
.get(id) as PieceRow | undefined;
if (!row) return null;
@@ -441,6 +466,7 @@ export function getPiece(id: string): PieceEditor | null {
presentationDescriptionManual: row.presentation_description_manual === 1,
presentationImageManual: row.presentation_image_manual === 1,
presentationMetadataFetchedAtUtc: row.presentation_metadata_fetched_at_utc,
presentationMediaId: row.presentation_media_id,
revision: row.revision,
createdAtUtc: row.created_at_utc,
updatedAtUtc: row.updated_at_utc,
@@ -470,7 +496,7 @@ export function getPiece(id: string): PieceEditor | null {
database
.prepare(
`SELECT id, original_name, mime_type, byte_size, alt_text, position
FROM piece_media WHERE piece_id = ? ORDER BY position`
FROM piece_media WHERE piece_id = ? AND removed_at_utc IS NULL ORDER BY position`
)
.all(id) as {
id: string;
@@ -491,7 +517,7 @@ export function getPiece(id: string): PieceEditor | null {
tools: (
database
.prepare(
`SELECT pt.id, pt.tool_id, pt.work_url, pt.note, t.name, t.start_url, t.is_active
`SELECT pt.id, pt.tool_id, pt.work_url, pt.note, t.name, t.start_url, t.icon_name, t.is_active
FROM piece_tools pt JOIN tools t ON t.id = pt.tool_id
WHERE pt.piece_id = ? ORDER BY pt.position`
)
@@ -502,6 +528,7 @@ export function getPiece(id: string): PieceEditor | null {
note: string;
name: string;
start_url: string;
icon_name: string;
is_active: number;
}[]
).map((tool) => ({
@@ -511,6 +538,7 @@ export function getPiece(id: string): PieceEditor | null {
note: tool.note,
name: tool.name,
startUrl: tool.start_url,
iconName: tool.icon_name,
isActive: tool.is_active === 1
}))
};
@@ -544,9 +572,20 @@ function replaceRelations(pieceId: string, input: PieceInput) {
);
}
function replacePresentationMedia(pieceId: string, mediaId: string | null | undefined) {
if (mediaId === undefined) return;
const database = getDatabase();
database.prepare('DELETE FROM piece_presentation_media WHERE piece_id = ?').run(pieceId);
if (mediaId) {
database
.prepare('INSERT INTO piece_presentation_media (piece_id, media_id) VALUES (?, ?)')
.run(pieceId, mediaId);
}
}
export function setPieceFavorite(id: string, favorite: boolean) {
const result = getDatabase()
.prepare('UPDATE pieces SET is_favorite = ? WHERE id = ?')
.prepare('UPDATE pieces SET is_favorite = ? WHERE id = ? AND deleted_at_utc IS NULL')
.run(favorite ? 1 : 0, id);
if (result.changes !== 1) throw new Error('NOT_FOUND');
}
@@ -568,7 +607,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
presentation_description_manual = ?, presentation_image_manual = ?,
presentation_metadata_fetched_at_utc = ?,
updated_at_utc = ?, revision = revision + 1, archived_from_status = NULL
WHERE id = ? AND revision = ? AND status != 'archived'`
WHERE id = ? AND revision = ? AND status != 'archived' AND deleted_at_utc IS NULL`
)
.run(
input.title,
@@ -599,10 +638,13 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
expectedRevision
);
if (result.changes !== 1) {
const exists = database.prepare('SELECT 1 FROM pieces WHERE id = ?').get(id);
const exists = database
.prepare('SELECT 1 FROM pieces WHERE id = ? AND deleted_at_utc IS NULL')
.get(id);
throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND');
}
replaceRelations(id, input);
replacePresentationMedia(id, input.presentationMediaId);
});
save();
return { revision: expectedRevision + 1, updatedAtUtc };
@@ -633,6 +675,7 @@ function blankPieceInput(): PieceInput {
presentationDescriptionManual: false,
presentationImageManual: false,
presentationMetadataFetchedAtUtc: null,
presentationMediaId: null,
topicIds: [],
categoryIds: [],
sources: []
@@ -647,6 +690,7 @@ function templateInput(templateId: string): PieceInput {
if (!row) throw new Error('INVALID_TEMPLATE');
try {
const snapshot = JSON.parse(row.snapshot_json) as Partial<PieceInput>;
delete snapshot.presentationMediaId;
return validatePieceInput({ ...blankPieceInput(), ...snapshot, status: 'preparing' });
} catch {
throw new Error('INVALID_TEMPLATE');
@@ -654,22 +698,33 @@ function templateInput(templateId: string): PieceInput {
}
export function listPieceTemplates(): PieceTemplate[] {
const rows = getDatabase()
const database = getDatabase();
const rows = database
.prepare('SELECT id, name, description, revision FROM piece_templates ORDER BY name')
.all() as PieceTemplate[];
.all() as Omit<PieceTemplate, 'iconName'>[];
const iconForTemplate = (templateId: string) => {
const input = templateInput(templateId);
if (!input.topicIds.length) return 'newspaper';
return (
(
database.prepare('SELECT icon_name FROM topics WHERE id = ?').get(input.topicIds[0]) as
{ icon_name: string } | undefined
)?.icon_name || 'newspaper'
);
};
return [
{
id: 'blank',
name: 'Leer starten',
description: 'Ein freies Piece ohne Vorgaben.',
revision: 0
revision: 0,
iconName: 'newspaper'
},
...rows.filter((template) => {
...rows.flatMap((template) => {
try {
templateInput(template.id);
return true;
return [{ ...template, iconName: iconForTemplate(template.id) }];
} catch {
return false;
return [];
}
})
];
@@ -726,6 +781,7 @@ export function savePieceAsTemplate(
delete snapshot.createdAtUtc;
delete snapshot.updatedAtUtc;
delete snapshot.media;
delete snapshot.presentationMediaId;
snapshot.status = 'preparing';
const snapshotJson = JSON.stringify(snapshot);
const database = getDatabase();
@@ -787,18 +843,22 @@ export function setPieceArchived(id: string, expectedRevision: number, archived:
? database
.prepare(
`UPDATE pieces SET archived_from_status = status, status = 'archived', revision = revision + 1,
updated_at_utc = ? WHERE id = ? AND revision = ? AND status != 'archived'`
updated_at_utc = ? WHERE id = ? AND revision = ? AND status != 'archived'
AND deleted_at_utc IS NULL`
)
.run(now, id, expectedRevision)
: database
.prepare(
`UPDATE pieces SET status = COALESCE(archived_from_status, 'preparing'), archived_from_status = NULL,
revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status = 'archived'`
WHERE id = ? AND revision = ? AND status = 'archived'
AND deleted_at_utc IS NULL`
)
.run(now, id, expectedRevision);
if (result.changes !== 1) {
const exists = database.prepare('SELECT 1 FROM pieces WHERE id = ?').get(id);
const exists = database
.prepare('SELECT 1 FROM pieces WHERE id = ? AND deleted_at_utc IS NULL')
.get(id);
throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND');
}
}
@@ -812,7 +872,11 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
await mkdir(uploadDirectory, { recursive: true, mode: 0o700 });
const database = getDatabase();
const storedMedia = database
.prepare('SELECT * FROM piece_media WHERE piece_id = ? ORDER BY position')
.prepare(
`SELECT pm.*, ppm.media_id IS NOT NULL AS is_presentation
FROM piece_media pm LEFT JOIN piece_presentation_media ppm ON ppm.media_id = pm.id
WHERE pm.piece_id = ? AND pm.removed_at_utc IS NULL ORDER BY pm.position`
)
.all(sourceId) as {
id: string;
storage_name: string;
@@ -821,8 +885,9 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
byte_size: number;
alt_text: string;
position: number;
is_presentation: number;
}[];
const copied: { source: (typeof storedMedia)[number]; storageName: string }[] = [];
const copied: { id: string; source: (typeof storedMedia)[number]; storageName: string }[] = [];
try {
for (const media of storedMedia) {
const extension = ALLOWED_IMAGES.get(media.mime_type)!;
@@ -831,7 +896,7 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
path.join(uploadDirectory, media.storage_name),
path.join(uploadDirectory, storageName)
);
copied.push({ source: media, storageName });
copied.push({ id: randomUUID(), source: media, storageName });
}
database.transaction(() => {
database
@@ -879,7 +944,7 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
);
for (const media of copied) {
addMedia.run(
randomUUID(),
media.id,
id,
media.storageName,
media.source.original_name,
@@ -890,6 +955,12 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
now
);
}
const presentation = copied.find((media) => media.source.is_presentation === 1);
if (presentation) {
database
.prepare('INSERT INTO piece_presentation_media (piece_id, media_id) VALUES (?, ?)')
.run(id, presentation.id);
}
})();
return id;
} catch (error) {
@@ -916,7 +987,7 @@ export async function loadPiecePresentationMetadata(
p.presentation_image_manual, s.url
FROM pieces p LEFT JOIN piece_sources s ON s.piece_id = p.id
AND s.position = p.presentation_source_position
WHERE p.id = ? AND p.status != 'archived'`
WHERE p.id = ? AND p.status != 'archived' AND p.deleted_at_utc IS NULL`
)
.get(id) as
| {
@@ -942,7 +1013,7 @@ export async function loadPiecePresentationMetadata(
presentation_image_url = CASE WHEN presentation_image_manual = 1 THEN presentation_image_url ELSE ? END,
presentation_site_name = ?, presentation_provider = ?, presentation_provider_id = ?,
presentation_metadata_fetched_at_utc = ?, updated_at_utc = ?, revision = revision + 1
WHERE id = ? AND revision = ? AND status != 'archived'`
WHERE id = ? AND revision = ? AND status != 'archived' AND deleted_at_utc IS NULL`
)
.run(
metadata.title,
@@ -964,7 +1035,8 @@ export async function uploadPieceImage(
pieceId: string,
expectedRevision: number,
file: File,
altTextValue: string
altTextValue: string,
selectForPresentation = false
) {
if (!file.size || file.size > MAX_UPLOAD_BYTES) throw new Error('INVALID_IMAGE_SIZE');
const buffer = Buffer.from(await file.arrayBuffer());
@@ -978,85 +1050,86 @@ export async function uploadPieceImage(
const filePath = path.join(uploadDirectory, storageName);
await writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 });
const database = getDatabase();
const mediaId = randomUUID();
const now = new Date().toISOString();
try {
database.transaction(() => {
const updated = database
.prepare(
`UPDATE pieces SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'archived'`
WHERE id = ? AND revision = ? AND status != 'archived' AND deleted_at_utc IS NULL`
)
.run(new Date().toISOString(), pieceId, expectedRevision);
.run(now, pieceId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
const count = database
.prepare('SELECT count(*) AS count FROM piece_media WHERE piece_id = ?')
.prepare(
`SELECT count(*) AS count
FROM piece_media WHERE piece_id = ? AND removed_at_utc IS NULL`
)
.get(pieceId) as { count: number };
if (count.count >= 8) throw new Error('TOO_MANY_IMAGES');
const position = (
database
.prepare(
'SELECT COALESCE(max(position), -1) + 1 AS next FROM piece_media WHERE piece_id = ?'
)
.get(pieceId) as { next: number }
).next;
database
.prepare(
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
randomUUID(),
mediaId,
pieceId,
storageName,
path.basename(file.name).slice(0, 200) || `bild.${extension}`,
detected.mime,
buffer.byteLength,
altText,
count.count,
new Date().toISOString()
position,
now
);
if (selectForPresentation) replacePresentationMedia(pieceId, mediaId);
})();
} catch (error) {
await unlink(filePath).catch(() => {});
throw error;
}
return { id: mediaId, revision: expectedRevision + 1, updatedAtUtc: now };
}
export async function deletePieceImage(pieceId: string, mediaId: string, expectedRevision: number) {
const database = getDatabase();
const media = database
.prepare('SELECT storage_name FROM piece_media WHERE id = ? AND piece_id = ?')
.get(mediaId, pieceId) as { storage_name: string } | undefined;
if (!media) throw new Error('NOT_FOUND');
const originalPath = path.join(getServerPaths().uploads, media.storage_name);
const quarantinePath = `${originalPath}.deleting-${randomUUID()}`;
await rename(originalPath, quarantinePath);
try {
database.transaction(() => {
const updated = database
.prepare(
`UPDATE pieces SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'archived'`
)
.run(new Date().toISOString(), pieceId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
database.prepare('DELETE FROM piece_media WHERE id = ?').run(mediaId);
})();
} catch (error) {
await rename(quarantinePath, originalPath).catch((restoreError) => {
console.error(
'Bilddatei konnte nach fehlgeschlagener Löschung nicht wiederhergestellt werden.',
restoreError
);
});
throw error;
}
try {
await unlink(quarantinePath);
} catch (error) {
console.error(
'Gelöschte Bilddatei konnte nicht aus dem Quarantänepfad entfernt werden.',
error
);
throw new Error('IMAGE_CLEANUP_FAILED', { cause: error });
}
const now = new Date().toISOString();
database.transaction(() => {
const updated = database
.prepare(
`UPDATE pieces SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'archived' AND deleted_at_utc IS NULL`
)
.run(now, pieceId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
const removed = database
.prepare(
`UPDATE piece_media SET removed_at_utc = ?
WHERE id = ? AND piece_id = ? AND removed_at_utc IS NULL`
)
.run(now, mediaId, pieceId);
if (removed.changes !== 1) throw new Error('NOT_FOUND');
database.prepare('DELETE FROM piece_presentation_media WHERE media_id = ?').run(mediaId);
})();
return { revision: expectedRevision + 1, updatedAtUtc: now };
}
export async function getMediaFile(id: string) {
const row = getDatabase()
.prepare('SELECT storage_name, mime_type, byte_size, alt_text FROM piece_media WHERE id = ?')
.prepare(
`SELECT pm.storage_name, pm.mime_type, pm.byte_size, pm.alt_text
FROM piece_media pm JOIN pieces p ON p.id = pm.piece_id
WHERE pm.id = ? AND pm.removed_at_utc IS NULL AND p.deleted_at_utc IS NULL`
)
.get(id) as
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
if (!row) return null;
@@ -1068,7 +1141,12 @@ export async function getMediaFile(id: string) {
};
}
export function createTaxonomy(kind: TaxonomyKind, nameValue: string, colorValue?: string) {
export function createTaxonomy(
kind: TaxonomyKind,
nameValue: string,
colorOrIconValue?: string,
iconNameValue?: string
) {
const name = cleanText(nameValue, 80, 'taxonomy');
if (!name) throw new Error('INVALID_TAXONOMY');
const database = getDatabase();
@@ -1081,7 +1159,7 @@ export function createTaxonomy(kind: TaxonomyKind, nameValue: string, colorValue
if (duplicate) throw new Error('TAXONOMY_EXISTS');
const id = randomUUID();
if (kind === 'category') {
const color = String(colorValue ?? '');
const color = String(colorOrIconValue ?? '');
if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new Error('INVALID_COLOR');
const order = database
.prepare('SELECT COALESCE(max(sort_order), -1) + 1 AS next FROM section_categories')
@@ -1091,10 +1169,53 @@ export function createTaxonomy(kind: TaxonomyKind, nameValue: string, colorValue
database
.prepare('INSERT INTO section_categories (id, name, color, sort_order) VALUES (?, ?, ?, ?)')
.run(id, name, color, order.next);
} else if (kind === 'topic') {
database
.prepare('INSERT INTO topics (id, name, icon_name) VALUES (?, ?, ?)')
.run(id, name, cleanIconName(iconNameValue ?? colorOrIconValue));
} else {
const table = kind === 'format' ? 'formats' : 'topics';
database.prepare(`INSERT INTO ${table} (id, name) VALUES (?, ?)`).run(id, name);
database.prepare('INSERT INTO formats (id, name) VALUES (?, ?)').run(id, name);
}
return id;
}
export function updateTaxonomy(
kind: TaxonomyKind,
id: string,
nameValue: string,
iconNameValue?: string
) {
const name = cleanText(nameValue, 80, 'taxonomy');
if (!name) throw new Error('INVALID_TAXONOMY');
const table =
kind === 'category' ? 'section_categories' : kind === 'format' ? 'formats' : 'topics';
const database = getDatabase();
const duplicate = database
.prepare(`SELECT 1 FROM ${table} WHERE lower(name) = lower(?) AND id != ?`)
.get(name, id);
if (duplicate) throw new Error('TAXONOMY_EXISTS');
const result =
kind === 'topic'
? database
.prepare('UPDATE topics SET name = ?, icon_name = ? WHERE id = ?')
.run(name, cleanIconName(iconNameValue), id)
: database.prepare(`UPDATE ${table} SET name = ? WHERE id = ?`).run(name, id);
if (result.changes !== 1) throw new Error('NOT_FOUND');
}
export function setPieceDeleted(id: string, expectedRevision: number, deleted: boolean) {
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
throw new Error('INVALID_REVISION');
}
const now = new Date().toISOString();
const result = getDatabase()
.prepare(
`UPDATE pieces SET deleted_at_utc = ?, updated_at_utc = ?, revision = revision + 1
WHERE id = ? AND revision = ? AND deleted_at_utc IS ${deleted ? 'NULL' : 'NOT NULL'}`
)
.run(deleted ? now : null, now, id, expectedRevision);
if (result.changes !== 1) throw new Error('CONFLICT');
return { revision: expectedRevision + 1, updatedAtUtc: now };
}
export function deleteTaxonomy(kind: TaxonomyKind, id: string) {