feat: hooked scripts into the blogmark pipeline

This commit is contained in:
2026-02-23 20:10:46 +01:00
parent 77ddacd52a
commit cd394bcacb
13 changed files with 1029 additions and 10 deletions

View File

@@ -0,0 +1,307 @@
import { z } from 'zod';
import { getScriptEngine } from './ScriptEngine';
const transformPostSchema = z.object({
title: z.string().trim().min(1),
content: z.string().trim().min(1),
tags: z.array(z.string().trim().min(1)),
categories: z.array(z.string().trim().min(1)),
});
export type BlogmarkTransformedPost = z.infer<typeof transformPostSchema>;
export interface BlogmarkTransformInput {
post: BlogmarkTransformedPost;
context: {
source: 'blogmark';
url: string;
};
}
export interface BlogmarkTransformScriptRecord {
id: string;
slug: string;
title: string;
kind: 'macro' | 'utility' | 'transform';
entrypoint: string;
enabled: boolean;
content: string;
updatedAt: Date | string;
}
export interface BlogmarkTransformExecutor {
runTransform(script: BlogmarkTransformScriptRecord, input: BlogmarkTransformInput): Promise<unknown>;
}
export interface BlogmarkTransformScriptProvider {
getScripts(): Promise<BlogmarkTransformScriptRecord[]>;
}
export interface BlogmarkTransformError {
scriptId: string;
scriptSlug: string;
message: string;
}
export interface BlogmarkTransformExecutionData {
output: unknown;
toasts: string[];
}
export interface BlogmarkTransformResult {
post: BlogmarkTransformedPost;
appliedScriptIds: string[];
errors: BlogmarkTransformError[];
toasts: string[];
}
const MAX_TOASTS_PER_SCRIPT = 5;
const MAX_TOASTS_TOTAL = 20;
const MAX_TOAST_LENGTH = 300;
const scriptEngineBackedProvider: BlogmarkTransformScriptProvider = {
async getScripts() {
return getScriptEngine().getAllScripts();
},
};
function toTimestamp(value: Date | string): number {
if (value instanceof Date) {
return value.getTime();
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function normalizePost(value: unknown): BlogmarkTransformedPost | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const valueRecord = value as Record<string, unknown>;
const maybePost = valueRecord.post;
const candidate = maybePost && typeof maybePost === 'object' && !Array.isArray(maybePost)
? maybePost
: value;
const parsed = transformPostSchema.safeParse(candidate);
if (!parsed.success) {
return null;
}
return parsed.data;
}
function normalizeToastMessage(value: unknown): string | null {
if (value === undefined || value === null) {
return null;
}
const normalized = String(value).trim();
if (normalized.length === 0) {
return null;
}
return normalized.slice(0, MAX_TOAST_LENGTH);
}
function toExecutionData(value: unknown): BlogmarkTransformExecutionData {
if (value && typeof value === 'object' && !Array.isArray(value)) {
const valueRecord = value as Record<string, unknown>;
const toasts = Array.isArray(valueRecord.toasts)
? valueRecord.toasts
.map((item) => normalizeToastMessage(item))
.filter((item): item is string => item !== null)
: [];
if (Object.prototype.hasOwnProperty.call(valueRecord, 'output')) {
return {
output: valueRecord.output,
toasts,
};
}
return {
output: value,
toasts,
};
}
return {
output: value,
toasts: [],
};
}
function toErrorMessage(error: unknown): string {
if (error instanceof Error && typeof error.message === 'string' && error.message.trim().length > 0) {
return error.message;
}
return String(error);
}
class PythonBlogmarkTransformExecutor implements BlogmarkTransformExecutor {
private runtimePromise: Promise<any> | null = null;
async runTransform(script: BlogmarkTransformScriptRecord, input: BlogmarkTransformInput): Promise<unknown> {
const runtime = await this.getRuntime();
const toastMessages: string[] = [];
const pushToast = (message: unknown): void => {
if (toastMessages.length >= MAX_TOASTS_PER_SCRIPT) {
return;
}
const normalizedMessage = normalizeToastMessage(message);
if (!normalizedMessage) {
return;
}
toastMessages.push(normalizedMessage);
};
runtime.globals.set('__bds_push_toast', pushToast);
await runtime.runPythonAsync(`
def toast(message):
__bds_push_toast(str(message))
`);
await runtime.runPythonAsync(script.content);
const requestedEntrypoint = this.resolveEntrypoint(script.entrypoint);
const payload = JSON.stringify(input);
runtime.globals.set('__bds_transform_payload_json', payload);
runtime.globals.set('__bds_transform_entrypoint', requestedEntrypoint);
const rawResult = await runtime.runPythonAsync(`
import json
_payload = json.loads(__bds_transform_payload_json)
_entrypoint = __bds_transform_entrypoint
_transform_fn = globals().get(_entrypoint)
if _transform_fn is None or not callable(_transform_fn):
raise RuntimeError(f"Transform entrypoint '{_entrypoint}' is not callable")
_result = _transform_fn(_payload)
json.dumps(_result)
`);
return {
output: JSON.parse(String(rawResult)),
toasts: toastMessages,
};
}
private resolveEntrypoint(value: string): string {
const nextEntrypoint = typeof value === 'string' ? value.trim() : '';
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(nextEntrypoint) && nextEntrypoint !== 'main') {
return nextEntrypoint;
}
return 'transform';
}
private async getRuntime(): Promise<any> {
if (!this.runtimePromise) {
this.runtimePromise = (async () => {
const pyodideModule = await import('pyodide');
return pyodideModule.loadPyodide();
})();
}
return this.runtimePromise;
}
}
export class BlogmarkTransformService {
constructor(
private readonly dependencies: {
provider?: BlogmarkTransformScriptProvider;
executor?: BlogmarkTransformExecutor;
} = {},
) {}
async applyTransforms(input: BlogmarkTransformInput): Promise<BlogmarkTransformResult> {
const parsedInput = transformPostSchema.parse(input.post);
const transformInput: BlogmarkTransformInput = {
...input,
post: parsedInput,
};
const provider = this.dependencies.provider ?? scriptEngineBackedProvider;
const executor = this.dependencies.executor ?? new PythonBlogmarkTransformExecutor();
const scripts = await provider.getScripts();
const activeTransforms = scripts
.filter((script) => script.enabled && script.kind === 'transform')
.sort((left, right) => {
const byUpdatedAt = toTimestamp(left.updatedAt) - toTimestamp(right.updatedAt);
if (byUpdatedAt !== 0) {
return byUpdatedAt;
}
const bySlug = left.slug.localeCompare(right.slug);
if (bySlug !== 0) {
return bySlug;
}
return left.id.localeCompare(right.id);
});
let currentPost = transformInput.post;
const appliedScriptIds: string[] = [];
const errors: BlogmarkTransformError[] = [];
const toasts: string[] = [];
for (const script of activeTransforms) {
try {
const execution = await executor.runTransform(script, {
...transformInput,
post: currentPost,
});
const executionData = toExecutionData(execution);
const nextToasts = executionData.toasts
.map((message) => normalizeToastMessage(message))
.filter((message): message is string => message !== null);
if (nextToasts.length > 0 && toasts.length < MAX_TOASTS_TOTAL) {
const remaining = MAX_TOASTS_TOTAL - toasts.length;
toasts.push(...nextToasts.slice(0, remaining));
}
const normalizedPost = normalizePost(executionData.output);
if (!normalizedPost) {
throw new Error('Transform output validation failed');
}
currentPost = normalizedPost;
appliedScriptIds.push(script.id);
} catch (error) {
const message = toErrorMessage(error);
errors.push({
scriptId: script.id,
scriptSlug: script.slug,
message,
});
console.error(`[blogmark-transform] ${script.slug}: ${message}`);
}
}
return {
post: currentPost,
appliedScriptIds,
errors,
toasts,
};
}
}
let blogmarkTransformServiceInstance: BlogmarkTransformService | null = null;
export function getBlogmarkTransformService(): BlogmarkTransformService {
if (!blogmarkTransformServiceInstance) {
blogmarkTransformServiceInstance = new BlogmarkTransformService();
}
return blogmarkTransformServiceInstance;
}

