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

@@ -205,11 +205,55 @@ Each script exposes an **Entrypoint** selector. bDS always provides a synthetic
At this stage, scripting is intended for controlled project workflows where scripts interact with application-provided tools. Keep scripts versioned through your normal Git workflow, review changes carefully, and prefer small, explicit scripts over monolithic utility files. At this stage, scripting is intended for controlled project workflows where scripts interact with application-provided tools. Keep scripts versioned through your normal Git workflow, review changes carefully, and prefer small, explicit scripts over monolithic utility files.
For transform scripts, bDS provides a built-in Python helper named `toast(message)`. It accepts a single string and emits a UI intent that the app handles on the renderer side. This keeps script ergonomics simple while preserving a controlled bridge between script runtime and user interface.
When transform scripts fail during a pipeline run, bDS automatically surfaces an error toast so users are notified immediately. Detailed transform diagnostics (applied scripts and per-script errors) are also written to the Output panel.
### Example transform script
Use a transform function to modify incoming bookmark/blogmark content before bDS creates the post. The function receives a mutable `post` dictionary and should return that dictionary.
```python
def normalize_blogmark(post):
# 1) Manipulate title
title = (post.get("title") or "").strip()
if title and not title.startswith("[Clipped]"):
post["title"] = f"[Clipped] {title}"
# 2) Manipulate text/content
content = (post.get("content") or "").strip()
prefix = "Imported from blogmark\n\n"
if content and not content.startswith(prefix):
post["content"] = prefix + content
# 3) Set or replace categories
post["categories"] = ["Inbox", "Research"]
# 4) Add and normalize tags
tags = post.get("tags") or []
tags.append("blogmark")
tags.append("clipped")
post["tags"] = sorted({str(tag).strip().lower() for tag in tags if str(tag).strip()})
# 5) Optional user notification
toast(f"Transform applied: {post.get('title')}")
return post
```
Notes:
- `title` and `content` are strings.
- `categories` and `tags` are string lists (e.g., `['News', 'AI']`).
- Return the mutated `post` dict from your transform function.
- Keep transforms small and deterministic, especially when multiple active transforms run in sequence.
### Key takeaways ### Key takeaways
- Scripting is available and intentionally evolving in small steps. - Scripting is available and intentionally evolving in small steps.
- `main` is always available and preserves whole-script execution behavior. - `main` is always available and preserves whole-script execution behavior.
- Script files and metadata remain filesystem-friendly and Git-reviewable. - Script files and metadata remain filesystem-friendly and Git-reviewable.
- Transform scripts can call `toast("...")` to send user-facing UI notifications.
- Transform scripts can directly manipulate `title`, `content`, `categories`, and `tags`.
- Transform pipeline failures always trigger automatic error toasts.
[↑ Back to In this article](#in-this-article) [↑ Back to In this article](#in-this-article)

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 { getMediaEngine } from './engine/MediaEngine';
import { getPostEngine } from './engine/PostEngine'; import { getPostEngine } from './engine/PostEngine';
import { getMetaEngine } from './engine/MetaEngine'; import { getMetaEngine } from './engine/MetaEngine';
import { getBlogmarkTransformService } from './engine/BlogmarkTransformService';
import { PreviewServer } from './engine/PreviewServer'; 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 { 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'; import { resolveUiLanguageFromSystemLocale, translateMenu } from './shared/i18n';
@@ -373,16 +374,40 @@ async function processBlogmarkDeepLink(rawDeepLink: string): Promise<void> {
const metadata = await getMetaEngine().getProjectMetadata(); const metadata = await getMetaEngine().getProjectMetadata();
const preferredCategory = normalizeBlogmarkCategory((metadata as { blogmarkCategory?: unknown } | null)?.blogmarkCategory); const preferredCategory = normalizeBlogmarkCategory((metadata as { blogmarkCategory?: unknown } | null)?.blogmarkCategory);
const createdPost = await getPostEngine().createPost({ const transformService = getBlogmarkTransformService();
title: payload.title, const transformResult = await transformService.applyTransforms({
content: buildBlogmarkMarkdownLink(payload.title, payload.url), post: {
categories: preferredCategory ? [preferredCategory] : [], 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) { if (mainWindow && !mainWindow.isDestroyed() && rendererReady) {
mainWindow.webContents.send('blogmark:created', createdPost); mainWindow.webContents.send('blogmark:created', blogmarkCreatedPayload);
} else { } else {
pendingBlogmarkCreatedEvents.push(createdPost); pendingBlogmarkCreatedEvents.push(blogmarkCreatedPayload);
} }
} }

View File

@@ -6,6 +6,11 @@ import { openSingletonToolTab } from './navigation/tabPolicy';
import { persistSiteValidationReport } from './navigation/siteValidationPersistence'; import { persistSiteValidationReport } from './navigation/siteValidationPersistence';
import { executeActivityClick } from './navigation/activityExecution'; import { executeActivityClick } from './navigation/activityExecution';
import { handleBlogmarkCreatedEvent } from './navigation/blogmarkHandling'; import { handleBlogmarkCreatedEvent } from './navigation/blogmarkHandling';
import {
buildBlogmarkTransformOutputEntries,
buildBlogmarkTransformToastNotifications,
parseBlogmarkCreatedEventPayload,
} from './navigation/blogmarkTransformOutput';
import { createDeferredEventGate } from './navigation/deferredEventGate'; import { createDeferredEventGate } from './navigation/deferredEventGate';
import { createAndFocusPost } from './navigation/postCreation'; import { createAndFocusPost } from './navigation/postCreation';
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme'; import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme';
@@ -34,6 +39,7 @@ const App: React.FC = () => {
setPicoTheme, setPicoTheme,
openTab, openTab,
restoreTabState, restoreTabState,
appendPanelOutputEntry,
} = useAppStore(); } = useAppStore();
const blogmarkEventGateRef = useRef(createDeferredEventGate<PostData>()); const blogmarkEventGateRef = useRef(createDeferredEventGate<PostData>());
@@ -239,12 +245,46 @@ const App: React.FC = () => {
); );
unsubscribers.push( unsubscribers.push(
window.electronAPI?.on('blogmark:created', (post: unknown) => { window.electronAPI?.on('blogmark:created', (payload: unknown) => {
const created = post as PostData; const parsedPayload = parseBlogmarkCreatedEventPayload(payload);
if (!parsedPayload) {
return;
}
const created = parsedPayload.post as PostData;
if (!created?.id) { if (!created?.id) {
return; 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); blogmarkEventGateRef.current.push(created, processBlogmarkCreated);
}) || (() => {}) }) || (() => {})
); );

View File

@@ -23,6 +23,11 @@
"tasks.triggerTitle": "{running} laufend, {pending} ausstehend", "tasks.triggerTitle": "{running} laufend, {pending} ausstehend",
"app.taskCompleted": "Aufgabe abgeschlossen: {message}", "app.taskCompleted": "Aufgabe abgeschlossen: {message}",
"app.taskFailed": "Aufgabe fehlgeschlagen: {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.databaseRebuildFailed": "Datenbank-Neuaufbau fehlgeschlagen",
"app.textReindexFailed": "Text-Neuindizierung fehlgeschlagen", "app.textReindexFailed": "Text-Neuindizierung fehlgeschlagen",
"app.sitemapGenerationFailed": "Sitemap-Erstellung fehlgeschlagen", "app.sitemapGenerationFailed": "Sitemap-Erstellung fehlgeschlagen",

View File

@@ -23,6 +23,11 @@
"tasks.triggerTitle": "{running} running, {pending} pending", "tasks.triggerTitle": "{running} running, {pending} pending",
"app.taskCompleted": "Task completed: {message}", "app.taskCompleted": "Task completed: {message}",
"app.taskFailed": "Task failed: {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.databaseRebuildFailed": "Database rebuild failed",
"app.textReindexFailed": "Text reindex failed", "app.textReindexFailed": "Text reindex failed",
"app.sitemapGenerationFailed": "Sitemap generation failed", "app.sitemapGenerationFailed": "Sitemap generation failed",

View File

@@ -23,6 +23,11 @@
"tasks.triggerTitle": "{running} en ejecución, {pending} pendiente", "tasks.triggerTitle": "{running} en ejecución, {pending} pendiente",
"app.taskCompleted": "Tarea completada: {message}", "app.taskCompleted": "Tarea completada: {message}",
"app.taskFailed": "Tarea fallida: {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.databaseRebuildFailed": "La reconstrucción de la base de datos falló",
"app.textReindexFailed": "La reindexación de texto falló", "app.textReindexFailed": "La reindexación de texto falló",
"app.sitemapGenerationFailed": "La generación del sitemap falló", "app.sitemapGenerationFailed": "La generación del sitemap falló",

View File

@@ -23,6 +23,11 @@
"tasks.triggerTitle": "{running} en cours, {pending} en attente", "tasks.triggerTitle": "{running} en cours, {pending} en attente",
"app.taskCompleted": "Tâche terminée : {message}", "app.taskCompleted": "Tâche terminée : {message}",
"app.taskFailed": "Échec de la tâche : {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.databaseRebuildFailed": "Échec de la reconstruction de la base de données",
"app.textReindexFailed": "Échec de la réindexation du texte", "app.textReindexFailed": "Échec de la réindexation du texte",
"app.sitemapGenerationFailed": "Échec de la génération du sitemap", "app.sitemapGenerationFailed": "Échec de la génération du sitemap",

View File

@@ -23,6 +23,11 @@
"tasks.triggerTitle": "{running} in esecuzione, {pending} in attesa", "tasks.triggerTitle": "{running} in esecuzione, {pending} in attesa",
"app.taskCompleted": "Attività completata: {message}", "app.taskCompleted": "Attività completata: {message}",
"app.taskFailed": "Attività non riuscita: {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.databaseRebuildFailed": "Ricostruzione database non riuscita",
"app.textReindexFailed": "Reindicizzazione testo non riuscita", "app.textReindexFailed": "Reindicizzazione testo non riuscita",
"app.sitemapGenerationFailed": "Generazione sitemap 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;
}

View File

@@ -0,0 +1,249 @@
import { describe, expect, it, vi } from 'vitest';
import type { ScriptData } from '../../src/main/shared/electronApi';
import {
BlogmarkTransformService,
type BlogmarkTransformExecutor,
type BlogmarkTransformInput,
type BlogmarkTransformScriptProvider,
} from '../../src/main/engine/BlogmarkTransformService';
function createScript(overrides: Partial<ScriptData>): ScriptData {
const baseDate = '2026-02-23T00:00:00.000Z';
return {
id: 'script-default',
projectId: 'default',
slug: 'script_default',
title: 'Default Script',
kind: 'transform',
entrypoint: 'transform',
enabled: true,
version: 1,
filePath: '/tmp/default.py',
content: 'def transform(payload):\n return payload',
createdAt: baseDate,
updatedAt: baseDate,
...overrides,
};
}
function createInput(overrides: Partial<BlogmarkTransformInput> = {}): BlogmarkTransformInput {
return {
post: {
title: 'Hello',
content: '[Hello](https://example.com)',
tags: ['inbox'],
categories: ['blogmark'],
},
context: {
source: 'blogmark',
url: 'https://example.com',
},
...overrides,
};
}
describe('BlogmarkTransformService', () => {
it('applies enabled transform scripts sequentially in deterministic order', async () => {
const scripts: ScriptData[] = [
createScript({ id: 'b', slug: 'b', updatedAt: '2026-02-23T00:00:01.000Z' }),
createScript({ id: 'a', slug: 'a', updatedAt: '2026-02-23T00:00:01.000Z' }),
createScript({ id: 'c', slug: 'c', updatedAt: '2026-02-23T00:00:00.000Z' }),
];
const executionOrder: string[] = [];
const executor: BlogmarkTransformExecutor = {
runTransform: vi.fn(async (script, input) => {
executionOrder.push(script.id);
return {
post: {
...input.post,
title: `${input.post.title}:${script.id}`,
},
};
}),
};
const provider: BlogmarkTransformScriptProvider = {
getScripts: vi.fn(async () => scripts),
};
const service = new BlogmarkTransformService({ executor, provider });
const result = await service.applyTransforms(createInput());
expect(executionOrder).toEqual(['c', 'a', 'b']);
expect(result.post.title).toBe('Hello:c:a:b');
expect(result.appliedScriptIds).toEqual(['c', 'a', 'b']);
expect(result.errors).toEqual([]);
expect(result.toasts).toEqual([]);
});
it('skips disabled and non-transform scripts', async () => {
const scripts: ScriptData[] = [
createScript({ id: 'transform-enabled', kind: 'transform', enabled: true }),
createScript({ id: 'transform-disabled', kind: 'transform', enabled: false }),
createScript({ id: 'macro-enabled', kind: 'macro', enabled: true }),
createScript({ id: 'utility-enabled', kind: 'utility', enabled: true }),
];
const executor: BlogmarkTransformExecutor = {
runTransform: vi.fn(async (script, input) => ({
post: {
...input.post,
title: `${input.post.title}:${script.id}`,
},
})),
};
const service = new BlogmarkTransformService({
executor,
provider: { getScripts: async () => scripts },
});
const result = await service.applyTransforms(createInput());
expect(result.post.title).toBe('Hello:transform-enabled');
expect(result.appliedScriptIds).toEqual(['transform-enabled']);
expect(result.errors).toEqual([]);
expect(result.toasts).toEqual([]);
});
it('continues with next scripts when one transform fails', async () => {
const scripts: ScriptData[] = [
createScript({ id: 'first', slug: 'first' }),
createScript({ id: 'broken', slug: 'broken' }),
createScript({ id: 'last', slug: 'last' }),
];
const executor: BlogmarkTransformExecutor = {
runTransform: vi.fn(async (script, input) => {
if (script.id === 'broken') {
throw new Error('boom');
}
return {
post: {
...input.post,
title: `${input.post.title}:${script.id}`,
},
};
}),
};
const service = new BlogmarkTransformService({
executor,
provider: { getScripts: async () => scripts },
});
const result = await service.applyTransforms(createInput());
expect(result.post.title).toBe('Hello:first:last');
expect(result.appliedScriptIds).toEqual(['first', 'last']);
expect(result.errors).toEqual([
{
scriptId: 'broken',
scriptSlug: 'broken',
message: 'boom',
},
]);
expect(result.toasts).toEqual([]);
});
it('rejects invalid transform result and keeps latest valid post', async () => {
const scripts: ScriptData[] = [
createScript({ id: 'valid-1', slug: 'valid-1' }),
createScript({ id: 'invalid', slug: 'invalid' }),
createScript({ id: 'valid-2', slug: 'valid-2' }),
];
const executor: BlogmarkTransformExecutor = {
runTransform: vi.fn(async (script, input) => {
if (script.id === 'invalid') {
return {
post: {
title: '',
content: '',
tags: [],
categories: [],
},
};
}
return {
title: `${input.post.title}:${script.id}`,
content: input.post.content,
tags: input.post.tags,
categories: input.post.categories,
};
}),
};
const service = new BlogmarkTransformService({
executor,
provider: { getScripts: async () => scripts },
});
const result = await service.applyTransforms(createInput());
expect(result.post.title).toBe('Hello:valid-1:valid-2');
expect(result.appliedScriptIds).toEqual(['valid-1', 'valid-2']);
expect(result.errors).toEqual([
{
scriptId: 'invalid',
scriptSlug: 'invalid',
message: 'Transform output validation failed',
},
]);
expect(result.toasts).toEqual([]);
});
it('allows transforms to set multiple categories and add tags', async () => {
const scripts: ScriptData[] = [
createScript({ id: 'taxonomy', slug: 'taxonomy' }),
];
const executor: BlogmarkTransformExecutor = {
runTransform: vi.fn(async (_script, input) => ({
output: {
...input.post,
tags: [...input.post.tags, 'reading-list', 'python'],
categories: ['link', 'reference'],
},
toasts: [],
})),
};
const service = new BlogmarkTransformService({
executor,
provider: { getScripts: async () => scripts },
});
const result = await service.applyTransforms(createInput());
expect(result.post.tags).toEqual(['inbox', 'reading-list', 'python']);
expect(result.post.categories).toEqual(['link', 'reference']);
});
it('collects toast intents emitted by transform scripts', async () => {
const scripts: ScriptData[] = [
createScript({ id: 'alpha', slug: 'alpha' }),
createScript({ id: 'beta', slug: 'beta' }),
];
const executor: BlogmarkTransformExecutor = {
runTransform: vi.fn(async (_script, input) => ({
post: input.post,
toasts: ['Step finished'],
})),
};
const service = new BlogmarkTransformService({
executor,
provider: { getScripts: async () => scripts },
});
const result = await service.applyTransforms(createInput());
expect(result.toasts).toEqual(['Step finished', 'Step finished']);
});
});

View File

@@ -740,6 +740,17 @@ describe('main bootstrap preview behavior', () => {
})), })),
})); }));
vi.doMock('../../src/main/engine/BlogmarkTransformService', () => ({
getBlogmarkTransformService: vi.fn(() => ({
applyTransforms: vi.fn(async (input: { post: { title: string; content: string; categories: string[] } }) => ({
post: input.post,
appliedScriptIds: [],
errors: [],
toasts: [],
})),
})),
}));
vi.doMock('../../src/main/database', () => ({ vi.doMock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({ getDatabase: vi.fn(() => ({
initializeLocal: vi.fn().mockResolvedValue(undefined), initializeLocal: vi.fn().mockResolvedValue(undefined),
@@ -802,7 +813,14 @@ describe('main bootstrap preview behavior', () => {
expect(windows[0]?.webContents.send).toHaveBeenCalledWith( expect(windows[0]?.webContents.send).toHaveBeenCalledWith(
'blogmark:created', 'blogmark:created',
expect.objectContaining({ id: 'new-post-id' }), expect.objectContaining({
post: expect.objectContaining({ id: 'new-post-id' }),
transform: expect.objectContaining({
appliedScriptIds: [],
errors: [],
toasts: [],
}),
}),
); );
}); });
@@ -903,6 +921,17 @@ describe('main bootstrap preview behavior', () => {
})), })),
})); }));
vi.doMock('../../src/main/engine/BlogmarkTransformService', () => ({
getBlogmarkTransformService: vi.fn(() => ({
applyTransforms: vi.fn(async (input: { post: { title: string; content: string; categories: string[] } }) => ({
post: input.post,
appliedScriptIds: [],
errors: [],
toasts: [],
})),
})),
}));
vi.doMock('../../src/main/database', () => ({ vi.doMock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({ getDatabase: vi.fn(() => ({
initializeLocal: vi.fn().mockResolvedValue(undefined), initializeLocal: vi.fn().mockResolvedValue(undefined),
@@ -971,7 +1000,14 @@ describe('main bootstrap preview behavior', () => {
expect(windows[0]?.webContents.send).toHaveBeenCalledWith( expect(windows[0]?.webContents.send).toHaveBeenCalledWith(
'blogmark:created', 'blogmark:created',
expect.objectContaining({ id: 'queued-post-id' }), expect.objectContaining({
post: expect.objectContaining({ id: 'queued-post-id' }),
transform: expect.objectContaining({
appliedScriptIds: [],
errors: [],
toasts: [],
}),
}),
); );
}); });

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import {
buildBlogmarkTransformOutputEntries,
buildBlogmarkTransformToastNotifications,
parseBlogmarkCreatedEventPayload,
} from '../../../src/renderer/navigation/blogmarkTransformOutput';
describe('parseBlogmarkCreatedEventPayload', () => {
it('parses legacy payload shape where event value is the post itself', () => {
const payload = parseBlogmarkCreatedEventPayload({ id: 'post-1', title: 'Legacy post' });
expect(payload).toEqual({
post: { id: 'post-1', title: 'Legacy post' },
transform: undefined,
});
});
it('parses new payload shape with post and transform metadata', () => {
const payload = parseBlogmarkCreatedEventPayload({
post: { id: 'post-2', title: 'With transforms' },
transform: {
appliedScriptIds: ['a', 'b'],
errors: [{ scriptId: 'c', scriptSlug: 'c', message: 'boom' }],
toasts: ['done'],
},
});
expect(payload).toEqual({
post: { id: 'post-2', title: 'With transforms' },
transform: {
appliedScriptIds: ['a', 'b'],
errors: [{ scriptId: 'c', scriptSlug: 'c', message: 'boom' }],
toasts: ['done'],
},
});
});
});
describe('buildBlogmarkTransformOutputEntries', () => {
const t = (key: string, values?: Record<string, string | number>) => {
if (key === 'app.blogmark.transforms.summary') {
return `summary:${values?.applied}:${values?.failed}`;
}
if (key === 'app.blogmark.transforms.appliedList') {
return `applied:${values?.scripts}`;
}
if (key === 'app.blogmark.transforms.failed') {
return `failed:${values?.script}:${values?.message}`;
}
if (key === 'app.blogmark.transforms.toast') {
return `toast:${values?.message}`;
}
if (key === 'app.blogmark.transforms.errorToast') {
return `error-toast:${values?.count}`;
}
return key;
};
it('returns empty list when no transform info is provided', () => {
expect(buildBlogmarkTransformOutputEntries(undefined, t)).toEqual([]);
});
it('returns summary and applied list entries for successful transforms', () => {
const entries = buildBlogmarkTransformOutputEntries(
{
appliedScriptIds: ['alpha', 'beta'],
errors: [],
toasts: [],
},
t,
);
expect(entries).toHaveLength(2);
expect(entries[0]?.kind).toBe('result');
expect(entries[0]?.message).toBe('summary:2:0');
expect(entries[1]?.kind).toBe('result');
expect(entries[1]?.message).toBe('applied:alpha, beta');
});
it('returns one error entry per failed transform', () => {
const entries = buildBlogmarkTransformOutputEntries(
{
appliedScriptIds: ['alpha'],
errors: [
{ scriptId: 'broken', scriptSlug: 'broken_slug', message: 'boom' },
{ scriptId: 'bad', scriptSlug: 'bad_slug', message: 'invalid output' },
],
toasts: ['Step finished'],
},
t,
);
expect(entries).toHaveLength(5);
expect(entries[0]?.message).toBe('summary:1:2');
expect(entries[1]?.message).toBe('applied:alpha');
expect(entries[2]?.kind).toBe('result');
expect(entries[2]?.message).toBe('toast:Step finished');
expect(entries[3]?.kind).toBe('error');
expect(entries[3]?.message).toBe('failed:broken_slug:boom');
expect(entries[4]?.kind).toBe('error');
expect(entries[4]?.message).toBe('failed:bad_slug:invalid output');
});
});
describe('buildBlogmarkTransformToastNotifications', () => {
const t = (key: string, values?: Record<string, string | number>) => {
if (key === 'app.blogmark.transforms.errorToast') {
return `error-toast:${values?.count}`;
}
return key;
};
it('returns toast notifications for script toasts and aggregated errors', () => {
const notifications = buildBlogmarkTransformToastNotifications({
appliedScriptIds: ['alpha'],
toasts: ['Saved one item'],
errors: [{ scriptId: 'broken', scriptSlug: 'broken_slug', message: 'boom' }],
}, t);
expect(notifications).toEqual([
{ kind: 'success', message: 'Saved one item' },
{ kind: 'error', message: 'error-toast:1' },
]);
});
});