feat: hooked scripts into the blogmark pipeline
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
307
src/main/engine/BlogmarkTransformService.ts
Normal file
307
src/main/engine/BlogmarkTransformService.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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ó",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
168
src/renderer/navigation/blogmarkTransformOutput.ts
Normal file
168
src/renderer/navigation/blogmarkTransformOutput.ts
Normal 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;
|
||||||
|
}
|
||||||
249
tests/engine/BlogmarkTransformService.test.ts
Normal file
249
tests/engine/BlogmarkTransformService.test.ts
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
125
tests/renderer/navigation/blogmarkTransformOutput.test.ts
Normal file
125
tests/renderer/navigation/blogmarkTransformOutput.test.ts
Normal 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' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user