fix: lots of missing pieces for python macro handling

This commit is contained in:
2026-02-27 08:33:12 +01:00
parent 916d9459ef
commit 00cf30a8f8
31 changed files with 1715 additions and 431 deletions

View File

@@ -12,7 +12,8 @@
"WebFetch(domain:a2ui-sdk.js.org)",
"WebFetch(domain:www.copilotkit.ai)",
"Bash(grep -l \"A2UIRenderer\\\\|useA2UISurface\\\\|a2ui-surface\" /Users/gb/Projects/bDS/src/renderer/components/**/*.tsx)",
"Bash(npm test)"
"Bash(npm test)",
"Bash(ls -la /Users/gb/Projects/bDS/*.md)"
]
}
}

View File

@@ -243,6 +243,37 @@ Notes:
- Return the mutated `post` dict from your transform function.
- Keep transforms small and deterministic, especially when multiple active transforms run in sequence.
### Macro scripts
Macro scripts let you create custom `[[macro_name ...]]` blocks that expand during preview and page generation. Create a script with kind set to **macro** and pick a slug — the slug becomes the macro name used in Markdown.
The entrypoint function always receives two arguments:
```python
def render(context, post):
params = context["params"] # dict of macro parameters
language = context["language"] # project language code
post_slug = context["post_slug"] # slug of the host post
title = post["title"] if post else "Unknown"
return {"html": f"<p>Post: {title}</p>"}
```
`context` is a dict containing `params` (the key-value pairs from the macro tag), `language`, and `post_slug`. `post` is the full PostData dict for the post containing the macro, or `None` when post data is unavailable. The function must return a dict with an `html` key containing the rendered HTML string.
Macro scripts can also call the application API through the `bds_api` module:
```python
from bds_api import bds
def render(context, post):
tags = await bds.posts.get_tags()
items = "".join(f"<li>{t}</li>" for t in tags)
return {"html": f"<ul>{items}</ul>"}
```
To use the macro in a post, write `[[your_slug param="value"]]` in Markdown. Built-in JS macros (youtube, vimeo, gallery, photo_archive, tag_cloud) always take priority over Python macros with the same slug.
### Key takeaways
- Scripting is available and intentionally evolving in small steps.
@@ -251,6 +282,9 @@ Notes:
- 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.
- Macro scripts use a two-argument entrypoint: `def render(context, post)`.
- Macro scripts can call `bds_api` to access posts, media, tags, and other application data.
- Built-in JS macros always take priority over Python macros with the same slug.
[↑ Back to In this article](#in-this-article)

View File

@@ -77,10 +77,16 @@ These are current realities and should be treated as authoritative unless we exp
### Implementation details
- **Production path**: `PageRenderer` now accepts an optional `PythonMacroRendererContract`. Macro replacement in the Liquid `markdown` filter is async via `replaceAllMacrosAsync()`. When an unknown macro name is encountered and a Python macro renderer is provided, the renderer resolves enabled macro scripts by slug and executes them via `PythonMacroWorkerRuntime` (Node.js worker thread + Pyodide). Script resolution only occurs when at least one non-built-in macro is present in the content, avoiding overhead for posts with only JS macros.
- **Calling convention**: Python macro entrypoints use a two-argument signature: `def render(context, post)`. `context` is the macro context dict (with `params`, `language`, `post_slug`, etc.). `post` is the full `PostData` dict for the post containing the macro (with `title`, `slug`, `content`, `tags`, `categories`, `createdAt`, etc.), or `None` if post data is unavailable.
- **Production path**: `PageRenderer` now accepts an optional `PythonMacroRendererContract`. Macro replacement in the Liquid `markdown` filter is async via `replaceAllMacrosAsync()`. When an unknown macro name is encountered and a Python macro renderer is provided, the renderer resolves enabled macro scripts by slug and executes them via `PythonMacroWorkerRuntime` (Node.js worker thread + Pyodide). Script resolution only occurs when at least one non-built-in macro is present in the content, avoiding overhead for posts with only JS macros. PostData is serialized per-post via `serializePostDataForMacro()` and threaded through the Liquid `markdown` filter as `post_data_json_by_id`.
- **Preview path**: The renderer macro registry (`registry.ts`) supports `setPythonMacroResolver()`. When a macro is not found in the JS registry, the Python resolver is consulted. If a matching script is found, the renderer delegates to `PythonMacroRendererFn` which uses the existing `PythonRuntimeManager` (web worker + Pyodide).
- **`bds_api` module**: Python macros can call app APIs via the `bds_api` module. In the main-process worker (`PythonMacroWorkerRuntime`), API calls are dispatched via `mainProcessPythonApiInvoker.ts` which routes to engine methods directly. In the renderer worker, API calls go through the existing IPC bridge. The `bds_api` module is auto-generated by `generatePythonApiModuleV1.ts` and installed in both workers at bootstrap.
- **Editor macro detection**: The editor macro plugin uses `hasMacro()` to distinguish known macros from unknown ones. `hasMacro()` checks both the JS macro registry and a set of known Python macro slugs fetched via `scripts:getEnabledMacroSlugs` IPC. The slug set is refreshed on startup and whenever scripts change (`BDS_EVENT_SCRIPTS_CHANGED`).
- **Precedence**: JS built-in macros (youtube, vimeo, gallery, photo_archive, tag_cloud) always take priority over Python scripts with the same slug. Python macros only activate for names not registered in the JS macro registry.
- **Error handling**: Python macro execution errors are caught and result in empty string output, preserving the rest of the document. Script resolution errors are also caught gracefully.

View File

@@ -25,6 +25,7 @@ export interface PythonMacroRendererContract {
scriptContent: string;
entrypoint: string;
contextJson: string;
postDataJson?: string | null;
cacheKey?: string;
timeoutMs?: number;
}): Promise<{ html: string; data?: Record<string, unknown>; warnings?: string[] }>;
@@ -99,6 +100,7 @@ export interface PostListTemplateContext {
next_page_href: string;
canonical_post_path_by_slug: Record<string, string>;
canonical_media_path_by_source_path: Record<string, string>;
post_data_json_by_id: Record<string, string>;
day_blocks: DayBlockContext[];
}
@@ -116,6 +118,7 @@ export interface SinglePostTemplateContext {
calendar_initial_month: number | null;
canonical_post_path_by_slug: Record<string, string>;
canonical_media_path_by_source_path: Record<string, string>;
post_data_json_by_id: Record<string, string>;
}
export interface NotFoundTemplateContext {
@@ -826,6 +829,24 @@ export function isBuiltInMacro(name: string): boolean {
return JS_BUILTIN_MACROS.has(normalizeMacroName(name));
}
export function serializePostDataForMacro(post: PostData): Record<string, unknown> {
return {
id: post.id,
projectId: post.projectId,
title: post.title,
slug: post.slug,
excerpt: post.excerpt ?? null,
content: post.content,
status: post.status,
author: post.author ?? null,
createdAt: post.createdAt instanceof Date ? post.createdAt.toISOString() : String(post.createdAt),
updatedAt: post.updatedAt instanceof Date ? post.updatedAt.toISOString() : String(post.updatedAt),
publishedAt: post.publishedAt instanceof Date ? post.publishedAt.toISOString() : (post.publishedAt ?? null),
tags: Array.isArray(post.tags) ? post.tags : [],
categories: Array.isArray(post.categories) ? post.categories : [],
};
}
export async function replaceAllMacrosAsync(
content: string,
postId: string,
@@ -834,6 +855,7 @@ export async function replaceAllMacrosAsync(
tagUsage: TagUsageEntry[],
renderLanguage: string,
pythonMacroRenderer?: PythonMacroRendererContract | null,
postDataJson?: string | null,
): Promise<string> {
const macroRegex = /\[\[(\w+)(?:\s+([^\]]+))?\]\]/g;
const matches: Array<{ fullMatch: string; name: string; rawParams: string | undefined; start: number; end: number }> = [];
@@ -900,6 +922,7 @@ export async function replaceAllMacrosAsync(
scriptContent: pythonScript.content,
entrypoint: pythonScript.entrypoint,
contextJson: JSON.stringify(context),
postDataJson: postDataJson ?? null,
cacheKey: `${pythonScript.id}:${pythonScript.version}`,
timeoutMs: 10000,
});
@@ -1055,10 +1078,14 @@ export class PageRenderer {
return translateRender(resolved, key);
});
this.liquid.registerFilter('markdown', async (value: unknown, postIdArg: unknown, canonicalPostsArg: unknown, canonicalMediaArg: unknown, renderLanguageArg: unknown) => {
this.liquid.registerFilter('markdown', async (value: unknown, postIdArg: unknown, postDataJsonByIdArg: unknown, canonicalPostsArg: unknown, canonicalMediaArg: unknown, renderLanguageArg: unknown) => {
const content = typeof value === 'string' ? value : '';
const postId = typeof postIdArg === 'string' ? postIdArg : '';
const renderLanguage = typeof renderLanguageArg === 'string' ? renderLanguageArg : 'en';
const postDataJsonById = (postDataJsonByIdArg && typeof postDataJsonByIdArg === 'object' && !Array.isArray(postDataJsonByIdArg))
? postDataJsonByIdArg as Record<string, string>
: {};
const postDataJson = postId ? (postDataJsonById[postId] ?? null) : null;
const rewriteContext: HtmlRewriteContext = {
canonicalPostPathBySlug: recordToMap(canonicalPostsArg),
canonicalMediaPathBySourcePath: recordToMap(canonicalMediaArg),
@@ -1081,7 +1108,7 @@ export class PageRenderer {
: null;
const withMacros = await replaceAllMacrosAsync(
content, postId, mediaItems, linkedMediaIds, tagUsage, renderLanguage, this.pythonMacroRenderer,
content, postId, mediaItems, linkedMediaIds, tagUsage, renderLanguage, this.pythonMacroRenderer, postDataJson,
);
const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false });
@@ -1280,6 +1307,9 @@ export class PageRenderer {
next_page_href: nextPageHref,
canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug),
canonical_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath),
post_data_json_by_id: Object.fromEntries(
posts.map((post) => [post.id, JSON.stringify(serializePostDataForMacro(post))]),
),
day_blocks: dayBlocks,
};
}
@@ -1362,6 +1392,9 @@ export class PageRenderer {
calendar_initial_month: renderablePost.createdAt.getMonth() + 1,
canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug),
canonical_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath),
post_data_json_by_id: {
[renderablePost.id]: JSON.stringify(serializePostDataForMacro(renderablePost)),
},
};
return this.liquid.renderFile('single-post', context);

View File

