fix: lots of missing pieces for python macro handling
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
232
src/main/engine/mainProcessPythonApiInvoker.ts
Normal file
232
src/main/engine/mainProcessPythonApiInvoker.ts
Normal 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);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
185
src/main/shared/generatePythonApiModuleV1.ts
Normal file
185
src/main/shared/generatePythonApiModuleV1.ts
Normal 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');
|
||||
}
|
||||
381
src/main/shared/pythonApiContractV1.ts
Normal file
381
src/main/shared/pythonApiContractV1.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user