Release 0.5.10
This commit is contained in:
1 parent
c3033479b5
commit
4fe61971e4
52 files changed
+38565
-466
No files matched your search
+199
-78
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user