@@ -7,6 +7,7 @@ interface WorkerRenderMacroRequest {
scriptContent: string;
entrypoint: string;
contextJson: string;
postDataJson?: string | null;
cacheKey?: string;
}
@@ -33,12 +34,29 @@ interface WorkerFatalErrorMessage {
error: string;
}
type WorkerResponseMessage = WorkerReadyMessage | WorkerMacroResultMessage | WorkerMacroErrorMessage | WorkerFatalErrorMessage;
interface WorkerApiCallMessage {
type: 'apiCall';
requestId: string;
callId: string;
method: string;
args: Record<string, unknown>;
}
interface WorkerApiResultMessage {
type: 'apiResult';
callId: string;
ok: boolean;
result?: unknown;
error?: string;
}
type WorkerResponseMessage = WorkerReadyMessage | WorkerMacroResultMessage | WorkerMacroErrorMessage | WorkerFatalErrorMessage | WorkerApiCallMessage;
export interface MacroRenderParams {
scriptContent: string;
entrypoint: string;
contextJson: string;
postDataJson?: string | null;
timeoutMs?: number;
cacheKey?: string;
}
@@ -69,6 +87,8 @@ export interface WorkerLike {
export type WorkerFactory = (workerPath: string) => WorkerLike;
export type ApiInvoker = (method: string, args: Record<string, unknown>) => Promise<unknown>;
export class PythonMacroWorkerRuntime {
private worker: WorkerLike | null = null;
private workerReady = false;
@@ -82,9 +102,11 @@ export class PythonMacroWorkerRuntime {
private _errorCount = 0;
private _timeoutCount = 0;
private readonly workerFactory: WorkerFactory;
private readonly apiInvoker: ApiInvoker | null;
constructor(workerFactory?: WorkerFactory) {
constructor(workerFactory?: WorkerFactory, apiInvoker?: ApiInvoker) {
this.workerFactory = workerFactory ?? ((workerPath: string) => new Worker(workerPath) as unknown as WorkerLike);
this.apiInvoker = apiInvoker ?? null;
}
async renderMacro(params: MacroRenderParams): Promise<MacroRenderResult> {
@@ -99,6 +121,7 @@ export class PythonMacroWorkerRuntime {
scriptContent: params.scriptContent,
entrypoint: params.entrypoint,
contextJson: params.contextJson,
postDataJson: params.postDataJson ?? null,
cacheKey: params.cacheKey,
},
timeoutMs,
@@ -218,6 +241,11 @@ export class PythonMacroWorkerRuntime {
return;
}
if (message.type === 'apiCall') {
void this.handleApiCall(message);
return;
}
const active = this.activeRequest;
if (!active) {
return;
@@ -254,6 +282,35 @@ export class PythonMacroWorkerRuntime {
this.resetWorker();
}
private async handleApiCall(message: WorkerApiCallMessage): Promise<void> {
if (!this.worker || !this.apiInvoker) {
this.worker?.postMessage({
type: 'apiResult',
callId: message.callId,
ok: false,
error: 'API invoker not available',
} satisfies WorkerApiResultMessage);
return;
}
try {
const result = await this.apiInvoker(message.method, message.args);
this.worker?.postMessage({
type: 'apiResult',
callId: message.callId,
ok: true,
result,
} satisfies WorkerApiResultMessage);
} catch (error) {
this.worker?.postMessage({
type: 'apiResult',
callId: message.callId,
ok: false,
error: error instanceof Error ? error.message : String(error),
} satisfies WorkerApiResultMessage);
}
}
private rejectActiveAndQueue(error: Error): void {
if (this.activeRequest) {
if (this.activeRequest.timeoutId) {
@@ -310,7 +367,8 @@ let pythonMacroWorkerRuntimeInstance: PythonMacroWorkerRuntime | null = null;
export function getPythonMacroWorkerRuntime(): PythonMacroWorkerRuntime {
if (!pythonMacroWorkerRuntimeInstance) {
pythonMacroWorkerRuntimeInstance = new PythonMacroWorkerRuntime();
const { invokeMainProcessPythonApi } = require('./mainProcessPythonApiInvoker') as { invokeMainProcessPythonApi: ApiInvoker };
pythonMacroWorkerRuntimeInstance = new PythonMacroWorkerRuntime(undefined, invokeMainProcessPythonApi);
}
return pythonMacroWorkerRuntimeInstance;

View File

@@ -0,0 +1,232 @@
import { getPythonApiMethodContract } from '../shared/pythonApiContractV1';
import type { PythonApiParamContractV1 } from '../shared/pythonApiContractV1';
function asRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
function validateParamValue(methodName: string, param: PythonApiParamContractV1, value: unknown): void {
if (param.type === 'stringOrNull') {
if (value === null || (typeof value === 'string' && value.length > 0)) {
return;
}
throw new Error(`${methodName} requires stringOrNull arg ${param.name}`);
}
if (value === undefined || value === null) {
if (!param.required) {
return;
}
throw new Error(`${methodName} requires ${param.type} arg ${param.name}`);
}
if (param.type === 'any') {
return;
}
if (param.type === 'string') {
if (typeof value === 'string' && value.length > 0) {
return;
}
throw new Error(`${methodName} requires string arg ${param.name}`);
}
if (param.type === 'number') {
if (typeof value === 'number' && Number.isFinite(value)) {
return;
}
throw new Error(`${methodName} requires number arg ${param.name}`);
}
if (param.type === 'boolean') {
if (typeof value === 'boolean') {
return;
}
throw new Error(`${methodName} requires boolean arg ${param.name}`);
}
if (param.type === 'array') {
if (Array.isArray(value)) {
return;
}
throw new Error(`${methodName} requires array arg ${param.name}`);
}
if (param.type === 'object') {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return;
}
throw new Error(`${methodName} requires object arg ${param.name}`);
}
}
type EngineGetter = () => Record<string, (...args: unknown[]) => unknown>;
const ENGINE_MAP: Record<string, EngineGetter> = {
posts: () => {
const { getPostEngine } = require('../engine/PostEngine');
return getPostEngine();
},
media: () => {
const { getMediaEngine } = require('../engine/MediaEngine');
return getMediaEngine();
},
projects: () => {
const { getProjectEngine } = require('../engine/ProjectEngine');
return getProjectEngine();
},
meta: () => {
const { getMetaEngine } = require('../engine/MetaEngine');
return getMetaEngine();
},
tags: () => {
const { getTagEngine } = require('../engine/TagEngine');
return getTagEngine();
},
scripts: () => {
const { getScriptEngine } = require('../engine/ScriptEngine');
return getScriptEngine();
},
tasks: () => {
const { taskManager } = require('../engine/TaskManager');
return taskManager;
},
};
// Map API method names to engine method names where they differ
const METHOD_NAME_MAP: Record<string, string> = {
'posts.get': 'getPost',
'posts.create': 'createPost',
'posts.update': 'updatePost',
'posts.delete': 'deletePost',
'posts.getAll': 'getAllPosts',
'posts.getByStatus': 'getPostsByStatus',
'posts.publish': 'publishPost',
'posts.discard': 'discardChanges',
'posts.hasPublishedVersion': 'hasPublishedVersion',
'posts.rebuildFromFiles': 'rebuildDatabaseFromFiles',
'posts.reindexText': 'reindexText',
'posts.search': 'searchPosts',
'posts.filter': 'getPostsFiltered',
'posts.getTags': 'getAvailableTags',
'posts.getCategories': 'getAvailableCategories',
'posts.getByYearMonth': 'getPostsByYearMonth',
'posts.getDashboardStats': 'getDashboardStats',
'posts.getTagsWithCounts': 'getTagsWithCounts',
'posts.getCategoriesWithCounts': 'getCategoriesWithCounts',
'posts.getLinksTo': 'getLinksTo',
'posts.getLinkedBy': 'getLinkedBy',
'posts.rebuildLinks': 'rebuildAllPostLinks',
'posts.isSlugAvailable': 'isSlugAvailable',
'posts.generateUniqueSlug': 'generateUniqueSlug',
'posts.getPreviewUrl': 'getPost', // handled specially
'media.import': 'importMedia',
'media.update': 'updateMedia',
'media.replaceFile': 'replaceMediaFile',
'media.delete': 'deleteMedia',
'media.get': 'getMedia',
'media.getUrl': 'getRelativePath',
'media.getAll': 'getAllMedia',
'media.rebuildFromFiles': 'rebuildDatabaseFromFiles',
'media.reindexText': 'reindexText',
'media.getThumbnail': 'getThumbnailDataUrl',
'media.regenerateThumbnails': 'generateThumbnails',
'media.regenerateMissingThumbnails': 'regenerateMissingThumbnails',
'media.filter': 'getMediaFiltered',
'media.search': 'searchMedia',
'media.getByYearMonth': 'getMediaByYearMonth',
'media.getTags': 'getAvailableTags',
'media.getTagsWithCounts': 'getTagsWithCounts',
'projects.create': 'createProject',
'projects.update': 'updateProject',
'projects.delete': 'deleteProject',
'projects.deleteWithData': 'deleteProjectWithData',
'projects.get': 'getProject',
'projects.getAll': 'getAllProjects',
'projects.getActive': 'getActiveProject',
'projects.setActive': 'setActiveProject',
'meta.getTags': 'getTags',
'meta.getCategories': 'getCategories',
'meta.addTag': 'addTag',
'meta.removeTag': 'removeTag',
'meta.addCategory': 'addCategory',
'meta.removeCategory': 'removeCategory',
'meta.syncOnStartup': 'syncOnStartup',
'meta.getProjectMetadata': 'getProjectMetadata',
'meta.setProjectMetadata': 'setProjectMetadata',
'meta.updateProjectMetadata': 'updateProjectMetadata',
'tags.getAll': 'getAllTags',
'tags.getWithCounts': 'getTagsWithCounts',
'tags.get': 'getTag',
'tags.getByName': 'getTagByName',
'tags.create': 'createTag',
'tags.update': 'updateTag',
'tags.delete': 'deleteTag',
'tags.merge': 'mergeTags',
'tags.rename': 'renameTag',
'tags.getPostsWithTag': 'getPostsWithTag',
'tags.syncFromPosts': 'syncTagsFromPosts',
'scripts.create': 'createScript',
'scripts.update': 'updateScript',
'scripts.delete': 'deleteScript',
'scripts.get': 'getScript',
'scripts.getAll': 'getAllScripts',
'scripts.rebuildFromFiles': 'rebuildDatabaseFromFiles',
'tasks.getAll': 'getAllTasks',
'tasks.getRunning': 'getRunningTasks',
'tasks.cancel': 'cancelTask',
'tasks.clearCompleted': 'clearCompletedTasks',
};
export async function invokeMainProcessPythonApi(method: string, args: Record<string, unknown>): Promise<unknown> {
const contract = getPythonApiMethodContract(method);
if (!contract) {
throw new Error(`Unsupported Python API method: ${method}`);
}
const normalizedArgs = asRecord(args);
const [namespace, member] = contract.method.split('.');
if (!namespace || !member) {
throw new Error(`Unsupported Python API method: ${method}`);
}
// Skip methods that require UI/dialog interaction or are not safe for background use
const unsafeMethods = new Set([
'media.importDialog', 'media.replaceFileDialog', 'media.getFilePath',
'app.openFolder', 'app.selectFolder', 'app.showItemInFolder',
'app.getTitleBarMetrics', 'app.notifyRendererReady', 'app.triggerMenuAction',
'app.getBlogmarkBookmarklet', 'app.copyToClipboard',
'chat.sendMessage', 'chat.abortMessage', 'chat.analyzeTaxonomy',
'chat.analyzeMediaImage',
'sync.configure', 'sync.start', 'sync.stopAutoSync',
]);
if (unsafeMethods.has(method)) {
throw new Error(`Python API method '${method}' is not available in main-process macro context`);
}
const engineGetter = ENGINE_MAP[namespace];
if (!engineGetter) {
throw new Error(`Unsupported Python API namespace: ${namespace}`);
}
const engine = engineGetter();
const engineMethodName = METHOD_NAME_MAP[method] ?? member;
const callable = engine[engineMethodName];
if (typeof callable !== 'function') {
throw new Error(`Unsupported Python API method: ${method} (engine method '${engineMethodName}' not found)`);
}
const orderedArgs = contract.params.map((param) => {
const value = normalizedArgs[param.name];
validateParamValue(contract.method, param, value);
return value;
});
return callable.apply(engine, orderedArgs);
}

View File

@@ -6,9 +6,20 @@ interface WorkerRenderMacroRequest {
scriptContent: string;
entrypoint: string;
contextJson: string;
postDataJson?: string | null;
cacheKey?: string;
}
interface WorkerApiResultMessage {
type: 'apiResult';
callId: string;
ok: boolean;
result?: unknown;
error?: string;
}
type WorkerIncomingMessage = WorkerRenderMacroRequest | WorkerApiResultMessage;
interface WorkerReadyMessage {
type: 'ready';
}
@@ -32,27 +43,131 @@ interface WorkerFatalErrorMessage {
error: string;
}
type WorkerResponseMessage = WorkerReadyMessage | WorkerMacroResultMessage | WorkerMacroErrorMessage | WorkerFatalErrorMessage;
interface WorkerApiCallMessage {
type: 'apiCall';
requestId: string;
callId: string;
method: string;
args: Record<string, unknown>;
}
type WorkerResponseMessage = WorkerReadyMessage | WorkerMacroResultMessage | WorkerMacroErrorMessage | WorkerFatalErrorMessage | WorkerApiCallMessage;
type PyodideRuntime = {
globals: {
set: (name: string, value: unknown) => void;
} | any;
runPythonAsync: (code: string) => Promise<unknown>;
registerJsModule: (name: string, module: Record<string, unknown>) => void;
};
let runtimePromise: Promise<PyodideRuntime> | null = null;
let lastCacheKey: string | null = null;
let activeRequestId: string | null = null;
let apiCallCounter = 0;
function postMessage(message: WorkerResponseMessage): void {
interface PendingApiCall {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
}
const pendingApiCalls = new Map<string, PendingApiCall>();
function postWorkerMessage(message: WorkerResponseMessage): void {
parentPort?.postMessage(message);
}
function toRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
function requestHostApi(requestId: string, method: string, args: Record<string, unknown>): Promise<unknown> {
apiCallCounter += 1;
const callId = `api-${apiCallCounter}`;
return new Promise((resolve, reject) => {
pendingApiCalls.set(callId, { resolve, reject });
postWorkerMessage({
type: 'apiCall',
requestId,
callId,
method,
args,
});
});
}
function handleApiResultMessage(message: WorkerApiResultMessage): void {
const pendingCall = pendingApiCalls.get(message.callId);
if (!pendingCall) {
return;
}
pendingApiCalls.delete(message.callId);
if (message.ok) {
pendingCall.resolve(message.result);
return;
}
pendingCall.reject(new Error(message.error ?? 'Host API call failed'));
}
function rejectPendingApiCalls(message: string): void {
for (const [callId, pendingCall] of pendingApiCalls.entries()) {
pendingApiCalls.delete(callId);
pendingCall.reject(new Error(message));
}
}
async function getRuntime(): Promise<PyodideRuntime> {
if (!runtimePromise) {
runtimePromise = (async () => {
const pyodideModule = await import('pyodide');
return (await pyodideModule.loadPyodide()) as unknown as PyodideRuntime;
const runtime = (await pyodideModule.loadPyodide()) as unknown as PyodideRuntime;
// Register bds_api transport bridge
runtime.registerJsModule('__bds_transport', {
call_host_api: async (method: unknown, argsJson: unknown) => {
if (!activeRequestId) {
throw new Error('No active Python request for host API bridge');
}
if (typeof method !== 'string' || method.length === 0) {
throw new Error('Host API method must be a non-empty string');
}
let parsedArgs: Record<string, unknown> = {};
if (typeof argsJson === 'string' && argsJson.length > 0) {
const decoded = JSON.parse(argsJson);
parsedArgs = toRecord(decoded);
}
const result = await requestHostApi(activeRequestId, method, parsedArgs);
return JSON.stringify(result ?? null);
},
});
// Install bds_api module
const { generatePythonApiModuleV1 } = await import('../shared/generatePythonApiModuleV1');
runtime.globals.set('__bds_api_module_source', generatePythonApiModuleV1());
await runtime.runPythonAsync(`
import sys
import types
__bds_api_module = types.ModuleType("bds_api")
exec(__bds_api_module_source, __bds_api_module.__dict__)
from __bds_transport import call_host_api as __bds_call_host_api
__bds_api_module.bds = __bds_api_module.install_bds_api(__bds_call_host_api)
sys.modules["bds_api"] = __bds_api_module
`);
return runtime;
})();
}
@@ -60,6 +175,7 @@ async function getRuntime(): Promise<PyodideRuntime> {
}
async function renderMacro(request: WorkerRenderMacroRequest): Promise<void> {
activeRequestId = request.requestId;
try {
const runtime = await getRuntime();
@@ -72,6 +188,7 @@ async function renderMacro(request: WorkerRenderMacroRequest): Promise<void> {
runtime.globals.set('__bds_macro_context_json', request.contextJson);
runtime.globals.set('__bds_macro_entrypoint', request.entrypoint);
runtime.globals.set('__bds_macro_post_data_json', request.postDataJson ?? '');
const rawResult = await runtime.runPythonAsync(`
import json as _json
@@ -81,7 +198,9 @@ _macro_ep = __bds_macro_entrypoint
_macro_fn = globals().get(_macro_ep)
if _macro_fn is None or not callable(_macro_fn):
raise RuntimeError(f"Macro entrypoint '{_macro_ep}' is not callable")
_macro_result = _macro_fn(_macro_ctx)
_macro_post_json = __bds_macro_post_data_json
_macro_post = _json.loads(_macro_post_json) if _macro_post_json else None
_macro_result = _macro_fn(_macro_ctx, _macro_post)
if _macro_result is None:
raise RuntimeError("Macro function returned None")
if not isinstance(_macro_result, dict):
@@ -93,7 +212,7 @@ _json.dumps(_macro_result)
const parsed = JSON.parse(String(rawResult));
postMessage({
postWorkerMessage({
type: 'macroResult',
requestId: request.requestId,
html: typeof parsed.html === 'string' ? parsed.html : '',
@@ -101,12 +220,21 @@ _json.dumps(_macro_result)
warnings: Array.isArray(parsed.warnings) ? parsed.warnings : undefined,
});
} catch (error) {
rejectPendingApiCalls('Python macro execution failed');
const message = error instanceof Error ? error.message : String(error);
postMessage({ type: 'macroError', requestId: request.requestId, error: message });
postWorkerMessage({ type: 'macroError', requestId: request.requestId, error: message });
} finally {
rejectPendingApiCalls('Python macro execution finished');
activeRequestId = null;
}
}
parentPort?.on('message', (message: WorkerRenderMacroRequest) => {
parentPort?.on('message', (message: WorkerIncomingMessage) => {
if (message.type === 'apiResult') {
handleApiResultMessage(message);
return;
}
if (message.type !== 'renderMacro') {
return;
}
@@ -116,9 +244,9 @@ parentPort?.on('message', (message: WorkerRenderMacroRequest) => {
void getRuntime()
.then(() => {
postMessage({ type: 'ready' });
postWorkerMessage({ type: 'ready' });
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
postMessage({ type: 'error', error: message });
postWorkerMessage({ type: 'error', error: message });
});

View File

@@ -44,7 +44,7 @@
{% endif %}
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
{% endif %}
{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}
</div>
{% endfor %}
</div>
@@ -59,7 +59,7 @@
{% endif %}
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
{% endif %}
{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}
</div>
{% endfor %}
{% endif %}

View File

@@ -17,7 +17,7 @@
</div>
{% endif %}
<article class="single-post" data-template="single-post">
<div class="post">{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}</div>
<div class="post">{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}</div>
</article>
</main>
</body>

View File

@@ -772,6 +772,12 @@ export function registerIpcHandlers(): void {
return engine.getAllScripts();
});
safeHandle('scripts:getEnabledMacroSlugs', async () => {
const engine = getScriptEngine();
const scripts = await engine.getEnabledMacroScripts();
return scripts.map((s) => s.slug);
});
safeHandle('scripts:rebuildFromFiles', async () => {
const projectEngine = getProjectEngine();
const project = await projectEngine.getActiveProject();

View File

@@ -108,6 +108,7 @@ export const electronAPI: ElectronAPI = {
delete: (id: string) => ipcRenderer.invoke('scripts:delete', id),
get: (id: string) => ipcRenderer.invoke('scripts:get', id),
getAll: () => ipcRenderer.invoke('scripts:getAll'),
getEnabledMacroSlugs: () => ipcRenderer.invoke('scripts:getEnabledMacroSlugs'),
rebuildFromFiles: () => ipcRenderer.invoke('scripts:rebuildFromFiles'),
},

View File

@@ -589,6 +589,7 @@ export interface ElectronAPI {
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<ScriptData | null>;
getAll: () => Promise<ScriptData[]>;
getEnabledMacroSlugs: () => Promise<string[]>;
rebuildFromFiles: () => Promise<void>;
};
postMedia: {

View File

@@ -0,0 +1,185 @@
import { BDS_PYTHON_API_CONTRACT_V1 } from './pythonApiContractV1';
const PYTHON_RESERVED_KEYWORDS = new Set([
'false',
'none',
'true',
'and',
'as',
'assert',
'async',
'await',
'break',
'class',
'continue',
'def',
'del',
'elif',
'else',
'except',
'finally',
'for',
'from',
'global',
'if',
'import',
'in',
'is',
'lambda',
'nonlocal',
'not',
'or',
'pass',
'raise',
'return',
'try',
'while',
'with',
'yield',
'match',
'case',
]);
function toSnakeCase(value: string): string {
return value
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/[^a-zA-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.toLowerCase();
}
function quotePython(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function toPythonIdentifier(value: string): string {
let identifier = toSnakeCase(value);
if (!identifier) {
identifier = '_';
}
if (/^[0-9]/.test(identifier)) {
identifier = `_${identifier}`;
}
if (PYTHON_RESERVED_KEYWORDS.has(identifier)) {
identifier = `${identifier}_`;
}
return identifier;
}
function buildPythonMethod(method: {
method: string;
description: string;
params: Array<{ name: string; required: boolean }>;
}): string {
const [namespace, member] = method.method.split('.');
if (!namespace || !member) {
return '';
}
const pythonMethodName = toPythonIdentifier(member);
const pythonParams = method.params.map((param) => ({
sourceName: param.name,
pythonName: toPythonIdentifier(param.name),
required: param.required,
}));
const signature = pythonParams.length > 0
? `, ${pythonParams.map((param) => (param.required ? param.pythonName : `${param.pythonName}=None`)).join(', ')}`
: '';
const argsDict = method.params.length > 0
? `{ ${method.params.map((param, index) => `"${param.name}": ${pythonParams[index]?.pythonName}`).join(', ')} }`
: '{}';
return [
` async def ${pythonMethodName}(self${signature}):`,
` \"\"\"${quotePython(method.description)}\"\"\"`,
` return await self._transport.call("${method.method}", ${argsDict})`,
'',
].join('\n');
}
function buildPythonNamespaceClass(
namespace: string,
methods: Array<{ method: string; description: string; params: Array<{ name: string; required: boolean }> }>
): string {
const className = `${namespace[0].toUpperCase()}${namespace.slice(1)}Api`;
const methodBlocks = methods.map((method) => buildPythonMethod(method)).join('');
return [
`class ${className}:`,
' def __init__(self, transport):',
' self._transport = transport',
'',
methodBlocks.trimEnd(),
'',
].join('\n');
}
export function generatePythonApiModuleV1(): string {
const namespaceMap = new Map<string, Array<{ method: string; description: string; params: Array<{ name: string; required: boolean }> }>>();
for (const method of BDS_PYTHON_API_CONTRACT_V1.methods) {
const [namespace] = method.method.split('.');
if (!namespace) {
continue;
}
const entries = namespaceMap.get(namespace) ?? [];
entries.push({
method: method.method,
description: method.description,
params: method.params.map((param) => ({
name: param.name,
required: param.required,
})),
});
namespaceMap.set(namespace, entries);
}
const namespaceBlocks = Array.from(namespaceMap.entries())
.sort(([left], [right]) => left.localeCompare(right))
.map(([namespace, methods]) => buildPythonNamespaceClass(namespace, methods))
.join('\n');
const namespaceAssignments = Array.from(namespaceMap.keys())
.sort((left, right) => left.localeCompare(right))
.map((namespace) => ` self.${toPythonIdentifier(namespace)} = ${namespace[0].toUpperCase()}${namespace.slice(1)}Api(transport)`)
.join('\n');
return [
'# Auto-generated by generatePythonApiModuleV1.ts',
`# Contract version: ${BDS_PYTHON_API_CONTRACT_V1.version}`,
'',
'import json',
'',
'class BdsApiError(Exception):',
' pass',
'',
namespaceBlocks.trimEnd(),
'',
'class BdsApi:',
' def __init__(self, transport):',
' self._transport = transport',
namespaceAssignments,
'',
'class _Transport:',
' def __init__(self, call_impl):',
' self._call_impl = call_impl',
'',
' async def call(self, method, args):',
' raw_result = await self._call_impl(method, json.dumps(args))',
' if raw_result is None or raw_result == "":',
' return None',
' return json.loads(raw_result)',
'',
'def install_bds_api(call_impl):',
' _transport = _Transport(call_impl)',
' bds = BdsApi(_transport)',
' return bds',
'',
].join('\n');
}

View File

@@ -0,0 +1,381 @@
import type { ElectronAPI } from './electronApi';
type PythonPromiseMethodPath = {
[Group in keyof ElectronAPI]: ElectronAPI[Group] extends Record<string, (...args: never[]) => unknown>
? {
[Method in keyof ElectronAPI[Group]]: ElectronAPI[Group][Method] extends (...args: never[]) => Promise<unknown>
? `${Extract<Group, string>}.${Extract<Method, string>}`
: never;
}[keyof ElectronAPI[Group]]
: never;
}[keyof ElectronAPI];
export type PythonApiParamType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'any' | 'stringOrNull';
export interface PythonApiParamContractV1 {
name: string;
type: PythonApiParamType;
required: boolean;
}
export interface PythonApiMethodContractV1 {
method: PythonPromiseMethodPath;
description: string;
params: PythonApiParamContractV1[];
returns: string;
}
export interface PythonApiDataStructureFieldContractV1 {
name: string;
type: string;
required: boolean;
description: string;
}
export interface PythonApiDataStructureContractV1 {
name: string;
description: string;
fields: PythonApiDataStructureFieldContractV1[];
}
export interface PythonApiContractV1 {
version: string;
generatedAt: string;
methods: PythonApiMethodContractV1[];
dataStructures: PythonApiDataStructureContractV1[];
}
const requiredString = (name: string): PythonApiParamContractV1 => ({ name, type: 'string', required: true });
const optionalString = (name: string): PythonApiParamContractV1 => ({ name, type: 'string', required: false });
const optionalNumber = (name: string): PythonApiParamContractV1 => ({ name, type: 'number', required: false });
const requiredObject = (name: string): PythonApiParamContractV1 => ({ name, type: 'object', required: true });
const optionalObject = (name: string): PythonApiParamContractV1 => ({ name, type: 'object', required: false });
const requiredArray = (name: string): PythonApiParamContractV1 => ({ name, type: 'array', required: true });
const requiredAny = (name: string): PythonApiParamContractV1 => ({ name, type: 'any', required: true });
const requiredStringOrNull = (name: string): PythonApiParamContractV1 => ({ name, type: 'stringOrNull', required: true });
function method(
methodName: PythonPromiseMethodPath,
description: string,
params: PythonApiParamContractV1[],
returns: string
): PythonApiMethodContractV1 {
return {
method: methodName,
description,
params,
returns,
};
}
const METHODS_V1: PythonApiMethodContractV1[] = [
method('projects.create', 'Create a project.', [requiredObject('data')], 'ProjectData'),
method('projects.update', 'Update a project by id.', [requiredString('id'), requiredObject('data')], 'ProjectData | null'),
method('projects.delete', 'Delete a project by id.', [requiredString('id')], 'boolean'),
method('projects.deleteWithData', 'Delete a project and data by id.', [requiredString('id')], 'boolean'),
method('projects.get', 'Fetch one project by id.', [requiredString('id')], 'ProjectData | null'),
method('projects.getAll', 'Fetch all projects.', [], 'ProjectData[]'),
method('projects.getActive', 'Fetch active project.', [], 'ProjectData | null'),
method('projects.setActive', 'Set active project by id.', [requiredString('id')], 'ProjectData | null'),
method('posts.create', 'Create a post.', [requiredObject('data')], 'PostData'),
method('posts.update', 'Update a post by id.', [requiredString('id'), requiredObject('data')], 'PostData | null'),
method('posts.delete', 'Delete a post by id.', [requiredString('id')], 'boolean'),
method('posts.get', 'Fetch one post by id.', [requiredString('postId')], 'PostData | null'),
method('posts.getPreviewUrl', 'Get preview URL for post.', [requiredString('id'), optionalObject('options')], 'string | null'),
method('posts.getAll', 'Fetch posts with pagination.', [optionalObject('options')], 'PaginatedPostsResult'),
method('posts.getByStatus', 'Fetch posts by status.', [requiredString('status')], 'PostData[]'),
method('posts.publish', 'Publish a post by id.', [requiredString('id')], 'PostData | null'),
method('posts.discard', 'Discard draft changes for post.', [requiredString('id')], 'PostData | null'),
method('posts.hasPublishedVersion', 'Check if post has published version.', [requiredString('id')], 'boolean'),
method('posts.rebuildFromFiles', 'Rebuild posts database from files.', [], 'void'),
method('posts.reindexText', 'Reindex post search text.', [], 'void'),
method('posts.search', 'Search posts by free-text query.', [requiredString('query')], 'SearchResult[]'),
method('posts.filter', 'Filter posts by criteria.', [requiredObject('filter')], 'PostData[]'),
method('posts.getTags', 'Get all post tags.', [], 'string[]'),
method('posts.getCategories', 'Get all post categories.', [], 'string[]'),
method('posts.getByYearMonth', 'Get post counts grouped by year/month.', [], 'Array<{ year: number; month: number; count: number } >'),
method('posts.getDashboardStats', 'Get post dashboard stats.', [], 'DashboardStats'),
method('posts.getTagsWithCounts', 'Get post tags with counts.', [], 'TagCount[]'),
method('posts.getCategoriesWithCounts', 'Get post categories with counts.', [], 'CategoryCount[]'),
method('posts.getLinksTo', 'Get posts linked to given post.', [requiredString('id')], 'PostData[]'),
method('posts.getLinkedBy', 'Get posts linking to given post.', [requiredString('id')], 'PostData[]'),
method('posts.rebuildLinks', 'Rebuild post link graph.', [], 'void'),
method('posts.isSlugAvailable', 'Check if post slug is available.', [requiredString('slug'), optionalString('excludePostId')], 'boolean'),
method('posts.generateUniqueSlug', 'Generate unique slug from title.', [requiredString('title'), optionalString('excludePostId')], 'string'),
method('media.import', 'Import media file.', [requiredString('sourcePath'), optionalObject('metadata')], 'MediaData'),
method('media.update', 'Update media metadata by id.', [requiredString('id'), requiredObject('data')], 'MediaData | null'),
method('media.replaceFile', 'Replace media file by id.', [requiredString('id'), requiredString('newSourcePath')], 'MediaData | null'),
method('media.delete', 'Delete media by id.', [requiredString('id')], 'boolean'),
method('media.get', 'Fetch one media by id.', [requiredString('id')], 'MediaData | null'),
method('media.getUrl', 'Get media URL by id.', [requiredString('id')], 'string | null'),
method('media.getFilePath', 'Get media file path by id.', [requiredString('id')], 'string | null'),
method('media.getAll', 'Fetch all media.', [], 'MediaData[]'),
method('media.rebuildFromFiles', 'Rebuild media database from files.', [], 'void'),
method('media.reindexText', 'Reindex media search text.', [], 'void'),
method('media.getThumbnail', 'Get media thumbnail URL.', [requiredString('id'), optionalString('size')], 'string | null'),
method('media.regenerateThumbnails', 'Regenerate thumbnails for media.', [requiredString('id')], 'Record<string, string> | null'),
method('media.regenerateMissingThumbnails', 'Regenerate all missing thumbnails.', [], '{ processed: number; generated: number; failed: number }'),
method('media.filter', 'Filter media by criteria.', [requiredObject('filter')], 'MediaData[]'),
method('media.search', 'Search media by free-text query.', [requiredString('query')], 'MediaSearchResult[]'),
method('media.getByYearMonth', 'Get media counts grouped by year/month.', [], 'Array<{ year: number; month: number; count: number } >'),
method('media.getTags', 'Get all media tags.', [], 'string[]'),
method('media.getTagsWithCounts', 'Get media tags with counts.', [], 'TagCount[]'),
method('scripts.create', 'Create script.', [requiredObject('data')], 'ScriptData'),
method('scripts.update', 'Update script by id.', [requiredString('id'), requiredObject('data')], 'ScriptData | null'),
method('scripts.delete', 'Delete script by id.', [requiredString('id')], 'boolean'),
method('scripts.get', 'Fetch script by id.', [requiredString('id')], 'ScriptData | null'),
method('scripts.getAll', 'Fetch all scripts.', [], 'ScriptData[]'),
method('scripts.rebuildFromFiles', 'Rebuild scripts from files.', [], 'void'),
method('tasks.getAll', 'Fetch all tasks.', [], 'TaskProgress[]'),
method('tasks.getRunning', 'Fetch running tasks.', [], 'TaskProgress[]'),
method('tasks.cancel', 'Cancel task by id.', [requiredString('taskId')], 'boolean'),
method('tasks.clearCompleted', 'Clear completed tasks.', [], 'void'),
method('app.getDataPaths', 'Get app data paths.', [], '{ database: string; posts: string; media: string }'),
method('app.getSystemLanguage', 'Get system language.', [], 'string'),
method('app.getTitleBarMetrics', 'Get title bar metrics.', [], '{ macosLeftInset: number } | null'),
method('app.openFolder', 'Open folder in system file manager.', [requiredString('folderPath')], 'string'),
method('app.showItemInFolder', 'Reveal item in system file manager.', [requiredString('itemPath')], 'void'),
method('app.selectFolder', 'Show folder picker dialog.', [optionalString('title')], 'string | null'),
method('app.getDefaultProjectPath', 'Get default project path.', [requiredString('projectId')], 'string'),
method('app.readProjectMetadata', 'Read project metadata from path.', [requiredString('folderPath')], '{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null'),
method('app.getBlogmarkBookmarklet', 'Get blogmark bookmarklet script.', [], 'string'),
method('app.copyToClipboard', 'Copy text to clipboard.', [requiredString('text')], 'boolean'),
method('app.notifyRendererReady', 'Notify main process renderer is ready.', [], 'boolean'),
method('app.setPreviewPostTarget', 'Set preview post target.', [requiredStringOrNull('postId')], 'void'),
method('app.triggerMenuAction', 'Trigger menu action.', [requiredString('action')], 'void'),
method('meta.getTags', 'Get project tags.', [], 'string[]'),
method('meta.getCategories', 'Get project categories.', [], 'string[]'),
method('meta.addTag', 'Add project tag.', [requiredString('tag')], 'string[]'),
method('meta.removeTag', 'Remove project tag.', [requiredString('tag')], 'string[]'),
method('meta.addCategory', 'Add project category.', [requiredString('category')], 'string[]'),
method('meta.removeCategory', 'Remove project category.', [requiredString('category')], 'string[]'),
method('meta.syncOnStartup', 'Sync meta values on startup.', [], '{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }'),
method('meta.getProjectMetadata', 'Read active project metadata.', [], 'ProjectMetadata | null'),
method('meta.setProjectMetadata', 'Set project metadata.', [requiredObject('metadata')], 'ProjectMetadata | null'),
method('meta.updateProjectMetadata', 'Update project metadata.', [requiredObject('updates')], 'ProjectMetadata | null'),
method('tags.getAll', 'Fetch all tags.', [], 'TagData[]'),
method('tags.getWithCounts', 'Fetch tags with counts.', [], 'TagWithCount[]'),
method('tags.get', 'Fetch tag by id.', [requiredString('id')], 'TagData | null'),
method('tags.getByName', 'Fetch tag by name.', [requiredString('name')], 'TagData | null'),
method('tags.create', 'Create tag.', [requiredObject('data')], 'TagData'),
method('tags.update', 'Update tag by id.', [requiredString('id'), requiredObject('data')], 'TagData | null'),
method('tags.delete', 'Delete tag by id.', [requiredString('id')], 'DeleteTagResult'),
method('tags.merge', 'Merge tags into target tag.', [requiredArray('sourceTagIds'), requiredString('targetTagId')], 'MergeTagsResult'),
method('tags.rename', 'Rename tag by id.', [requiredString('id'), requiredString('newName')], 'RenameTagResult'),
method('tags.getPostsWithTag', 'Get posts using a tag.', [requiredString('tagId')], 'string[]'),
method('tags.syncFromPosts', 'Sync tag index from posts.', [], 'SyncTagsResult'),
method('chat.checkReady', 'Check chat backend readiness.', [], 'ChatReadyStatus'),
method('chat.validateApiKey', 'Validate chat API key and list available models.', [requiredString('apiKey')], '{ isValid: boolean; models: ChatModel[] }'),
method('chat.setApiKey', 'Store chat API key.', [requiredString('apiKey')], '{ success: boolean; error?: string }'),
method('chat.getApiKey', 'Get stored chat API key status.', [], 'ChatApiKeyStatus'),
method('chat.getAvailableModels', 'Get available chat models and selected default.', [], '{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }'),
method('chat.setDefaultModel', 'Set default chat model.', [requiredString('modelId')], '{ success: boolean; error?: string }'),
method('chat.getSystemPrompt', 'Get configured system prompt.', [], '{ success: boolean; prompt?: string; error?: string }'),
method('chat.setSystemPrompt', 'Set system prompt.', [requiredString('prompt')], '{ success: boolean; error?: string }'),
method('chat.getConversations', 'Fetch all chat conversations.', [], 'ChatConversation[]'),
method('chat.createConversation', 'Create a chat conversation.', [optionalString('title'), optionalString('model')], 'ChatConversation'),
method('chat.getConversation', 'Fetch one chat conversation by id.', [requiredString('id')], 'ChatConversation | null'),
method('chat.updateConversation', 'Update chat conversation metadata.', [requiredString('id'), requiredObject('updates')], 'ChatConversation | null'),
method('chat.deleteConversation', 'Delete chat conversation by id.', [requiredString('id')], 'boolean'),
method('chat.sendMessage', 'Send message to chat conversation.', [requiredString('conversationId'), requiredString('message'), optionalObject('metadata')], '{ success: boolean; message?: string; error?: string }'),
method('chat.abortMessage', 'Abort active streaming chat response.', [requiredString('conversationId')], 'void'),
method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'),
method('chat.clearMessages', 'Clear messages for conversation.', [requiredString('conversationId')], 'void'),
method('chat.setConversationModel', 'Set model for a conversation.', [requiredString('conversationId'), requiredString('modelId')], 'void'),
method('chat.analyzeTaxonomy', 'Analyze categories and tags using AI.', [requiredArray('categories'), requiredArray('tags'), requiredString('modelId')], '{ success: boolean; categoryMappings?: Record<string, string>; tagMappings?: Record<string, string>; error?: string }'),
method('chat.analyzeMediaImage', 'Analyze media image and propose metadata.', [requiredString('mediaId'), optionalString('language')], '{ success: boolean; title?: string; alt?: string; caption?: string; error?: string }'),
method('sync.configure', 'Configure sync.', [requiredObject('config')], 'void'),
method('sync.start', 'Start sync operation.', [optionalString('direction')], 'SyncResult'),
method('sync.getStatus', 'Get sync status.', [], "'idle' | 'syncing' | 'error'"),
method('sync.isConfigured', 'Check if sync is configured.', [], 'boolean'),
method('sync.getPendingCount', 'Get pending sync item count.', [], '{ posts: number; media: number }'),
method('sync.getLog', 'Get sync log.', [optionalNumber('limit')], 'unknown[]'),
method('sync.stopAutoSync', 'Stop automatic sync.', [], 'void'),
];
const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{
name: 'ProjectData',
description: 'Project metadata stored in the app database.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique project identifier.' },
{ name: 'name', type: 'string', required: true, description: 'Human-readable project name.' },
{ name: 'slug', type: 'string', required: true, description: 'URL-friendly project slug.' },
{ name: 'description', type: 'string', required: false, description: 'Optional project description.' },
{ name: 'dataPath', type: 'string', required: false, description: 'Filesystem path for project data.' },
{ name: 'isActive', type: 'boolean', required: true, description: 'Whether this project is currently active.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
],
},
{
name: 'PostData',
description: 'Canonical post object used across editor and generation flows.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique post identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
{ name: 'title', type: 'string', required: true, description: 'Post title.' },
{ name: 'slug', type: 'string', required: true, description: 'URL slug used for generated routes.' },
{ name: 'excerpt', type: 'string', required: false, description: 'Optional short summary.' },
{ name: 'content', type: 'string', required: true, description: 'Markdown body content.' },
{ name: 'status', type: "'draft' | 'published' | 'archived'", required: true, description: 'Publication lifecycle state.' },
{ name: 'author', type: 'string', required: false, description: 'Optional author name.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
{ name: 'publishedAt', type: 'string', required: false, description: 'Publication timestamp for published posts.' },
{ name: 'tags', type: 'string[]', required: true, description: 'List of tag names.' },
{ name: 'categories', type: 'string[]', required: true, description: 'List of category names.' },
],
},
{
name: 'MediaData',
description: 'Canonical media object representing imported files and metadata.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique media identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
{ name: 'filename', type: 'string', required: true, description: 'Stored filename in project media folder.' },
{ name: 'originalName', type: 'string', required: true, description: 'Original imported filename.' },
{ name: 'mimeType', type: 'string', required: true, description: 'Detected MIME type.' },
{ name: 'size', type: 'number', required: true, description: 'File size in bytes.' },
{ name: 'width', type: 'number', required: false, description: 'Image width in pixels when available.' },
{ name: 'height', type: 'number', required: false, description: 'Image height in pixels when available.' },
{ name: 'title', type: 'string', required: false, description: 'Optional display title.' },
{ name: 'alt', type: 'string', required: false, description: 'Optional alternative text.' },
{ name: 'caption', type: 'string', required: false, description: 'Optional caption text.' },
{ name: 'author', type: 'string', required: false, description: 'Optional author credit.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
{ name: 'tags', type: 'string[]', required: true, description: 'List of media tags.' },
],
},
{
name: 'ScriptData',
description: 'Script definition for Python macros, utilities, and transforms.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique script identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
{ name: 'slug', type: 'string', required: true, description: 'Stable script slug.' },
{ name: 'title', type: 'string', required: true, description: 'Human-readable script title.' },
{ name: 'kind', type: "'macro' | 'utility' | 'transform'", required: true, description: 'Script category.' },
{ name: 'entrypoint', type: 'string', required: true, description: 'Python entrypoint function name.' },
{ name: 'enabled', type: 'boolean', required: true, description: 'Whether script is enabled.' },
{ name: 'version', type: 'number', required: true, description: 'Incrementing script version.' },
{ name: 'filePath', type: 'string', required: true, description: 'Filesystem path to script file.' },
{ name: 'content', type: 'string', required: true, description: 'Script source code.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
],
},
{
name: 'TaskProgress',
description: 'Task queue status object for long-running operations.',
fields: [
{ name: 'taskId', type: 'string', required: true, description: 'Unique task identifier.' },
{ name: 'name', type: 'string', required: true, description: 'Task display name.' },
{ name: 'status', type: "'pending' | 'running' | 'completed' | 'failed' | 'cancelled'", required: true, description: 'Current task status.' },
{ name: 'progress', type: 'number', required: true, description: 'Progress percentage from 0-100.' },
{ name: 'message', type: 'string', required: true, description: 'Current progress message.' },
{ name: 'startTime', type: 'string', required: true, description: 'Task start time (ISO string).' },
{ name: 'endTime', type: 'string', required: false, description: 'Task completion time (ISO string).' },
{ name: 'error', type: 'string', required: false, description: 'Error message when failed.' },
{ name: 'groupId', type: 'string', required: false, description: 'Optional grouping id.' },
{ name: 'groupName', type: 'string', required: false, description: 'Optional grouping label.' },
],
},
{
name: 'ProjectMetadata',
description: 'Extended project metadata from project settings.',
fields: [
{ name: 'name', type: 'string', required: true, description: 'Project display name.' },
{ name: 'description', type: 'string', required: false, description: 'Optional project description.' },
{ name: 'dataPath', type: 'string', required: false, description: 'Optional custom data path.' },
{ name: 'publicUrl', type: 'string', required: false, description: 'Optional public site URL.' },
{ name: 'mainLanguage', type: 'string', required: false, description: 'Main render language code.' },
{ name: 'defaultAuthor', type: 'string', required: false, description: 'Default author for new posts.' },
{ name: 'maxPostsPerPage', type: 'number', required: false, description: 'Pagination size for generated lists.' },
{ name: 'blogmarkCategory', type: 'string', required: false, description: 'Default category for blogmark imports.' },
{ name: 'pythonRuntimeMode', type: "'webworker' | 'main-thread'", required: false, description: 'Python runtime execution mode.' },
{ name: 'picoTheme', type: 'string', required: false, description: 'Preferred Pico theme token.' },
{ name: 'categoryMetadata', type: 'object', required: false, description: 'Category metadata keyed by category slug.' },
{ name: 'categorySettings', type: 'object', required: false, description: 'Category render settings keyed by category slug.' },
],
},
{
name: 'ChatConversation',
description: 'Chat conversation container.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique conversation identifier.' },
{ name: 'title', type: 'string', required: true, description: 'Conversation title.' },
{ name: 'model', type: 'string', required: false, description: 'Optional model id used by this conversation.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
],
},
{
name: 'ChatMessage',
description: 'Single message entry in a conversation history.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique message identifier.' },
{ name: 'conversationId', type: 'string', required: true, description: 'Owning conversation id.' },
{ name: 'role', type: "'user' | 'assistant' | 'system' | 'tool'", required: true, description: 'Message author role.' },
{ name: 'content', type: 'string', required: true, description: 'Message text content.' },
{ name: 'toolCallId', type: 'string', required: false, description: 'Tool call id when associated with tool output.' },
{ name: 'toolCalls', type: 'string', required: false, description: 'Serialized tool call payload when present.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
],
},
{
name: 'ChatModel',
description: 'Available chat model descriptor.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Model identifier.' },
{ name: 'name', type: 'string', required: true, description: 'Human-readable model name.' },
{ name: 'provider', type: 'string', required: false, description: 'Model provider name.' },
],
},
{
name: 'ChatReadyStatus',
description: 'Chat backend readiness status.',
fields: [
{ name: 'ready', type: 'boolean', required: true, description: 'Whether chat backend is ready.' },
{ name: 'error', type: 'string', required: false, description: 'Error description when not ready.' },
{ name: 'backend', type: 'string', required: false, description: 'Selected backend identifier.' },
],
},
{
name: 'ChatApiKeyStatus',
description: 'Stored API key state for chat provider.',
fields: [
{ name: 'hasKey', type: 'boolean', required: true, description: 'Whether a key is configured.' },
{ name: 'maskedKey', type: 'string', required: true, description: 'Masked key representation for UI display.' },
],
},
];
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
version: '1.6.0',
generatedAt: '2026-02-25T00:00:00.000Z',
methods: METHODS_V1,
dataStructures: DATA_STRUCTURES_V1,
};
export function listPythonApiMethodNames(): string[] {
return BDS_PYTHON_API_CONTRACT_V1.methods.map((entry) => entry.method);
}
export function getPythonApiMethodContract(methodName: string): PythonApiMethodContractV1 | undefined {
return BDS_PYTHON_API_CONTRACT_V1.methods.find((entry) => entry.method === methodName);
}
export function getPythonApiDataStructureContracts(): PythonApiDataStructureContractV1[] {
return BDS_PYTHON_API_CONTRACT_V1.dataStructures;
}

View File

@@ -14,6 +14,8 @@ import {
import { createDeferredEventGate } from './navigation/deferredEventGate';
import { createAndFocusPost } from './navigation/postCreation';
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme';
import { addWindowEventListener, BDS_EVENT_SCRIPTS_CHANGED } from './utils/windowEvents';
import { refreshPythonMacroSlugs } from './macros';
import { useI18n } from './i18n';
import './App.css';
@@ -104,6 +106,9 @@ const App: React.FC = () => {
if (tasks) {
setTasks(tasks as TaskProgress[]);
}
// Load known Python macro slugs for editor detection
await refreshPythonMacroSlugs();
} catch (error) {
console.error('Failed to load initial data:', error);
} finally {
@@ -557,6 +562,13 @@ const App: React.FC = () => {
}) || (() => {})
);
// Refresh Python macro slugs when scripts change
unsubscribers.push(
addWindowEventListener(BDS_EVENT_SCRIPTS_CHANGED, () => {
void refreshPythonMacroSlugs();
})
);
void window.electronAPI?.app.notifyRendererReady?.().catch((error) => {
console.error('Failed to notify renderer readiness:', error);
});

View File

@@ -43,4 +43,5 @@ export {
renderAllMacros,
getEditorPreview,
setPythonMacroResolver,
refreshPythonMacroSlugs,
} from './registry';

View File

@@ -21,6 +21,9 @@ const macroRegistry = new Map<string, MacroDefinition>();
let pythonMacroResolverFn: PythonMacroResolver | null = null;
let pythonMacroRendererFn: PythonMacroRendererFn | null = null;
// Python macro slugs for editor known/unknown detection
const pythonMacroSlugs = new Set<string>();
/**
* Register a macro definition.
* Call this from each macro definition file.
@@ -48,6 +51,25 @@ export function setPythonMacroResolver(
pythonMacroRendererFn = renderer;
}
/**
* Refresh the set of known Python macro slugs from the backend.
* Call on startup and when scripts change.
*/
export async function refreshPythonMacroSlugs(): Promise<void> {
try {
if (typeof window === 'undefined' || !window.electronAPI?.scripts?.getEnabledMacroSlugs) {
return;
}
const slugs = await window.electronAPI.scripts.getEnabledMacroSlugs();
pythonMacroSlugs.clear();
for (const slug of slugs) {
pythonMacroSlugs.add(slug.toLowerCase());
}
} catch {
// Silently ignore — may be called before IPC bridge is ready
}
}
/**
* Get a macro definition by name.
*
@@ -59,12 +81,13 @@ export function getMacro(name: string): MacroDefinition | undefined {
}
/**
* Check if a macro is registered.
* Check if a macro is registered (JS registry or Python macro slug).
*
* @param name - The macro name (case-insensitive)
*/
export function hasMacro(name: string): boolean {
return macroRegistry.has(name.toLowerCase());
const lower = name.toLowerCase();
return macroRegistry.has(lower) || pythonMacroSlugs.has(lower);
}
/**
@@ -86,6 +109,7 @@ export function getAllMacros(): MacroDefinition[] {
*/
export function clearMacros(): void {
macroRegistry.clear();
pythonMacroSlugs.clear();
}
// Regex to match [[macroName param1="value1" param2='value2']]

View File

@@ -43,6 +43,7 @@ export interface PythonMacroSourceOptions {
export interface PythonMacroRenderOptions extends PythonExecuteOptions {
macroHook?: string;
macroSource?: PythonMacroSourceOptions;
postDataJson?: string | null;
}
export interface PythonMacroV1Result {
@@ -173,6 +174,8 @@ export class PythonRuntimeManager {
requestId,
code,
context: validatedContext,
entrypoint: options?.entrypoint,
postDataJson: options?.postDataJson,
cacheKey: options?.cacheKey,
};

View File

@@ -2,7 +2,7 @@ import {
BDS_PYTHON_API_CONTRACT_V1,
type PythonApiDataStructureContractV1,
type PythonApiParamContractV1,
} from './pythonApiContractV1';
} from '../../main/shared/pythonApiContractV1';
function toSnakeCase(value: string): string {
return value
@@ -206,6 +206,8 @@ export function generateApiDocumentationMarkdownV1(): string {
sections.push('');
sections.push('This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.');
sections.push('');
sections.push('`bds_api` is available in both **macro scripts** (executed during preview and page generation) and **transform scripts** (executed during blogmark import). In macro entrypoints, API calls run in the same runtime context as the macro and can be used to fetch posts, media, tags, or other application data.');
sections.push('');
sections.push('## Usage');
sections.push('');
sections.push('```python');

View File

@@ -0,0 +1,2 @@
export declare function generatePythonApiModuleV1(): string;
//# sourceMappingURL=generatePythonApiModuleV1.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"generatePythonApiModuleV1.d.ts","sourceRoot":"","sources":["generatePythonApiModuleV1.ts"],"names":[],"mappings":"AAyHA,wBAAgB,yBAAyB,IAAI,MAAM,CA+DlD"}

View File

@@ -0,0 +1,162 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.generatePythonApiModuleV1 = generatePythonApiModuleV1;
const pythonApiContractV1_1 = require("./pythonApiContractV1");
const PYTHON_RESERVED_KEYWORDS = new Set([
'false',
'none',
'true',
'and',
'as',
'assert',
'async',
'await',
'break',
'class',
'continue',
'def',
'del',
'elif',
'else',
'except',
'finally',
'for',
'from',
'global',
'if',
'import',
'in',
'is',
'lambda',
'nonlocal',
'not',
'or',
'pass',
'raise',
'return',
'try',
'while',
'with',
'yield',
'match',
'case',
]);
function toSnakeCase(value) {
return value
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/[^a-zA-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.toLowerCase();
}
function quotePython(value) {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function toPythonIdentifier(value) {
let identifier = toSnakeCase(value);
if (!identifier) {
identifier = '_';
}
if (/^[0-9]/.test(identifier)) {
identifier = `_${identifier}`;
}
if (PYTHON_RESERVED_KEYWORDS.has(identifier)) {
identifier = `${identifier}_`;
}
return identifier;
}
function buildPythonMethod(method) {
const [namespace, member] = method.method.split('.');
if (!namespace || !member) {
return '';
}
const pythonMethodName = toPythonIdentifier(member);
const pythonParams = method.params.map((param) => ({
sourceName: param.name,
pythonName: toPythonIdentifier(param.name),
required: param.required,
}));
const signature = pythonParams.length > 0
? `, ${pythonParams.map((param) => (param.required ? param.pythonName : `${param.pythonName}=None`)).join(', ')}`
: '';
const argsDict = method.params.length > 0
? `{ ${method.params.map((param, index) => `"${param.name}": ${pythonParams[index]?.pythonName}`).join(', ')} }`
: '{}';
return [
` async def ${pythonMethodName}(self${signature}):`,
` \"\"\"${quotePython(method.description)}\"\"\"`,
` return await self._transport.call("${method.method}", ${argsDict})`,
'',
].join('\n');
}
function buildPythonNamespaceClass(namespace, methods) {
const className = `${namespace[0].toUpperCase()}${namespace.slice(1)}Api`;
const methodBlocks = methods.map((method) => buildPythonMethod(method)).join('');
return [
`class ${className}:`,
' def __init__(self, transport):',
' self._transport = transport',
'',
methodBlocks.trimEnd(),
'',
].join('\n');
}
function generatePythonApiModuleV1() {
const namespaceMap = new Map();
for (const method of pythonApiContractV1_1.BDS_PYTHON_API_CONTRACT_V1.methods) {
const [namespace] = method.method.split('.');
if (!namespace) {
continue;
}
const entries = namespaceMap.get(namespace) ?? [];
entries.push({
method: method.method,
description: method.description,
params: method.params.map((param) => ({
name: param.name,
required: param.required,
})),
});
namespaceMap.set(namespace, entries);
}
const namespaceBlocks = Array.from(namespaceMap.entries())
.sort(([left], [right]) => left.localeCompare(right))
.map(([namespace, methods]) => buildPythonNamespaceClass(namespace, methods))
.join('\n');
const namespaceAssignments = Array.from(namespaceMap.keys())
.sort((left, right) => left.localeCompare(right))
.map((namespace) => ` self.${toPythonIdentifier(namespace)} = ${namespace[0].toUpperCase()}${namespace.slice(1)}Api(transport)`)
.join('\n');
return [
'# Auto-generated by generatePythonApiModuleV1.ts',
`# Contract version: ${pythonApiContractV1_1.BDS_PYTHON_API_CONTRACT_V1.version}`,
'',
'import json',
'',
'class BdsApiError(Exception):',
' pass',
'',
namespaceBlocks.trimEnd(),
'',
'class BdsApi:',
' def __init__(self, transport):',
' self._transport = transport',
namespaceAssignments,
'',
'class _Transport:',
' def __init__(self, call_impl):',
' self._call_impl = call_impl',
'',
' async def call(self, method, args):',
' raw_result = await self._call_impl(method, json.dumps(args))',
' if raw_result is None or raw_result == "":',
' return None',
' return json.loads(raw_result)',
'',
'def install_bds_api(call_impl):',
' _transport = _Transport(call_impl)',
' bds = BdsApi(_transport)',
' return bds',
'',
].join('\n');
}
//# sourceMappingURL=generatePythonApiModuleV1.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"generatePythonApiModuleV1.js","sourceRoot":"","sources":["generatePythonApiModuleV1.ts"],"names":[],"mappings":";;AAyHA,8DA+DC;AAxLD,+DAAmE;AAEnE,MAAM,wBAAwB,GAAG,IAAI,GAAG,CAAC;IACvC,OAAO;IACP,MAAM;IACN,MAAM;IACN,KAAK;IACL,IAAI;IACJ,QAAQ;IACR,OAAO;IACP,OAAO;IACP,OAAO;IACP,OAAO;IACP,UAAU;IACV,KAAK;IACL,KAAK;IACL,MAAM;IACN,MAAM;IACN,QAAQ;IACR,SAAS;IACT,KAAK;IACL,MAAM;IACN,QAAQ;IACR,IAAI;IACJ,QAAQ;IACR,IAAI;IACJ,IAAI;IACJ,QAAQ;IACR,UAAU;IACV,KAAK;IACL,IAAI;IACJ,MAAM;IACN,OAAO;IACP,QAAQ;IACR,KAAK;IACL,OAAO;IACP,MAAM;IACN,OAAO;IACP,OAAO;IACP,MAAM;CACP,CAAC,CAAC;AAEH,SAAS,WAAW,CAAC,KAAa;IAChC,OAAO,KAAK;SACT,OAAO,CAAC,oBAAoB,EAAE,OAAO,CAAC;SACtC,OAAO,CAAC,gBAAgB,EAAE,GAAG,CAAC;SAC9B,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC;SACvB,WAAW,EAAE,CAAC;AACnB,CAAC;AAED,SAAS,WAAW,CAAC,KAAa;IAChC,OAAO,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AAC3D,CAAC;AAED,SAAS,kBAAkB,CAAC,KAAa;IACvC,IAAI,UAAU,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;IACpC,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,UAAU,GAAG,GAAG,CAAC;IACnB,CAAC;IAED,IAAI,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC;QAC9B,UAAU,GAAG,IAAI,UAAU,EAAE,CAAC;IAChC,CAAC;IAED,IAAI,wBAAwB,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,CAAC;QAC7C,UAAU,GAAG,GAAG,UAAU,GAAG,CAAC;IAChC,CAAC;IAED,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,SAAS,iBAAiB,CAAC,MAI1B;IACC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACrD,IAAI,CAAC,SAAS,IAAI,CAAC,MAAM,EAAE,CAAC;QAC1B,OAAO,EAAE,CAAC;IACZ,CAAC;IAED,MAAM,gBAAgB,GAAG,kBAAkB,CAAC,MAAM,CAAC,CAAC;IACpD,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;QACjD,UAAU,EAAE,KAAK,CAAC,IAAI;QACtB,UAAU,EAAE,kBAAkB,CAAC,KAAK,CAAC,IAAI,CAAC;QAC1C,QAAQ,EAAE,KAAK,CAAC,QAAQ;KACzB,CAAC,CAAC,CAAC;IAEJ,MAAM,SAAS,GAAG,YAAY,CAAC,MAAM,GAAG,CAAC;QACvC,CAAC,CAAC,KAAK,YAAY,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,UAAU,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE;QACjH,CAAC,CAAC,EAAE,CAAC;IAEP,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC;QACvC,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,CAAC,IAAI,KAAK,CAAC,IAAI,MAAM,YAAY,CAAC,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI;QAChH,CAAC,CAAC,IAAI,CAAC;IAET,OAAO;QACL,iBAAiB,gBAAgB,QAAQ,SAAS,IAAI;QACtD,iBAAiB,WAAW,CAAC,MAAM,CAAC,WAAW,CAAC,QAAQ;QACxD,8CAA8C,MAAM,CAAC,MAAM,MAAM,QAAQ,GAAG;QAC5E,EAAE;KACH,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC;AAED,SAAS,yBAAyB,CAChC,SAAiB,EACjB,OAA2G;IAE3G,MAAM,SAAS,GAAG,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC;IAC1E,MAAM,YAAY,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,iBAAiB,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAEjF,OAAO;QACL,SAAS,SAAS,GAAG;QACrB,oCAAoC;QACpC,qCAAqC;QACrC,EAAE;QACF,YAAY,CAAC,OAAO,EAAE;QACtB,EAAE;KACH,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC;AAED,SAAgB,yBAAyB;IACvC,MAAM,YAAY,GAAG,IAAI,GAAG,EAA8G,CAAC;IAE3I,KAAK,MAAM,MAAM,IAAI,gDAA0B,CAAC,OAAO,EAAE,CAAC;QACxD,MAAM,CAAC,SAAS,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC7C,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,SAAS;QACX,CAAC;QAED,MAAM,OAAO,GAAG,YAAY,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;QAClD,OAAO,CAAC,IAAI,CAAC;YACX,MAAM,EAAE,MAAM,CAAC,MAAM;YACrB,WAAW,EAAE,MAAM,CAAC,WAAW;YAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;gBACpC,IAAI,EAAE,KAAK,CAAC,IAAI;gBAChB,QAAQ,EAAE,KAAK,CAAC,QAAQ;aACzB,CAAC,CAAC;SACJ,CAAC,CAAC;QACH,YAAY,CAAC,GAAG,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;IACvC,CAAC;IAED,MAAM,eAAe,GAAG,KAAK,CAAC,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,CAAC;SACvD,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;SACpD,GAAG,CAAC,CAAC,CAAC,SAAS,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC,yBAAyB,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;SAC5E,IAAI,CAAC,IAAI,CAAC,CAAC;IAEd,MAAM,oBAAoB,GAAG,KAAK,CAAC,IAAI,CAAC,YAAY,CAAC,IAAI,EAAE,CAAC;SACzD,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;SAChD,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,gBAAgB,kBAAkB,CAAC,SAAS,CAAC,MAAM,SAAS,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,gBAAgB,CAAC;SACtI,IAAI,CAAC,IAAI,CAAC,CAAC;IAEd,OAAO;QACL,kDAAkD;QAClD,uBAAuB,gDAA0B,CAAC,OAAO,EAAE;QAC3D,EAAE;QACF,aAAa;QACb,EAAE;QACF,+BAA+B;QAC/B,UAAU;QACV,EAAE;QACF,eAAe,CAAC,OAAO,EAAE;QACzB,EAAE;QACF,eAAe;QACf,oCAAoC;QACpC,qCAAqC;QACrC,oBAAoB;QACpB,EAAE;QACF,mBAAmB;QACnB,oCAAoC;QACpC,qCAAqC;QACrC,EAAE;QACF,yCAAyC;QACzC,sEAAsE;QACtE,oDAAoD;QACpD,yBAAyB;QACzB,uCAAuC;QACvC,EAAE;QACF,iCAAiC;QACjC,wCAAwC;QACxC,8BAA8B;QAC9B,gBAAgB;QAChB,EAAE;KACH,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC"}

View File

@@ -0,0 +1,41 @@
import type { ElectronAPI } from '../../main/shared/electronApi';
type PythonPromiseMethodPath = {
[Group in keyof ElectronAPI]: ElectronAPI[Group] extends Record<string, (...args: never[]) => unknown> ? {
[Method in keyof ElectronAPI[Group]]: ElectronAPI[Group][Method] extends (...args: never[]) => Promise<unknown> ? `${Extract<Group, string>}.${Extract<Method, string>}` : never;
}[keyof ElectronAPI[Group]] : never;
}[keyof ElectronAPI];
export type PythonApiParamType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'any' | 'stringOrNull';
export interface PythonApiParamContractV1 {
name: string;
type: PythonApiParamType;
required: boolean;
}
export interface PythonApiMethodContractV1 {
method: PythonPromiseMethodPath;
description: string;
params: PythonApiParamContractV1[];
returns: string;
}
export interface PythonApiDataStructureFieldContractV1 {
name: string;
type: string;
required: boolean;
description: string;
}
export interface PythonApiDataStructureContractV1 {
name: string;
description: string;
fields: PythonApiDataStructureFieldContractV1[];
}
export interface PythonApiContractV1 {
version: string;
generatedAt: string;
methods: PythonApiMethodContractV1[];
dataStructures: PythonApiDataStructureContractV1[];
}
export declare const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1;
export declare function listPythonApiMethodNames(): string[];
export declare function getPythonApiMethodContract(methodName: string): PythonApiMethodContractV1 | undefined;
export declare function getPythonApiDataStructureContracts(): PythonApiDataStructureContractV1[];
export {};
//# sourceMappingURL=pythonApiContractV1.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"pythonApiContractV1.d.ts","sourceRoot":"","sources":["pythonApiContractV1.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAEjE,KAAK,uBAAuB,GAAG;KAC5B,KAAK,IAAI,MAAM,WAAW,GAAG,WAAW,CAAC,KAAK,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,OAAO,CAAC,GAClG;SACG,MAAM,IAAI,MAAM,WAAW,CAAC,KAAK,CAAC,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,GAC3G,GAAG,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,IAAI,OAAO,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,GACtD,KAAK;KACV,CAAC,MAAM,WAAW,CAAC,KAAK,CAAC,CAAC,GAC3B,KAAK;CACV,CAAC,MAAM,WAAW,CAAC,CAAC;AAErB,MAAM,MAAM,kBAAkB,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,OAAO,GAAG,KAAK,GAAG,cAAc,CAAC;AAE/G,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,kBAAkB,CAAC;IACzB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,yBAAyB;IACxC,MAAM,EAAE,uBAAuB,CAAC;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,wBAAwB,EAAE,CAAC;IACnC,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,qCAAqC;IACpD,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gCAAgC;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,qCAAqC,EAAE,CAAC;CACjD;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,yBAAyB,EAAE,CAAC;IACrC,cAAc,EAAE,gCAAgC,EAAE,CAAC;CACpD;AA8TD,eAAO,MAAM,0BAA0B,EAAE,mBAKxC,CAAC;AAEF,wBAAgB,wBAAwB,IAAI,MAAM,EAAE,CAEnD;AAED,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,MAAM,GAAG,yBAAyB,GAAG,SAAS,CAEpG;AAED,wBAAgB,kCAAkC,IAAI,gCAAgC,EAAE,CAEvF"}

View File

@@ -0,0 +1,320 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.BDS_PYTHON_API_CONTRACT_V1 = void 0;
exports.listPythonApiMethodNames = listPythonApiMethodNames;
exports.getPythonApiMethodContract = getPythonApiMethodContract;
exports.getPythonApiDataStructureContracts = getPythonApiDataStructureContracts;
const requiredString = (name) => ({ name, type: 'string', required: true });
const optionalString = (name) => ({ name, type: 'string', required: false });
const optionalNumber = (name) => ({ name, type: 'number', required: false });
const requiredObject = (name) => ({ name, type: 'object', required: true });
const optionalObject = (name) => ({ name, type: 'object', required: false });
const requiredArray = (name) => ({ name, type: 'array', required: true });
const requiredAny = (name) => ({ name, type: 'any', required: true });
const requiredStringOrNull = (name) => ({ name, type: 'stringOrNull', required: true });
function method(methodName, description, params, returns) {
return {
method: methodName,
description,
params,
returns,
};
}
const METHODS_V1 = [
method('projects.create', 'Create a project.', [requiredObject('data')], 'ProjectData'),
method('projects.update', 'Update a project by id.', [requiredString('id'), requiredObject('data')], 'ProjectData | null'),
method('projects.delete', 'Delete a project by id.', [requiredString('id')], 'boolean'),
method('projects.deleteWithData', 'Delete a project and data by id.', [requiredString('id')], 'boolean'),
method('projects.get', 'Fetch one project by id.', [requiredString('id')], 'ProjectData | null'),
method('projects.getAll', 'Fetch all projects.', [], 'ProjectData[]'),
method('projects.getActive', 'Fetch active project.', [], 'ProjectData | null'),
method('projects.setActive', 'Set active project by id.', [requiredString('id')], 'ProjectData | null'),
method('posts.create', 'Create a post.', [requiredObject('data')], 'PostData'),
method('posts.update', 'Update a post by id.', [requiredString('id'), requiredObject('data')], 'PostData | null'),
method('posts.delete', 'Delete a post by id.', [requiredString('id')], 'boolean'),
method('posts.get', 'Fetch one post by id.', [requiredString('postId')], 'PostData | null'),
method('posts.getPreviewUrl', 'Get preview URL for post.', [requiredString('id'), optionalObject('options')], 'string | null'),
method('posts.getAll', 'Fetch posts with pagination.', [optionalObject('options')], 'PaginatedPostsResult'),
method('posts.getByStatus', 'Fetch posts by status.', [requiredString('status')], 'PostData[]'),
method('posts.publish', 'Publish a post by id.', [requiredString('id')], 'PostData | null'),
method('posts.discard', 'Discard draft changes for post.', [requiredString('id')], 'PostData | null'),
method('posts.hasPublishedVersion', 'Check if post has published version.', [requiredString('id')], 'boolean'),
method('posts.rebuildFromFiles', 'Rebuild posts database from files.', [], 'void'),
method('posts.reindexText', 'Reindex post search text.', [], 'void'),
method('posts.search', 'Search posts by free-text query.', [requiredString('query')], 'SearchResult[]'),
method('posts.filter', 'Filter posts by criteria.', [requiredObject('filter')], 'PostData[]'),
method('posts.getTags', 'Get all post tags.', [], 'string[]'),
method('posts.getCategories', 'Get all post categories.', [], 'string[]'),
method('posts.getByYearMonth', 'Get post counts grouped by year/month.', [], 'Array<{ year: number; month: number; count: number } >'),
method('posts.getDashboardStats', 'Get post dashboard stats.', [], 'DashboardStats'),
method('posts.getTagsWithCounts', 'Get post tags with counts.', [], 'TagCount[]'),
method('posts.getCategoriesWithCounts', 'Get post categories with counts.', [], 'CategoryCount[]'),
method('posts.getLinksTo', 'Get posts linked to given post.', [requiredString('id')], 'PostData[]'),
method('posts.getLinkedBy', 'Get posts linking to given post.', [requiredString('id')], 'PostData[]'),
method('posts.rebuildLinks', 'Rebuild post link graph.', [], 'void'),
method('posts.isSlugAvailable', 'Check if post slug is available.', [requiredString('slug'), optionalString('excludePostId')], 'boolean'),
method('posts.generateUniqueSlug', 'Generate unique slug from title.', [requiredString('title'), optionalString('excludePostId')], 'string'),
method('media.import', 'Import media file.', [requiredString('sourcePath'), optionalObject('metadata')], 'MediaData'),
method('media.update', 'Update media metadata by id.', [requiredString('id'), requiredObject('data')], 'MediaData | null'),
method('media.replaceFile', 'Replace media file by id.', [requiredString('id'), requiredString('newSourcePath')], 'MediaData | null'),
method('media.delete', 'Delete media by id.', [requiredString('id')], 'boolean'),
method('media.get', 'Fetch one media by id.', [requiredString('id')], 'MediaData | null'),
method('media.getUrl', 'Get media URL by id.', [requiredString('id')], 'string | null'),
method('media.getFilePath', 'Get media file path by id.', [requiredString('id')], 'string | null'),
method('media.getAll', 'Fetch all media.', [], 'MediaData[]'),
method('media.rebuildFromFiles', 'Rebuild media database from files.', [], 'void'),
method('media.reindexText', 'Reindex media search text.', [], 'void'),
method('media.getThumbnail', 'Get media thumbnail URL.', [requiredString('id'), optionalString('size')], 'string | null'),
method('media.regenerateThumbnails', 'Regenerate thumbnails for media.', [requiredString('id')], 'Record<string, string> | null'),
method('media.regenerateMissingThumbnails', 'Regenerate all missing thumbnails.', [], '{ processed: number; generated: number; failed: number }'),
method('media.filter', 'Filter media by criteria.', [requiredObject('filter')], 'MediaData[]'),
method('media.search', 'Search media by free-text query.', [requiredString('query')], 'MediaSearchResult[]'),
method('media.getByYearMonth', 'Get media counts grouped by year/month.', [], 'Array<{ year: number; month: number; count: number } >'),
method('media.getTags', 'Get all media tags.', [], 'string[]'),
method('media.getTagsWithCounts', 'Get media tags with counts.', [], 'TagCount[]'),
method('scripts.create', 'Create script.', [requiredObject('data')], 'ScriptData'),
method('scripts.update', 'Update script by id.', [requiredString('id'), requiredObject('data')], 'ScriptData | null'),
method('scripts.delete', 'Delete script by id.', [requiredString('id')], 'boolean'),
method('scripts.get', 'Fetch script by id.', [requiredString('id')], 'ScriptData | null'),
method('scripts.getAll', 'Fetch all scripts.', [], 'ScriptData[]'),
method('scripts.rebuildFromFiles', 'Rebuild scripts from files.', [], 'void'),
method('tasks.getAll', 'Fetch all tasks.', [], 'TaskProgress[]'),
method('tasks.getRunning', 'Fetch running tasks.', [], 'TaskProgress[]'),
method('tasks.cancel', 'Cancel task by id.', [requiredString('taskId')], 'boolean'),
method('tasks.clearCompleted', 'Clear completed tasks.', [], 'void'),
method('app.getDataPaths', 'Get app data paths.', [], '{ database: string; posts: string; media: string }'),
method('app.getSystemLanguage', 'Get system language.', [], 'string'),
method('app.getTitleBarMetrics', 'Get title bar metrics.', [], '{ macosLeftInset: number } | null'),
method('app.openFolder', 'Open folder in system file manager.', [requiredString('folderPath')], 'string'),
method('app.showItemInFolder', 'Reveal item in system file manager.', [requiredString('itemPath')], 'void'),
method('app.selectFolder', 'Show folder picker dialog.', [optionalString('title')], 'string | null'),
method('app.getDefaultProjectPath', 'Get default project path.', [requiredString('projectId')], 'string'),
method('app.readProjectMetadata', 'Read project metadata from path.', [requiredString('folderPath')], '{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null'),
method('app.getBlogmarkBookmarklet', 'Get blogmark bookmarklet script.', [], 'string'),
method('app.copyToClipboard', 'Copy text to clipboard.', [requiredString('text')], 'boolean'),
method('app.notifyRendererReady', 'Notify main process renderer is ready.', [], 'boolean'),
method('app.setPreviewPostTarget', 'Set preview post target.', [requiredStringOrNull('postId')], 'void'),
method('app.triggerMenuAction', 'Trigger menu action.', [requiredString('action')], 'void'),
method('meta.getTags', 'Get project tags.', [], 'string[]'),
method('meta.getCategories', 'Get project categories.', [], 'string[]'),
method('meta.addTag', 'Add project tag.', [requiredString('tag')], 'string[]'),
method('meta.removeTag', 'Remove project tag.', [requiredString('tag')], 'string[]'),
method('meta.addCategory', 'Add project category.', [requiredString('category')], 'string[]'),
method('meta.removeCategory', 'Remove project category.', [requiredString('category')], 'string[]'),
method('meta.syncOnStartup', 'Sync meta values on startup.', [], '{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }'),
method('meta.getProjectMetadata', 'Read active project metadata.', [], 'ProjectMetadata | null'),
method('meta.setProjectMetadata', 'Set project metadata.', [requiredObject('metadata')], 'ProjectMetadata | null'),
method('meta.updateProjectMetadata', 'Update project metadata.', [requiredObject('updates')], 'ProjectMetadata | null'),
method('tags.getAll', 'Fetch all tags.', [], 'TagData[]'),
method('tags.getWithCounts', 'Fetch tags with counts.', [], 'TagWithCount[]'),
method('tags.get', 'Fetch tag by id.', [requiredString('id')], 'TagData | null'),
method('tags.getByName', 'Fetch tag by name.', [requiredString('name')], 'TagData | null'),
method('tags.create', 'Create tag.', [requiredObject('data')], 'TagData'),
method('tags.update', 'Update tag by id.', [requiredString('id'), requiredObject('data')], 'TagData | null'),
method('tags.delete', 'Delete tag by id.', [requiredString('id')], 'DeleteTagResult'),
method('tags.merge', 'Merge tags into target tag.', [requiredArray('sourceTagIds'), requiredString('targetTagId')], 'MergeTagsResult'),
method('tags.rename', 'Rename tag by id.', [requiredString('id'), requiredString('newName')], 'RenameTagResult'),
method('tags.getPostsWithTag', 'Get posts using a tag.', [requiredString('tagId')], 'string[]'),
method('tags.syncFromPosts', 'Sync tag index from posts.', [], 'SyncTagsResult'),
method('chat.checkReady', 'Check chat backend readiness.', [], 'ChatReadyStatus'),
method('chat.validateApiKey', 'Validate chat API key and list available models.', [requiredString('apiKey')], '{ isValid: boolean; models: ChatModel[] }'),
method('chat.setApiKey', 'Store chat API key.', [requiredString('apiKey')], '{ success: boolean; error?: string }'),
method('chat.getApiKey', 'Get stored chat API key status.', [], 'ChatApiKeyStatus'),
method('chat.getAvailableModels', 'Get available chat models and selected default.', [], '{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }'),
method('chat.setDefaultModel', 'Set default chat model.', [requiredString('modelId')], '{ success: boolean; error?: string }'),
method('chat.getSystemPrompt', 'Get configured system prompt.', [], '{ success: boolean; prompt?: string; error?: string }'),
method('chat.setSystemPrompt', 'Set system prompt.', [requiredString('prompt')], '{ success: boolean; error?: string }'),
method('chat.getConversations', 'Fetch all chat conversations.', [], 'ChatConversation[]'),
method('chat.createConversation', 'Create a chat conversation.', [optionalString('title'), optionalString('model')], 'ChatConversation'),
method('chat.getConversation', 'Fetch one chat conversation by id.', [requiredString('id')], 'ChatConversation | null'),
method('chat.updateConversation', 'Update chat conversation metadata.', [requiredString('id'), requiredObject('updates')], 'ChatConversation | null'),
method('chat.deleteConversation', 'Delete chat conversation by id.', [requiredString('id')], 'boolean'),
method('chat.sendMessage', 'Send message to chat conversation.', [requiredString('conversationId'), requiredString('message'), optionalObject('metadata')], '{ success: boolean; message?: string; error?: string }'),
method('chat.abortMessage', 'Abort active streaming chat response.', [requiredString('conversationId')], 'void'),
method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'),
method('chat.clearMessages', 'Clear messages for conversation.', [requiredString('conversationId')], 'void'),
method('chat.setConversationModel', 'Set model for a conversation.', [requiredString('conversationId'), requiredString('modelId')], 'void'),
method('chat.analyzeTaxonomy', 'Analyze categories and tags using AI.', [requiredArray('categories'), requiredArray('tags'), requiredString('modelId')], '{ success: boolean; categoryMappings?: Record<string, string>; tagMappings?: Record<string, string>; error?: string }'),
method('chat.analyzeMediaImage', 'Analyze media image and propose metadata.', [requiredString('mediaId'), optionalString('language')], '{ success: boolean; title?: string; alt?: string; caption?: string; error?: string }'),
method('sync.configure', 'Configure sync.', [requiredObject('config')], 'void'),
method('sync.start', 'Start sync operation.', [optionalString('direction')], 'SyncResult'),
method('sync.getStatus', 'Get sync status.', [], "'idle' | 'syncing' | 'error'"),
method('sync.isConfigured', 'Check if sync is configured.', [], 'boolean'),
method('sync.getPendingCount', 'Get pending sync item count.', [], '{ posts: number; media: number }'),
method('sync.getLog', 'Get sync log.', [optionalNumber('limit')], 'unknown[]'),
method('sync.stopAutoSync', 'Stop automatic sync.', [], 'void'),
];
const DATA_STRUCTURES_V1 = [
{
name: 'ProjectData',
description: 'Project metadata stored in the app database.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique project identifier.' },
{ name: 'name', type: 'string', required: true, description: 'Human-readable project name.' },
{ name: 'slug', type: 'string', required: true, description: 'URL-friendly project slug.' },
{ name: 'description', type: 'string', required: false, description: 'Optional project description.' },
{ name: 'dataPath', type: 'string', required: false, description: 'Filesystem path for project data.' },
{ name: 'isActive', type: 'boolean', required: true, description: 'Whether this project is currently active.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
],
},
{
name: 'PostData',
description: 'Canonical post object used across editor and generation flows.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique post identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
{ name: 'title', type: 'string', required: true, description: 'Post title.' },
{ name: 'slug', type: 'string', required: true, description: 'URL slug used for generated routes.' },
{ name: 'excerpt', type: 'string', required: false, description: 'Optional short summary.' },
{ name: 'content', type: 'string', required: true, description: 'Markdown body content.' },
{ name: 'status', type: "'draft' | 'published' | 'archived'", required: true, description: 'Publication lifecycle state.' },
{ name: 'author', type: 'string', required: false, description: 'Optional author name.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
{ name: 'publishedAt', type: 'string', required: false, description: 'Publication timestamp for published posts.' },
{ name: 'tags', type: 'string[]', required: true, description: 'List of tag names.' },
{ name: 'categories', type: 'string[]', required: true, description: 'List of category names.' },
],
},
{
name: 'MediaData',
description: 'Canonical media object representing imported files and metadata.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique media identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
{ name: 'filename', type: 'string', required: true, description: 'Stored filename in project media folder.' },
{ name: 'originalName', type: 'string', required: true, description: 'Original imported filename.' },
{ name: 'mimeType', type: 'string', required: true, description: 'Detected MIME type.' },
{ name: 'size', type: 'number', required: true, description: 'File size in bytes.' },
{ name: 'width', type: 'number', required: false, description: 'Image width in pixels when available.' },
{ name: 'height', type: 'number', required: false, description: 'Image height in pixels when available.' },
{ name: 'title', type: 'string', required: false, description: 'Optional display title.' },
{ name: 'alt', type: 'string', required: false, description: 'Optional alternative text.' },
{ name: 'caption', type: 'string', required: false, description: 'Optional caption text.' },
{ name: 'author', type: 'string', required: false, description: 'Optional author credit.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
{ name: 'tags', type: 'string[]', required: true, description: 'List of media tags.' },
],
},
{
name: 'ScriptData',
description: 'Script definition for Python macros, utilities, and transforms.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique script identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
{ name: 'slug', type: 'string', required: true, description: 'Stable script slug.' },
{ name: 'title', type: 'string', required: true, description: 'Human-readable script title.' },
{ name: 'kind', type: "'macro' | 'utility' | 'transform'", required: true, description: 'Script category.' },
{ name: 'entrypoint', type: 'string', required: true, description: 'Python entrypoint function name.' },
{ name: 'enabled', type: 'boolean', required: true, description: 'Whether script is enabled.' },
{ name: 'version', type: 'number', required: true, description: 'Incrementing script version.' },
{ name: 'filePath', type: 'string', required: true, description: 'Filesystem path to script file.' },
{ name: 'content', type: 'string', required: true, description: 'Script source code.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
],
},
{
name: 'TaskProgress',
description: 'Task queue status object for long-running operations.',
fields: [
{ name: 'taskId', type: 'string', required: true, description: 'Unique task identifier.' },
{ name: 'name', type: 'string', required: true, description: 'Task display name.' },
{ name: 'status', type: "'pending' | 'running' | 'completed' | 'failed' | 'cancelled'", required: true, description: 'Current task status.' },
{ name: 'progress', type: 'number', required: true, description: 'Progress percentage from 0-100.' },
{ name: 'message', type: 'string', required: true, description: 'Current progress message.' },
{ name: 'startTime', type: 'string', required: true, description: 'Task start time (ISO string).' },
{ name: 'endTime', type: 'string', required: false, description: 'Task completion time (ISO string).' },
{ name: 'error', type: 'string', required: false, description: 'Error message when failed.' },
{ name: 'groupId', type: 'string', required: false, description: 'Optional grouping id.' },
{ name: 'groupName', type: 'string', required: false, description: 'Optional grouping label.' },
],
},
{
name: 'ProjectMetadata',
description: 'Extended project metadata from project settings.',
fields: [
{ name: 'name', type: 'string', required: true, description: 'Project display name.' },
{ name: 'description', type: 'string', required: false, description: 'Optional project description.' },
{ name: 'dataPath', type: 'string', required: false, description: 'Optional custom data path.' },
{ name: 'publicUrl', type: 'string', required: false, description: 'Optional public site URL.' },
{ name: 'mainLanguage', type: 'string', required: false, description: 'Main render language code.' },
{ name: 'defaultAuthor', type: 'string', required: false, description: 'Default author for new posts.' },
{ name: 'maxPostsPerPage', type: 'number', required: false, description: 'Pagination size for generated lists.' },
{ name: 'blogmarkCategory', type: 'string', required: false, description: 'Default category for blogmark imports.' },
{ name: 'pythonRuntimeMode', type: "'webworker' | 'main-thread'", required: false, description: 'Python runtime execution mode.' },
{ name: 'picoTheme', type: 'string', required: false, description: 'Preferred Pico theme token.' },
{ name: 'categoryMetadata', type: 'object', required: false, description: 'Category metadata keyed by category slug.' },
{ name: 'categorySettings', type: 'object', required: false, description: 'Category render settings keyed by category slug.' },
],
},
{
name: 'ChatConversation',
description: 'Chat conversation container.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique conversation identifier.' },
{ name: 'title', type: 'string', required: true, description: 'Conversation title.' },
{ name: 'model', type: 'string', required: false, description: 'Optional model id used by this conversation.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
],
},
{
name: 'ChatMessage',
description: 'Single message entry in a conversation history.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique message identifier.' },
{ name: 'conversationId', type: 'string', required: true, description: 'Owning conversation id.' },
{ name: 'role', type: "'user' | 'assistant' | 'system' | 'tool'", required: true, description: 'Message author role.' },
{ name: 'content', type: 'string', required: true, description: 'Message text content.' },
{ name: 'toolCallId', type: 'string', required: false, description: 'Tool call id when associated with tool output.' },
{ name: 'toolCalls', type: 'string', required: false, description: 'Serialized tool call payload when present.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
],
},
{
name: 'ChatModel',
description: 'Available chat model descriptor.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Model identifier.' },
{ name: 'name', type: 'string', required: true, description: 'Human-readable model name.' },
{ name: 'provider', type: 'string', required: false, description: 'Model provider name.' },
],
},
{
name: 'ChatReadyStatus',
description: 'Chat backend readiness status.',
fields: [
{ name: 'ready', type: 'boolean', required: true, description: 'Whether chat backend is ready.' },
{ name: 'error', type: 'string', required: false, description: 'Error description when not ready.' },
{ name: 'backend', type: 'string', required: false, description: 'Selected backend identifier.' },
],
},
{
name: 'ChatApiKeyStatus',
description: 'Stored API key state for chat provider.',
fields: [
{ name: 'hasKey', type: 'boolean', required: true, description: 'Whether a key is configured.' },
{ name: 'maskedKey', type: 'string', required: true, description: 'Masked key representation for UI display.' },
],
},
];
exports.BDS_PYTHON_API_CONTRACT_V1 = {
version: '1.6.0',
generatedAt: '2026-02-25T00:00:00.000Z',
methods: METHODS_V1,
dataStructures: DATA_STRUCTURES_V1,
};
function listPythonApiMethodNames() {
return exports.BDS_PYTHON_API_CONTRACT_V1.methods.map((entry) => entry.method);
}
function getPythonApiMethodContract(methodName) {
return exports.BDS_PYTHON_API_CONTRACT_V1.methods.find((entry) => entry.method === methodName);
}
function getPythonApiDataStructureContracts() {
return exports.BDS_PYTHON_API_CONTRACT_V1.dataStructures;
}
//# sourceMappingURL=pythonApiContractV1.js.map

File diff suppressed because one or more lines are too long

View File

@@ -1,381 +1,16 @@
import type { ElectronAPI } from '../../main/shared/electronApi';
// Re-export from shared location (canonical source is src/main/shared/)
export {
BDS_PYTHON_API_CONTRACT_V1,
listPythonApiMethodNames,
getPythonApiMethodContract,
getPythonApiDataStructureContracts,
} from '../../main/shared/pythonApiContractV1';
type PythonPromiseMethodPath = {
[Group in keyof ElectronAPI]: ElectronAPI[Group] extends Record<string, (...args: never[]) => unknown>
? {
[Method in keyof ElectronAPI[Group]]: ElectronAPI[Group][Method] extends (...args: never[]) => Promise<unknown>
? `${Extract<Group, string>}.${Extract<Method, string>}`
: never;
}[keyof ElectronAPI[Group]]
: never;
}[keyof ElectronAPI];
export type PythonApiParamType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'any' | 'stringOrNull';
export interface PythonApiParamContractV1 {
name: string;
type: PythonApiParamType;
required: boolean;
}
export interface PythonApiMethodContractV1 {
method: PythonPromiseMethodPath;
description: string;
params: PythonApiParamContractV1[];
returns: string;
}
export interface PythonApiDataStructureFieldContractV1 {
name: string;
type: string;
required: boolean;
description: string;
}
export interface PythonApiDataStructureContractV1 {
name: string;
description: string;
fields: PythonApiDataStructureFieldContractV1[];
}
export interface PythonApiContractV1 {
version: string;
generatedAt: string;
methods: PythonApiMethodContractV1[];
dataStructures: PythonApiDataStructureContractV1[];
}
const requiredString = (name: string): PythonApiParamContractV1 => ({ name, type: 'string', required: true });
const optionalString = (name: string): PythonApiParamContractV1 => ({ name, type: 'string', required: false });
const optionalNumber = (name: string): PythonApiParamContractV1 => ({ name, type: 'number', required: false });
const requiredObject = (name: string): PythonApiParamContractV1 => ({ name, type: 'object', required: true });
const optionalObject = (name: string): PythonApiParamContractV1 => ({ name, type: 'object', required: false });
const requiredArray = (name: string): PythonApiParamContractV1 => ({ name, type: 'array', required: true });
const requiredAny = (name: string): PythonApiParamContractV1 => ({ name, type: 'any', required: true });
const requiredStringOrNull = (name: string): PythonApiParamContractV1 => ({ name, type: 'stringOrNull', required: true });
function method(
methodName: PythonPromiseMethodPath,
description: string,
params: PythonApiParamContractV1[],
returns: string
): PythonApiMethodContractV1 {
return {
method: methodName,
description,
params,
returns,
};
}
const METHODS_V1: PythonApiMethodContractV1[] = [
method('projects.create', 'Create a project.', [requiredObject('data')], 'ProjectData'),
method('projects.update', 'Update a project by id.', [requiredString('id'), requiredObject('data')], 'ProjectData | null'),
method('projects.delete', 'Delete a project by id.', [requiredString('id')], 'boolean'),
method('projects.deleteWithData', 'Delete a project and data by id.', [requiredString('id')], 'boolean'),
method('projects.get', 'Fetch one project by id.', [requiredString('id')], 'ProjectData | null'),
method('projects.getAll', 'Fetch all projects.', [], 'ProjectData[]'),
method('projects.getActive', 'Fetch active project.', [], 'ProjectData | null'),
method('projects.setActive', 'Set active project by id.', [requiredString('id')], 'ProjectData | null'),
method('posts.create', 'Create a post.', [requiredObject('data')], 'PostData'),
method('posts.update', 'Update a post by id.', [requiredString('id'), requiredObject('data')], 'PostData | null'),
method('posts.delete', 'Delete a post by id.', [requiredString('id')], 'boolean'),
method('posts.get', 'Fetch one post by id.', [requiredString('postId')], 'PostData | null'),
method('posts.getPreviewUrl', 'Get preview URL for post.', [requiredString('id'), optionalObject('options')], 'string | null'),
method('posts.getAll', 'Fetch posts with pagination.', [optionalObject('options')], 'PaginatedPostsResult'),
method('posts.getByStatus', 'Fetch posts by status.', [requiredString('status')], 'PostData[]'),
method('posts.publish', 'Publish a post by id.', [requiredString('id')], 'PostData | null'),
method('posts.discard', 'Discard draft changes for post.', [requiredString('id')], 'PostData | null'),
method('posts.hasPublishedVersion', 'Check if post has published version.', [requiredString('id')], 'boolean'),
method('posts.rebuildFromFiles', 'Rebuild posts database from files.', [], 'void'),
method('posts.reindexText', 'Reindex post search text.', [], 'void'),
method('posts.search', 'Search posts by free-text query.', [requiredString('query')], 'SearchResult[]'),
method('posts.filter', 'Filter posts by criteria.', [requiredObject('filter')], 'PostData[]'),
method('posts.getTags', 'Get all post tags.', [], 'string[]'),
method('posts.getCategories', 'Get all post categories.', [], 'string[]'),
method('posts.getByYearMonth', 'Get post counts grouped by year/month.', [], 'Array<{ year: number; month: number; count: number } >'),
method('posts.getDashboardStats', 'Get post dashboard stats.', [], 'DashboardStats'),
method('posts.getTagsWithCounts', 'Get post tags with counts.', [], 'TagCount[]'),
method('posts.getCategoriesWithCounts', 'Get post categories with counts.', [], 'CategoryCount[]'),
method('posts.getLinksTo', 'Get posts linked to given post.', [requiredString('id')], 'PostData[]'),
method('posts.getLinkedBy', 'Get posts linking to given post.', [requiredString('id')], 'PostData[]'),
method('posts.rebuildLinks', 'Rebuild post link graph.', [], 'void'),
method('posts.isSlugAvailable', 'Check if post slug is available.', [requiredString('slug'), optionalString('excludePostId')], 'boolean'),
method('posts.generateUniqueSlug', 'Generate unique slug from title.', [requiredString('title'), optionalString('excludePostId')], 'string'),
method('media.import', 'Import media file.', [requiredString('sourcePath'), optionalObject('metadata')], 'MediaData'),
method('media.update', 'Update media metadata by id.', [requiredString('id'), requiredObject('data')], 'MediaData | null'),
method('media.replaceFile', 'Replace media file by id.', [requiredString('id'), requiredString('newSourcePath')], 'MediaData | null'),
method('media.delete', 'Delete media by id.', [requiredString('id')], 'boolean'),
method('media.get', 'Fetch one media by id.', [requiredString('id')], 'MediaData | null'),
method('media.getUrl', 'Get media URL by id.', [requiredString('id')], 'string | null'),
method('media.getFilePath', 'Get media file path by id.', [requiredString('id')], 'string | null'),
method('media.getAll', 'Fetch all media.', [], 'MediaData[]'),
method('media.rebuildFromFiles', 'Rebuild media database from files.', [], 'void'),
method('media.reindexText', 'Reindex media search text.', [], 'void'),
method('media.getThumbnail', 'Get media thumbnail URL.', [requiredString('id'), optionalString('size')], 'string | null'),
method('media.regenerateThumbnails', 'Regenerate thumbnails for media.', [requiredString('id')], 'Record<string, string> | null'),
method('media.regenerateMissingThumbnails', 'Regenerate all missing thumbnails.', [], '{ processed: number; generated: number; failed: number }'),
method('media.filter', 'Filter media by criteria.', [requiredObject('filter')], 'MediaData[]'),
method('media.search', 'Search media by free-text query.', [requiredString('query')], 'MediaSearchResult[]'),
method('media.getByYearMonth', 'Get media counts grouped by year/month.', [], 'Array<{ year: number; month: number; count: number } >'),
method('media.getTags', 'Get all media tags.', [], 'string[]'),
method('media.getTagsWithCounts', 'Get media tags with counts.', [], 'TagCount[]'),
method('scripts.create', 'Create script.', [requiredObject('data')], 'ScriptData'),
method('scripts.update', 'Update script by id.', [requiredString('id'), requiredObject('data')], 'ScriptData | null'),
method('scripts.delete', 'Delete script by id.', [requiredString('id')], 'boolean'),
method('scripts.get', 'Fetch script by id.', [requiredString('id')], 'ScriptData | null'),
method('scripts.getAll', 'Fetch all scripts.', [], 'ScriptData[]'),
method('scripts.rebuildFromFiles', 'Rebuild scripts from files.', [], 'void'),
method('tasks.getAll', 'Fetch all tasks.', [], 'TaskProgress[]'),
method('tasks.getRunning', 'Fetch running tasks.', [], 'TaskProgress[]'),
method('tasks.cancel', 'Cancel task by id.', [requiredString('taskId')], 'boolean'),
method('tasks.clearCompleted', 'Clear completed tasks.', [], 'void'),
method('app.getDataPaths', 'Get app data paths.', [], '{ database: string; posts: string; media: string }'),
method('app.getSystemLanguage', 'Get system language.', [], 'string'),
method('app.getTitleBarMetrics', 'Get title bar metrics.', [], '{ macosLeftInset: number } | null'),
method('app.openFolder', 'Open folder in system file manager.', [requiredString('folderPath')], 'string'),
method('app.showItemInFolder', 'Reveal item in system file manager.', [requiredString('itemPath')], 'void'),
method('app.selectFolder', 'Show folder picker dialog.', [optionalString('title')], 'string | null'),
method('app.getDefaultProjectPath', 'Get default project path.', [requiredString('projectId')], 'string'),
method('app.readProjectMetadata', 'Read project metadata from path.', [requiredString('folderPath')], '{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null'),
method('app.getBlogmarkBookmarklet', 'Get blogmark bookmarklet script.', [], 'string'),
method('app.copyToClipboard', 'Copy text to clipboard.', [requiredString('text')], 'boolean'),
method('app.notifyRendererReady', 'Notify main process renderer is ready.', [], 'boolean'),
method('app.setPreviewPostTarget', 'Set preview post target.', [requiredStringOrNull('postId')], 'void'),
method('app.triggerMenuAction', 'Trigger menu action.', [requiredString('action')], 'void'),
method('meta.getTags', 'Get project tags.', [], 'string[]'),
method('meta.getCategories', 'Get project categories.', [], 'string[]'),
method('meta.addTag', 'Add project tag.', [requiredString('tag')], 'string[]'),
method('meta.removeTag', 'Remove project tag.', [requiredString('tag')], 'string[]'),
method('meta.addCategory', 'Add project category.', [requiredString('category')], 'string[]'),
method('meta.removeCategory', 'Remove project category.', [requiredString('category')], 'string[]'),
method('meta.syncOnStartup', 'Sync meta values on startup.', [], '{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }'),
method('meta.getProjectMetadata', 'Read active project metadata.', [], 'ProjectMetadata | null'),
method('meta.setProjectMetadata', 'Set project metadata.', [requiredObject('metadata')], 'ProjectMetadata | null'),
method('meta.updateProjectMetadata', 'Update project metadata.', [requiredObject('updates')], 'ProjectMetadata | null'),
method('tags.getAll', 'Fetch all tags.', [], 'TagData[]'),
method('tags.getWithCounts', 'Fetch tags with counts.', [], 'TagWithCount[]'),
method('tags.get', 'Fetch tag by id.', [requiredString('id')], 'TagData | null'),
method('tags.getByName', 'Fetch tag by name.', [requiredString('name')], 'TagData | null'),
method('tags.create', 'Create tag.', [requiredObject('data')], 'TagData'),
method('tags.update', 'Update tag by id.', [requiredString('id'), requiredObject('data')], 'TagData | null'),
method('tags.delete', 'Delete tag by id.', [requiredString('id')], 'DeleteTagResult'),
method('tags.merge', 'Merge tags into target tag.', [requiredArray('sourceTagIds'), requiredString('targetTagId')], 'MergeTagsResult'),
method('tags.rename', 'Rename tag by id.', [requiredString('id'), requiredString('newName')], 'RenameTagResult'),
method('tags.getPostsWithTag', 'Get posts using a tag.', [requiredString('tagId')], 'string[]'),
method('tags.syncFromPosts', 'Sync tag index from posts.', [], 'SyncTagsResult'),
method('chat.checkReady', 'Check chat backend readiness.', [], 'ChatReadyStatus'),
method('chat.validateApiKey', 'Validate chat API key and list available models.', [requiredString('apiKey')], '{ isValid: boolean; models: ChatModel[] }'),
method('chat.setApiKey', 'Store chat API key.', [requiredString('apiKey')], '{ success: boolean; error?: string }'),
method('chat.getApiKey', 'Get stored chat API key status.', [], 'ChatApiKeyStatus'),
method('chat.getAvailableModels', 'Get available chat models and selected default.', [], '{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }'),
method('chat.setDefaultModel', 'Set default chat model.', [requiredString('modelId')], '{ success: boolean; error?: string }'),
method('chat.getSystemPrompt', 'Get configured system prompt.', [], '{ success: boolean; prompt?: string; error?: string }'),
method('chat.setSystemPrompt', 'Set system prompt.', [requiredString('prompt')], '{ success: boolean; error?: string }'),
method('chat.getConversations', 'Fetch all chat conversations.', [], 'ChatConversation[]'),
method('chat.createConversation', 'Create a chat conversation.', [optionalString('title'), optionalString('model')], 'ChatConversation'),
method('chat.getConversation', 'Fetch one chat conversation by id.', [requiredString('id')], 'ChatConversation | null'),
method('chat.updateConversation', 'Update chat conversation metadata.', [requiredString('id'), requiredObject('updates')], 'ChatConversation | null'),
method('chat.deleteConversation', 'Delete chat conversation by id.', [requiredString('id')], 'boolean'),
method('chat.sendMessage', 'Send message to chat conversation.', [requiredString('conversationId'), requiredString('message'), optionalObject('metadata')], '{ success: boolean; message?: string; error?: string }'),
method('chat.abortMessage', 'Abort active streaming chat response.', [requiredString('conversationId')], 'void'),
method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'),
method('chat.clearMessages', 'Clear messages for conversation.', [requiredString('conversationId')], 'void'),
method('chat.setConversationModel', 'Set model for a conversation.', [requiredString('conversationId'), requiredString('modelId')], 'void'),
method('chat.analyzeTaxonomy', 'Analyze categories and tags using AI.', [requiredArray('categories'), requiredArray('tags'), requiredString('modelId')], '{ success: boolean; categoryMappings?: Record<string, string>; tagMappings?: Record<string, string>; error?: string }'),
method('chat.analyzeMediaImage', 'Analyze media image and propose metadata.', [requiredString('mediaId'), optionalString('language')], '{ success: boolean; title?: string; alt?: string; caption?: string; error?: string }'),
method('sync.configure', 'Configure sync.', [requiredObject('config')], 'void'),
method('sync.start', 'Start sync operation.', [optionalString('direction')], 'SyncResult'),
method('sync.getStatus', 'Get sync status.', [], "'idle' | 'syncing' | 'error'"),
method('sync.isConfigured', 'Check if sync is configured.', [], 'boolean'),
method('sync.getPendingCount', 'Get pending sync item count.', [], '{ posts: number; media: number }'),
method('sync.getLog', 'Get sync log.', [optionalNumber('limit')], 'unknown[]'),
method('sync.stopAutoSync', 'Stop automatic sync.', [], 'void'),
];
const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{
name: 'ProjectData',
description: 'Project metadata stored in the app database.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique project identifier.' },
{ name: 'name', type: 'string', required: true, description: 'Human-readable project name.' },
{ name: 'slug', type: 'string', required: true, description: 'URL-friendly project slug.' },
{ name: 'description', type: 'string', required: false, description: 'Optional project description.' },
{ name: 'dataPath', type: 'string', required: false, description: 'Filesystem path for project data.' },
{ name: 'isActive', type: 'boolean', required: true, description: 'Whether this project is currently active.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
],
},
{
name: 'PostData',
description: 'Canonical post object used across editor and generation flows.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique post identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
{ name: 'title', type: 'string', required: true, description: 'Post title.' },
{ name: 'slug', type: 'string', required: true, description: 'URL slug used for generated routes.' },
{ name: 'excerpt', type: 'string', required: false, description: 'Optional short summary.' },
{ name: 'content', type: 'string', required: true, description: 'Markdown body content.' },
{ name: 'status', type: "'draft' | 'published' | 'archived'", required: true, description: 'Publication lifecycle state.' },
{ name: 'author', type: 'string', required: false, description: 'Optional author name.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
{ name: 'publishedAt', type: 'string', required: false, description: 'Publication timestamp for published posts.' },
{ name: 'tags', type: 'string[]', required: true, description: 'List of tag names.' },
{ name: 'categories', type: 'string[]', required: true, description: 'List of category names.' },
],
},
{
name: 'MediaData',
description: 'Canonical media object representing imported files and metadata.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique media identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
{ name: 'filename', type: 'string', required: true, description: 'Stored filename in project media folder.' },
{ name: 'originalName', type: 'string', required: true, description: 'Original imported filename.' },
{ name: 'mimeType', type: 'string', required: true, description: 'Detected MIME type.' },
{ name: 'size', type: 'number', required: true, description: 'File size in bytes.' },
{ name: 'width', type: 'number', required: false, description: 'Image width in pixels when available.' },
{ name: 'height', type: 'number', required: false, description: 'Image height in pixels when available.' },
{ name: 'title', type: 'string', required: false, description: 'Optional display title.' },
{ name: 'alt', type: 'string', required: false, description: 'Optional alternative text.' },
{ name: 'caption', type: 'string', required: false, description: 'Optional caption text.' },
{ name: 'author', type: 'string', required: false, description: 'Optional author credit.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
{ name: 'tags', type: 'string[]', required: true, description: 'List of media tags.' },
],
},
{
name: 'ScriptData',
description: 'Script definition for Python macros, utilities, and transforms.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique script identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
{ name: 'slug', type: 'string', required: true, description: 'Stable script slug.' },
{ name: 'title', type: 'string', required: true, description: 'Human-readable script title.' },
{ name: 'kind', type: "'macro' | 'utility' | 'transform'", required: true, description: 'Script category.' },
{ name: 'entrypoint', type: 'string', required: true, description: 'Python entrypoint function name.' },
{ name: 'enabled', type: 'boolean', required: true, description: 'Whether script is enabled.' },
{ name: 'version', type: 'number', required: true, description: 'Incrementing script version.' },
{ name: 'filePath', type: 'string', required: true, description: 'Filesystem path to script file.' },
{ name: 'content', type: 'string', required: true, description: 'Script source code.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
],
},
{
name: 'TaskProgress',
description: 'Task queue status object for long-running operations.',
fields: [
{ name: 'taskId', type: 'string', required: true, description: 'Unique task identifier.' },
{ name: 'name', type: 'string', required: true, description: 'Task display name.' },
{ name: 'status', type: "'pending' | 'running' | 'completed' | 'failed' | 'cancelled'", required: true, description: 'Current task status.' },
{ name: 'progress', type: 'number', required: true, description: 'Progress percentage from 0-100.' },
{ name: 'message', type: 'string', required: true, description: 'Current progress message.' },
{ name: 'startTime', type: 'string', required: true, description: 'Task start time (ISO string).' },
{ name: 'endTime', type: 'string', required: false, description: 'Task completion time (ISO string).' },
{ name: 'error', type: 'string', required: false, description: 'Error message when failed.' },
{ name: 'groupId', type: 'string', required: false, description: 'Optional grouping id.' },
{ name: 'groupName', type: 'string', required: false, description: 'Optional grouping label.' },
],
},
{
name: 'ProjectMetadata',
description: 'Extended project metadata from project settings.',
fields: [
{ name: 'name', type: 'string', required: true, description: 'Project display name.' },
{ name: 'description', type: 'string', required: false, description: 'Optional project description.' },
{ name: 'dataPath', type: 'string', required: false, description: 'Optional custom data path.' },
{ name: 'publicUrl', type: 'string', required: false, description: 'Optional public site URL.' },
{ name: 'mainLanguage', type: 'string', required: false, description: 'Main render language code.' },
{ name: 'defaultAuthor', type: 'string', required: false, description: 'Default author for new posts.' },
{ name: 'maxPostsPerPage', type: 'number', required: false, description: 'Pagination size for generated lists.' },
{ name: 'blogmarkCategory', type: 'string', required: false, description: 'Default category for blogmark imports.' },
{ name: 'pythonRuntimeMode', type: "'webworker' | 'main-thread'", required: false, description: 'Python runtime execution mode.' },
{ name: 'picoTheme', type: 'string', required: false, description: 'Preferred Pico theme token.' },
{ name: 'categoryMetadata', type: 'object', required: false, description: 'Category metadata keyed by category slug.' },
{ name: 'categorySettings', type: 'object', required: false, description: 'Category render settings keyed by category slug.' },
],
},
{
name: 'ChatConversation',
description: 'Chat conversation container.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique conversation identifier.' },
{ name: 'title', type: 'string', required: true, description: 'Conversation title.' },
{ name: 'model', type: 'string', required: false, description: 'Optional model id used by this conversation.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
],
},
{
name: 'ChatMessage',
description: 'Single message entry in a conversation history.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique message identifier.' },
{ name: 'conversationId', type: 'string', required: true, description: 'Owning conversation id.' },
{ name: 'role', type: "'user' | 'assistant' | 'system' | 'tool'", required: true, description: 'Message author role.' },
{ name: 'content', type: 'string', required: true, description: 'Message text content.' },
{ name: 'toolCallId', type: 'string', required: false, description: 'Tool call id when associated with tool output.' },
{ name: 'toolCalls', type: 'string', required: false, description: 'Serialized tool call payload when present.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
],
},
{
name: 'ChatModel',
description: 'Available chat model descriptor.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Model identifier.' },
{ name: 'name', type: 'string', required: true, description: 'Human-readable model name.' },
{ name: 'provider', type: 'string', required: false, description: 'Model provider name.' },
],
},
{
name: 'ChatReadyStatus',
description: 'Chat backend readiness status.',
fields: [
{ name: 'ready', type: 'boolean', required: true, description: 'Whether chat backend is ready.' },
{ name: 'error', type: 'string', required: false, description: 'Error description when not ready.' },
{ name: 'backend', type: 'string', required: false, description: 'Selected backend identifier.' },
],
},
{
name: 'ChatApiKeyStatus',
description: 'Stored API key state for chat provider.',
fields: [
{ name: 'hasKey', type: 'boolean', required: true, description: 'Whether a key is configured.' },
{ name: 'maskedKey', type: 'string', required: true, description: 'Masked key representation for UI display.' },
],
},
];
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
version: '1.6.0',
generatedAt: '2026-02-25T00:00:00.000Z',
methods: METHODS_V1,
dataStructures: DATA_STRUCTURES_V1,
};
export function listPythonApiMethodNames(): string[] {
return BDS_PYTHON_API_CONTRACT_V1.methods.map((entry) => entry.method);
}
export function getPythonApiMethodContract(methodName: string): PythonApiMethodContractV1 | undefined {
return BDS_PYTHON_API_CONTRACT_V1.methods.find((entry) => entry.method === methodName);
}
export function getPythonApiDataStructureContracts(): PythonApiDataStructureContractV1[] {
return BDS_PYTHON_API_CONTRACT_V1.dataStructures;
}
export type {
PythonApiParamType,
PythonApiParamContractV1,
PythonApiMethodContractV1,
PythonApiDataStructureFieldContractV1,
PythonApiDataStructureContractV1,
PythonApiContractV1,
} from '../../main/shared/pythonApiContractV1';

