diff --git a/PYTHON_SCRIPTING.md b/PYTHON_SCRIPTING.md index b89d445..e12c15d 100644 --- a/PYTHON_SCRIPTING.md +++ b/PYTHON_SCRIPTING.md @@ -10,6 +10,7 @@ When plan and code differ, code is the source of truth. - [x] Pyodide dependency integrated. - [x] Renderer worker runtime exists (`pythonRuntime.worker.ts`) with ready/error/stdout/run protocol. - [x] Runtime timeout watchdog + reset/recovery implemented in `PythonRuntimeManager`. +- [x] Renderer runtime request queueing implemented (concurrent calls are serialized in manager). - [x] ABI v1 schemas and validation for macro context/result implemented (`abiV1.ts`). - [x] Benchmark harness implemented (`npm run bench:python-runtime -- `). - [x] Script persistence model implemented (`scripts` DB table + `scripts/*.py` files). @@ -18,15 +19,16 @@ When plan and code differ, code is the source of truth. - [x] Preload + shared API typings for scripts implemented. - [x] Renderer scripts UX implemented (sidebar list, editor, save, run, delete). - [x] Script syntax check + entrypoint discovery integrated in editor UX. -- [x] Blogmark transform pipeline executes Python transform scripts (`kind='transform'`). +- [x] Blogmark transform pipeline executes Python transform scripts (`kind='transform'`) via a queued worker runtime by default. +- [x] Project preference `pythonRuntimeMode` added with Settings → Technology section. ## Confirmed Deviations from Original Plan These are current realities and should be treated as authoritative unless we explicitly decide to change them. -1. **Transform script runtime location differs** - - Original plan: untrusted Python runs in renderer worker only. - - Actual implementation: Blogmark transform scripts run in **main process** Pyodide (`BlogmarkTransformService`). +1. **Transform runtime is now configurable (project-level)** + - Default: `webworker` (worker-thread based Python runtime with queued requests). + - Optional fallback: `main-thread` legacy execution mode. 2. **Render-time macro migration has not happened yet** - Original plan: all render-time macros become Python-backed. @@ -42,12 +44,11 @@ These are current realities and should be treated as authoritative unless we exp ## Remaining Work Only -## 1) Decide and enforce Python runtime boundary (P0) +## 1) Python runtime boundary (P0) — Implemented -- [ ] Decide if `transform` scripts should stay in main process or move to renderer worker. -- [ ] If staying in main process: add explicit timeout/kill/recovery safeguards equivalent to worker watchdog behavior. -- [ ] If moving to worker: route transform execution through typed IPC/worker bridge and remove main-process execution path. -- [ ] Document final security model in this file after decision. +- [x] Worker model introduced for Blogmark transform execution with queued communication. +- [x] Runtime mode made project-configurable via Settings → Technology (`pythonRuntimeMode`). +- [x] Legacy main-thread mode retained as explicit fallback option. ## 2) Add scripts file-system rebuild/sync (P1) @@ -89,6 +90,6 @@ These are current realities and should be treated as authoritative unless we exp - [ ] Render-time macros run through Python script path in production generation flow. - [ ] Scripts directory external changes are synchronized reliably. -- [ ] Runtime boundary decision implemented and protected by tests. +- [x] Runtime boundary decision implemented and protected by tests. - [ ] Legacy JS macro path removed (or explicitly retained with documented rationale). - [ ] `npm test` and `npm run build` pass. diff --git a/src/main/engine/BlogmarkPythonWorkerRuntime.ts b/src/main/engine/BlogmarkPythonWorkerRuntime.ts new file mode 100644 index 0000000..f3256d5 --- /dev/null +++ b/src/main/engine/BlogmarkPythonWorkerRuntime.ts @@ -0,0 +1,261 @@ +import * as path from 'path'; +import { Worker } from 'worker_threads'; + +interface WorkerRunTransformRequest { + type: 'runTransform'; + requestId: string; + scriptContent: string; + entrypoint: string; + payloadJson: string; +} + +interface WorkerReadyMessage { + type: 'ready'; +} + +interface WorkerResultMessage { + type: 'transformResult'; + requestId: string; + output: unknown; + toasts: string[]; +} + +interface WorkerErrorMessage { + type: 'transformError'; + requestId: string; + error: string; +} + +interface WorkerFatalErrorMessage { + type: 'error'; + error: string; +} + +type WorkerResponseMessage = WorkerReadyMessage | WorkerResultMessage | WorkerErrorMessage | WorkerFatalErrorMessage; + +interface QueuedRequest { + request: WorkerRunTransformRequest; + timeoutMs: number; + resolve: (value: { output: unknown; toasts: string[] }) => void; + reject: (error: Error) => void; +} + +interface ActiveRequest extends QueuedRequest { + timeoutId: ReturnType | null; +} + +export class BlogmarkPythonWorkerRuntime { + private worker: Worker | null = null; + private workerReady = false; + private workerStartPromise: Promise | null = null; + private workerStartResolve: (() => void) | null = null; + private workerStartReject: ((error: Error) => void) | null = null; + private activeRequest: ActiveRequest | null = null; + private queue: QueuedRequest[] = []; + private requestCounter = 0; + + async executeTransform(params: { + scriptContent: string; + entrypoint: string; + payloadJson: string; + timeoutMs?: number; + }): Promise<{ output: unknown; toasts: string[] }> { + const requestId = this.nextRequestId(); + const timeoutMs = params.timeoutMs ?? 5000; + + return new Promise<{ output: unknown; toasts: string[] }>((resolve, reject) => { + this.queue.push({ + request: { + type: 'runTransform', + requestId, + scriptContent: params.scriptContent, + entrypoint: params.entrypoint, + payloadJson: params.payloadJson, + }, + timeoutMs, + resolve, + reject, + }); + + this.dispatchNext().catch((error) => { + reject(error instanceof Error ? error : new Error(String(error))); + }); + }); + } + + dispose(): void { + this.rejectStartPromise(new Error('Python worker runtime disposed')); + this.rejectActiveAndQueue(new Error('Python worker runtime disposed')); + this.resetWorker(); + } + + private async dispatchNext(): Promise { + if (this.activeRequest || this.queue.length === 0) { + return; + } + + await this.ensureWorkerStarted(); + + const nextRequest = this.queue.shift(); + if (!nextRequest) { + return; + } + + const timeoutId = setTimeout(() => { + if (!this.activeRequest || this.activeRequest.request.requestId !== nextRequest.request.requestId) { + return; + } + + const timeoutError = new Error(`Python transform timed out after ${nextRequest.timeoutMs}ms`); + this.activeRequest.reject(timeoutError); + this.activeRequest = null; + this.resetWorker(); + void this.dispatchNext(); + }, nextRequest.timeoutMs); + + this.activeRequest = { + ...nextRequest, + timeoutId, + }; + + this.worker?.postMessage(nextRequest.request); + } + + private async ensureWorkerStarted(): Promise { + if (this.worker && this.workerReady) { + return; + } + + if (this.workerStartPromise) { + return this.workerStartPromise; + } + + const workerPath = path.join(__dirname, 'blogmarkPython.worker.js'); + this.worker = new Worker(workerPath); + this.workerReady = false; + + this.worker.on('message', (message: WorkerResponseMessage) => { + this.handleWorkerMessage(message); + }); + + this.worker.on('error', (error) => { + this.handleWorkerCrash(error instanceof Error ? error : new Error(String(error))); + }); + + this.worker.on('exit', (code) => { + if (code !== 0) { + this.handleWorkerCrash(new Error(`Python worker exited with code ${code}`)); + } + }); + + this.workerStartPromise = new Promise((resolve, reject) => { + this.workerStartResolve = resolve; + this.workerStartReject = reject; + }); + + return this.workerStartPromise; + } + + private handleWorkerMessage(message: WorkerResponseMessage): void { + if (message.type === 'ready') { + this.workerReady = true; + this.resolveStartPromise(); + return; + } + + if (message.type === 'error') { + this.handleWorkerCrash(new Error(message.error)); + return; + } + + const active = this.activeRequest; + if (!active) { + return; + } + + if (active.request.requestId !== message.requestId) { + return; + } + + if (active.timeoutId) { + clearTimeout(active.timeoutId); + } + + this.activeRequest = null; + + if (message.type === 'transformResult') { + active.resolve({ output: message.output, toasts: message.toasts }); + } else { + active.reject(new Error(message.error)); + } + + void this.dispatchNext(); + } + + private handleWorkerCrash(error: Error): void { + this.rejectStartPromise(error); + this.rejectActiveAndQueue(error); + this.resetWorker(); + } + + private rejectActiveAndQueue(error: Error): void { + if (this.activeRequest) { + if (this.activeRequest.timeoutId) { + clearTimeout(this.activeRequest.timeoutId); + } + this.activeRequest.reject(error); + this.activeRequest = null; + } + + while (this.queue.length > 0) { + const queued = this.queue.shift(); + queued?.reject(error); + } + } + + private resolveStartPromise(): void { + if (this.workerStartResolve) { + this.workerStartResolve(); + } + this.workerStartResolve = null; + this.workerStartReject = null; + this.workerStartPromise = null; + } + + private rejectStartPromise(error: Error): void { + if (this.workerStartReject) { + this.workerStartReject(error); + } + this.workerStartResolve = null; + this.workerStartReject = null; + this.workerStartPromise = null; + } + + private resetWorker(): void { + if (this.worker) { + this.worker.removeAllListeners(); + this.worker.terminate(); + } + + this.worker = null; + this.workerReady = false; + this.workerStartPromise = null; + this.workerStartResolve = null; + this.workerStartReject = null; + } + + private nextRequestId(): string { + this.requestCounter += 1; + return `blogmark-py-${this.requestCounter}`; + } +} + +let blogmarkPythonWorkerRuntimeInstance: BlogmarkPythonWorkerRuntime | null = null; + +export function getBlogmarkPythonWorkerRuntime(): BlogmarkPythonWorkerRuntime { + if (!blogmarkPythonWorkerRuntimeInstance) { + blogmarkPythonWorkerRuntimeInstance = new BlogmarkPythonWorkerRuntime(); + } + + return blogmarkPythonWorkerRuntimeInstance; +} diff --git a/src/main/engine/BlogmarkTransformService.ts b/src/main/engine/BlogmarkTransformService.ts index 7f65c66..a888682 100644 --- a/src/main/engine/BlogmarkTransformService.ts +++ b/src/main/engine/BlogmarkTransformService.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; import { getScriptEngine } from './ScriptEngine'; +import { getMetaEngine } from './MetaEngine'; +import { getBlogmarkPythonWorkerRuntime } from './BlogmarkPythonWorkerRuntime'; const transformPostSchema = z.object({ title: z.string().trim().min(1), @@ -55,6 +57,8 @@ export interface BlogmarkTransformResult { toasts: string[]; } +export type PythonRuntimeMode = 'webworker' | 'main-thread'; + const MAX_TOASTS_PER_SCRIPT = 5; const MAX_TOASTS_TOTAL = 20; const MAX_TOAST_LENGTH = 300; @@ -142,6 +146,28 @@ function toErrorMessage(error: unknown): string { return String(error); } +function resolveTransformEntrypoint(value: string): string { + const nextEntrypoint = typeof value === 'string' ? value.trim() : ''; + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(nextEntrypoint) && nextEntrypoint !== 'main') { + return nextEntrypoint; + } + + return 'transform'; +} + +function resolvePythonRuntimeMode(value: unknown): PythonRuntimeMode { + if (value === 'main-thread') { + return 'main-thread'; + } + + return 'webworker'; +} + +async function getConfiguredPythonRuntimeMode(): Promise { + const metadata = await getMetaEngine().getProjectMetadata(); + return resolvePythonRuntimeMode((metadata as { pythonRuntimeMode?: unknown } | null)?.pythonRuntimeMode); +} + class PythonBlogmarkTransformExecutor implements BlogmarkTransformExecutor { private runtimePromise: Promise | null = null; @@ -169,7 +195,7 @@ def toast(message): await runtime.runPythonAsync(script.content); - const requestedEntrypoint = this.resolveEntrypoint(script.entrypoint); + const requestedEntrypoint = resolveTransformEntrypoint(script.entrypoint); const payload = JSON.stringify(input); runtime.globals.set('__bds_transform_payload_json', payload); runtime.globals.set('__bds_transform_entrypoint', requestedEntrypoint); @@ -200,15 +226,6 @@ json.dumps(_result) }; } - private resolveEntrypoint(value: string): string { - const nextEntrypoint = typeof value === 'string' ? value.trim() : ''; - if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(nextEntrypoint) && nextEntrypoint !== 'main') { - return nextEntrypoint; - } - - return 'transform'; - } - private async getRuntime(): Promise { if (!this.runtimePromise) { this.runtimePromise = (async () => { @@ -221,11 +238,26 @@ json.dumps(_result) } } +class PythonWorkerBlogmarkTransformExecutor implements BlogmarkTransformExecutor { + async runTransform(script: BlogmarkTransformScriptRecord, input: BlogmarkTransformInput): Promise { + return getBlogmarkPythonWorkerRuntime().executeTransform({ + scriptContent: script.content, + entrypoint: resolveTransformEntrypoint(script.entrypoint), + payloadJson: JSON.stringify(input), + }); + } +} + +const mainThreadExecutor = new PythonBlogmarkTransformExecutor(); +const workerExecutor = new PythonWorkerBlogmarkTransformExecutor(); + export class BlogmarkTransformService { constructor( private readonly dependencies: { provider?: BlogmarkTransformScriptProvider; executor?: BlogmarkTransformExecutor; + resolvePythonRuntimeMode?: () => Promise; + executors?: Partial>; } = {}, ) {} @@ -237,7 +269,7 @@ export class BlogmarkTransformService { }; const provider = this.dependencies.provider ?? scriptEngineBackedProvider; - const executor = this.dependencies.executor ?? new PythonBlogmarkTransformExecutor(); + const executor = this.dependencies.executor ?? await this.resolveExecutorForConfiguredRuntime(); const scripts = await provider.getScripts(); const activeTransforms = scripts @@ -303,6 +335,18 @@ export class BlogmarkTransformService { toasts, }; } + + private async resolveExecutorForConfiguredRuntime(): Promise { + const resolveMode = this.dependencies.resolvePythonRuntimeMode ?? getConfiguredPythonRuntimeMode; + const mode = await resolveMode(); + const executors = this.dependencies.executors ?? {}; + + if (mode === 'main-thread') { + return executors['main-thread'] ?? mainThreadExecutor; + } + + return executors.webworker ?? workerExecutor; + } } let blogmarkTransformServiceInstance: BlogmarkTransformService | null = null; diff --git a/src/main/engine/MetaEngine.ts b/src/main/engine/MetaEngine.ts index 0ed48bd..85ad141 100644 --- a/src/main/engine/MetaEngine.ts +++ b/src/main/engine/MetaEngine.ts @@ -24,6 +24,7 @@ export interface ProjectMetadata { defaultAuthor?: string; // Default author for new posts and media maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50) blogmarkCategory?: string; // Category used for externally captured bookmark posts + pythonRuntimeMode?: 'webworker' | 'main-thread'; // Runtime mode for Python script execution picoTheme?: PicoThemeName; // Selected Pico CSS theme for preview/rendering categoryMetadata?: Record; // Per-category metadata for UI/rendering categorySettings?: Record; // Per-category list rendering preferences @@ -85,6 +86,7 @@ function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata { const blogmarkCategory = typeof metadata.blogmarkCategory === 'string' ? normalizeNonEmptyTaxonomyTerm(metadata.blogmarkCategory) ?? undefined : undefined; + const pythonRuntimeMode = metadata.pythonRuntimeMode === 'main-thread' ? 'main-thread' : 'webworker'; const picoTheme = sanitizePicoTheme(metadata.picoTheme); const categoryMetadata = normalizeCategoryMetadata(metadata.categoryMetadata ?? metadata.categorySettings); return { @@ -92,6 +94,7 @@ function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata { publicUrl, maxPostsPerPage, blogmarkCategory, + pythonRuntimeMode, picoTheme, categoryMetadata, categorySettings: undefined, @@ -306,6 +309,7 @@ export class MetaEngine extends EventEmitter { defaultAuthor: normalizedUpdates.defaultAuthor, maxPostsPerPage: normalizedUpdates.maxPostsPerPage, blogmarkCategory: normalizedUpdates.blogmarkCategory, + pythonRuntimeMode: normalizedUpdates.pythonRuntimeMode, picoTheme: normalizedUpdates.picoTheme, categoryMetadata: normalizedUpdates.categoryMetadata, }); diff --git a/src/main/engine/blogmarkPython.worker.ts b/src/main/engine/blogmarkPython.worker.ts new file mode 100644 index 0000000..a63f719 --- /dev/null +++ b/src/main/engine/blogmarkPython.worker.ts @@ -0,0 +1,150 @@ +import { parentPort } from 'worker_threads'; + +interface WorkerRunTransformRequest { + type: 'runTransform'; + requestId: string; + scriptContent: string; + entrypoint: string; + payloadJson: string; +} + +interface WorkerReadyMessage { + type: 'ready'; +} + +interface WorkerResultMessage { + type: 'transformResult'; + requestId: string; + output: unknown; + toasts: string[]; +} + +interface WorkerErrorMessage { + type: 'transformError'; + requestId: string; + error: string; +} + +interface WorkerFatalErrorMessage { + type: 'error'; + error: string; +} + +type WorkerResponseMessage = WorkerReadyMessage | WorkerResultMessage | WorkerErrorMessage | WorkerFatalErrorMessage; + +type PyodideRuntime = { + globals: { + set: (name: string, value: unknown) => void; + } | any; + runPythonAsync: (code: string) => Promise; +}; + +const MAX_TOASTS_PER_SCRIPT = 5; +const MAX_TOAST_LENGTH = 300; + +let runtimePromise: Promise | null = null; + +function postMessage(message: WorkerResponseMessage): void { + parentPort?.postMessage(message); +} + +function normalizeToastMessage(value: unknown): string | null { + if (value === undefined || value === null) { + return null; + } + + const normalized = String(value).trim(); + if (normalized.length === 0) { + return null; + } + + return normalized.slice(0, MAX_TOAST_LENGTH); +} + +async function getRuntime(): Promise { + if (!runtimePromise) { + runtimePromise = (async () => { + const pyodideModule = await import('pyodide'); + return (await pyodideModule.loadPyodide()) as unknown as PyodideRuntime; + })(); + } + + return runtimePromise; +} + +async function runTransform(request: WorkerRunTransformRequest): Promise { + try { + const runtime = await getRuntime(); + const toastMessages: string[] = []; + + const pushToast = (message: unknown): void => { + if (toastMessages.length >= MAX_TOASTS_PER_SCRIPT) { + return; + } + + const normalized = normalizeToastMessage(message); + if (!normalized) { + return; + } + + toastMessages.push(normalized); + }; + + runtime.globals.set('__bds_push_toast', pushToast); + await runtime.runPythonAsync(` +def toast(message): + __bds_push_toast(str(message)) +`); + + await runtime.runPythonAsync(request.scriptContent); + runtime.globals.set('__bds_transform_payload_json', request.payloadJson); + runtime.globals.set('__bds_transform_entrypoint', request.entrypoint); + + const rawResult = await runtime.runPythonAsync(` +import json +_payload = json.loads(__bds_transform_payload_json) +_entrypoint = __bds_transform_entrypoint +_transform_fn = globals().get(_entrypoint) +if _transform_fn is None or not callable(_transform_fn): + raise RuntimeError(f"Transform entrypoint '{_entrypoint}' is not callable") +_post = _payload.get("post") +if not isinstance(_post, dict): + raise RuntimeError("Transform payload is missing a valid 'post' object") +_context = _payload.get("context") +try: + _result = _transform_fn(_post, _context) +except TypeError: + _result = _transform_fn(_post) +if _result is None: + _result = _post +json.dumps(_result) +`); + + postMessage({ + type: 'transformResult', + requestId: request.requestId, + output: JSON.parse(String(rawResult)), + toasts: toastMessages, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + postMessage({ type: 'transformError', requestId: request.requestId, error: message }); + } +} + +parentPort?.on('message', (message: WorkerRunTransformRequest) => { + if (message.type !== 'runTransform') { + return; + } + + void runTransform(message); +}); + +void getRuntime() + .then(() => { + postMessage({ type: 'ready' }); + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + postMessage({ type: 'error', error: message }); + }); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index a60d3fa..301ba2a 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -1006,7 +1006,7 @@ export function registerIpcHandlers(): void { return engine.getProjectMetadata(); }); - safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('../shared/picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record }) => { + safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('../shared/picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record }) => { const engine = getMetaEngine(); await ensureMetaContext(engine); await engine.updateProjectMetadata(updates); diff --git a/src/main/preload.ts b/src/main/preload.ts index 71e3da9..5d0ec12 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -172,7 +172,7 @@ export const electronAPI: ElectronAPI = { syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'), getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'), setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata), - updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; picoTheme?: import('./shared/picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates), + updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./shared/picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates), }, // Tag Management (advanced tag operations) diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index ab0dd6f..72a5b84 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -43,6 +43,7 @@ export interface ProjectMetadata { defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; + pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record; @@ -619,7 +620,7 @@ export interface ElectronAPI { syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>; getProjectMetadata: () => Promise; setProjectMetadata: (metadata: { name: string; description?: string }) => Promise; - updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record }) => Promise; + updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record }) => Promise; }; tags: { getAll: () => Promise; diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index 9472133..228a88e 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -10,7 +10,7 @@ import { import './SettingsView.css'; // Export category IDs for sidebar navigation -export type SettingsCategory = 'project' | 'editor' | 'content' | 'ai' | 'publishing' | 'data'; +export type SettingsCategory = 'project' | 'editor' | 'content' | 'ai' | 'technology' | 'publishing' | 'data'; // Scroll to a settings section by category ID export const scrollToSettingsSection = (category: SettingsCategory) => { @@ -150,6 +150,7 @@ export const SettingsView: React.FC = () => { const [projectDefaultAuthor, setProjectDefaultAuthor] = useState(''); const [projectMaxPostsPerPage, setProjectMaxPostsPerPage] = useState(50); const [projectBlogmarkCategory, setProjectBlogmarkCategory] = useState('article'); + const [projectPythonRuntimeMode, setProjectPythonRuntimeMode] = useState<'webworker' | 'main-thread'>('webworker'); // Post categories management const [postCategories, setPostCategories] = useState(DEFAULT_POST_CATEGORIES); @@ -208,6 +209,9 @@ export const SettingsView: React.FC = () => { const incomingBlogmarkCategory = normalizeBlogmarkCategory((metadata as { blogmarkCategory?: unknown } | null)?.blogmarkCategory); setProjectBlogmarkCategory(incomingBlogmarkCategory || 'article'); + const incomingPythonRuntimeMode = (metadata as { pythonRuntimeMode?: unknown } | null)?.pythonRuntimeMode; + setProjectPythonRuntimeMode(incomingPythonRuntimeMode === 'main-thread' ? 'main-thread' : 'webworker'); + const incomingCategoryMetadata = (metadata as any)?.categoryMetadata as Record | undefined; const incomingLegacyCategorySettings = (metadata as any)?.categorySettings as Record | undefined; setCategoryMetadata((current) => { @@ -342,6 +346,7 @@ export const SettingsView: React.FC = () => { defaultAuthor: projectDefaultAuthor.trim() || undefined, maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))), blogmarkCategory: normalizeBlogmarkCategory(projectBlogmarkCategory) || undefined, + pythonRuntimeMode: projectPythonRuntimeMode, categoryMetadata, }); } @@ -389,6 +394,7 @@ export const SettingsView: React.FC = () => { const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual']; const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page']; const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode']; + const technologyKeywords = ['technology', 'python', 'runtime', 'worker', 'webworker', 'main thread', 'execution']; const publishingKeywords = ['publishing', 'ftp', 'ssh', 'deploy', 'server', 'host', 'upload']; const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'links', 'folder', 'filesystem']; @@ -1023,6 +1029,30 @@ export const SettingsView: React.FC = () => { ); + const renderTechnologySettings = () => ( + + ); + const renderPublishingSettings = () => ( <> { sectionHasMatches(editorKeywords) || sectionHasMatches(contentKeywords) || sectionHasMatches(aiKeywords) || + sectionHasMatches(technologyKeywords) || sectionHasMatches(publishingKeywords) || sectionHasMatches(dataKeywords); @@ -1325,6 +1356,7 @@ export const SettingsView: React.FC = () => { {renderEditorSettings()} {renderContentSettings()} {renderAISettings()} + {renderTechnologySettings()} {renderPublishingSettings()} {renderDataSettings()} diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index bd54ee5..77245a7 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -1261,7 +1261,7 @@ const SettingsNav: React.FC = () => { const { tabs, activeTabId, openTab } = useAppStore(); const [activeSection, setActiveSection] = useState(() => { const persisted = getPersistedSidebarSection('settings'); - if (persisted === 'project' || persisted === 'editor' || persisted === 'content' || persisted === 'ai' || persisted === 'publishing' || persisted === 'data') { + if (persisted === 'project' || persisted === 'editor' || persisted === 'content' || persisted === 'ai' || persisted === 'technology' || persisted === 'publishing' || persisted === 'data') { return persisted; } return null; @@ -1322,6 +1322,13 @@ const SettingsNav: React.FC = () => { 🤖 {t('sidebar.nav.ai')} +