Release 0.5.1

This commit is contained in:
hnzio committed 2026-10-22 07:56:25 +02:00
1 parent 5a9d7735d4
commit fb24d171ae
36 files changed
+5099 -263

No files matched your search

+190 -3
View File
@@ -4,6 +4,11 @@ import path from 'node:path';
import { fileTypeFromBuffer } from 'file-type';
import { getDatabase } from './database.js';
import { getServerPaths } from './config.js';
import {
detectVideoProvider,
fetchPresentationMetadata,
type PresentationMode
} from './presentation.js';
export const PIECE_STATUSES = ['inbox', 'preparing', 'ready', 'archived'] as const;
export type PieceStatus = (typeof PIECE_STATUSES)[number];
@@ -27,6 +32,18 @@ export type PieceInput = {
moderationNotes: string;
transitionText: string;
presentationText: string;
presentationMode: PresentationMode;
presentationSourcePosition: number | null;
presentationTitle: string;
presentationDescription: string;
presentationImageUrl: string;
presentationSiteName: string;
presentationProvider: string;
presentationProviderId: string;
presentationTitleManual: boolean;
presentationDescriptionManual: boolean;
presentationImageManual: boolean;
presentationMetadataFetchedAtUtc: string | null;
topicIds: string[];
categoryIds: string[];
sources: PieceSourceInput[];
@@ -164,6 +181,25 @@ function validatePieceInput(input: PieceInput): PieceInput {
}
return { label, url, timecodeSeconds: source.timecodeSeconds };
});
if (!['text', 'link', 'video'].includes(input.presentationMode)) {
throw new Error('INVALID_PRESENTATION');
}
const sourcePosition = input.presentationSourcePosition;
if (
sourcePosition !== null &&
(!Number.isInteger(sourcePosition) || sourcePosition < 0 || sourcePosition >= sources.length)
) {
throw new Error('INVALID_PRESENTATION');
}
if (input.presentationMode !== 'text' && sourcePosition === null) {
throw new Error('INVALID_PRESENTATION');
}
const presentationImageUrl = cleanText(input.presentationImageUrl, 2_000, 'presentation_image');
if (presentationImageUrl && !validUrl(presentationImageUrl)) {
throw new Error('INVALID_PRESENTATION');
}
const detected =
sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url);
return {
title,
summary: cleanText(input.summary, 2_000, 'summary'),
@@ -175,6 +211,22 @@ function validatePieceInput(input: PieceInput): PieceInput {
moderationNotes: cleanText(input.moderationNotes, 50_000, 'moderation'),
transitionText: cleanText(input.transitionText, 50_000, 'transition'),
presentationText: cleanText(input.presentationText, 50_000, 'presentation'),
presentationMode: input.presentationMode,
presentationSourcePosition: sourcePosition,
presentationTitle: cleanText(input.presentationTitle, 300, 'presentation_title'),
presentationDescription: cleanText(
input.presentationDescription,
1_000,
'presentation_description'
),
presentationImageUrl,
presentationSiteName: cleanText(input.presentationSiteName, 200, 'presentation_site'),
presentationProvider: detected?.provider ?? '',
presentationProviderId: detected?.id ?? '',
presentationTitleManual: Boolean(input.presentationTitleManual),
presentationDescriptionManual: Boolean(input.presentationDescriptionManual),
presentationImageManual: Boolean(input.presentationImageManual),
presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null,
topicIds,
categoryIds,
sources
@@ -298,6 +350,18 @@ type PieceRow = {
moderation_notes: string;
transition_text: string;
presentation_text: string;
presentation_mode: PresentationMode;
presentation_source_position: number | null;
presentation_title: string;
presentation_description: string;
presentation_image_url: string;
presentation_site_name: string;
presentation_provider: string;
presentation_provider_id: string;
presentation_title_manual: number;
presentation_description_manual: number;
presentation_image_manual: number;
presentation_metadata_fetched_at_utc: string | null;
revision: number;
created_at_utc: string;
updated_at_utc: string;
@@ -308,7 +372,12 @@ export function getPiece(id: string): PieceEditor | null {
const row = database
.prepare(
`SELECT id, title, summary, format_id, status, duration_seconds, intro, research_text,
moderation_notes, transition_text, presentation_text, revision, created_at_utc, updated_at_utc
moderation_notes, transition_text, presentation_text, presentation_mode,
presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual, presentation_description_manual,
presentation_image_manual, presentation_metadata_fetched_at_utc,
revision, created_at_utc, updated_at_utc
FROM pieces WHERE id = ?`
)
.get(id) as PieceRow | undefined;
@@ -325,6 +394,18 @@ export function getPiece(id: string): PieceEditor | null {
moderationNotes: row.moderation_notes,
transitionText: row.transition_text,
presentationText: row.presentation_text,
presentationMode: row.presentation_mode,
presentationSourcePosition: row.presentation_source_position,
presentationTitle: row.presentation_title,
presentationDescription: row.presentation_description,
presentationImageUrl: row.presentation_image_url,
presentationSiteName: row.presentation_site_name,
presentationProvider: row.presentation_provider,
presentationProviderId: row.presentation_provider_id,
presentationTitleManual: row.presentation_title_manual === 1,
presentationDescriptionManual: row.presentation_description_manual === 1,
presentationImageManual: row.presentation_image_manual === 1,
presentationMetadataFetchedAtUtc: row.presentation_metadata_fetched_at_utc,
revision: row.revision,
createdAtUtc: row.created_at_utc,
updatedAtUtc: row.updated_at_utc,
@@ -405,6 +486,11 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
.prepare(
`UPDATE pieces SET title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?,
intro = ?, research_text = ?, moderation_notes = ?, transition_text = ?, presentation_text = ?,
presentation_mode = ?, presentation_source_position = ?, presentation_title = ?,
presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?,
presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?,
presentation_description_manual = ?, presentation_image_manual = ?,
presentation_metadata_fetched_at_utc = ?,
updated_at_utc = ?, revision = revision + 1, archived_from_status = NULL
WHERE id = ? AND revision = ? AND status != 'archived'`
)
@@ -419,6 +505,18 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
input.moderationNotes,
input.transitionText,
input.presentationText,
input.presentationMode,
input.presentationSourcePosition,
input.presentationTitle,
input.presentationDescription,
input.presentationImageUrl,
input.presentationSiteName,
input.presentationProvider,
input.presentationProviderId,
input.presentationTitleManual ? 1 : 0,
input.presentationDescriptionManual ? 1 : 0,
input.presentationImageManual ? 1 : 0,
input.presentationMetadataFetchedAtUtc,
updatedAtUtc,
id,
expectedRevision
@@ -477,6 +575,18 @@ export function createPiece(template: PieceTemplateId = 'blank'): string {
moderationNotes: defaults.moderationNotes ?? '',
transitionText: '',
presentationText: defaults.presentationText ?? '',
presentationMode: 'text',
presentationSourcePosition: null,
presentationTitle: '',
presentationDescription: '',
presentationImageUrl: '',
presentationSiteName: '',
presentationProvider: '',
presentationProviderId: '',
presentationTitleManual: false,
presentationDescriptionManual: false,
presentationImageManual: false,
presentationMetadataFetchedAtUtc: null,
topicIds: [],
categoryIds: defaults.categoryIds ?? [],
sources: []
@@ -582,8 +692,13 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
database
.prepare(
`INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro,
research_text, moderation_notes, transition_text, presentation_text, created_at_utc,
updated_at_utc, revision) VALUES (?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, 1)`
research_text, moderation_notes, transition_text, presentation_text, presentation_mode,
presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual,
presentation_description_manual, presentation_image_manual,
presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision)
VALUES (?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
id,
@@ -596,6 +711,18 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
source.moderationNotes,
source.transitionText,
source.presentationText,
source.presentationMode,
source.presentationSourcePosition,
source.presentationTitle,
source.presentationDescription,
source.presentationImageUrl,
source.presentationSiteName,
source.presentationProvider,
source.presentationProviderId,
source.presentationTitleManual ? 1 : 0,
source.presentationDescriptionManual ? 1 : 0,
source.presentationImageManual ? 1 : 0,
source.presentationMetadataFetchedAtUtc,
now,
now
);
@@ -627,6 +754,66 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
}
}
export async function loadPiecePresentationMetadata(
id: string,
expectedRevision: number,
metadataLoader = fetchPresentationMetadata
) {
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
throw new Error('INVALID_REVISION');
}
const database = getDatabase();
const piece = database
.prepare(
`SELECT p.revision, p.presentation_mode, p.presentation_source_position,
p.presentation_title_manual, p.presentation_description_manual,
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'`
)
.get(id) as
| {
revision: number;
presentation_mode: PresentationMode;
presentation_source_position: number | null;
presentation_title_manual: number;
presentation_description_manual: number;
presentation_image_manual: number;
url: string | null;
}
| undefined;
if (!piece) throw new Error('NOT_FOUND');
if (piece.revision !== expectedRevision) throw new Error('CONFLICT');
if (piece.presentation_mode === 'text' || !piece.url) throw new Error('INVALID_PRESENTATION');
const metadata = await metadataLoader(piece.url);
const now = new Date().toISOString();
const result = database
.prepare(
`UPDATE pieces SET
presentation_title = CASE WHEN presentation_title_manual = 1 THEN presentation_title ELSE ? END,
presentation_description = CASE WHEN presentation_description_manual = 1 THEN presentation_description ELSE ? END,
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'`
)
.run(
metadata.title,
metadata.description,
metadata.imageUrl,
metadata.siteName,
metadata.provider,
metadata.providerId,
now,
now,
id,
expectedRevision
);
if (result.changes !== 1) throw new Error('CONFLICT');
return { revision: expectedRevision + 1, updatedAtUtc: now };
}
export async function uploadPieceImage(
pieceId: string,
expectedRevision: number,