View File

@@ -1,4 +1,4 @@
import { getPythonApiMethodContract, type PythonApiParamContractV1 } from './pythonApiContractV1';
import { getPythonApiMethodContract, type PythonApiParamContractV1 } from '../../main/shared/pythonApiContractV1';
function asRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {

View File

@@ -3,7 +3,7 @@ import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol
import { parseMacroContextV1, parseMacroResultV1 } from './abiV1';
import { resolvePyodideIndexURL } from './pyodideAssetUrl';
import { runPythonSyntaxCheck } from './pythonSyntaxCheck';
import { generatePythonApiModuleV1 } from './generatePythonApiModuleV1';
import { generatePythonApiModuleV1 } from '../../main/shared/generatePythonApiModuleV1';
let runtime: PyodideInterface | null = null;
let activeRequestId: string | null = null;
@@ -181,11 +181,21 @@ async function runMacroV1(request: PythonWorkerRequest): Promise<void> {
const validatedContext = parseMacroContextV1(request.context);
runtime.globals.set('__bds_context_v1', validatedContext);
const macroEntrypoint = request.entrypoint || 'render';
runtime.globals.set('__bds_macro_entrypoint', macroEntrypoint);
runtime.globals.set('__bds_macro_post_data_json', request.postDataJson ?? '');
await runPythonCode(request.code, request.cacheKey);
const rawJsonResult = await runtime.runPythonAsync(`
import json
json.dumps(render(__bds_context_v1))
import json as _json
_macro_ep = __bds_macro_entrypoint
_macro_fn = globals().get(_macro_ep)
if _macro_fn is None or not callable(_macro_fn):
raise RuntimeError(f"Macro entrypoint '{_macro_ep}' is not callable")
_macro_post_json = __bds_macro_post_data_json
_macro_post = _json.loads(_macro_post_json) if _macro_post_json else None
_json.dumps(_macro_fn(__bds_context_v1, _macro_post))
`);
const parsedResult = parseMacroResultV1(JSON.parse(toResultString(rawJsonResult)));

View File

@@ -21,6 +21,8 @@ export type PythonWorkerRequest =
requestId: string;
code: string;
context: MacroContextV1;
entrypoint?: string;
postDataJson?: string | null;
cacheKey?: string;
}
| {