From cd394bcacb860cb82d873f23d1e5659750ecac51 Mon Sep 17 00:00:00 2001 From: hugo Date: Mon, 23 Feb 2026 20:10:46 +0100 Subject: [PATCH] feat: hooked scripts into the blogmark pipeline --- DOCUMENTATION.md | 44 +++ src/main/engine/BlogmarkTransformService.ts | 307 ++++++++++++++++++ src/main/main.ts | 37 ++- src/renderer/App.tsx | 44 ++- src/renderer/i18n/locales/de.json | 5 + src/renderer/i18n/locales/en.json | 5 + src/renderer/i18n/locales/es.json | 5 + src/renderer/i18n/locales/fr.json | 5 + src/renderer/i18n/locales/it.json | 5 + .../navigation/blogmarkTransformOutput.ts | 168 ++++++++++ tests/engine/BlogmarkTransformService.test.ts | 249 ++++++++++++++ tests/engine/mainStartup.test.ts | 40 ++- .../blogmarkTransformOutput.test.ts | 125 +++++++ 13 files changed, 1029 insertions(+), 10 deletions(-) create mode 100644 src/main/engine/BlogmarkTransformService.ts create mode 100644 src/renderer/navigation/blogmarkTransformOutput.ts create mode 100644 tests/engine/BlogmarkTransformService.test.ts create mode 100644 tests/renderer/navigation/blogmarkTransformOutput.test.ts diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 07ad9c9..f72b471 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -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. +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 - Scripting is available and intentionally evolving in small steps. - `main` is always available and preserves whole-script execution behavior. - 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) diff --git a/src/main/engine/BlogmarkTransformService.ts b/src/main/engine/BlogmarkTransformService.ts new file mode 100644 index 0000000..91077a0 --- /dev/null +++ b/src/main/engine/BlogmarkTransformService.ts @@ -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; + +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; +} + +export interface BlogmarkTransformScriptProvider { + getScripts(): Promise; +} + +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; + 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; + 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 | null = null; + + async runTransform(script: BlogmarkTransformScriptRecord, input: BlogmarkTransformInput): Promise { + 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 { + 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 { + 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; +} diff --git a/src/main/main.ts b/src/main/main.ts index 080a4eb..69023b1 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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 { 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); } } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index ca526d8..452f60e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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()); @@ -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); }) || (() => {}) ); diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index 1d9a738..e7901ac 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -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", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index 7263b00..3090d18 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -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", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index eaccd78..1617338 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -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ó", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index 6201684..ea8ebef 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -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", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index 021a194..fe09b96 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -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", diff --git a/src/renderer/navigation/blogmarkTransformOutput.ts b/src/renderer/navigation/blogmarkTransformOutput.ts new file mode 100644 index 0000000..82297e7 --- /dev/null +++ b/src/renderer/navigation/blogmarkTransformOutput.ts @@ -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 { + 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, +): Array> { + if (!transform) { + return []; + } + + const entries: Array> = []; + 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, +): 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; +} diff --git a/tests/engine/BlogmarkTransformService.test.ts b/tests/engine/BlogmarkTransformService.test.ts new file mode 100644 index 0000000..7d3be02 --- /dev/null +++ b/tests/engine/BlogmarkTransformService.test.ts @@ -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 { + 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 { + 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']); + }); +}); diff --git a/tests/engine/mainStartup.test.ts b/tests/engine/mainStartup.test.ts index 30755da..53ea49d 100644 --- a/tests/engine/mainStartup.test.ts +++ b/tests/engine/mainStartup.test.ts @@ -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', () => ({ getDatabase: vi.fn(() => ({ initializeLocal: vi.fn().mockResolvedValue(undefined), @@ -802,7 +813,14 @@ describe('main bootstrap preview behavior', () => { expect(windows[0]?.webContents.send).toHaveBeenCalledWith( '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', () => ({ getDatabase: vi.fn(() => ({ initializeLocal: vi.fn().mockResolvedValue(undefined), @@ -971,7 +1000,14 @@ describe('main bootstrap preview behavior', () => { expect(windows[0]?.webContents.send).toHaveBeenCalledWith( 'blogmark:created', - expect.objectContaining({ id: 'queued-post-id' }), + expect.objectContaining({ + post: expect.objectContaining({ id: 'queued-post-id' }), + transform: expect.objectContaining({ + appliedScriptIds: [], + errors: [], + toasts: [], + }), + }), ); }); diff --git a/tests/renderer/navigation/blogmarkTransformOutput.test.ts b/tests/renderer/navigation/blogmarkTransformOutput.test.ts new file mode 100644 index 0000000..96b975f --- /dev/null +++ b/tests/renderer/navigation/blogmarkTransformOutput.test.ts @@ -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) => { + 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) => { + 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' }, + ]); + }); +});