View File

@@ -8,6 +8,7 @@ import { eq } from 'drizzle-orm';
import { getMediaEngine } from './engine/MediaEngine';
import { getPostEngine } from './engine/PostEngine';
import { getMetaEngine } from './engine/MetaEngine';
import { getBlogmarkTransformService } from './engine/BlogmarkTransformService';
import { PreviewServer } from './engine/PreviewServer';
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_GROUPS, APP_MENU_ITEM_IDS, type AppMenuAction, type AppMenuItemDefinition } from './shared/menuCommands';
import { resolveUiLanguageFromSystemLocale, translateMenu } from './shared/i18n';
@@ -373,16 +374,40 @@ async function processBlogmarkDeepLink(rawDeepLink: string): Promise<void> {
const metadata = await getMetaEngine().getProjectMetadata();
const preferredCategory = normalizeBlogmarkCategory((metadata as { blogmarkCategory?: unknown } | null)?.blogmarkCategory);
const createdPost = await getPostEngine().createPost({
title: payload.title,
content: buildBlogmarkMarkdownLink(payload.title, payload.url),
categories: preferredCategory ? [preferredCategory] : [],
const transformService = getBlogmarkTransformService();
const transformResult = await transformService.applyTransforms({
post: {
title: payload.title,
content: buildBlogmarkMarkdownLink(payload.title, payload.url),
tags: [],
categories: preferredCategory ? [preferredCategory] : [],
},
context: {
source: 'blogmark',
url: payload.url,
},
});
const createdPost = await getPostEngine().createPost({
title: transformResult.post.title,
content: transformResult.post.content,
tags: transformResult.post.tags,
categories: transformResult.post.categories,
});
const blogmarkCreatedPayload = {
post: createdPost,
transform: {
appliedScriptIds: transformResult.appliedScriptIds,
errors: transformResult.errors,
toasts: transformResult.toasts,
},
};
if (mainWindow && !mainWindow.isDestroyed() && rendererReady) {
mainWindow.webContents.send('blogmark:created', createdPost);
mainWindow.webContents.send('blogmark:created', blogmarkCreatedPayload);
} else {
pendingBlogmarkCreatedEvents.push(createdPost);
pendingBlogmarkCreatedEvents.push(blogmarkCreatedPayload);
}
}

View File

@@ -6,6 +6,11 @@ import { openSingletonToolTab } from './navigation/tabPolicy';
import { persistSiteValidationReport } from './navigation/siteValidationPersistence';
import { executeActivityClick } from './navigation/activityExecution';
import { handleBlogmarkCreatedEvent } from './navigation/blogmarkHandling';
import {
buildBlogmarkTransformOutputEntries,
buildBlogmarkTransformToastNotifications,
parseBlogmarkCreatedEventPayload,
} from './navigation/blogmarkTransformOutput';
import { createDeferredEventGate } from './navigation/deferredEventGate';
import { createAndFocusPost } from './navigation/postCreation';
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme';
@@ -34,6 +39,7 @@ const App: React.FC = () => {
setPicoTheme,
openTab,
restoreTabState,
appendPanelOutputEntry,
} = useAppStore();
const blogmarkEventGateRef = useRef(createDeferredEventGate<PostData>());
@@ -239,12 +245,46 @@ const App: React.FC = () => {
);
unsubscribers.push(
window.electronAPI?.on('blogmark:created', (post: unknown) => {
const created = post as PostData;
window.electronAPI?.on('blogmark:created', (payload: unknown) => {
const parsedPayload = parseBlogmarkCreatedEventPayload(payload);
if (!parsedPayload) {
return;
}
const created = parsedPayload.post as PostData;
if (!created?.id) {
return;
}
const outputEntries = buildBlogmarkTransformOutputEntries(parsedPayload.transform, tr);
const toastNotifications = buildBlogmarkTransformToastNotifications(parsedPayload.transform, tr);
toastNotifications.forEach((notification) => {
if (notification.kind === 'error') {
showToast.error(notification.message);
return;
}
showToast.success(notification.message);
});
if (outputEntries.length > 0) {
const createdAt = new Date().toISOString();
outputEntries.forEach((entry, index) => {
appendPanelOutputEntry({
id: `blogmark-transform-${Date.now()}-${index}`,
createdAt,
message: entry.message,
kind: entry.kind,
});
});
useAppStore.setState({
panelVisible: true,
panelActiveTab: 'output',
});
}
blogmarkEventGateRef.current.push(created, processBlogmarkCreated);
}) || (() => {})
);

View File

@@ -23,6 +23,11 @@
"tasks.triggerTitle": "{running} laufend, {pending} ausstehend",
"app.taskCompleted": "Aufgabe abgeschlossen: {message}",
"app.taskFailed": "Aufgabe fehlgeschlagen: {message}",
"app.blogmark.transforms.summary": "Blogmark-Transformationen: {applied} angewendet, {failed} fehlgeschlagen",
"app.blogmark.transforms.appliedList": "Angewendete Skripte: {scripts}",
"app.blogmark.transforms.failed": "Transformation fehlgeschlagen ({script}): {message}",
"app.blogmark.transforms.toast": "Skript-Toast: {message}",
"app.blogmark.transforms.errorToast": "Blogmark-Transformationsfehler: {count}",
"app.databaseRebuildFailed": "Datenbank-Neuaufbau fehlgeschlagen",
"app.textReindexFailed": "Text-Neuindizierung fehlgeschlagen",
"app.sitemapGenerationFailed": "Sitemap-Erstellung fehlgeschlagen",

View File

@@ -23,6 +23,11 @@
"tasks.triggerTitle": "{running} running, {pending} pending",
"app.taskCompleted": "Task completed: {message}",
"app.taskFailed": "Task failed: {message}",
"app.blogmark.transforms.summary": "Blogmark transforms: {applied} applied, {failed} failed",
"app.blogmark.transforms.appliedList": "Applied scripts: {scripts}",
"app.blogmark.transforms.failed": "Transform failed ({script}): {message}",
"app.blogmark.transforms.toast": "Script toast: {message}",
"app.blogmark.transforms.errorToast": "Blogmark transform errors: {count}",
"app.databaseRebuildFailed": "Database rebuild failed",
"app.textReindexFailed": "Text reindex failed",
"app.sitemapGenerationFailed": "Sitemap generation failed",

View File

@@ -23,6 +23,11 @@
"tasks.triggerTitle": "{running} en ejecución, {pending} pendiente",
"app.taskCompleted": "Tarea completada: {message}",
"app.taskFailed": "Tarea fallida: {message}",
"app.blogmark.transforms.summary": "Transformaciones de blogmark: {applied} aplicadas, {failed} fallidas",
"app.blogmark.transforms.appliedList": "Scripts aplicados: {scripts}",
"app.blogmark.transforms.failed": "Transformación fallida ({script}): {message}",
"app.blogmark.transforms.toast": "Toast del script: {message}",
"app.blogmark.transforms.errorToast": "Errores de transformación de blogmark: {count}",
"app.databaseRebuildFailed": "La reconstrucción de la base de datos falló",
"app.textReindexFailed": "La reindexación de texto falló",
"app.sitemapGenerationFailed": "La generación del sitemap falló",

View File

@@ -23,6 +23,11 @@
"tasks.triggerTitle": "{running} en cours, {pending} en attente",
"app.taskCompleted": "Tâche terminée : {message}",
"app.taskFailed": "Échec de la tâche : {message}",
"app.blogmark.transforms.summary": "Transformations blogmark : {applied} appliquées, {failed} en échec",
"app.blogmark.transforms.appliedList": "Scripts appliqués : {scripts}",
"app.blogmark.transforms.failed": "Échec de transformation ({script}) : {message}",
"app.blogmark.transforms.toast": "Toast du script : {message}",
"app.blogmark.transforms.errorToast": "Erreurs de transformation blogmark : {count}",
"app.databaseRebuildFailed": "Échec de la reconstruction de la base de données",
"app.textReindexFailed": "Échec de la réindexation du texte",
"app.sitemapGenerationFailed": "Échec de la génération du sitemap",

View File

@@ -23,6 +23,11 @@
"tasks.triggerTitle": "{running} in esecuzione, {pending} in attesa",
"app.taskCompleted": "Attività completata: {message}",
"app.taskFailed": "Attività non riuscita: {message}",
"app.blogmark.transforms.summary": "Trasformazioni blogmark: {applied} applicate, {failed} non riuscite",
"app.blogmark.transforms.appliedList": "Script applicati: {scripts}",
"app.blogmark.transforms.failed": "Trasformazione non riuscita ({script}): {message}",
"app.blogmark.transforms.toast": "Toast script: {message}",
"app.blogmark.transforms.errorToast": "Errori di trasformazione blogmark: {count}",
"app.databaseRebuildFailed": "Ricostruzione database non riuscita",
"app.textReindexFailed": "Reindicizzazione testo non riuscita",
"app.sitemapGenerationFailed": "Generazione sitemap non riuscita",

View File

@@ -0,0 +1,168 @@
import type { PanelOutputEntry, PostData } from '../store';
export interface BlogmarkTransformDebugError {
scriptId: string;
scriptSlug: string;
message: string;
}
export interface BlogmarkTransformDebugInfo {
appliedScriptIds: string[];
errors: BlogmarkTransformDebugError[];
toasts: string[];
}
export interface BlogmarkTransformToastNotification {
kind: 'success' | 'error';
message: string;
}
export interface BlogmarkCreatedEventPayload {
post: PostData;
transform?: BlogmarkTransformDebugInfo;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseTransformDebugInfo(value: unknown): BlogmarkTransformDebugInfo | undefined {
if (!isRecord(value)) {
return undefined;
}
const appliedScriptIds = Array.isArray(value.appliedScriptIds)
? value.appliedScriptIds.filter((item): item is string => typeof item === 'string')
: [];
const errors = Array.isArray(value.errors)
? value.errors
.map((entry) => {
if (!isRecord(entry)) {
return null;
}
const scriptId = typeof entry.scriptId === 'string' ? entry.scriptId : '';
const scriptSlug = typeof entry.scriptSlug === 'string' ? entry.scriptSlug : '';
const message = typeof entry.message === 'string' ? entry.message : '';
if (!scriptId || !scriptSlug || !message) {
return null;
}
return {
scriptId,
scriptSlug,
message,
};
})
.filter((item): item is BlogmarkTransformDebugError => item !== null)
: [];
const toasts = Array.isArray(value.toasts)
? value.toasts
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0)
: [];
if (appliedScriptIds.length === 0 && errors.length === 0 && toasts.length === 0) {
return undefined;
}
return {
appliedScriptIds,
errors,
toasts,
};
}
export function parseBlogmarkCreatedEventPayload(payload: unknown): BlogmarkCreatedEventPayload | null {
if (!isRecord(payload)) {
return null;
}
if (isRecord(payload.post)) {
return {
post: payload.post as PostData,
transform: parseTransformDebugInfo(payload.transform),
};
}
return {
post: payload as PostData,
transform: undefined,
};
}
export function buildBlogmarkTransformOutputEntries(
transform: BlogmarkTransformDebugInfo | undefined,
t: (key: string, values?: Record<string, string | number>) => string,
): Array<Omit<PanelOutputEntry, 'id' | 'createdAt'>> {
if (!transform) {
return [];
}
const entries: Array<Omit<PanelOutputEntry, 'id' | 'createdAt'>> = [];
entries.push({
kind: 'result',
message: t('app.blogmark.transforms.summary', {
applied: transform.appliedScriptIds.length,
failed: transform.errors.length,
}),
});
if (transform.appliedScriptIds.length > 0) {
entries.push({
kind: 'result',
message: t('app.blogmark.transforms.appliedList', {
scripts: transform.appliedScriptIds.join(', '),
}),
});
}
for (const toastMessage of transform.toasts) {
entries.push({
kind: 'result',
message: t('app.blogmark.transforms.toast', {
message: toastMessage,
}),
});
}
for (const error of transform.errors) {
entries.push({
kind: 'error',
message: t('app.blogmark.transforms.failed', {
script: error.scriptSlug,
message: error.message,
}),
});
}
return entries;
}
export function buildBlogmarkTransformToastNotifications(
transform: BlogmarkTransformDebugInfo | undefined,
t: (key: string, values?: Record<string, string | number>) => string,
): BlogmarkTransformToastNotification[] {
if (!transform) {
return [];
}
const notifications: BlogmarkTransformToastNotification[] = transform.toasts.map((message) => ({
kind: 'success',
message,
}));
if (transform.errors.length > 0) {
notifications.push({
kind: 'error',
message: t('app.blogmark.transforms.errorToast', {
count: transform.errors.length,
}),
});
}
return notifications;
}