From c358e1b11c8a525887ea17ca2d78583fde257201 Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 28 Feb 2026 21:23:22 +0100 Subject: [PATCH] feat: first round of mcp standalone server --- .claude/settings.local.json | 3 +- drizzle/0007_closed_sabretooth.sql | 14 + drizzle/meta/0007_snapshot.json | 1109 +++++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 7 +- scripts/fix-ipc-handlers.mjs | 33 + src/cli/bds-mcp.ts | 109 ++ src/cli/platform.ts | 31 + src/main/database/connection.ts | 173 ++- src/main/database/schema.ts | 20 + src/main/engine/AppApiAdapter.ts | 20 +- src/main/engine/BlogGenerationEngine.ts | 25 +- .../engine/BlogmarkPythonWorkerRuntime.ts | 9 - src/main/engine/BlogmarkTransformService.ts | 46 +- src/main/engine/CliNotifier.ts | 55 + src/main/engine/EngineBundle.ts | 55 + src/main/engine/GitApiAdapter.ts | 45 +- src/main/engine/GitEngine.ts | 9 - src/main/engine/ImportExecutionEngine.ts | 50 +- src/main/engine/MCPAgentConfigEngine.ts | 64 +- src/main/engine/MCPServer.ts | 142 ++- src/main/engine/MediaEngine.ts | 19 +- src/main/engine/MenuEngine.ts | 8 - src/main/engine/MetaEngine.ts | 9 - src/main/engine/MetadataDiffEngine.ts | 17 +- src/main/engine/NotificationWatcher.ts | 125 ++ src/main/engine/OpenCodeManager.ts | 9 +- src/main/engine/PostEngine.ts | 52 +- src/main/engine/PostMediaEngine.ts | 32 +- src/main/engine/PreviewServer.ts | 62 +- src/main/engine/ProjectEngine.ts | 9 - src/main/engine/ProposalStore.ts | 7 +- src/main/engine/PublishApiAdapter.ts | 40 +- src/main/engine/PublishEngine.ts | 9 - src/main/engine/PythonMacroWorkerRuntime.ts | 10 - src/main/engine/ScriptEngine.ts | 100 +- src/main/engine/TagEngine.ts | 19 +- src/main/engine/TemplateEngine.ts | 99 +- src/main/engine/index.ts | 17 +- .../engine/mainProcessPythonApiInvoker.ts | 71 +- src/main/ipc/blogHandlers.ts | 39 +- src/main/ipc/chatHandlers.ts | 12 +- src/main/ipc/handlers.ts | 446 +++---- src/main/ipc/metadataDiffHandlers.ts | 27 +- src/main/ipc/publishHandlers.ts | 17 +- src/main/main.ts | 179 ++- src/main/preload.ts | 6 + src/main/shared/electronApi.ts | 9 + src/renderer/App.tsx | 33 + tests/engine/AppApiAdapter.test.ts | 5 +- tests/engine/BlogGenerationEngine.test.ts | 47 +- tests/engine/GitApiAdapter.test.ts | 2 +- .../engine/ImportExecutionEngine.e2e.test.ts | 7 +- tests/engine/ImportExecutionEngine.test.ts | 18 +- tests/engine/MCPConfigEngine.test.ts | 161 ++- tests/engine/MCPServer.test.ts | 50 +- tests/engine/MetadataDiffEngine.test.ts | 2 +- tests/engine/NotificationWatcher.test.ts | 248 ++++ tests/engine/PostMediaEngine.test.ts | 10 +- tests/engine/PreviewServer.test.ts | 137 ++ tests/engine/PublishApiAdapter.test.ts | 2 +- tests/engine/PublishEngine.test.ts | 7 - tests/engine/TagEngine.test.ts | 2 +- .../engine/WxrReferenceComparison.e2e.test.ts | 7 +- tests/ipc/chatHandlers.test.ts | 7 +- tests/ipc/handlers.test.ts | 37 +- vite.config.cli.ts | 70 ++ 67 files changed, 3426 insertions(+), 901 deletions(-) create mode 100644 drizzle/0007_closed_sabretooth.sql create mode 100644 drizzle/meta/0007_snapshot.json create mode 100644 scripts/fix-ipc-handlers.mjs create mode 100644 src/cli/bds-mcp.ts create mode 100644 src/cli/platform.ts create mode 100644 src/main/engine/CliNotifier.ts create mode 100644 src/main/engine/EngineBundle.ts create mode 100644 src/main/engine/NotificationWatcher.ts create mode 100644 tests/engine/NotificationWatcher.test.ts create mode 100644 vite.config.cli.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a658218..5ffe431 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,8 @@ "Bash(ls -la /Users/gb/Projects/bDS/*.md)", "Bash(grep -n \"templateSlug\\\\|postTemplateSlug\" /Users/gb/Projects/bDS/src/main/engine/*.ts)", "Bash(npm test -- tests/renderer/i18nLocaleCompleteness.test.ts)", - "WebFetch(domain:ricmac.org)" + "WebFetch(domain:ricmac.org)", + "WebFetch(domain:docs.mistral.ai)" ] } } diff --git a/drizzle/0007_closed_sabretooth.sql b/drizzle/0007_closed_sabretooth.sql new file mode 100644 index 0000000..db3549b --- /dev/null +++ b/drizzle/0007_closed_sabretooth.sql @@ -0,0 +1,14 @@ +CREATE TABLE `db_notifications` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `entity` text NOT NULL, + `entity_id` text NOT NULL, + `action` text NOT NULL, + `from_cli` integer DEFAULT 1 NOT NULL, + `seen_at` integer, + `created_at` integer NOT NULL +); +--> statement-breakpoint +ALTER TABLE `scripts` ADD `status` text DEFAULT 'published' NOT NULL;--> statement-breakpoint +ALTER TABLE `scripts` ADD `content` text;--> statement-breakpoint +ALTER TABLE `templates` ADD `status` text DEFAULT 'published' NOT NULL;--> statement-breakpoint +ALTER TABLE `templates` ADD `content` text; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..0d2b14d --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1109 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8b10ef6d-99dc-4772-8a32-66718f095dcf", + "prevId": "800cf66e-5f65-460f-98a8-b9451c078106", + "tables": { + "chat_conversations": { + "name": "chat_conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "copilot_session_id": { + "name": "copilot_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_calls": { + "name": "tool_calls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "db_notifications": { + "name": "db_notifications", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_cli": { + "name": "from_cli", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "seen_at": { + "name": "seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "generated_file_hashes": { + "name": "generated_file_hashes", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "relative_path": { + "name": "relative_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "generated_file_hashes_project_path_idx": { + "name": "generated_file_hashes_project_path_idx", + "columns": [ + "project_id", + "relative_path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "import_definitions": { + "name": "import_definitions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wxr_file_path": { + "name": "wxr_file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploads_folder_path": { + "name": "uploads_folder_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_analysis_result": { + "name": "last_analysis_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sidecar_path": { + "name": "sidecar_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "post_links": { + "name": "post_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_post_id": { + "name": "source_post_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_post_id": { + "name": "target_post_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "link_text": { + "name": "link_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "post_media": { + "name": "post_media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "post_media_post_media_idx": { + "name": "post_media_post_media_idx", + "columns": [ + "post_id", + "media_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "posts": { + "name": "posts", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "categories": { + "name": "categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_slug": { + "name": "template_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_title": { + "name": "published_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_content": { + "name": "published_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_tags": { + "name": "published_tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_categories": { + "name": "published_categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_excerpt": { + "name": "published_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "posts_project_slug_idx": { + "name": "posts_project_slug_idx", + "columns": [ + "project_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data_path": { + "name": "data_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scripts": { + "name": "scripts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'utility'" + }, + "entrypoint": { + "name": "entrypoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'render'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'published'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "scripts_project_slug_idx": { + "name": "scripts_project_slug_idx", + "columns": [ + "project_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "post_template_slug": { + "name": "post_template_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tags_project_name_idx": { + "name": "tags_project_name_idx", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "templates": { + "name": "templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'post'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'published'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "templates_project_slug_idx": { + "name": "templates_project_slug_idx", + "columns": [ + "project_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d3b63be..4b4f6c4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1772213213016, "tag": "0006_yummy_scorpion", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1772301340810, + "tag": "0007_closed_sabretooth", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 6bfc294..5277dc7 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev:electron": "wait-on http://localhost:5173 && cross-env NODE_ENV=development node ./node_modules/electron/cli.js .", "start": "concurrently --kill-others \"npm run dev:renderer\" \"npm run start:electron\"", "start:electron": "wait-on http://localhost:5173 && cross-env NODE_ENV=development node ./node_modules/electron/cli.js .", - "build": "npm run lint && npm run db:generate && npm run build:main && npm run build:renderer", + "build": "npm run lint && npm run db:generate && npm run build:main && npm run build:cli && npm run build:renderer", "icons:generate": "node scripts/regenerate-icons.mjs", "package": "npm run icons:generate && npm run build && electron-builder --dir", "dist": "npm run icons:generate && npm run build && electron-builder", @@ -19,6 +19,7 @@ "dist:win": "npm run icons:generate && npm run build && electron-builder --win", "dist:linux": "npm run icons:generate && npm run build && electron-builder --linux", "build:main": "node ./node_modules/typescript/bin/tsc -p tsconfig.main.json", + "build:cli": "node ./node_modules/vite/bin/vite.js build --config vite.config.cli.ts", "build:renderer": "node ./node_modules/vite/bin/vite.js build", "start:prod": "node ./node_modules/electron/cli.js .", "start:dev": "cross-env NODE_ENV=development node ./node_modules/electron/cli.js .", @@ -137,6 +138,10 @@ "!**/._*" ], "extraResources": [ + { + "from": "dist/cli/bds-mcp.cjs", + "to": "bds-mcp.cjs" + }, { "from": "drizzle", "to": "drizzle" diff --git a/scripts/fix-ipc-handlers.mjs b/scripts/fix-ipc-handlers.mjs new file mode 100644 index 0000000..bd89b57 --- /dev/null +++ b/scripts/fix-ipc-handlers.mjs @@ -0,0 +1,33 @@ +import { readFileSync, writeFileSync } from 'fs'; + +const files = [ + 'src/main/ipc/handlers.ts', + 'src/main/ipc/blogHandlers.ts', + 'src/main/ipc/publishHandlers.ts', + 'src/main/ipc/chatHandlers.ts', + 'src/main/ipc/metadataDiffHandlers.ts', +]; + +const replacements = [ + [/getPostEngine\(\)/g, 'bundle.postEngine'], + [/getMediaEngine\(\)/g, 'bundle.mediaEngine'], + [/getProjectEngine\(\)/g, 'bundle.projectEngine'], + [/getMetaEngine\(\)/g, 'bundle.metaEngine'], + [/getMenuEngine\(\)/g, 'bundle.menuEngine'], + [/getTagEngine\(\)/g, 'bundle.tagEngine'], + [/getScriptEngine\(\)/g, 'bundle.scriptEngine'], + [/getTemplateEngine\(\)/g, 'bundle.templateEngine'], + [/getGitEngine\(\)/g, 'bundle.gitEngine'], + [/getBlogGenerationEngine\(\)/g, 'bundle.blogGenerationEngine'], + [/getPublishEngine\(\)/g, 'bundle.publishEngine'], + [/getMetadataDiffEngine\(\)/g, 'bundle.metadataDiffEngine'], +]; + +for (const file of files) { + let content = readFileSync(file, 'utf8'); + for (const [pattern, replacement] of replacements) { + content = content.replace(pattern, replacement); + } + writeFileSync(file, content, 'utf8'); + console.log(`Updated: ${file}`); +} diff --git a/src/cli/bds-mcp.ts b/src/cli/bds-mcp.ts new file mode 100644 index 0000000..a61790a --- /dev/null +++ b/src/cli/bds-mcp.ts @@ -0,0 +1,109 @@ +/** + * bds-mcp — standalone MCP server for Blogging Desktop Server. + * + * Launched by coding agents (Claude Desktop, etc.) via: + * ELECTRON_RUN_AS_NODE=1 /path/to/app.app/Contents/MacOS/app bds-mcp.cjs + * + * No `electron` imports in this file. Engine modules may import from + * `electron` — those imports are satisfied because we run as ELECTRON_RUN_AS_NODE. + */ + +import * as path from 'path'; +import { eq } from 'drizzle-orm'; +import { platformConfigPath } from './platform'; +import { initDatabase } from '../main/database/connection'; +import { projects } from '../main/database/schema'; +import { DbNotifier } from '../main/engine/CliNotifier'; +import { PostEngine } from '../main/engine/PostEngine'; +import { MediaEngine } from '../main/engine/MediaEngine'; +import { PostMediaEngine } from '../main/engine/PostMediaEngine'; +import { TagEngine } from '../main/engine/TagEngine'; +import { ScriptEngine } from '../main/engine/ScriptEngine'; +import { TemplateEngine } from '../main/engine/TemplateEngine'; +import { MetaEngine } from '../main/engine/MetaEngine'; +import { MCPServer } from '../main/engine/MCPServer'; + +// ── Bootstrap ──────────────────────────────────────────────────────────────── + +const userData = platformConfigPath(); +const dbPath = path.join(userData, 'bds.db'); + +// __dirname points to Contents/Resources/ in the packaged app (bds-mcp.cjs +// is placed there by extraResources). The drizzle/ migrations folder is also +// shipped to Contents/Resources/drizzle/ via extraResources. +const migrationsFolder = path.join(__dirname, 'drizzle'); + +const db = initDatabase({ dbPath, migrationsFolder }); + +async function main(): Promise { + // 1. Open + migrate the local database. + await db.initializeLocal(); + + const localDb = db.getLocal(); + + // 2. Verify an active project exists. + const activeProject = await localDb + .select() + .from(projects) + .where(eq(projects.isActive, true)) + .get(); + + if (!activeProject) { + process.stderr.write( + '[bds-mcp] No active project found. Open the Blogging Desktop Server app ' + + 'and ensure at least one project is active.\n', + ); + process.exit(1); + } + + // 3. Construct engines with the DbNotifier so every mutation writes a + // db_notifications row that the running Electron app can pick up. + const notifier = new DbNotifier(localDb as any); + + const mediaEngine = new MediaEngine(notifier); + const postEngine = new PostEngine({ notifier, mediaEngine }); + const postMediaEngine = new PostMediaEngine(mediaEngine); + const tagEngine = new TagEngine(postEngine); + const scriptEngine = new ScriptEngine(notifier); + const templateEngine = new TemplateEngine(notifier); + const metaEngine = new MetaEngine(); + + // 4. Create the MCP server with an 8-hour proposal TTL (CLI sessions can + // last overnight). + const mcpServer = new MCPServer( + { + postEngine, + mediaEngine, + postMediaEngine, + scriptEngine, + templateEngine, + metaEngine, + tagEngine, + }, + { proposalTtlMs: 8 * 60 * 60 * 1000 }, + ); + + // 5. Graceful shutdown — two paths, no racing. + // process.exit() makes this non-reentrant even without explicit guards. + async function shutdown(): Promise { + await mcpServer.cleanup(); + await db.close(); + process.exit(0); + } + + // Signal handlers own interruption; registered before startCli() so they + // are active during the entire session. + process.once('SIGTERM', shutdown); + process.once('SIGINT', shutdown); + + // 6. Start the MCP stdio server and block until stdin closes. + await mcpServer.startCli(); + + // 7. Normal exit path: stdin closed → startCli() resolved. + await shutdown(); +} + +main().catch((err: unknown) => { + process.stderr.write(`[bds-mcp] Fatal error: ${String(err)}\n`); + process.exit(1); +}); diff --git a/src/cli/platform.ts b/src/cli/platform.ts new file mode 100644 index 0000000..62c8340 --- /dev/null +++ b/src/cli/platform.ts @@ -0,0 +1,31 @@ +/** + * Pure-Node helper to resolve the same path as Electron's + * `app.getPath('userData')` without loading Electron. + * + * | Platform | Path | + * |----------|------------------------------------------------| + * | macOS | ~/Library/Application Support/ | + * | Windows | %APPDATA%\ | + * | Linux | ~/.config/ | + */ + +import * as os from 'os'; +import * as path from 'path'; + +const APP_NAME = 'Blogging Desktop Server'; + +export function platformConfigPath(): string { + const home = os.homedir(); + const platform = process.platform; + + if (platform === 'darwin') { + return path.join(home, 'Library', 'Application Support', APP_NAME); + } + if (platform === 'win32') { + const appData = process.env['APPDATA'] ?? path.join(home, 'AppData', 'Roaming'); + return path.join(appData, APP_NAME); + } + // Linux and others + const configDir = process.env['XDG_CONFIG_HOME'] ?? path.join(home, '.config'); + return path.join(configDir, APP_NAME); +} diff --git a/src/main/database/connection.ts b/src/main/database/connection.ts index b7217dc..180b2eb 100644 --- a/src/main/database/connection.ts +++ b/src/main/database/connection.ts @@ -4,12 +4,20 @@ import { migrate } from 'drizzle-orm/libsql/migrator'; import { eq, sql } from 'drizzle-orm'; import * as schema from './schema'; import { projects } from './schema'; -import { app } from 'electron'; import * as path from 'path'; import * as fs from 'fs'; -export interface DatabaseConfig { - localPath: string; +export interface DatabaseConnectionConfig { + /** Absolute path to the bds.db SQLite file. */ + dbPath: string; + /** Absolute path to the drizzle/ migrations folder. */ + migrationsFolder: string; + /** + * Extra directories to create on startup (e.g. posts/, media/ inside userData). + * Caller is responsible for providing these; connection.ts no longer computes + * paths via app.getPath(). + */ + dataDirs?: string[]; } type DrizzleDB = ReturnType; @@ -17,31 +25,27 @@ type DrizzleDB = ReturnType; export class DatabaseConnection { private localDb: DrizzleDB | null = null; private localClient: Client | null = null; - private config: DatabaseConfig; + private readonly dbPath: string; + private readonly migrationsFolder: string; + private readonly dataDirs: string[]; private _closing = false; - constructor(config?: Partial) { - const userDataPath = app.getPath('userData'); - - this.config = { - localPath: config?.localPath || path.join(userDataPath, 'bds.db'), - }; + constructor(config: DatabaseConnectionConfig) { + this.dbPath = config.dbPath; + this.migrationsFolder = config.migrationsFolder; + this.dataDirs = config.dataDirs ?? []; - // Ensure user data directory exists - const dataDir = path.dirname(this.config.localPath); + // Ensure the directory containing the DB file exists. + const dataDir = path.dirname(this.dbPath); if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } - // Ensure posts and media directories exist - const postsDir = path.join(userDataPath, 'posts'); - const mediaDir = path.join(userDataPath, 'media'); - - if (!fs.existsSync(postsDir)) { - fs.mkdirSync(postsDir, { recursive: true }); - } - if (!fs.existsSync(mediaDir)) { - fs.mkdirSync(mediaDir, { recursive: true }); + // Ensure caller-supplied extra directories exist. + for (const dir of this.dataDirs) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } } } @@ -52,12 +56,55 @@ export class DatabaseConnection { // Use file: URL for local SQLite database via libsql this.localClient = createClient({ - url: `file:${this.config.localPath}`, + url: `file:${this.dbPath}`, }); this.localDb = drizzle(this.localClient, { schema }); - // Run migrations - await this.runMigrations(); + // Enable WAL mode and set synchronous=NORMAL for better concurrency and + // performance. WAL mode is a database-level, one-way change — SQLite + // persists it in the file header so subsequent opens keep it automatically. + await this.localClient.execute('PRAGMA journal_mode=WAL'); + await this.localClient.execute('PRAGMA synchronous=NORMAL'); + + // Run Drizzle migrations (creates __drizzle_migrations table automatically) + await migrate(this.localDb, { migrationsFolder: this.migrationsFolder }); + + // Create FTS5 virtual tables (not supported by Drizzle schema). + // These use IF NOT EXISTS so they're safe to run every time. + await this.localClient.execute(` + CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5( + id UNINDEXED, + project_id UNINDEXED, + content, + content_rowid=rowid + ) + `); + + await this.localClient.execute(` + CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5( + id UNINDEXED, + project_id UNINDEXED, + content, + content_rowid=rowid + ) + `); + + // Create a default project if none exists. + const existingProjects = await this.localDb + .select({ count: sql`COUNT(*)` }) + .from(projects); + if (existingProjects[0] && existingProjects[0].count === 0) { + const now = new Date(); + await this.localDb.insert(projects).values({ + id: 'default', + name: 'Default Project', + slug: 'default', + description: 'Your first blog project', + createdAt: now, + updatedAt: now, + isActive: true, + }); + } return this.localDb; } @@ -79,6 +126,11 @@ export class DatabaseConnection { return this.localClient; } + /** Returns the absolute path to the SQLite database file. */ + getDbPath(): string { + return this.dbPath; + } + async getActiveProject(): Promise<{ id: string; name: string; slug: string } | null> { if (!this.localDb) return null; const rows = await this.localDb @@ -103,57 +155,6 @@ export class DatabaseConnection { .where(eq(projects.id, projectId)); } - private async runMigrations(): Promise { - if (!this.localClient || !this.localDb) return; - - // Determine migrations folder path (works in both dev and production) - // In production, migrations are bundled in the app resources - const isDev = !app.isPackaged; - const migrationsFolder = isDev - ? path.join(app.getAppPath(), 'drizzle') - : path.join(process.resourcesPath, 'drizzle'); - - // Run Drizzle migrations (creates __drizzle_migrations table automatically) - await migrate(this.localDb, { migrationsFolder }); - - // Create FTS5 virtual tables (not supported by Drizzle schema) - // These use IF NOT EXISTS so they're safe to run every time - await this.localClient.execute(` - CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5( - id UNINDEXED, - project_id UNINDEXED, - content, - content_rowid=rowid - ) - `); - - await this.localClient.execute(` - CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5( - id UNINDEXED, - project_id UNINDEXED, - content, - content_rowid=rowid - ) - `); - - // Create default project if none exists - const existingProjects = await this.localDb - .select({ count: sql`COUNT(*)` }) - .from(projects); - if (existingProjects[0] && existingProjects[0].count === 0) { - const now = new Date(); - await this.localDb.insert(projects).values({ - id: 'default', - name: 'Default Project', - slug: 'default', - description: 'Your first blog project', - createdAt: now, - updatedAt: now, - isActive: true, - }); - } - } - async close(): Promise { this._closing = true; if (this.localClient) { @@ -162,28 +163,26 @@ export class DatabaseConnection { this.localDb = null; } } - - getDataPaths() { - const userDataPath = app.getPath('userData'); - return { - database: this.config.localPath, - posts: path.join(userDataPath, 'posts'), - media: path.join(userDataPath, 'media'), - }; - } } -// Singleton instance +// ── Singleton ───────────────────────────────────────────────────────────────── +// The singleton is initialised by main.ts (Electron app) or bds-mcp.ts (CLI) +// via initDatabase() before any engine code runs. Calling getDatabase() before +// initDatabase() throws so bugs are caught early. + let dbConnection: DatabaseConnection | null = null; export function getDatabase(): DatabaseConnection { if (!dbConnection) { - dbConnection = new DatabaseConnection(); + throw new Error( + 'DatabaseConnection has not been initialised. ' + + 'Call initDatabase() before calling getDatabase().', + ); } return dbConnection; } -export function initDatabase(config?: Partial): DatabaseConnection { +export function initDatabase(config: DatabaseConnectionConfig): DatabaseConnection { dbConnection = new DatabaseConnection(config); return dbConnection; } diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index b10fcb1..62cbbab 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -164,6 +164,9 @@ export const scripts = sqliteTable('scripts', { enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), version: integer('version').notNull().default(1), filePath: text('file_path').notNull(), + // Draft lifecycle columns (added in 0007) + status: text('status', { enum: ['draft', 'published'] }).notNull().default('published'), + content: text('content'), // draft body; NULL when on-disk (published) createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }, (table) => ({ @@ -181,6 +184,9 @@ export const templates = sqliteTable('templates', { enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), version: integer('version').notNull().default(1), filePath: text('file_path').notNull(), + // Draft lifecycle columns (added in 0007) + status: text('status', { enum: ['draft', 'published'] }).notNull().default('published'), + content: text('content'), // draft body; NULL when on-disk (published) createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }, (table) => ({ @@ -188,6 +194,18 @@ export const templates = sqliteTable('templates', { projectSlugIdx: uniqueIndex('templates_project_slug_idx').on(table.projectId, table.slug), })); +// DB notifications table - CLI writes a row after every mutation; app's NotificationWatcher +// queries for seenAt IS NULL AND fromCli = 1, invalidates engine caches, emits IPC events. +export const dbNotifications = sqliteTable('db_notifications', { + id: integer('id').primaryKey({ autoIncrement: true }), + entity: text('entity').notNull(), // 'post' | 'media' | 'script' | 'template' + entityId: text('entity_id').notNull(), + action: text('action').notNull(), // 'created' | 'updated' | 'deleted' + fromCli: integer('from_cli').notNull().default(1), // 1 = written by CLI; reserved for future app→CLI + seenAt: integer('seen_at'), // NULL = unprocessed by app + createdAt: integer('created_at').notNull(), +}); + // Types for TypeScript export type Project = typeof projects.$inferSelect; export type NewProject = typeof projects.$inferInsert; @@ -215,3 +233,5 @@ export type Script = typeof scripts.$inferSelect; export type NewScript = typeof scripts.$inferInsert; export type Template = typeof templates.$inferSelect; export type NewTemplate = typeof templates.$inferInsert; +export type DbNotification = typeof dbNotifications.$inferSelect; +export type NewDbNotification = typeof dbNotifications.$inferInsert; diff --git a/src/main/engine/AppApiAdapter.ts b/src/main/engine/AppApiAdapter.ts index 09ca430..12077da 100644 --- a/src/main/engine/AppApiAdapter.ts +++ b/src/main/engine/AppApiAdapter.ts @@ -1,7 +1,7 @@ import * as path from 'path'; import * as fsPromises from 'fs/promises'; import { app } from 'electron'; -import { getProjectEngine } from './ProjectEngine'; +import type { ProjectEngine } from './ProjectEngine'; import { getDatabase } from '../database'; /** @@ -9,13 +9,13 @@ import { getDatabase } from '../database'; * Provides safe, read-only app methods without requiring Electron UI facilities. */ export class AppApiAdapter { + constructor(private readonly projectEngine: ProjectEngine) {} async getDataPaths(): Promise<{ database: string; posts: string; media: string }> { - const projectEngine = getProjectEngine(); - const activeProject = await projectEngine.getActiveProject(); + const activeProject = await this.projectEngine.getActiveProject(); const projectId = activeProject?.id || 'default'; - const paths = projectEngine.getProjectPaths(projectId, activeProject?.dataPath); + const paths = this.projectEngine.getProjectPaths(projectId, activeProject?.dataPath); return { - database: getDatabase().getDataPaths().database, + database: getDatabase().getDbPath(), posts: paths.posts, media: paths.media, }; @@ -26,7 +26,7 @@ export class AppApiAdapter { } async getDefaultProjectPath(projectId: string): Promise { - return getProjectEngine().getDefaultProjectBaseDir(projectId); + return this.projectEngine.getDefaultProjectBaseDir(projectId); } async readProjectMetadata(folderPath: string): Promise<{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null> { @@ -46,11 +46,3 @@ export class AppApiAdapter { } } -let instance: AppApiAdapter | null = null; - -export function getAppApiAdapter(): AppApiAdapter { - if (!instance) { - instance = new AppApiAdapter(); - } - return instance; -} diff --git a/src/main/engine/BlogGenerationEngine.ts b/src/main/engine/BlogGenerationEngine.ts index 624ff9d..311f4dd 100644 --- a/src/main/engine/BlogGenerationEngine.ts +++ b/src/main/engine/BlogGenerationEngine.ts @@ -1,8 +1,8 @@ import * as path from 'path'; import * as fs from 'fs/promises'; -import { getPostEngine, type PostData } from './PostEngine'; -import { getMediaEngine, type MediaData } from './MediaEngine'; -import { getPostMediaEngine } from './PostMediaEngine'; +import type { PostEngine, PostData } from './PostEngine'; +import type { MediaEngine, MediaData } from './MediaEngine'; +import type { PostMediaEngine } from './PostMediaEngine'; import { PageRenderer, buildTemplateMenuItems, @@ -195,9 +195,15 @@ function resolvePostCreatedAt(post: { createdAt: Date | string }): Date { } export class BlogGenerationEngine { - private readonly postEngine = getPostEngine(); - private readonly mediaEngine = getMediaEngine(); - private readonly postMediaEngine = getPostMediaEngine(); + private readonly postEngine: PostEngine; + private readonly mediaEngine: MediaEngine; + private readonly postMediaEngine: PostMediaEngine; + + constructor(postEngine: PostEngine, mediaEngine: MediaEngine, postMediaEngine: PostMediaEngine) { + this.postEngine = postEngine; + this.mediaEngine = mediaEngine; + this.postMediaEngine = postMediaEngine; + } async generate(options: BlogGenerationOptions, onProgress: (progress: number, message?: string) => void): Promise { onProgress(0, 'Loading posts...'); @@ -834,11 +840,4 @@ export class BlogGenerationEngine { } -let blogGenerationEngine: BlogGenerationEngine | null = null; -export function getBlogGenerationEngine(): BlogGenerationEngine { - if (!blogGenerationEngine) { - blogGenerationEngine = new BlogGenerationEngine(); - } - return blogGenerationEngine; -} diff --git a/src/main/engine/BlogmarkPythonWorkerRuntime.ts b/src/main/engine/BlogmarkPythonWorkerRuntime.ts index ae5b394..5dcf477 100644 --- a/src/main/engine/BlogmarkPythonWorkerRuntime.ts +++ b/src/main/engine/BlogmarkPythonWorkerRuntime.ts @@ -272,12 +272,3 @@ export class BlogmarkPythonWorkerRuntime { } } -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 a888682..bdbbb58 100644 --- a/src/main/engine/BlogmarkTransformService.ts +++ b/src/main/engine/BlogmarkTransformService.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { getScriptEngine } from './ScriptEngine'; -import { getMetaEngine } from './MetaEngine'; -import { getBlogmarkPythonWorkerRuntime } from './BlogmarkPythonWorkerRuntime'; +import type { BlogmarkPythonWorkerRuntime } from './BlogmarkPythonWorkerRuntime'; +import type { ScriptEngine, ScriptData } from './ScriptEngine'; +import type { MetaEngine } from './MetaEngine'; const transformPostSchema = z.object({ title: z.string().trim().min(1), @@ -63,11 +63,7 @@ const MAX_TOASTS_PER_SCRIPT = 5; const MAX_TOASTS_TOTAL = 20; const MAX_TOAST_LENGTH = 300; -const scriptEngineBackedProvider: BlogmarkTransformScriptProvider = { - async getScripts() { - return getScriptEngine().getAllScripts(); - }, -}; +// Note: scriptEngineBackedProvider removed — ScriptEngine is injected via constructor dep. function toTimestamp(value: Date | string): number { if (value instanceof Date) { @@ -163,8 +159,8 @@ function resolvePythonRuntimeMode(value: unknown): PythonRuntimeMode { return 'webworker'; } -async function getConfiguredPythonRuntimeMode(): Promise { - const metadata = await getMetaEngine().getProjectMetadata(); +async function getConfiguredPythonRuntimeModeFromEngine(metaEngine: MetaEngine): Promise { + const metadata = await metaEngine.getProjectMetadata(); return resolvePythonRuntimeMode((metadata as { pythonRuntimeMode?: unknown } | null)?.pythonRuntimeMode); } @@ -239,8 +235,10 @@ json.dumps(_result) } class PythonWorkerBlogmarkTransformExecutor implements BlogmarkTransformExecutor { + constructor(private readonly runtime: BlogmarkPythonWorkerRuntime) {} + async runTransform(script: BlogmarkTransformScriptRecord, input: BlogmarkTransformInput): Promise { - return getBlogmarkPythonWorkerRuntime().executeTransform({ + return this.runtime.executeTransform({ scriptContent: script.content, entrypoint: resolveTransformEntrypoint(script.entrypoint), payloadJson: JSON.stringify(input), @@ -249,12 +247,14 @@ class PythonWorkerBlogmarkTransformExecutor implements BlogmarkTransformExecutor } const mainThreadExecutor = new PythonBlogmarkTransformExecutor(); -const workerExecutor = new PythonWorkerBlogmarkTransformExecutor(); export class BlogmarkTransformService { constructor( private readonly dependencies: { provider?: BlogmarkTransformScriptProvider; + scriptEngine?: ScriptEngine; + metaEngine?: MetaEngine; + blogmarkWorkerRuntime?: BlogmarkPythonWorkerRuntime; executor?: BlogmarkTransformExecutor; resolvePythonRuntimeMode?: () => Promise; executors?: Partial>; @@ -268,7 +268,10 @@ export class BlogmarkTransformService { post: parsedInput, }; - const provider = this.dependencies.provider ?? scriptEngineBackedProvider; + const provider = this.dependencies.provider + ?? (this.dependencies.scriptEngine + ? { getScripts: (): Promise => this.dependencies.scriptEngine!.getAllScripts() } + : { getScripts: async () => [] }); const executor = this.dependencies.executor ?? await this.resolveExecutorForConfiguredRuntime(); const scripts = await provider.getScripts(); @@ -337,7 +340,10 @@ export class BlogmarkTransformService { } private async resolveExecutorForConfiguredRuntime(): Promise { - const resolveMode = this.dependencies.resolvePythonRuntimeMode ?? getConfiguredPythonRuntimeMode; + const resolveMode = this.dependencies.resolvePythonRuntimeMode + ?? (this.dependencies.metaEngine + ? () => getConfiguredPythonRuntimeModeFromEngine(this.dependencies.metaEngine!) + : () => Promise.resolve('webworker')); const mode = await resolveMode(); const executors = this.dependencies.executors ?? {}; @@ -345,16 +351,12 @@ export class BlogmarkTransformService { return executors['main-thread'] ?? mainThreadExecutor; } + const workerRuntime = this.dependencies.blogmarkWorkerRuntime; + const workerExecutor = workerRuntime + ? new PythonWorkerBlogmarkTransformExecutor(workerRuntime) + : mainThreadExecutor; // fall back to main-thread if no worker runtime injected return executors.webworker ?? workerExecutor; } } -let blogmarkTransformServiceInstance: BlogmarkTransformService | null = null; -export function getBlogmarkTransformService(): BlogmarkTransformService { - if (!blogmarkTransformServiceInstance) { - blogmarkTransformServiceInstance = new BlogmarkTransformService(); - } - - return blogmarkTransformServiceInstance; -} diff --git a/src/main/engine/CliNotifier.ts b/src/main/engine/CliNotifier.ts new file mode 100644 index 0000000..60e13a0 --- /dev/null +++ b/src/main/engine/CliNotifier.ts @@ -0,0 +1,55 @@ +/** + * CliNotifier — interface for signalling the Electron app about mutations + * made by the standalone CLI (`bds-mcp`). + * + * `NoopNotifier` — used by the Electron app; all mutations are no-ops because + * the app is already aware of its own writes. + * `DbNotifier` — used by the CLI; inserts a row into `db_notifications` so + * the app's `NotificationWatcher` can pick it up. + */ + +import { dbNotifications } from '../database/schema'; + +export type NotifyEntity = 'post' | 'media' | 'script' | 'template'; +export type NotifyAction = 'created' | 'updated' | 'deleted'; + +export interface CliNotifier { + notify(entity: NotifyEntity, id: string, action: NotifyAction): Promise; +} + +// ── NoopNotifier ────────────────────────────────────────────────────────────── + +/** Used by the Electron app. All notify calls are instant no-ops. */ +export class NoopNotifier implements CliNotifier { + async notify(_entity: NotifyEntity, _id: string, _action: NotifyAction): Promise { + // intentional no-op + } +} + +// ── DbNotifier ──────────────────────────────────────────────────────────────── + +type DrizzleInsertable = { + insert: (table: typeof dbNotifications) => { + values: (row: typeof dbNotifications.$inferInsert) => Promise; + }; +}; + +/** + * Used by the CLI. Inserts a row into `db_notifications` so the running + * Electron app's `NotificationWatcher` can invalidate its caches and push + * `entity:changed` IPC events to the renderer. + */ +export class DbNotifier implements CliNotifier { + constructor(private readonly db: DrizzleInsertable) {} + + async notify(entity: NotifyEntity, id: string, action: NotifyAction): Promise { + await this.db.insert(dbNotifications).values({ + entity, + entityId: id, + action, + fromCli: 1, + seenAt: null, + createdAt: Date.now(), + }); + } +} diff --git a/src/main/engine/EngineBundle.ts b/src/main/engine/EngineBundle.ts new file mode 100644 index 0000000..cbf2f86 --- /dev/null +++ b/src/main/engine/EngineBundle.ts @@ -0,0 +1,55 @@ +/** + * EngineBundle — the collection of all engine instances constructed at startup. + * + * main.ts (Electron app) constructs these with NoopNotifier and passes them to + * registerIpcHandlers() and MCPServer. + * + * bds-mcp.ts (CLI) constructs the subset it needs (post/media/script/template) + * with DbNotifier and passes them to MCPServer. + */ + +import type { PostEngine } from './PostEngine'; +import type { MediaEngine } from './MediaEngine'; +import type { ScriptEngine } from './ScriptEngine'; +import type { TemplateEngine } from './TemplateEngine'; +import type { MetaEngine } from './MetaEngine'; +import type { MenuEngine } from './MenuEngine'; +import type { TagEngine } from './TagEngine'; +import type { PostMediaEngine } from './PostMediaEngine'; +import type { ProjectEngine } from './ProjectEngine'; +import type { GitEngine } from './GitEngine'; +import type { GitApiAdapter } from './GitApiAdapter'; +import type { BlogGenerationEngine } from './BlogGenerationEngine'; +import type { PublishEngine } from './PublishEngine'; +import type { MetadataDiffEngine } from './MetadataDiffEngine'; +import type { TaskManager } from './TaskManager'; +import type { BlogmarkTransformService } from './BlogmarkTransformService'; +import type { MCPServer } from './MCPServer'; +import type { BlogmarkPythonWorkerRuntime } from './BlogmarkPythonWorkerRuntime'; +import type { PythonMacroWorkerRuntime } from './PythonMacroWorkerRuntime'; +import type { PublishApiAdapter } from './PublishApiAdapter'; +import type { AppApiAdapter } from './AppApiAdapter'; + +export interface EngineBundle { + postEngine: PostEngine; + mediaEngine: MediaEngine; + scriptEngine: ScriptEngine; + templateEngine: TemplateEngine; + metaEngine: MetaEngine; + menuEngine: MenuEngine; + tagEngine: TagEngine; + postMediaEngine: PostMediaEngine; + projectEngine: ProjectEngine; + gitEngine: GitEngine; + gitApiAdapter: GitApiAdapter; + blogGenerationEngine: BlogGenerationEngine; + publishEngine: PublishEngine; + metadataDiffEngine: MetadataDiffEngine; + taskManager: TaskManager; + blogmarkTransformService: BlogmarkTransformService; + mcpServer: MCPServer; + blogmarkPythonWorkerRuntime: BlogmarkPythonWorkerRuntime; + pythonMacroWorkerRuntime: PythonMacroWorkerRuntime; + publishApiAdapter: PublishApiAdapter; + appApiAdapter: AppApiAdapter; +} diff --git a/src/main/engine/GitApiAdapter.ts b/src/main/engine/GitApiAdapter.ts index d3c4ea6..cd11dd1 100644 --- a/src/main/engine/GitApiAdapter.ts +++ b/src/main/engine/GitApiAdapter.ts @@ -1,13 +1,5 @@ -import { getGitEngine } from './GitEngine'; -import { getProjectEngine } from './ProjectEngine'; -import type { - GitAvailability, - RepoState, - GitStatusDto, - GitHistoryEntry, - GitRemoteStateDto, - GitActionResult, -} from './GitEngine'; +import type { GitEngine, GitAvailability, RepoState, GitStatusDto, GitHistoryEntry, GitRemoteStateDto, GitActionResult } from './GitEngine'; +import type { ProjectEngine } from './ProjectEngine'; export type { GitAvailability, RepoState, GitStatusDto, GitHistoryEntry, GitRemoteStateDto, GitActionResult }; @@ -17,8 +9,13 @@ export type { GitAvailability, RepoState, GitStatusDto, GitHistoryEntry, GitRemo * don't need to pass it. */ export class GitApiAdapter { + constructor( + private readonly gitEngine: GitEngine, + private readonly projectEngine: ProjectEngine, + ) {} + private async resolveProjectPath(): Promise { - const project = await getProjectEngine().getActiveProject(); + const project = await this.projectEngine.getActiveProject(); if (!project?.dataPath) { throw new Error('No active project with a data path'); } @@ -26,55 +23,47 @@ export class GitApiAdapter { } async checkAvailability(): Promise { - return getGitEngine().checkAvailability(); + return this.gitEngine.checkAvailability(); } async getRepoState(): Promise { const projectPath = await this.resolveProjectPath(); - return getGitEngine().getRepoState(projectPath); + return this.gitEngine.getRepoState(projectPath); } async getStatus(): Promise { const projectPath = await this.resolveProjectPath(); - return getGitEngine().getStatus(projectPath); + return this.gitEngine.getStatus(projectPath); } async getHistory(limit?: number): Promise { const projectPath = await this.resolveProjectPath(); - return getGitEngine().getHistory(projectPath, limit); + return this.gitEngine.getHistory(projectPath, limit); } async getRemoteState(): Promise { const projectPath = await this.resolveProjectPath(); - return getGitEngine().getRemoteState(projectPath); + return this.gitEngine.getRemoteState(projectPath); } async fetch(): Promise { const projectPath = await this.resolveProjectPath(); - return getGitEngine().fetch(projectPath); + return this.gitEngine.fetch(projectPath); } async pull(): Promise { const projectPath = await this.resolveProjectPath(); - return getGitEngine().pull(projectPath); + return this.gitEngine.pull(projectPath); } async push(): Promise { const projectPath = await this.resolveProjectPath(); - return getGitEngine().push(projectPath); + return this.gitEngine.push(projectPath); } async commitAll(message: string): Promise { const projectPath = await this.resolveProjectPath(); - return getGitEngine().commitAll(projectPath, message); + return this.gitEngine.commitAll(projectPath, message); } } -let instance: GitApiAdapter | null = null; - -export function getGitApiAdapter(): GitApiAdapter { - if (!instance) { - instance = new GitApiAdapter(); - } - return instance; -} diff --git a/src/main/engine/GitEngine.ts b/src/main/engine/GitEngine.ts index c8a771c..1dde68f 100644 --- a/src/main/engine/GitEngine.ts +++ b/src/main/engine/GitEngine.ts @@ -147,15 +147,6 @@ export type { GitTemplateFileChange, GitTemplateFileChangeStatus }; type GitProvider = 'unknown' | 'github' | 'gitlab' | 'gitea-forgejo'; -let gitEngineInstance: GitEngine | null = null; - -export function getGitEngine(): GitEngine { - if (!gitEngineInstance) { - gitEngineInstance = new GitEngine(); - } - return gitEngineInstance; -} - export class GitEngine { private readonly markdownExtensions = new Set(['.md', '.markdown', '.mdx']); diff --git a/src/main/engine/ImportExecutionEngine.ts b/src/main/engine/ImportExecutionEngine.ts index e6cdf6d..c973578 100644 --- a/src/main/engine/ImportExecutionEngine.ts +++ b/src/main/engine/ImportExecutionEngine.ts @@ -19,10 +19,10 @@ import TurndownService from 'turndown'; import { getDatabase } from '../database'; import { posts, media, NewPost, NewMedia } from '../database/schema'; import { eq } from 'drizzle-orm'; -import { getTagEngine } from './TagEngine'; -import { getPostEngine, PostData } from './PostEngine'; -import { getMediaEngine, MediaData } from './MediaEngine'; -import { getPostMediaEngine } from './PostMediaEngine'; +import type { TagEngine } from './TagEngine'; +import type { PostEngine, PostData } from './PostEngine'; +import type { MediaEngine, MediaData } from './MediaEngine'; +import type { PostMediaEngine } from './PostMediaEngine'; import type { ImportAnalysisReport, AnalyzedPost, @@ -71,14 +71,29 @@ export interface ImportExecutionResult { // Regex to match WordPress shortcodes: [macroname ...] but NOT [[macroname ...]] const WP_SHORTCODE_REGEX = /(? void ): Promise { - const tagEngine = getTagEngine(); + const tagEngine = this.tagEngine; tagEngine.setProjectContext(this.currentProjectId); let current = 0; @@ -459,7 +474,7 @@ export class ImportExecutionEngine extends EventEmitter { result: ImportExecutionResult, options: ImportExecutionOptions ): Promise { - const postEngine = getPostEngine(); + const postEngine = this.postEngine; if (resolution === 'overwrite') { // Update the existing post with new content and set to draft for review @@ -493,7 +508,7 @@ export class ImportExecutionEngine extends EventEmitter { ): Promise { const wxrPost = analyzed.wxrPost; const db = getDatabase().getLocal(); - const postEngine = getPostEngine(); + const postEngine = this.postEngine; // Convert Vimeo iframes to [[vimeo]] macros BEFORE markdown conversion const contentWithVimeo = this.convertVimeoIframes(wxrPost.content); @@ -640,7 +655,7 @@ export class ImportExecutionEngine extends EventEmitter { await db.insert(posts).values(dbPost); // Update FTS index - const postEngine = getPostEngine(); + const postEngine = this.postEngine; await postEngine.updateFTSIndex(postData); // Track wpId to postId mapping @@ -774,7 +789,7 @@ export class ImportExecutionEngine extends EventEmitter { const createdAt = this.toDate(wxrMedia.pubDate) || new Date(); // Import the media file - const mediaEngine = getMediaEngine(); + const mediaEngine = this.mediaEngine; const importedMedia = await mediaEngine.importMedia(sourcePath, { title: wxrMedia.title || undefined, alt: wxrMedia.description || undefined, @@ -788,7 +803,7 @@ export class ImportExecutionEngine extends EventEmitter { // Link media to posts in the postMedia table if (linkedPostIds.length > 0) { - const postMediaEngine = getPostMediaEngine(); + const postMediaEngine = this.postMediaEngine; postMediaEngine.setProjectContext(this.currentProjectId); for (const postId of linkedPostIds) { await postMediaEngine.linkMediaToPost(postId, importedMedia.id); @@ -824,7 +839,7 @@ export class ImportExecutionEngine extends EventEmitter { return false; } - const mediaEngine = getMediaEngine(); + const mediaEngine = this.mediaEngine; // Replace the file on disk and update size/checksum/dimensions in database await mediaEngine.replaceMediaFile(existingMediaId, sourcePath); @@ -847,7 +862,7 @@ export class ImportExecutionEngine extends EventEmitter { // Link media to posts in the postMedia table if needed if (linkedPostIds.length > 0) { - const postMediaEngine = getPostMediaEngine(); + const postMediaEngine = this.postMediaEngine; postMediaEngine.setProjectContext(this.currentProjectId); for (const postId of linkedPostIds) { await postMediaEngine.linkMediaToPost(postId, existingMediaId); @@ -1164,12 +1179,3 @@ export class ImportExecutionEngine extends EventEmitter { } } -// Singleton instance -let importExecutionEngineInstance: ImportExecutionEngine | null = null; - -export function getImportExecutionEngine(): ImportExecutionEngine { - if (!importExecutionEngineInstance) { - importExecutionEngineInstance = new ImportExecutionEngine(); - } - return importExecutionEngineInstance; -} diff --git a/src/main/engine/MCPAgentConfigEngine.ts b/src/main/engine/MCPAgentConfigEngine.ts index 8dac123..e42902c 100644 --- a/src/main/engine/MCPAgentConfigEngine.ts +++ b/src/main/engine/MCPAgentConfigEngine.ts @@ -11,7 +11,7 @@ import path from 'path'; // ── Public types ───────────────────────────────────────────────────── -export type MCPAgentId = 'claude-code' | 'github-copilot' | 'gemini-cli' | 'opencode'; +export type MCPAgentId = 'claude-code' | 'claude-desktop' | 'github-copilot' | 'gemini-cli' | 'opencode'; export interface AgentDefinition { id: MCPAgentId; @@ -28,12 +28,17 @@ export interface MCPAgentConfigOptions { homeDir: string; platform: NodeJS.Platform; mcpUrl: string; + /** Required when agentId is 'claude-desktop'; unused otherwise. */ + execPath?: string; + /** Required when agentId is 'claude-desktop'; unused otherwise. */ + scriptPath?: string; } // ── Agent definitions ──────────────────────────────────────────────── const AGENTS: AgentDefinition[] = [ { id: 'claude-code', label: 'Claude Code' }, + { id: 'claude-desktop', label: 'Claude Desktop' }, { id: 'github-copilot', label: 'GitHub Copilot' }, { id: 'gemini-cli', label: 'Gemini CLI' }, { id: 'opencode', label: 'OpenCode' }, @@ -47,11 +52,15 @@ export class MCPAgentConfigEngine { private readonly homeDir: string; private readonly platform: NodeJS.Platform; private readonly mcpUrl: string; + private readonly execPath?: string; + private readonly scriptPath?: string; constructor(opts: MCPAgentConfigOptions) { this.homeDir = opts.homeDir; this.platform = opts.platform; this.mcpUrl = opts.mcpUrl; + this.execPath = opts.execPath; + this.scriptPath = opts.scriptPath; } /** Return the list of supported agent definitions. */ @@ -64,6 +73,8 @@ export class MCPAgentConfigEngine { switch (agentId) { case 'claude-code': return path.join(this.homeDir, '.claude.json'); + case 'claude-desktop': + return this.claudeDesktopConfigPath(); case 'github-copilot': return this.vsCodeMcpPath(); case 'gemini-cli': @@ -73,6 +84,35 @@ export class MCPAgentConfigEngine { } } + /** Remove the bDS MCP server entry from the agent's config file. */ + removeFromConfig(agentId: MCPAgentId): AgentConfigResult { + const configPath = this.getConfigPath(agentId); + try { + if (!existsSync(configPath)) { + return { success: true, configPath }; + } + const existing = this.readExisting(configPath); + const serversKey = agentId === 'github-copilot' ? 'servers' : 'mcpServers'; + const currentServers = (existing[serversKey] as Record | undefined) ?? {}; + if (!(SERVER_NAME in currentServers)) { + return { success: true, configPath }; + } + const { [SERVER_NAME]: _removed, ...remainingServers } = currentServers; + const updated: Record = { ...existing }; + if (Object.keys(remainingServers).length === 0) { + delete updated[serversKey]; + } else { + updated[serversKey] = remainingServers; + } + this.ensureDir(configPath); + writeFileSync(configPath, JSON.stringify(updated, null, 2) + '\n', 'utf-8'); + return { success: true, configPath }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, configPath, error: message }; + } + } + /** Read-merge-write the bDS MCP server entry into the agent's config file. */ addToConfig(agentId: MCPAgentId): AgentConfigResult { const configPath = this.getConfigPath(agentId); @@ -103,6 +143,16 @@ export class MCPAgentConfigEngine { // ── Private helpers ────────────────────────────────────────────── + private claudeDesktopConfigPath(): string { + if (this.platform === 'darwin') { + return path.join(this.homeDir, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'); + } + if (this.platform === 'win32') { + return path.join(this.homeDir, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json'); + } + return path.join(this.homeDir, '.config', 'Claude', 'claude_desktop_config.json'); + } + private vsCodeMcpPath(): string { if (this.platform === 'darwin') { return path.join(this.homeDir, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'); @@ -138,6 +188,18 @@ export class MCPAgentConfigEngine { switch (agentId) { case 'claude-code': return { type: 'http', url: this.mcpUrl }; + case 'claude-desktop': { + if (!this.execPath || !this.scriptPath) { + throw new Error( + 'claude-desktop requires execPath and scriptPath options in MCPAgentConfigOptions', + ); + } + return { + command: this.execPath, + args: [this.scriptPath], + env: { ELECTRON_RUN_AS_NODE: '1' }, + }; + } case 'github-copilot': return { type: 'http', url: this.mcpUrl }; case 'gemini-cli': diff --git a/src/main/engine/MCPServer.ts b/src/main/engine/MCPServer.ts index a5f09d2..0d14a00 100644 --- a/src/main/engine/MCPServer.ts +++ b/src/main/engine/MCPServer.ts @@ -1,4 +1,5 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { registerAppTool, @@ -82,12 +83,16 @@ interface MediaEngineContract { } interface ScriptEngineContract { - createScript: (input: CreateScriptInput) => Promise; + createDraftScript: (input: CreateScriptInput) => Promise; + publishScript: (id: string) => Promise; + deleteDraftScript: (id: string) => Promise; validateScript: (content: string) => Promise; } interface TemplateEngineContract { - createTemplate: (input: CreateTemplateInput) => Promise; + createDraftTemplate: (input: CreateTemplateInput) => Promise; + publishTemplate: (id: string) => Promise; + deleteDraftTemplate: (id: string) => Promise; validateTemplate: (content: string) => Promise; } @@ -105,13 +110,13 @@ interface TagEngineContract { } export interface MCPServerDependencies { - getPostEngine: () => PostEngineContract; - getMediaEngine: () => MediaEngineContract; - getScriptEngine: () => ScriptEngineContract; - getTemplateEngine: () => TemplateEngineContract; - getMetaEngine: () => MetaEngineContract; - getPostMediaEngine: () => PostMediaEngineContract; - getTagEngine: () => TagEngineContract; + postEngine: PostEngineContract; + mediaEngine: MediaEngineContract; + scriptEngine: ScriptEngineContract; + templateEngine: TemplateEngineContract; + metaEngine: MetaEngineContract; + postMediaEngine: PostMediaEngineContract; + tagEngine: TagEngineContract; } /** @@ -120,8 +125,8 @@ export interface MCPServerDependencies { */ export interface ProposalDataMap { draftPost: { postId: string }; - proposeScript: CreateScriptInput; - proposeTemplate: CreateTemplateInput; + proposeScript: { scriptId: string }; + proposeTemplate: { templateId: string }; proposeMediaMetadata: { mediaId: string; changes: Partial }; proposePostMetadata: { postId: string; changes: Partial }; } @@ -139,9 +144,21 @@ export class MCPServer { private httpServer: Server | null = null; private port: number | null = null; - constructor(deps: MCPServerDependencies) { + constructor(deps: MCPServerDependencies, opts?: { proposalTtlMs?: number }) { this.deps = deps; - this.proposalStore = new ProposalStore(); + this.proposalStore = new ProposalStore( + opts?.proposalTtlMs, + (proposal) => { + // Clean up draft DB rows on TTL expiry + if (proposal.type === 'proposeScript') { + const { scriptId } = proposalData<'proposeScript'>(proposal); + this.deps.scriptEngine.deleteDraftScript(scriptId).catch(() => {}); + } else if (proposal.type === 'proposeTemplate') { + const { templateId } = proposalData<'proposeTemplate'>(proposal); + this.deps.templateEngine.deleteDraftTemplate(templateId).catch(() => {}); + } + }, + ); } /** Create a fresh McpServer with all tools/resources/prompts registered (stateless — one per request). */ @@ -268,6 +285,16 @@ export class MCPServer { return this.port; } + async startCli(): Promise { + const server = this.createMcpServer(); + const transport = new StdioServerTransport(); + await server.connect(transport); + await new Promise((resolve) => { + process.stdin.on('close', resolve); + }); + await server.close(); + } + // ── Accept / Discard ──────────────────────────────────────────────── async acceptProposal(proposalId: string): Promise<{ success: boolean; message: string }> { @@ -280,25 +307,27 @@ export class MCPServer { switch (proposal.type) { case 'draftPost': { const { postId } = proposalData<'draftPost'>(proposal); - await this.deps.getPostEngine().publishPost(postId); + await this.deps.postEngine.publishPost(postId); break; } case 'proposeScript': { - await this.deps.getScriptEngine().createScript(proposalData<'proposeScript'>(proposal)); + const { scriptId } = proposalData<'proposeScript'>(proposal); + await this.deps.scriptEngine.publishScript(scriptId); break; } case 'proposeTemplate': { - await this.deps.getTemplateEngine().createTemplate(proposalData<'proposeTemplate'>(proposal)); + const { templateId } = proposalData<'proposeTemplate'>(proposal); + await this.deps.templateEngine.publishTemplate(templateId); break; } case 'proposeMediaMetadata': { const { mediaId, changes } = proposalData<'proposeMediaMetadata'>(proposal); - await this.deps.getMediaEngine().updateMedia(mediaId, changes); + await this.deps.mediaEngine.updateMedia(mediaId, changes); break; } case 'proposePostMetadata': { const { postId, changes } = proposalData<'proposePostMetadata'>(proposal); - await this.deps.getPostEngine().updatePost(postId, changes); + await this.deps.postEngine.updatePost(postId, changes); break; } } @@ -318,7 +347,13 @@ export class MCPServer { try { if (proposal.type === 'draftPost') { const { postId } = proposalData<'draftPost'>(proposal); - await this.deps.getPostEngine().deletePost(postId); + await this.deps.postEngine.deletePost(postId); + } else if (proposal.type === 'proposeScript') { + const { scriptId } = proposalData<'proposeScript'>(proposal); + await this.deps.scriptEngine.deleteDraftScript(scriptId); + } else if (proposal.type === 'proposeTemplate') { + const { templateId } = proposalData<'proposeTemplate'>(proposal); + await this.deps.templateEngine.deleteDraftTemplate(templateId); } this.proposalStore.remove(proposalId); return { success: true, message: `Proposal ${proposalId} discarded.` }; @@ -331,7 +366,7 @@ export class MCPServer { private registerResources(server: McpServer): void { server.registerResource('posts', 'bds://posts', { description: 'All blog posts (first page)' }, async () => { - const result = await this.deps.getPostEngine().getAllPosts({ limit: DEFAULT_PAGE_SIZE }); + const result = await this.deps.postEngine.getAllPosts({ limit: DEFAULT_PAGE_SIZE }); const response: Record = { ...result }; if (result.hasMore) { response.nextCursor = encodeCursor(DEFAULT_PAGE_SIZE); @@ -340,7 +375,7 @@ export class MCPServer { }); server.registerResource('media', 'bds://media', { description: 'All media files (first page)' }, async () => { - const allMedia = await this.deps.getMediaEngine().getAllMedia(); + const allMedia = await this.deps.mediaEngine.getAllMedia(); const items = allMedia.slice(0, DEFAULT_PAGE_SIZE); const total = allMedia.length; const hasMore = DEFAULT_PAGE_SIZE < total; @@ -352,17 +387,17 @@ export class MCPServer { }); server.registerResource('tags', 'bds://tags', { description: 'Tags with post counts' }, async () => { - const result = await this.deps.getPostEngine().getTagsWithCounts(); + const result = await this.deps.postEngine.getTagsWithCounts(); return { contents: [{ uri: 'bds://tags', mimeType: 'application/json', text: JSON.stringify(result) }] }; }); server.registerResource('categories', 'bds://categories', { description: 'Categories with post counts' }, async () => { - const result = await this.deps.getPostEngine().getCategoriesWithCounts(); + const result = await this.deps.postEngine.getCategoriesWithCounts(); return { contents: [{ uri: 'bds://categories', mimeType: 'application/json', text: JSON.stringify(result) }] }; }); server.registerResource('stats', 'bds://stats', { description: 'Blog statistics' }, async () => { - const result = await this.deps.getPostEngine().getBlogStats(); + const result = await this.deps.postEngine.getBlogStats(); return { contents: [{ uri: 'bds://stats', mimeType: 'application/json', text: JSON.stringify(result) }] }; }); } @@ -371,7 +406,7 @@ export class MCPServer { // ── Pagination templates ── server.registerResource('posts-page', new ResourceTemplate('bds://posts{?cursor}', { list: undefined }), { description: 'Paginated blog posts (use cursor from previous page)' }, async (uri, { cursor }) => { const offset = decodeCursor(cursor as string); - const result = await this.deps.getPostEngine().getAllPosts({ limit: DEFAULT_PAGE_SIZE, offset }); + const result = await this.deps.postEngine.getAllPosts({ limit: DEFAULT_PAGE_SIZE, offset }); const response: Record = { ...result }; if (result.hasMore) { response.nextCursor = encodeCursor(offset + DEFAULT_PAGE_SIZE); @@ -381,7 +416,7 @@ export class MCPServer { server.registerResource('media-page', new ResourceTemplate('bds://media{?cursor}', { list: undefined }), { description: 'Paginated media files (use cursor from previous page)' }, async (uri, { cursor }) => { const offset = decodeCursor(cursor as string); - const allMedia = await this.deps.getMediaEngine().getAllMedia(); + const allMedia = await this.deps.mediaEngine.getAllMedia(); const items = allMedia.slice(offset, offset + DEFAULT_PAGE_SIZE); const total = allMedia.length; const hasMore = offset + DEFAULT_PAGE_SIZE < total; @@ -394,42 +429,42 @@ export class MCPServer { // ── Entity templates ── server.registerResource('post', new ResourceTemplate('bds://posts/{id}', { list: undefined }), { description: 'A single post by ID' }, async (uri, { id }) => { - const result = await this.deps.getPostEngine().getPost(id as string); + const result = await this.deps.postEngine.getPost(id as string); return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] }; }); server.registerResource('media-item', new ResourceTemplate('bds://media/{id}', { list: undefined }), { description: 'A single media item by ID' }, async (uri, { id }) => { - const result = await this.deps.getMediaEngine().getMedia(id as string); + const result = await this.deps.mediaEngine.getMedia(id as string); return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] }; }); server.registerResource('post-backlinks', new ResourceTemplate('bds://posts/{id}/backlinks', { list: undefined }), { description: 'Posts linking to this post' }, async (uri, { id }) => { - const result = await this.deps.getPostEngine().getLinkedBy(id as string); + const result = await this.deps.postEngine.getLinkedBy(id as string); return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] }; }); server.registerResource('post-outlinks', new ResourceTemplate('bds://posts/{id}/outlinks', { list: undefined }), { description: 'Posts this post links to' }, async (uri, { id }) => { - const result = await this.deps.getPostEngine().getLinksTo(id as string); + const result = await this.deps.postEngine.getLinksTo(id as string); return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] }; }); server.registerResource('post-media', new ResourceTemplate('bds://posts/{id}/media', { list: undefined }), { description: 'Media linked to a post' }, async (uri, { id }) => { - const result = await this.deps.getPostMediaEngine().getLinkedMediaDataForPost(id as string); + const result = await this.deps.postMediaEngine.getLinkedMediaDataForPost(id as string); return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] }; }); server.registerResource('media-posts', new ResourceTemplate('bds://media/{id}/posts', { list: undefined }), { description: 'Posts linked to a media item' }, async (uri, { id }) => { - const result = await this.deps.getPostMediaEngine().getLinkedPostsForMedia(id as string); + const result = await this.deps.postMediaEngine.getLinkedPostsForMedia(id as string); return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] }; }); server.registerResource('media-image', new ResourceTemplate('bds://media/{id}/image', { list: undefined }), { description: 'Image thumbnail (medium size, base64 WebP) for visual context' }, async (uri, { id }) => { const mediaId = id as string; - const media = await this.deps.getMediaEngine().getMedia(mediaId); + const media = await this.deps.mediaEngine.getMedia(mediaId); if (!media || !media.mimeType.startsWith('image/')) { return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'Not an image or media not found' }] }; } - const dataUrl = await this.deps.getMediaEngine().getThumbnailDataUrl(mediaId, 'medium'); + const dataUrl = await this.deps.mediaEngine.getThumbnailDataUrl(mediaId, 'medium'); if (!dataUrl) { return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'Thumbnail not available' }] }; } @@ -464,7 +499,7 @@ export class MCPServer { if (args.query && !hasFilters) { // Pure text search — use FTS - const results = await this.deps.getPostEngine().searchPosts(args.query); + const results = await this.deps.postEngine.searchPosts(args.query); const paginated = results.slice(offset, offset + limit); return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] }; } @@ -479,14 +514,14 @@ export class MCPServer { if (args.query && hasFilters) { // FTS + structural filters: single SQL JOIN query, ranked by FTS score - const results = await this.deps.getPostEngine().searchPostsFiltered( + const results = await this.deps.postEngine.searchPostsFiltered( args.query, filter, { offset, limit }, ); return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] }; } // Filter-only query (no text search) - const results = await this.deps.getPostEngine().getPostsFiltered(filter); + const results = await this.deps.postEngine.getPostsFiltered(filter); const paginated = results.slice(offset, offset + limit); return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] }; }); @@ -509,7 +544,7 @@ export class MCPServer { _meta: { ui: { resourceUri: 'ui://bds/review-post' } }, }, async (args: { title: string; content: string; excerpt?: string; tags?: string[]; categories?: string[]; author?: string }) => { try { - const post = await this.deps.getPostEngine().createPost({ + const post = await this.deps.postEngine.createPost({ title: args.title, content: args.content, excerpt: args.excerpt, @@ -543,13 +578,14 @@ export class MCPServer { annotations: { readOnlyHint: false, destructiveHint: false }, _meta: { ui: { resourceUri: 'ui://bds/review-script' } }, }, async (args: { title: string; kind: 'macro' | 'utility' | 'transform'; content: string; entrypoint?: string }) => { - const validation = await this.deps.getScriptEngine().validateScript(args.content); - const proposalId = this.proposalStore.create('proposeScript', { + const validation = await this.deps.scriptEngine.validateScript(args.content); + const draft = await this.deps.scriptEngine.createDraftScript({ title: args.title, kind: args.kind, content: args.content, entrypoint: args.entrypoint, }); + const proposalId = this.proposalStore.create('proposeScript', { scriptId: draft.id }); return { content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, preview: { title: args.title, kind: args.kind, contentLength: args.content.length, syntaxValid: validation.valid, syntaxErrors: validation.errors } }) }], }; @@ -567,12 +603,13 @@ export class MCPServer { annotations: { readOnlyHint: false, destructiveHint: false }, _meta: { ui: { resourceUri: 'ui://bds/review-template' } }, }, async (args: { title: string; kind: 'post' | 'list' | 'not-found' | 'partial'; content: string }) => { - const validation = await this.deps.getTemplateEngine().validateTemplate(args.content); - const proposalId = this.proposalStore.create('proposeTemplate', { + const validation = await this.deps.templateEngine.validateTemplate(args.content); + const draft = await this.deps.templateEngine.createDraftTemplate({ title: args.title, kind: args.kind, content: args.content, }); + const proposalId = this.proposalStore.create('proposeTemplate', { templateId: draft.id }); return { content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, preview: { title: args.title, kind: args.kind, contentLength: args.content.length, syntaxValid: validation.valid, syntaxErrors: validation.errors } }) }], }; @@ -594,7 +631,7 @@ export class MCPServer { }, async (args: { mediaId: string; alt?: string; caption?: string; title?: string; tags?: string[] }) => { try { const { mediaId, ...changes } = args; - const current = await this.deps.getMediaEngine().getMedia(mediaId); + const current = await this.deps.mediaEngine.getMedia(mediaId); if (!current) { return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `Media item ${mediaId} not found.` }) }], @@ -632,7 +669,7 @@ export class MCPServer { }, async (args: { postId: string; title?: string; excerpt?: string; tags?: string[]; categories?: string[] }) => { try { const { postId, ...changes } = args; - const current = await this.deps.getPostEngine().getPost(postId); + const current = await this.deps.postEngine.getPost(postId); if (!current) { return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `Post ${postId} not found.` }) }], @@ -850,20 +887,3 @@ function parseBody(req: { on: (event: string, cb: (data: unknown) => void) => vo }); } -// ── Singleton ─────────────────────────────────────────────────────── - -let mcpServerInstance: MCPServer | null = null; - -export function getMCPServer(deps?: MCPServerDependencies): MCPServer { - if (!mcpServerInstance) { - if (!deps) { - throw new Error('MCPServer dependencies must be provided on first call to getMCPServer()'); - } - mcpServerInstance = new MCPServer(deps); - } - return mcpServerInstance; -} - -export function resetMCPServer(): void { - mcpServerInstance = null; -} diff --git a/src/main/engine/MediaEngine.ts b/src/main/engine/MediaEngine.ts index 73f7f19..2923d84 100644 --- a/src/main/engine/MediaEngine.ts +++ b/src/main/engine/MediaEngine.ts @@ -8,6 +8,7 @@ import { app } from 'electron'; import { getDatabase } from '../database'; import { media, Media, NewMedia, postMedia } from '../database/schema'; import { stemText, stemQuery, SupportedLanguage } from './stemmer'; +import { CliNotifier, NoopNotifier } from './CliNotifier'; // Thumbnail sizes const THUMBNAIL_SIZES = { @@ -75,11 +76,16 @@ export class MediaEngine extends EventEmitter { private dataDir: string | null = null; // For media files (may be external) private internalDir: string | null = null; // For thumbnails (always local) private searchLanguage: SupportedLanguage = 'english'; + private readonly notifier: CliNotifier; - constructor() { + constructor(notifier: CliNotifier = new NoopNotifier()) { super(); + this.notifier = notifier; } + /** No persistent cache — DB is the source of truth. No-op for watcher compat. */ + invalidate(_entityId?: string): void {} + /** * Set the language used for full-text search stemming. */ @@ -582,6 +588,7 @@ export class MediaEngine extends EventEmitter { }); this.emit('mediaImported', mediaData); + await this.notifier.notify('media', mediaData.id, 'created'); return mediaData; } @@ -628,6 +635,7 @@ export class MediaEngine extends EventEmitter { }); this.emit('mediaUpdated', updated); + await this.notifier.notify('media', id, 'updated'); return updated; } @@ -738,6 +746,7 @@ export class MediaEngine extends EventEmitter { await this.deleteFTSIndex(id); this.emit('mediaDeleted', id); + await this.notifier.notify('media', id, 'deleted'); return true; } @@ -1275,12 +1284,4 @@ export class MediaEngine extends EventEmitter { } } -// Singleton instance -let mediaEngine: MediaEngine | null = null; -export function getMediaEngine(): MediaEngine { - if (!mediaEngine) { - mediaEngine = new MediaEngine(); - } - return mediaEngine; -} diff --git a/src/main/engine/MenuEngine.ts b/src/main/engine/MenuEngine.ts index 78f7bd6..88cb679 100644 --- a/src/main/engine/MenuEngine.ts +++ b/src/main/engine/MenuEngine.ts @@ -309,11 +309,3 @@ export class MenuEngine extends EventEmitter { } } -let menuEngine: MenuEngine | null = null; - -export function getMenuEngine(): MenuEngine { - if (!menuEngine) { - menuEngine = new MenuEngine(); - } - return menuEngine; -} diff --git a/src/main/engine/MetaEngine.ts b/src/main/engine/MetaEngine.ts index cf4b12a..abe7dd2 100644 --- a/src/main/engine/MetaEngine.ts +++ b/src/main/engine/MetaEngine.ts @@ -901,12 +901,3 @@ export class MetaEngine extends EventEmitter { } } -// Singleton instance -let metaEngineInstance: MetaEngine | null = null; - -export function getMetaEngine(): MetaEngine { - if (!metaEngineInstance) { - metaEngineInstance = new MetaEngine(); - } - return metaEngineInstance; -} diff --git a/src/main/engine/MetadataDiffEngine.ts b/src/main/engine/MetadataDiffEngine.ts index a7effad..8fc498b 100644 --- a/src/main/engine/MetadataDiffEngine.ts +++ b/src/main/engine/MetadataDiffEngine.ts @@ -11,8 +11,8 @@ import { eq, and } from 'drizzle-orm'; import { getDatabase } from '../database'; import { posts, media } from '../database/schema'; import { readPostFile, PostFileData } from './postFileUtils'; -import { getPostEngine } from './PostEngine'; import { taskManager } from './TaskManager'; +import type { PostEngine } from './PostEngine'; /** * A difference in a specific metadata field @@ -77,6 +77,10 @@ export interface TableStats { export class MetadataDiffEngine extends EventEmitter { private currentProjectId = 'default'; + constructor(private readonly postEngine?: PostEngine) { + super(); + } + private async runSyncLoop( postIds: string[], onProgress: ((percent: number, message: string) => void) | undefined, @@ -363,7 +367,8 @@ export class MetadataDiffEngine extends EventEmitter { postIds: string[], onProgress?: (percent: number, message: string) => void ): Promise<{ success: number; failed: number }> { - const postEngine = getPostEngine(); + const postEngine = this.postEngine; + if (!postEngine) throw new Error('MetadataDiffEngine: postEngine not injected'); return this.runSyncLoop( postIds, onProgress, @@ -483,12 +488,4 @@ export class MetadataDiffEngine extends EventEmitter { } } -// Singleton instance -let metadataDiffEngineInstance: MetadataDiffEngine | null = null; -export function getMetadataDiffEngine(): MetadataDiffEngine { - if (!metadataDiffEngineInstance) { - metadataDiffEngineInstance = new MetadataDiffEngine(); - } - return metadataDiffEngineInstance; -} diff --git a/src/main/engine/NotificationWatcher.ts b/src/main/engine/NotificationWatcher.ts new file mode 100644 index 0000000..7bba9f2 --- /dev/null +++ b/src/main/engine/NotificationWatcher.ts @@ -0,0 +1,125 @@ +/** + * NotificationWatcher — watches bds.db and bds.db-wal for changes made by the + * standalone CLI (`bds-mcp`), then invalidates engine caches and emits + * `entity:changed` IPC events to the renderer so the UI stays in sync. + * + * The watcher fires on every DB write (not only CLI writes). `process()` reads + * `db_notifications` for rows where `seenAt IS NULL AND fromCli = 1`. When the + * CLI is not running that query returns zero rows in one cheap SELECT. + */ + +import chokidar, { type FSWatcher } from 'chokidar'; +import type { BrowserWindow } from 'electron'; +import { and, eq, isNull, lt } from 'drizzle-orm'; +import { dbNotifications } from '../database/schema'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface WatchableEngine { + invalidate(entityId?: string): void; +} + +export type WatchableEngines = Partial>; + +// The minimal subset of a Drizzle LibSQL db that NotificationWatcher needs. +type DrizzleDB = { + select: () => { + from: (table: typeof dbNotifications) => { + where: (condition: unknown) => Promise< + Array<{ + id: number; + entity: string; + entityId: string; + action: string; + fromCli: number; + seenAt: number | null; + createdAt: number; + }> + >; + }; + }; + update: (table: typeof dbNotifications) => { + set: (values: Partial) => { + where: (condition: unknown) => Promise; + }; + }; + delete: (table: typeof dbNotifications) => { + where: (condition: unknown) => Promise; + }; +}; + +// ── NotificationWatcher ────────────────────────────────────────────────────── + +export class NotificationWatcher { + private watcher: FSWatcher | null = null; + private debounceTimer: ReturnType | null = null; + private isProcessing = false; + + constructor( + private readonly dbPath: string, + private readonly db: DrizzleDB, + private readonly engines: WatchableEngines, + private readonly mainWindow: BrowserWindow, + private readonly debounceMs = 100, + ) {} + + start(): void { + this.watcher = chokidar.watch([this.dbPath, `${this.dbPath}-wal`], { + persistent: false, + ignoreInitial: true, + usePolling: false, + awaitWriteFinish: false, + }); + this.watcher.on('change', () => this.schedule()); + this.watcher.on('add', () => this.schedule()); + } + + stop(): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + this.watcher?.close().catch(() => {}); + } + + private schedule(): void { + if (this.debounceTimer) clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => this.process(), this.debounceMs); + } + + private async process(): Promise { + if (this.isProcessing) return; + this.isProcessing = true; + try { + const rows = await this.db + .select() + .from(dbNotifications) + .where(and(isNull(dbNotifications.seenAt), eq(dbNotifications.fromCli, 1))); + + for (const row of rows) { + this.engines[row.entity]?.invalidate(row.entityId); + this.mainWindow.webContents.send('entity:changed', { + entity: row.entity, + entityId: row.entityId, + action: row.action, + }); + await this.db + .update(dbNotifications) + .set({ seenAt: Date.now() }) + .where(eq(dbNotifications.id, row.id)); + } + + const now = Date.now(); + // Prune rows processed more than 1 hour ago. + await this.db + .delete(dbNotifications) + .where(lt(dbNotifications.seenAt, now - 3_600_000)); + // Prune unprocessed rows older than 24 hours (written while app was closed). + await this.db.delete(dbNotifications).where( + and(isNull(dbNotifications.seenAt), lt(dbNotifications.createdAt, now - 86_400_000)), + ); + } finally { + this.isProcessing = false; + } + } +} diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index b5210c8..4c1f9d0 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -15,7 +15,7 @@ import { BrowserWindow } from 'electron'; import { ChatEngine } from './ChatEngine'; import { PostEngine, type PostData } from './PostEngine'; import { MediaEngine, type MediaData } from './MediaEngine'; -import { getPostMediaEngine } from './PostMediaEngine'; +import type { PostMediaEngine } from './PostMediaEngine'; import { isRenderTool, generateFromToolCall } from '../a2ui/generator'; import type { A2UIServerMessage } from '../a2ui/types'; @@ -155,6 +155,7 @@ export class OpenCodeManager { private chatEngine: ChatEngine; private postEngine: PostEngine; private mediaEngine: MediaEngine; + private postMediaEngine: PostMediaEngine; private getMainWindow: () => BrowserWindow | null; private apiKey: string = ''; private abortControllers: Map = new Map(); @@ -172,11 +173,13 @@ export class OpenCodeManager { chatEngine: ChatEngine, postEngine: PostEngine, mediaEngine: MediaEngine, + postMediaEngine: PostMediaEngine, getMainWindow: () => BrowserWindow | null ) { this.chatEngine = chatEngine; this.postEngine = postEngine; this.mediaEngine = mediaEngine; + this.postMediaEngine = postMediaEngine; this.getMainWindow = getMainWindow; } @@ -1522,7 +1525,7 @@ export class OpenCodeManager { } case 'get_post_media': { - const postMediaEngine = getPostMediaEngine(); + const postMediaEngine = this.postMediaEngine; const linkedMedia = await postMediaEngine.getLinkedMediaDataForPost(args.postId as string); return { success: true, @@ -1544,7 +1547,7 @@ export class OpenCodeManager { } case 'get_media_posts': { - const postMediaEngine = getPostMediaEngine(); + const postMediaEngine = this.postMediaEngine; const linkedPosts = await postMediaEngine.getLinkedPostsForMedia(args.mediaId as string); // Fetch full post data for each linked post diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index 0e2ba32..65659e7 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -11,6 +11,8 @@ import { posts, Post, NewPost, postLinks } from '../database/schema'; import { taskManager, Task } from './TaskManager'; import { stemText, stemQuery, SupportedLanguage } from './stemmer'; import { readPostFile as readPostFileShared, type PostFileData } from './postFileUtils'; +import { CliNotifier, NoopNotifier } from './CliNotifier'; +import type { MediaEngine } from './MediaEngine'; export interface PostData { id: string; @@ -90,11 +92,18 @@ export interface PublishedPostReconcileResult { export class PostEngine extends EventEmitter { private currentProjectId: string = 'default'; private searchLanguage: SupportedLanguage = 'english'; + private readonly notifier: CliNotifier; + private readonly mediaEngine: MediaEngine | undefined; - constructor() { + constructor(opts: { notifier?: CliNotifier; mediaEngine?: MediaEngine } = {}) { super(); + this.notifier = opts.notifier ?? new NoopNotifier(); + this.mediaEngine = opts.mediaEngine; } + /** No persistent cache — DB is the source of truth. No-op for watcher compat. */ + invalidate(_entityId?: string): void {} + /** * Set the language used for full-text search stemming. * Affects both indexing and query processing. @@ -419,6 +428,7 @@ export class PostEngine extends EventEmitter { await this.updateFTSIndex(post); this.emit('postCreated', post); + await this.notifier.notify('post', post.id, 'created'); return post; } @@ -488,6 +498,7 @@ export class PostEngine extends EventEmitter { } this.emit('postUpdated', updated); + await this.notifier.notify('post', id, 'updated'); return updated; } @@ -515,20 +526,31 @@ export class PostEngine extends EventEmitter { // Delete post-media links and update media sidecars const { postMedia } = await import('../database/schema'); - const { getMediaEngine } = await import('./MediaEngine'); const linkedMediaResult = await db.select().from(postMedia).where(eq(postMedia.postId, id)); const linkedMedia = Array.isArray(linkedMediaResult) ? linkedMediaResult : []; - // Remove this post from each linked media's sidecar - const mediaEngine = getMediaEngine(); - for (const link of linkedMedia) { - const media = await mediaEngine.getMedia(link.mediaId); - if (media && media.linkedPostIds) { - const updatedLinkedPostIds = media.linkedPostIds.filter(pid => pid !== id); - await mediaEngine.updateMedia(link.mediaId, { linkedPostIds: updatedLinkedPostIds }); + // Remove this post from each linked media's sidecar. + // Requires mediaEngine to be injected at construction time. + if (linkedMedia.length > 0 && this.mediaEngine) { + for (const link of linkedMedia) { + const media = await this.mediaEngine.getMedia(link.mediaId); + if (media && media.linkedPostIds) { + const updatedLinkedPostIds = media.linkedPostIds.filter(pid => pid !== id); + await this.mediaEngine.updateMedia(link.mediaId, { linkedPostIds: updatedLinkedPostIds }); + } + } + } else if (linkedMedia.length > 0) { + // Fallback: lazy-import (app singleton path, pre-DI callers) + const { MediaEngine: ME } = await import('./MediaEngine'); + const fallbackEngine = new ME(); + for (const link of linkedMedia) { + const media = await fallbackEngine.getMedia(link.mediaId); + if (media && media.linkedPostIds) { + const updatedLinkedPostIds = media.linkedPostIds.filter(pid => pid !== id); + await fallbackEngine.updateMedia(link.mediaId, { linkedPostIds: updatedLinkedPostIds }); + } } } - // Delete post-media junction entries await db.delete(postMedia).where(eq(postMedia.postId, id)); @@ -539,6 +561,7 @@ export class PostEngine extends EventEmitter { await this.deleteFTSIndex(id); this.emit('postDeleted', id); + await this.notifier.notify('post', id, 'deleted'); return true; } @@ -1198,6 +1221,7 @@ export class PostEngine extends EventEmitter { await this.updatePostLinks(id, published.content); this.emit('postUpdated', published); + await this.notifier.notify('post', id, 'updated'); return published; } @@ -1904,12 +1928,4 @@ export class PostEngine extends EventEmitter { } } -// Singleton instance -let postEngine: PostEngine | null = null; -export function getPostEngine(): PostEngine { - if (!postEngine) { - postEngine = new PostEngine(); - } - return postEngine; -} diff --git a/src/main/engine/PostMediaEngine.ts b/src/main/engine/PostMediaEngine.ts index dfba841..fa9af04 100644 --- a/src/main/engine/PostMediaEngine.ts +++ b/src/main/engine/PostMediaEngine.ts @@ -14,7 +14,7 @@ import { v4 as uuidv4 } from 'uuid'; import { eq, and, asc } from 'drizzle-orm'; import { getDatabase } from '../database'; import { postMedia, PostMediaLink, NewPostMediaLink } from '../database/schema'; -import { getMediaEngine, MediaData } from './MediaEngine'; +import type { MediaEngine, MediaData } from './MediaEngine'; export interface PostMediaLinkData { id: string; @@ -25,13 +25,12 @@ export interface PostMediaLinkData { createdAt: Date; } -// Singleton instance -let postMediaEngineInstance: PostMediaEngine | null = null; +// Singleton instance — removed in favour of explicit construction (see EngineBundle) export class PostMediaEngine extends EventEmitter { private currentProjectId: string = 'default'; - constructor() { + constructor(private readonly mediaEngine: MediaEngine) { super(); } @@ -44,7 +43,7 @@ export class PostMediaEngine extends EventEmitter { } private async addPostToMediaSidecar(mediaId: string, postId: string): Promise { - const media = await getMediaEngine().getMedia(mediaId); + const media = await this.mediaEngine.getMedia(mediaId); if (!media) { return; } @@ -54,19 +53,19 @@ export class PostMediaEngine extends EventEmitter { return; } - await getMediaEngine().updateMedia(mediaId, { + await this.mediaEngine.updateMedia(mediaId, { linkedPostIds: [...linkedPostIds, postId], }); } private async removePostFromMediaSidecar(mediaId: string, postId: string): Promise { - const media = await getMediaEngine().getMedia(mediaId); + const media = await this.mediaEngine.getMedia(mediaId); if (!media) { return; } const linkedPostIds = (media.linkedPostIds || []).filter(id => id !== postId); - await getMediaEngine().updateMedia(mediaId, { linkedPostIds }); + await this.mediaEngine.updateMedia(mediaId, { linkedPostIds }); } private createLinkData(link: NewPostMediaLink): PostMediaLinkData { @@ -319,7 +318,7 @@ export class PostMediaEngine extends EventEmitter { await db.delete(postMedia).where(eq(postMedia.projectId, this.currentProjectId)); // Get all media with their linkedPostIds - const allMedia = await getMediaEngine().getAllMedia(); + const allMedia = await this.mediaEngine.getAllMedia(); let linksCreated = 0; for (const media of allMedia) { @@ -352,7 +351,7 @@ export class PostMediaEngine extends EventEmitter { */ async importMediaForPost(postId: string, sourcePath: string): Promise { // Import the media file - const importedMedia = await getMediaEngine().importMedia(sourcePath); + const importedMedia = await this.mediaEngine.importMedia(sourcePath); // Link it to the post return this.linkMediaToPost(postId, importedMedia.id); @@ -366,7 +365,7 @@ export class PostMediaEngine extends EventEmitter { const result: Array = []; for (const link of links) { - const media = await getMediaEngine().getMedia(link.mediaId); + const media = await this.mediaEngine.getMedia(link.mediaId); if (media) { result.push({ ...link, media }); } @@ -410,15 +409,4 @@ export class PostMediaEngine extends EventEmitter { } } -/** - * Get the singleton PostMediaEngine instance - */ -export function getPostMediaEngine(): PostMediaEngine { - if (!postMediaEngineInstance) { - postMediaEngineInstance = new PostMediaEngine(); - } - return postMediaEngineInstance; -} -// Export singleton for convenience -export const postMediaEngine = getPostMediaEngine(); diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index 7471159..eab8cf9 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -1,12 +1,10 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import { getMetaEngine, type CategoryMetadata, type ProjectMetadata } from './MetaEngine'; -import { getMediaEngine, type MediaData } from './MediaEngine'; -import { getMenuEngine, type MenuDocument } from './MenuEngine'; -import { getPostMediaEngine } from './PostMediaEngine'; -import { getPostEngine, type PostData, type PostFilter } from './PostEngine'; -import { getProjectEngine } from './ProjectEngine'; +import { type CategoryMetadata, type ProjectMetadata } from './MetaEngine'; +import { type MediaData } from './MediaEngine'; +import { type MenuDocument } from './MenuEngine'; +import { type PostData, type PostFilter } from './PostEngine'; import { PageRenderer, PREVIEW_ASSETS, @@ -21,9 +19,6 @@ import { type PostMediaEngineContract, type PythonMacroRendererContract, } from './PageRenderer'; -import { getScriptEngine } from './ScriptEngine'; -import { getTemplateEngine } from './TemplateEngine'; -import { getPythonMacroWorkerRuntime } from './PythonMacroWorkerRuntime'; import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes'; import { renderRouteWithSharedContext } from './SharedRouteRenderer'; import { @@ -71,6 +66,7 @@ interface PreviewServerDependencies { menuEngine: MenuEngineContract; getActiveProjectContext: () => Promise; userTemplatesDir?: string; + macroRenderer?: PythonMacroRendererContract; } interface SerializedTag { @@ -91,29 +87,24 @@ export class PreviewServer { private port: number | null = null; constructor(dependencies?: Partial) { - this.postEngine = dependencies?.postEngine ?? getPostEngine(); - this.mediaEngine = dependencies?.mediaEngine ?? getMediaEngine(); - this.postMediaEngine = dependencies?.postMediaEngine ?? getPostMediaEngine(); - this.settingsEngine = dependencies?.settingsEngine ?? getMetaEngine(); - this.menuEngine = dependencies?.menuEngine ?? getMenuEngine(); - this.getActiveProjectContext = dependencies?.getActiveProjectContext ?? (async () => { - const projectEngine = getProjectEngine(); - const activeProject = await projectEngine.getActiveProject(); - const projectId = activeProject?.id ?? 'default'; - const dataDir = projectEngine.getDataDir(projectId, activeProject?.dataPath); - return { - projectId, - dataDir, - projectName: activeProject?.name, - projectDescription: activeProject?.description ?? undefined, - }; - }); + if (!dependencies?.postEngine) throw new Error('PreviewServer: postEngine not provided'); + if (!dependencies?.mediaEngine) throw new Error('PreviewServer: mediaEngine not provided'); + if (!dependencies?.postMediaEngine) throw new Error('PreviewServer: postMediaEngine not provided'); + if (!dependencies?.settingsEngine) throw new Error('PreviewServer: settingsEngine not provided'); + if (!dependencies?.menuEngine) throw new Error('PreviewServer: menuEngine not provided'); + if (!dependencies?.getActiveProjectContext) throw new Error('PreviewServer: getActiveProjectContext not provided'); + this.postEngine = dependencies.postEngine; + this.mediaEngine = dependencies.mediaEngine; + this.postMediaEngine = dependencies.postMediaEngine; + this.settingsEngine = dependencies.settingsEngine; + this.menuEngine = dependencies.menuEngine; + this.getActiveProjectContext = dependencies.getActiveProjectContext; this.pageRenderer = new PageRenderer( this.mediaEngine, this.postMediaEngine, this.postEngine, - buildPythonMacroRenderer(), - dependencies?.userTemplatesDir ?? getTemplateEngine().getTemplatesDirectory(), + dependencies.macroRenderer ?? buildNoopMacroRenderer(), + dependencies.userTemplatesDir, ); } @@ -662,20 +653,13 @@ export class PreviewServer { } } -function buildPythonMacroRenderer(): PythonMacroRendererContract { +function buildNoopMacroRenderer(): PythonMacroRendererContract { return { async getEnabledMacroScripts() { - const scripts = await getScriptEngine().getEnabledMacroScripts(); - return scripts.map((s) => ({ - id: s.id, - slug: s.slug, - entrypoint: s.entrypoint, - content: s.content, - version: s.version, - })); + return []; }, - async renderMacro(params) { - return getPythonMacroWorkerRuntime().renderMacro(params); + async renderMacro() { + throw new Error('Python macro renderer not configured'); }, }; } diff --git a/src/main/engine/ProjectEngine.ts b/src/main/engine/ProjectEngine.ts index 667280f..f331c25 100644 --- a/src/main/engine/ProjectEngine.ts +++ b/src/main/engine/ProjectEngine.ts @@ -358,12 +358,3 @@ export class ProjectEngine extends EventEmitter { } } -// Singleton instance -let projectEngine: ProjectEngine | null = null; - -export function getProjectEngine(): ProjectEngine { - if (!projectEngine) { - projectEngine = new ProjectEngine(); - } - return projectEngine; -} diff --git a/src/main/engine/ProposalStore.ts b/src/main/engine/ProposalStore.ts index 0860329..4e4311f 100644 --- a/src/main/engine/ProposalStore.ts +++ b/src/main/engine/ProposalStore.ts @@ -19,10 +19,12 @@ const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 minutes export class ProposalStore { private readonly proposals = new Map(); private readonly ttlMs: number; + private readonly onExpiry: ((proposal: Proposal) => void) | undefined; private cleanupInterval: ReturnType | null = null; - constructor(ttlMs: number = DEFAULT_TTL_MS) { - this.ttlMs = ttlMs; + constructor(ttlMs?: number, onExpiry?: (proposal: Proposal) => void) { + this.ttlMs = ttlMs ?? DEFAULT_TTL_MS; + this.onExpiry = onExpiry; this.cleanupInterval = setInterval(() => this.cleanup(), this.ttlMs); } @@ -53,6 +55,7 @@ export class ProposalStore { const now = Date.now(); for (const [id, proposal] of this.proposals) { if (now - proposal.createdAt > this.ttlMs) { + this.onExpiry?.(proposal); this.proposals.delete(id); } } diff --git a/src/main/engine/PublishApiAdapter.ts b/src/main/engine/PublishApiAdapter.ts index 458e8dc..26fa17f 100644 --- a/src/main/engine/PublishApiAdapter.ts +++ b/src/main/engine/PublishApiAdapter.ts @@ -1,6 +1,8 @@ -import { getProjectEngine } from './ProjectEngine'; -import { getPublishEngine, type PublishCredentials } from './PublishEngine'; -import { taskManager } from './TaskManager'; +import type { ProjectEngine } from './ProjectEngine'; +import type { PublishEngine, PublishCredentials } from './PublishEngine'; +import type { TaskManager } from './TaskManager'; + +export type { PublishCredentials }; export interface PublishSiteResult { htmlFilesUploaded: number; @@ -15,41 +17,46 @@ export interface PublishSiteResult { * context, launches three parallel upload tasks, and returns aggregate results. */ export class PublishApiAdapter { + constructor( + private readonly projectEngine: ProjectEngine, + private readonly publishEngine: PublishEngine, + private readonly taskManager: TaskManager, + ) {} + async uploadSite(credentials: PublishCredentials): Promise { - const project = await getProjectEngine().getActiveProject(); + const project = await this.projectEngine.getActiveProject(); if (!project) { throw new Error('No active project'); } - const publishEngine = getPublishEngine(); - publishEngine.setProjectContext(project.id, project.dataPath!); + this.publishEngine.setProjectContext(project.id, project.dataPath!); const ts = Date.now(); const groupId = `publish-${ts}`; const groupName = 'Site Publishing'; - const htmlTask = taskManager.runTask({ + const htmlTask = this.taskManager.runTask({ id: `publish-html-${ts}`, name: 'Upload HTML', groupId, groupName, - execute: (onProgress) => publishEngine.uploadHtml(credentials, onProgress), + execute: (onProgress) => this.publishEngine.uploadHtml(credentials, onProgress), }); - const thumbsTask = taskManager.runTask({ + const thumbsTask = this.taskManager.runTask({ id: `publish-thumbnails-${ts}`, name: 'Upload Thumbnails', groupId, groupName, - execute: (onProgress) => publishEngine.uploadThumbnails(credentials, onProgress), + execute: (onProgress) => this.publishEngine.uploadThumbnails(credentials, onProgress), }); - const mediaTask = taskManager.runTask({ + const mediaTask = this.taskManager.runTask({ id: `publish-media-${ts}`, name: 'Upload Media', groupId, groupName, - execute: (onProgress) => publishEngine.uploadMedia(credentials, onProgress), + execute: (onProgress) => this.publishEngine.uploadMedia(credentials, onProgress), }); const [html, thumbnails, media] = await Promise.all([htmlTask, thumbsTask, mediaTask]); @@ -62,12 +69,3 @@ export class PublishApiAdapter { }; } } - -let instance: PublishApiAdapter | null = null; - -export function getPublishApiAdapter(): PublishApiAdapter { - if (!instance) { - instance = new PublishApiAdapter(); - } - return instance; -} diff --git a/src/main/engine/PublishEngine.ts b/src/main/engine/PublishEngine.ts index 7bdeab3..447a169 100644 --- a/src/main/engine/PublishEngine.ts +++ b/src/main/engine/PublishEngine.ts @@ -330,12 +330,3 @@ export class PublishEngine extends EventEmitter { } } -// Singleton -let publishEngine: PublishEngine | null = null; - -export function getPublishEngine(): PublishEngine { - if (!publishEngine) { - publishEngine = new PublishEngine(); - } - return publishEngine; -} diff --git a/src/main/engine/PythonMacroWorkerRuntime.ts b/src/main/engine/PythonMacroWorkerRuntime.ts index ba649d2..9a1785d 100644 --- a/src/main/engine/PythonMacroWorkerRuntime.ts +++ b/src/main/engine/PythonMacroWorkerRuntime.ts @@ -363,13 +363,3 @@ export class PythonMacroWorkerRuntime { } } -let pythonMacroWorkerRuntimeInstance: PythonMacroWorkerRuntime | null = null; - -export function getPythonMacroWorkerRuntime(): PythonMacroWorkerRuntime { - if (!pythonMacroWorkerRuntimeInstance) { - const { invokeMainProcessPythonApi } = require('./mainProcessPythonApiInvoker') as { invokeMainProcessPythonApi: ApiInvoker }; - pythonMacroWorkerRuntimeInstance = new PythonMacroWorkerRuntime(undefined, invokeMainProcessPythonApi); - } - - return pythonMacroWorkerRuntimeInstance; -} diff --git a/src/main/engine/ScriptEngine.ts b/src/main/engine/ScriptEngine.ts index 87cfecd..919949b 100644 --- a/src/main/engine/ScriptEngine.ts +++ b/src/main/engine/ScriptEngine.ts @@ -6,6 +6,7 @@ import { app } from 'electron'; import { and, desc, eq } from 'drizzle-orm'; import { getDatabase } from '../database'; import { scripts, type NewScript, type Script } from '../database/schema'; +import { CliNotifier, NoopNotifier } from './CliNotifier'; export type ScriptKind = 'macro' | 'utility' | 'transform'; @@ -20,6 +21,7 @@ export interface ScriptData { version: number; filePath: string; content: string; + status: 'draft' | 'published'; createdAt: Date; updatedAt: Date; } @@ -81,6 +83,15 @@ interface ParsedScriptFile { export class ScriptEngine extends EventEmitter { private currentProjectId = 'default'; private dataDir: string | null = null; + private readonly notifier: CliNotifier; + + constructor(notifier: CliNotifier = new NoopNotifier()) { + super(); + this.notifier = notifier; + } + + /** No persistent cache — no-op for watcher compat. */ + invalidate(_entityId?: string): void {} setProjectContext(projectId: string, dataDir?: string): void { this.currentProjectId = projectId; @@ -159,6 +170,7 @@ export class ScriptEngine extends EventEmitter { const created = await this.toScriptData(row as Script); this.emit('scriptCreated', created); + await this.notifier.notify('script', created.id, 'created'); return created; } @@ -227,6 +239,7 @@ export class ScriptEngine extends EventEmitter { const updated = await this.toScriptData(updatedRow); this.emit('scriptUpdated', updated); + await this.notifier.notify('script', updated.id, 'updated'); return updated; } @@ -250,6 +263,7 @@ export class ScriptEngine extends EventEmitter { } this.emit('scriptDeleted', id); + await this.notifier.notify('script', id, 'deleted'); return true; } @@ -498,7 +512,10 @@ export class ScriptEngine extends EventEmitter { } private async toScriptData(row: Script): Promise { - const content = await this.readScriptBody(row.filePath); + // Draft scripts store content in the DB; published scripts read from disk. + const content = row.status === 'draft' && row.content != null + ? row.content + : await this.readScriptBody(row.filePath); return { id: row.id, @@ -511,6 +528,7 @@ export class ScriptEngine extends EventEmitter { version: row.version, filePath: row.filePath, content, + status: (row.status as 'draft' | 'published') ?? 'published', createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -784,6 +802,79 @@ export class ScriptEngine extends EventEmitter { } } + // ── Draft lifecycle ──────────────────────────────────────────────────────── + + /** Create a script DB row with status='draft'; no file is written. */ + async createDraftScript(data: CreateScriptInput): Promise { + const now = new Date(); + const allScripts = await this.getAllScriptRows(); + const desiredSlug = this.normalizeSlug(data.slug || data.title || 'script'); + const uniqueSlug = this.ensureUniqueSlug(desiredSlug, allScripts); + const scriptId = uuidv4(); + const filePath = this.getScriptFilePath(uniqueSlug); // path reserved but not yet written + + const row: NewScript = { + id: scriptId, + projectId: this.currentProjectId, + slug: uniqueSlug, + title: data.title, + kind: data.kind, + entrypoint: data.entrypoint || 'render', + enabled: data.enabled ?? true, + version: 1, + filePath, + status: 'draft', + content: data.content, + createdAt: now, + updatedAt: now, + }; + + await getDatabase().getLocal().insert(scripts).values(row); + const created = await this.toScriptData(row as Script); + this.emit('scriptCreated', created); + return created; + } + + /** Publish a draft script: write file to disk, set status='published', clear DB content. */ + async publishScript(id: string): Promise { + const existing = await this.getScriptRow(id); + if (!existing) return null; + + const content = existing.status === 'draft' && existing.content != null + ? existing.content + : await this.readScriptBody(existing.filePath); + + await fs.mkdir(this.getScriptsDir(), { recursive: true }); + await fs.writeFile(existing.filePath, this.serializeScriptFile(existing, content), 'utf-8'); + + const now = new Date(); + await getDatabase().getLocal() + .update(scripts) + .set({ status: 'published', content: null, updatedAt: now }) + .where(eq(scripts.id, id)); + + const updatedRow = await this.getScriptRow(id); + if (!updatedRow) return null; + const result = await this.toScriptData(updatedRow); + this.emit('scriptUpdated', result); + await this.notifier.notify('script', id, 'updated'); + return result; + } + + /** Delete a draft script (only if status='draft'). Returns false if not found or already published. */ + async deleteDraftScript(id: string): Promise { + const existing = await this.getScriptRow(id); + if (!existing || existing.status !== 'draft') return false; + + await getDatabase().getLocal() + .delete(scripts) + .where(and(eq(scripts.id, id), eq(scripts.projectId, this.currentProjectId))); + + this.emit('scriptDeleted', id); + await this.notifier.notify('script', id, 'deleted'); + return true; + } + private async readScriptBody(filePath: string): Promise { try { const rawContent = await fs.readFile(filePath, 'utf-8'); @@ -798,11 +889,4 @@ export class ScriptEngine extends EventEmitter { } } -let scriptEngineInstance: ScriptEngine | null = null; -export function getScriptEngine(): ScriptEngine { - if (!scriptEngineInstance) { - scriptEngineInstance = new ScriptEngine(); - } - return scriptEngineInstance; -} diff --git a/src/main/engine/TagEngine.ts b/src/main/engine/TagEngine.ts index 8edfd15..eafafab 100644 --- a/src/main/engine/TagEngine.ts +++ b/src/main/engine/TagEngine.ts @@ -7,8 +7,8 @@ import { eq, and, asc, sql, like } from 'drizzle-orm'; import { getDatabase } from '../database'; import { tags, posts } from '../database/schema'; import { taskManager } from './TaskManager'; -import { getPostEngine } from './PostEngine'; import { normalizeTaxonomyTerm, normalizeNonEmptyTaxonomyTerm } from './taxonomyUtils'; +import type { PostEngine } from './PostEngine'; /** * Tag data stored in the database @@ -85,18 +85,7 @@ export interface SyncTagsResult { added: string[]; } -// Singleton instance -let tagEngineInstance: TagEngine | null = null; -/** - * Get the singleton TagEngine instance - */ -export function getTagEngine(): TagEngine { - if (!tagEngineInstance) { - tagEngineInstance = new TagEngine(); - } - return tagEngineInstance; -} /** * Validate hex color format @@ -128,7 +117,7 @@ export class TagEngine extends EventEmitter { private currentProjectId: string = 'default'; private dataDir: string | null = null; // Custom data directory (null = use internal userData) - constructor() { + constructor(private readonly postEngine?: PostEngine) { super(); } @@ -189,7 +178,9 @@ export class TagEngine extends EventEmitter { }) .where(eq(posts.id, postId)); - await getPostEngine().syncPublishedPostFile(postId); + if (this.postEngine) { + await this.postEngine.syncPublishedPostFile(postId); + } } private async updateMatchingPosts( diff --git a/src/main/engine/TemplateEngine.ts b/src/main/engine/TemplateEngine.ts index 9a7e0ff..1eb2131 100644 --- a/src/main/engine/TemplateEngine.ts +++ b/src/main/engine/TemplateEngine.ts @@ -7,6 +7,7 @@ import { and, desc, eq } from 'drizzle-orm'; import { Liquid } from 'liquidjs'; import { getDatabase } from '../database'; import { posts, tags, templates, type NewTemplate, type Template } from '../database/schema'; +import { CliNotifier, NoopNotifier } from './CliNotifier'; export type TemplateKind = 'post' | 'list' | 'not-found' | 'partial'; @@ -20,6 +21,7 @@ export interface TemplateData { version: number; filePath: string; content: string; + status: 'draft' | 'published'; createdAt: Date; updatedAt: Date; } @@ -83,6 +85,15 @@ interface ParsedTemplateFile { export class TemplateEngine extends EventEmitter { private currentProjectId = 'default'; private dataDir: string | null = null; + private readonly notifier: CliNotifier; + + constructor(notifier: CliNotifier = new NoopNotifier()) { + super(); + this.notifier = notifier; + } + + /** No persistent cache — no-op for watcher compat. */ + invalidate(_entityId?: string): void {} setProjectContext(projectId: string, dataDir?: string): void { this.currentProjectId = projectId; @@ -125,6 +136,7 @@ export class TemplateEngine extends EventEmitter { const created = await this.toTemplateData(row as Template); this.emit('templateCreated', created); + await this.notifier.notify('template', created.id, 'created'); return created; } @@ -222,6 +234,7 @@ export class TemplateEngine extends EventEmitter { const updated = await this.toTemplateData(updatedRow); this.emit('templateUpdated', updated); + await this.notifier.notify('template', updated.id, 'updated'); return updated; } @@ -282,6 +295,7 @@ export class TemplateEngine extends EventEmitter { } this.emit('templateDeleted', id); + await this.notifier.notify('template', id, 'deleted'); return { deleted: true }; } @@ -538,7 +552,10 @@ export class TemplateEngine extends EventEmitter { } private async toTemplateData(row: Template): Promise { - const content = await this.readTemplateBody(row.filePath); + // Draft templates store content in the DB; published templates read from disk. + const content = row.status === 'draft' && row.content != null + ? row.content + : await this.readTemplateBody(row.filePath); return { id: row.id, @@ -550,11 +567,84 @@ export class TemplateEngine extends EventEmitter { version: row.version, filePath: row.filePath, content, + status: (row.status as 'draft' | 'published') ?? 'published', createdAt: row.createdAt, updatedAt: row.updatedAt, }; } + // ── Draft lifecycle ──────────────────────────────────────────────────────── + + /** Create a template DB row with status='draft'; no file is written. */ + async createDraftTemplate(data: CreateTemplateInput): Promise { + const now = new Date(); + const allTemplates = await this.getAllTemplateRows(); + const desiredSlug = this.normalizeSlug(data.slug || data.title || 'template'); + const uniqueSlug = this.ensureUniqueSlug(desiredSlug, allTemplates); + const templateId = uuidv4(); + const filePath = this.getTemplateFilePath(uniqueSlug); // path reserved but not yet written + + const row: NewTemplate = { + id: templateId, + projectId: this.currentProjectId, + slug: uniqueSlug, + title: data.title, + kind: data.kind, + enabled: data.enabled ?? true, + version: 1, + filePath, + status: 'draft', + content: data.content, + createdAt: now, + updatedAt: now, + }; + + await getDatabase().getLocal().insert(templates).values(row); + const created = await this.toTemplateData(row as Template); + this.emit('templateCreated', created); + return created; + } + + /** Publish a draft template: write file to disk, set status='published', clear DB content. */ + async publishTemplate(id: string): Promise { + const existing = await this.getTemplateRow(id); + if (!existing) return null; + + const content = existing.status === 'draft' && existing.content != null + ? existing.content + : await this.readTemplateBody(existing.filePath); + + await fs.mkdir(this.getTemplatesDir(), { recursive: true }); + await fs.writeFile(existing.filePath, this.serializeTemplateFile(existing, content), 'utf-8'); + + const now = new Date(); + await getDatabase().getLocal() + .update(templates) + .set({ status: 'published', content: null, updatedAt: now }) + .where(eq(templates.id, id)); + + const updatedRow = await this.getTemplateRow(id); + if (!updatedRow) return null; + const result = await this.toTemplateData(updatedRow); + this.emit('templateUpdated', result); + await this.notifier.notify('template', id, 'updated'); + return result; + } + + /** Delete a draft template (only if status='draft'). Returns false if not found or already published. */ + async deleteDraftTemplate(id: string): Promise { + const existing = await this.getTemplateRow(id); + if (!existing || existing.status !== 'draft') return false; + + await getDatabase().getLocal() + .delete(templates) + .where(and(eq(templates.id, id), eq(templates.projectId, this.currentProjectId))); + + this.emit('templateDeleted', id); + await this.notifier.notify('template', id, 'deleted'); + return true; + } + private getDataDir(): string { if (this.dataDir) { return this.dataDir; @@ -826,11 +916,4 @@ export class TemplateEngine extends EventEmitter { } } -let templateEngineInstance: TemplateEngine | null = null; -export function getTemplateEngine(): TemplateEngine { - if (!templateEngineInstance) { - templateEngineInstance = new TemplateEngine(); - } - return templateEngineInstance; -} diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts index a670824..d7ebcb8 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -1,12 +1,11 @@ export { TaskManager, taskManager, type Task, type TaskProgress, type TaskStatus } from './TaskManager'; -export { PostEngine, getPostEngine, type PostData, type PostFilter, type SearchResult, type PaginatedResult, type PaginationOptions } from './PostEngine'; -export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine'; -export { PostMediaEngine, getPostMediaEngine, postMediaEngine, type PostMediaLinkData } from './PostMediaEngine'; -export { ProjectEngine, getProjectEngine, type ProjectData } from './ProjectEngine'; -export { MetaEngine, getMetaEngine, type ProjectMetadata, DEFAULT_CATEGORIES } from './MetaEngine'; +export { PostEngine, type PostData, type PostFilter, type SearchResult, type PaginatedResult, type PaginationOptions } from './PostEngine'; +export { MediaEngine, type MediaData } from './MediaEngine'; +export { PostMediaEngine, type PostMediaLinkData } from './PostMediaEngine'; +export { ProjectEngine, type ProjectData } from './ProjectEngine'; +export { MetaEngine, type ProjectMetadata, DEFAULT_CATEGORIES } from './MetaEngine'; export { TagEngine, - getTagEngine, type TagData, type TagWithCount, type CreateTagInput, @@ -66,7 +65,6 @@ export { } from './postFileUtils'; export { MetadataDiffEngine, - getMetadataDiffEngine, type PostMetadataDiff, type DiffGroup, type DiffField, @@ -75,7 +73,6 @@ export { } from './MetadataDiffEngine'; export { GitEngine, - getGitEngine, type GitAvailability, type RepoState, type GitStatusDto, @@ -88,21 +85,18 @@ export { } from './GitEngine'; export { BlogGenerationEngine, - getBlogGenerationEngine, resolvePublicBaseUrl, type BlogGenerationOptions, type BlogGenerationResult, } from './BlogGenerationEngine'; export { MenuEngine, - getMenuEngine, type MenuItemData, type MenuDocument, type MenuItemKind, } from './MenuEngine'; export { ScriptEngine, - getScriptEngine, type ScriptData, type ScriptKind, type CreateScriptInput, @@ -110,7 +104,6 @@ export { } from './ScriptEngine'; export { PublishEngine, - getPublishEngine, type PublishCredentials, type DirectoryUploadResult, } from './PublishEngine'; diff --git a/src/main/engine/mainProcessPythonApiInvoker.ts b/src/main/engine/mainProcessPythonApiInvoker.ts index c99f4a6..b2de2a0 100644 --- a/src/main/engine/mainProcessPythonApiInvoker.ts +++ b/src/main/engine/mainProcessPythonApiInvoker.ts @@ -1,5 +1,21 @@ import { getPythonApiMethodContract } from '../shared/pythonApiContractV1'; import type { PythonApiParamContractV1 } from '../shared/pythonApiContractV1'; +import type { EngineBundle } from './EngineBundle'; + +// Module-level bundle set by main.ts at startup. +// All ENGINE_MAP getters read from this bundle. +let registeredBundle: EngineBundle | null = null; + +export function setEngineBundle(bundle: EngineBundle): void { + registeredBundle = bundle; +} + +function requireBundle(): EngineBundle { + if (!registeredBundle) { + throw new Error('Engine bundle not registered. Call setEngineBundle() before invoking Python API methods.'); + } + return registeredBundle; +} function asRecord(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -66,50 +82,17 @@ function validateParamValue(methodName: string, param: PythonApiParamContractV1, type EngineGetter = () => Record unknown>; export const ENGINE_MAP: Record = { - 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(); - }, - templates: () => { - const { getTemplateEngine } = require('../engine/TemplateEngine'); - return getTemplateEngine(); - }, - tasks: () => { - const { taskManager } = require('../engine/TaskManager'); - return taskManager; - }, - sync: () => { - const { getGitApiAdapter } = require('../engine/GitApiAdapter'); - return getGitApiAdapter(); - }, - publish: () => { - const { getPublishApiAdapter } = require('../engine/PublishApiAdapter'); - return getPublishApiAdapter(); - }, - app: () => { - const { getAppApiAdapter } = require('../engine/AppApiAdapter'); - return getAppApiAdapter(); - }, + posts: () => requireBundle().postEngine as any, + media: () => requireBundle().mediaEngine as any, + projects: () => requireBundle().projectEngine as any, + meta: () => requireBundle().metaEngine as any, + tags: () => requireBundle().tagEngine as any, + scripts: () => requireBundle().scriptEngine as any, + templates: () => requireBundle().templateEngine as any, + tasks: () => requireBundle().taskManager as any, + sync: () => requireBundle().gitApiAdapter as any, + publish: () => requireBundle().publishApiAdapter as any, + app: () => requireBundle().appApiAdapter as any, }; // Map API method names to engine method names where they differ diff --git a/src/main/ipc/blogHandlers.ts b/src/main/ipc/blogHandlers.ts index 713e2e2..f2761a8 100644 --- a/src/main/ipc/blogHandlers.ts +++ b/src/main/ipc/blogHandlers.ts @@ -1,13 +1,5 @@ import { dialog } from 'electron'; -import { getPostEngine } from '../engine/PostEngine'; -import { getProjectEngine } from '../engine/ProjectEngine'; -import { getMetaEngine } from '../engine/MetaEngine'; -import { getMediaEngine } from '../engine/MediaEngine'; -import { getPostMediaEngine } from '../engine/PostMediaEngine'; -import { getMenuEngine } from '../engine/MenuEngine'; -import { taskManager } from '../engine/TaskManager'; import { - getBlogGenerationEngine, resolvePublicBaseUrl, type BlogGenerationResult, type BlogGenerationSection, @@ -15,17 +7,18 @@ import { type SiteValidationReport, } from '../engine/BlogGenerationEngine'; import { resolvePageTitle } from '../engine/PageRenderer'; +import type { EngineBundle } from '../engine/EngineBundle'; type SafeHandle = (channel: string, handler: (...args: any[]) => Promise) => void; -export function registerBlogHandlers(safeHandle: SafeHandle): void { +export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundle): void { const resolveBlogGenerationBaseOptions = async (): Promise => { - const projectEngine = getProjectEngine(); - const postEngine = getPostEngine(); - const metaEngine = getMetaEngine(); - const mediaEngine = getMediaEngine(); - const postMediaEngine = getPostMediaEngine(); - const menuEngine = getMenuEngine(); + const projectEngine = bundle.projectEngine; + const postEngine = bundle.postEngine; + const metaEngine = bundle.metaEngine; + const mediaEngine = bundle.mediaEngine; + const postMediaEngine = bundle.postMediaEngine; + const menuEngine = bundle.menuEngine; const project = await projectEngine.getActiveProject(); if (!project) { @@ -76,7 +69,7 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void { }; safeHandle('blog:generateSitemap', async () => { - const blogGenerationEngine = getBlogGenerationEngine(); + const blogGenerationEngine = bundle.blogGenerationEngine; const baseOptions = await resolveBlogGenerationBaseOptions(); const taskTimestamp = Date.now(); @@ -88,7 +81,7 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void { taskName: string, taskIdPrefix: string, ): Promise => { - return taskManager.runTask({ + return bundle.taskManager.runTask({ id: `${taskIdPrefix}-${taskTimestamp}`, name: taskName, groupId: taskGroupId, @@ -137,11 +130,11 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void { }); safeHandle('blog:validateSite', async () => { - const blogGenerationEngine = getBlogGenerationEngine(); + const blogGenerationEngine = bundle.blogGenerationEngine; const baseOptions = await resolveBlogGenerationBaseOptions(); const taskTimestamp = Date.now(); - return taskManager.runTask({ + return bundle.taskManager.runTask({ id: `site-validate-${taskTimestamp}`, name: 'Validate Site', execute: async (onProgress) => { @@ -153,11 +146,11 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void { }); safeHandle('blog:regenerateCalendar', async () => { - const blogGenerationEngine = getBlogGenerationEngine(); + const blogGenerationEngine = bundle.blogGenerationEngine; const baseOptions = await resolveBlogGenerationBaseOptions(); const taskTimestamp = Date.now(); - return taskManager.runTask({ + return bundle.taskManager.runTask({ id: `site-calendar-regenerate-${taskTimestamp}`, name: 'Regenerate Calendar', execute: async (onProgress) => { @@ -169,11 +162,11 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void { }); safeHandle('blog:applyValidation', async (_event, report: SiteValidationReport) => { - const blogGenerationEngine = getBlogGenerationEngine(); + const blogGenerationEngine = bundle.blogGenerationEngine; const baseOptions = await resolveBlogGenerationBaseOptions(); const taskTimestamp = Date.now(); - return taskManager.runTask({ + return bundle.taskManager.runTask({ id: `site-validate-apply-${taskTimestamp}`, name: 'Apply Site Validation', execute: async (onProgress) => { diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index beedcd6..638c2f1 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -5,20 +5,21 @@ import { ipcMain, BrowserWindow } from 'electron'; import { ChatEngine } from '../engine/ChatEngine'; import { OpenCodeManager } from '../engine/OpenCodeManager'; -import { getPostEngine } from '../engine/PostEngine'; -import { getMediaEngine } from '../engine/MediaEngine'; import { getDatabase } from '../database'; +import type { EngineBundle } from '../engine/EngineBundle'; let chatEngine: ChatEngine | null = null; let openCodeManager: OpenCodeManager | null = null; let openCodeManagerInitPromise: Promise | null = null; let mainWindowGetter: (() => BrowserWindow | null) | null = null; +let engineBundle: EngineBundle | null = null; /** * Initialize chat handlers with the main window reference */ -export function initializeChatHandlers(getMainWindow: () => BrowserWindow | null): void { +export function initializeChatHandlers(getMainWindow: () => BrowserWindow | null, bundle: EngineBundle): void { mainWindowGetter = getMainWindow; + engineBundle = bundle; } /** @@ -40,8 +41,9 @@ async function getOpenCodeManager(): Promise { if (!openCodeManager) { openCodeManager = new OpenCodeManager( getChatEngine(), - getPostEngine(), - getMediaEngine(), + engineBundle!.postEngine, + engineBundle!.mediaEngine, + engineBundle!.postMediaEngine, () => mainWindowGetter?.() || null ); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 8c5cc0e..97b6de9 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -2,18 +2,14 @@ import { app, BrowserWindow, ipcMain, dialog, shell, clipboard } from 'electron' import * as path from 'path'; import * as fsPromises from 'fs/promises'; import { eq } from 'drizzle-orm'; -import { getPostEngine, PostData, PostFilter, PaginationOptions } from '../engine/PostEngine'; -import { getMediaEngine, MediaData } from '../engine/MediaEngine'; -import { getProjectEngine, ProjectData } from '../engine/ProjectEngine'; -import { getMetaEngine } from '../engine/MetaEngine'; -import { getMenuEngine, type MenuDocument } from '../engine/MenuEngine'; -import { getTagEngine } from '../engine/TagEngine'; -import { getPostMediaEngine } from '../engine/PostMediaEngine'; -import { getScriptEngine, type CreateScriptInput, type UpdateScriptInput } from '../engine/ScriptEngine'; -import { getTemplateEngine, type CreateTemplateInput, type UpdateTemplateInput } from '../engine/TemplateEngine'; -import { getGitEngine } from '../engine/GitEngine'; -import { getGitApiAdapter } from '../engine/GitApiAdapter'; -import { taskManager, TaskProgress } from '../engine/TaskManager'; +import type { PostData, PostFilter, PaginationOptions } from '../engine/PostEngine'; +import type { MediaData } from '../engine/MediaEngine'; +import type { ProjectData } from '../engine/ProjectEngine'; +import { MetaEngine } from '../engine/MetaEngine'; +import type { MenuDocument } from '../engine/MenuEngine'; +import type { CreateScriptInput, UpdateScriptInput } from '../engine/ScriptEngine'; +import type { CreateTemplateInput, UpdateTemplateInput } from '../engine/TemplateEngine'; +import type { TaskProgress } from '../engine/TaskManager'; import { getDatabase } from '../database'; import { media } from '../database/schema'; import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_WEB_CONTENTS_ACTIONS, type AppMenuAction } from '../shared/menuCommands'; @@ -21,6 +17,7 @@ import { generateBlogmarkBookmarkletSource } from '../shared/blogmark'; import { registerMetadataDiffHandlers } from './metadataDiffHandlers'; import { registerBlogHandlers } from './blogHandlers'; import { registerPublishHandlers } from './publishHandlers'; +import type { EngineBundle } from '../engine/EngineBundle'; /** * Wrap an IPC handler so that "Database is closing" errors during shutdown @@ -125,71 +122,70 @@ function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean { } } -function buildMcpUrl(): string { +function buildMcpUrl(bundle: EngineBundle): string { try { - const { getMCPServer } = require('../engine/MCPServer'); - const port = getMCPServer().getPort() ?? 4124; + const port = bundle.mcpServer.getPort() ?? 4124; return `http://127.0.0.1:${port}/mcp`; } catch { return 'http://127.0.0.1:4124/mcp'; } } -export function registerIpcHandlers(): void { +export function registerIpcHandlers(bundle: EngineBundle): void { // ============ Git Handlers ============ safeHandle('git:checkAvailability', async () => { - const engine = getGitEngine(); + const engine = bundle.gitEngine; return engine.checkAvailability(); }); safeHandle('git:getRepoState', async (_, projectPath: string) => { - const engine = getGitEngine(); + const engine = bundle.gitEngine; return engine.getRepoState(projectPath); }); safeHandle('git:status', async (_, projectPath: string) => { - const engine = getGitEngine(); + const engine = bundle.gitEngine; return engine.getStatus(projectPath); }); safeHandle('git:diff', async (_, projectPath: string, filePath: string) => { - const engine = getGitEngine(); + const engine = bundle.gitEngine; return engine.getDiff(projectPath, filePath); }); safeHandle('git:diffContent', async (_, projectPath: string, filePath: string) => { - const engine = getGitEngine(); + const engine = bundle.gitEngine; return engine.getDiffContent(projectPath, filePath); }); safeHandle('git:commitDiffContent', async (_, projectPath: string, commitHash: string) => { - const engine = getGitEngine(); + const engine = bundle.gitEngine; return engine.getCommitDiffContent(projectPath, commitHash); }); safeHandle('git:history', async (_, projectPath: string, limit?: number) => { - const engine = getGitEngine(); + const engine = bundle.gitEngine; return engine.getHistory(projectPath, limit); }); safeHandle('git:fileHistory', async (_, projectPath: string, filePath: string, limit?: number) => { - const engine = getGitEngine(); + const engine = bundle.gitEngine; return engine.getFileHistory(projectPath, filePath, limit); }); safeHandle('git:remoteState', async (_, projectPath: string) => { - const engine = getGitEngine(); + const engine = bundle.gitEngine; return engine.getRemoteState(projectPath); }); safeHandle('git:fetch', async (_, projectPath: string) => { - const engine = getGitEngine(); + const engine = bundle.gitEngine; return engine.fetch(projectPath); }); safeHandle('git:pull', async (_, projectPath: string) => { - const engine = getGitEngine(); + const engine = bundle.gitEngine; const beforeHead = await engine.getHeadCommit(projectPath); const pullResult = await engine.pull(projectPath); @@ -212,11 +208,11 @@ export function registerIpcHandlers(): void { } try { - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const project = await projectEngine.getActiveProject(); - const postEngine = getPostEngine(); - const scriptEngine = getScriptEngine(); - const templateEngine = getTemplateEngine(); + const postEngine = bundle.postEngine; + const scriptEngine = bundle.scriptEngine; + const templateEngine = bundle.templateEngine; if (project) { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); @@ -244,51 +240,51 @@ export function registerIpcHandlers(): void { }); safeHandle('git:push', async (_, projectPath: string) => { - const engine = getGitEngine(); + const engine = bundle.gitEngine; return engine.push(projectPath); }); safeHandle('git:commitAll', async (_, projectPath: string, message: string) => { - const engine = getGitEngine(); + const engine = bundle.gitEngine; return engine.commitAll(projectPath, message); }); safeHandle('git:init', async (event, projectPath: string, remoteUrl?: string) => { - const engine = getGitEngine(); + const engine = bundle.gitEngine; return engine.initializeRepo(projectPath, remoteUrl, (progress) => { event.sender.send('git:initProgress', progress); }); }); safeHandle('git:ensureGitignore', async (_, projectPath: string) => { - const engine = getGitEngine(); + const engine = bundle.gitEngine; return engine.ensureGitignore(projectPath); }); safeHandle('git:pruneLfs', async (_, projectPath: string, options?: { dryRun?: boolean; verifyRemote?: boolean }) => { - const engine = getGitEngine(); + const engine = bundle.gitEngine; return engine.pruneLfsCache(projectPath, options); }); // ============ Project Handlers ============ safeHandle('projects:create', async (_, data: { name: string; description?: string; slug?: string; dataPath?: string }) => { - const engine = getProjectEngine(); + const engine = bundle.projectEngine; return engine.createProject(data); }); safeHandle('projects:update', async (_, id: string, data: Partial) => { - const engine = getProjectEngine(); + const engine = bundle.projectEngine; return engine.updateProject(id, data); }); safeHandle('projects:delete', async (_, id: string) => { - const engine = getProjectEngine(); + const engine = bundle.projectEngine; return engine.deleteProject(id); }); safeHandle('projects:deleteWithData', async (_, id: string) => { - const engine = getProjectEngine(); + const engine = bundle.projectEngine; return engine.deleteProjectWithData(id); }); @@ -297,17 +293,17 @@ export function registerIpcHandlers(): void { }); safeHandle('projects:get', async (_, id: string) => { - const engine = getProjectEngine(); + const engine = bundle.projectEngine; return engine.getProject(id); }); safeHandle('projects:getAll', async () => { - const engine = getProjectEngine(); + const engine = bundle.projectEngine; return engine.getAllProjects(); }); safeHandle('projects:getActive', async () => { - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const project = await projectEngine.getActiveProject(); // Ensure all engines have the correct project context @@ -315,13 +311,13 @@ export function registerIpcHandlers(): void { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); // For thumbnails and meta: use dataDir (whether custom or internal) // This ensures all project data lives in the same location for backup - const postEngine = getPostEngine(); - const mediaEngine = getMediaEngine(); - const metaEngine = getMetaEngine(); - const menuEngine = getMenuEngine(); - const tagEngine = getTagEngine(); - const scriptEngine = getScriptEngine(); - const templateEngine = getTemplateEngine(); + const postEngine = bundle.postEngine; + const mediaEngine = bundle.mediaEngine; + const metaEngine = bundle.metaEngine; + const menuEngine = bundle.menuEngine; + const tagEngine = bundle.tagEngine; + const scriptEngine = bundle.scriptEngine; + const templateEngine = bundle.templateEngine; postEngine.setProjectContext(project.id, dataDir); mediaEngine.setProjectContext(project.id, dataDir, dataDir); metaEngine.setProjectContext(project.id, dataDir); @@ -329,7 +325,7 @@ export function registerIpcHandlers(): void { tagEngine.setProjectContext(project.id, dataDir); scriptEngine.setProjectContext(project.id, dataDir); templateEngine.setProjectContext(project.id, dataDir); - const postMediaEngine = getPostMediaEngine(); + const postMediaEngine = bundle.postMediaEngine; postMediaEngine.setProjectContext(project.id); // Sync meta on startup @@ -349,7 +345,7 @@ export function registerIpcHandlers(): void { }); safeHandle('projects:setActive', async (_, id: string) => { - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const project = await projectEngine.setActiveProject(id); // Update all engines to use the new project context @@ -357,13 +353,13 @@ export function registerIpcHandlers(): void { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); // For thumbnails and meta: use dataDir (whether custom or internal) // This ensures all project data lives in the same location for backup - const postEngine = getPostEngine(); - const mediaEngine = getMediaEngine(); - const metaEngine = getMetaEngine(); - const menuEngine = getMenuEngine(); - const tagEngine = getTagEngine(); - const scriptEngine = getScriptEngine(); - const templateEngine = getTemplateEngine(); + const postEngine = bundle.postEngine; + const mediaEngine = bundle.mediaEngine; + const metaEngine = bundle.metaEngine; + const menuEngine = bundle.menuEngine; + const tagEngine = bundle.tagEngine; + const scriptEngine = bundle.scriptEngine; + const templateEngine = bundle.templateEngine; postEngine.setProjectContext(project.id, dataDir); mediaEngine.setProjectContext(project.id, dataDir, dataDir); metaEngine.setProjectContext(project.id, dataDir); @@ -371,7 +367,7 @@ export function registerIpcHandlers(): void { tagEngine.setProjectContext(project.id, dataDir); scriptEngine.setProjectContext(project.id, dataDir); templateEngine.setProjectContext(project.id, dataDir); - const postMediaEngine = getPostMediaEngine(); + const postMediaEngine = bundle.postMediaEngine; postMediaEngine.setProjectContext(project.id); // Sync meta on project switch @@ -393,11 +389,11 @@ export function registerIpcHandlers(): void { // ============ Post Handlers ============ safeHandle('posts:create', async (_, data: Partial) => { - const engine = getPostEngine(); + const engine = bundle.postEngine; // If no author provided, use default author from project settings if (!data.author) { - const metaEngine = getMetaEngine(); + const metaEngine = bundle.metaEngine; const metadata = await metaEngine.getProjectMetadata(); if (metadata?.defaultAuthor) { data.author = metadata.defaultAuthor; @@ -408,32 +404,32 @@ export function registerIpcHandlers(): void { }); safeHandle('posts:isSlugAvailable', async (_, slug: string, excludePostId?: string) => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.isSlugAvailable(slug, excludePostId); }); safeHandle('posts:generateUniqueSlug', async (_, title: string, excludePostId?: string) => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.generateUniqueSlug(title, excludePostId); }); safeHandle('posts:update', async (_, id: string, data: Partial) => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.updatePost(id, data); }); safeHandle('posts:delete', async (_, id: string) => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.deletePost(id); }); safeHandle('posts:get', async (_, id: string) => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.getPost(id); }); safeHandle('posts:getPreviewUrl', async (_, id: string, options?: { draft?: boolean }) => { - const engine = getPostEngine(); + const engine = bundle.postEngine; const post = await engine.getPost(id); if (!post) { @@ -450,36 +446,36 @@ export function registerIpcHandlers(): void { }); safeHandle('posts:getAll', async (_, options?: PaginationOptions) => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.getAllPosts(options); }); safeHandle('posts:getByStatus', async (_, status: 'draft' | 'published' | 'archived') => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.getPostsByStatus(status); }); safeHandle('posts:publish', async (_, id: string) => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.publishPost(id); }); safeHandle('posts:discard', async (_, id: string) => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.discardChanges(id); }); safeHandle('posts:hasPublishedVersion', async (_, id: string) => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.hasPublishedVersion(id); }); safeHandle('posts:rebuildFromFiles', async () => { // Ensure project context is current before rebuilding - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const project = await projectEngine.getActiveProject(); - const engine = getPostEngine(); - const metaEngine = getMetaEngine(); + const engine = bundle.postEngine; + const metaEngine = bundle.metaEngine; if (project) { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); engine.setProjectContext(project.id, dataDir); @@ -491,64 +487,64 @@ export function registerIpcHandlers(): void { }); safeHandle('posts:search', async (_, query: string) => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.searchPosts(query); }); safeHandle('posts:filter', async (_, filter: PostFilter) => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.getPostsFiltered(filter); }); safeHandle('posts:getTags', async () => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.getAvailableTags(); }); safeHandle('posts:getCategories', async () => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.getAvailableCategories(); }); safeHandle('posts:getByYearMonth', async () => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.getPostsByYearMonth(); }); safeHandle('posts:getTagsWithCounts', async () => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.getTagsWithCounts(); }); safeHandle('posts:getCategoriesWithCounts', async () => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.getCategoriesWithCounts(); }); safeHandle('posts:getDashboardStats', async () => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.getDashboardStats(); }); safeHandle('posts:getLinksTo', async (_, id: string) => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.getLinksTo(id); }); safeHandle('posts:getLinkedBy', async (_, id: string) => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.getLinkedBy(id); }); safeHandle('posts:rebuildLinks', async () => { - const engine = getPostEngine(); + const engine = bundle.postEngine; return engine.rebuildAllPostLinks(); }); safeHandle('posts:reindexText', async () => { - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const project = await projectEngine.getActiveProject(); - const engine = getPostEngine(); + const engine = bundle.postEngine; if (project) { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); engine.setProjectContext(project.id, dataDir); @@ -559,11 +555,11 @@ export function registerIpcHandlers(): void { // ============ Media Handlers ============ safeHandle('media:import', async (_, sourcePath: string, metadata?: Partial) => { - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; // If no author provided, use default author from project settings if (!metadata?.author) { - const metaEngine = getMetaEngine(); + const metaEngine = bundle.metaEngine; const projectMetadata = await metaEngine.getProjectMetadata(); if (projectMetadata?.defaultAuthor) { metadata = metadata || {}; @@ -589,9 +585,9 @@ export function registerIpcHandlers(): void { } // Ensure project context is current before importing - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const project = await projectEngine.getActiveProject(); - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; if (project) { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); // For thumbnails and meta: use dataDir (whether custom or internal) @@ -602,7 +598,7 @@ export function registerIpcHandlers(): void { const imported: MediaData[] = []; // Get default author from project settings - const metaEngine = getMetaEngine(); + const metaEngine = bundle.metaEngine; const projectMetadata = await metaEngine.getProjectMetadata(); const defaultAuthor = projectMetadata?.defaultAuthor; @@ -619,18 +615,18 @@ export function registerIpcHandlers(): void { }); safeHandle('media:update', async (_, id: string, data: Partial) => { - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; return engine.updateMedia(id, data); }); safeHandle('media:replaceFile', async (_, id: string, newSourcePath: string) => { - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; return engine.replaceMediaFile(id, newSourcePath); }); safeHandle('media:replaceFileDialog', async (_, id: string) => { // Get the current media to determine file type filter - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; const currentMedia = await engine.getMedia(id); if (!currentMedia) { return null; @@ -661,12 +657,12 @@ export function registerIpcHandlers(): void { }); safeHandle('media:delete', async (_, id: string) => { - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; return engine.deleteMedia(id); }); safeHandle('media:get', async (_, id: string) => { - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; return engine.getMedia(id); }); @@ -674,7 +670,7 @@ export function registerIpcHandlers(): void { // Returns the relative path for a media item (e.g. media/2025/01/uuid.jpg) // and exposes it as an absolute preview path (e.g. /media/2025/01/uuid.jpg) // so inserted markdown uses root-absolute URLs. - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; const relativePath = await engine.getRelativePath(id); const normalized = relativePath ?? `media/${id}`; return normalized.startsWith('/') ? normalized : `/${normalized}`; @@ -688,40 +684,40 @@ export function registerIpcHandlers(): void { }); safeHandle('media:getAll', async () => { - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; return engine.getAllMedia(); }); safeHandle('media:filter', async (_, filter: import('../engine/MediaEngine').MediaFilter) => { - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; return engine.getMediaFiltered(filter); }); safeHandle('media:search', async (_, query: string) => { - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; return engine.searchMedia(query); }); safeHandle('media:getByYearMonth', async () => { - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; return engine.getMediaByYearMonth(); }); safeHandle('media:getTags', async () => { - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; return engine.getAvailableTags(); }); safeHandle('media:getTagsWithCounts', async () => { - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; return engine.getTagsWithCounts(); }); safeHandle('media:rebuildFromFiles', async () => { // Ensure project context is current before rebuilding - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const project = await projectEngine.getActiveProject(); - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; if (project) { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); // For thumbnails and meta: use dataDir (whether custom or internal) @@ -732,17 +728,17 @@ export function registerIpcHandlers(): void { }); safeHandle('media:reindexText', async () => { - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; return engine.reindexText(); }); safeHandle('media:getThumbnail', async (_, id: string, size?: 'small' | 'medium' | 'large') => { - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; return engine.getThumbnailDataUrl(id, size || 'small'); }); safeHandle('media:regenerateThumbnails', async (_, id: string) => { - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; const mediaItem = await engine.getMedia(id); if (mediaItem && mediaItem.mimeType.startsWith('image/')) { const db = getDatabase().getLocal(); @@ -755,9 +751,9 @@ export function registerIpcHandlers(): void { }); safeHandle('media:regenerateMissingThumbnails', async () => { - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const project = await projectEngine.getActiveProject(); - const engine = getMediaEngine(); + const engine = bundle.mediaEngine; if (project) { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); // For thumbnails and meta: use dataDir (whether custom or internal) @@ -770,27 +766,27 @@ export function registerIpcHandlers(): void { // ============ Script Handlers ============ safeHandle('scripts:create', async (_, data: CreateScriptInput) => { - const engine = getScriptEngine(); + const engine = bundle.scriptEngine; return engine.createScript(data); }); safeHandle('scripts:update', async (_, id: string, data: UpdateScriptInput) => { - const engine = getScriptEngine(); + const engine = bundle.scriptEngine; return engine.updateScript(id, data); }); safeHandle('scripts:delete', async (_, id: string) => { - const engine = getScriptEngine(); + const engine = bundle.scriptEngine; return engine.deleteScript(id); }); safeHandle('scripts:get', async (_, id: string) => { - const engine = getScriptEngine(); + const engine = bundle.scriptEngine; return engine.getScript(id); }); safeHandle('scripts:getAll', async () => { - const engine = getScriptEngine(); + const engine = bundle.scriptEngine; return engine.getAllScripts(); }); @@ -798,15 +794,15 @@ export function registerIpcHandlers(): void { // Intentionally excluded from the Python API contract and API.md because // it is an internal renderer helper, not a user-facing scripting API. safeHandle('scripts:getEnabledMacroSlugs', async () => { - const engine = getScriptEngine(); + const engine = bundle.scriptEngine; const scripts = await engine.getEnabledMacroScripts(); return scripts.map((s) => s.slug); }); safeHandle('scripts:rebuildFromFiles', async () => { - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const project = await projectEngine.getActiveProject(); - const engine = getScriptEngine(); + const engine = bundle.scriptEngine; if (project) { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); engine.setProjectContext(project.id, dataDir); @@ -818,44 +814,44 @@ export function registerIpcHandlers(): void { // ============ Template Handlers ============ safeHandle('templates:create', async (_, data: CreateTemplateInput) => { - const engine = getTemplateEngine(); + const engine = bundle.templateEngine; return engine.createTemplate(data); }); safeHandle('templates:update', async (_, id: string, data: UpdateTemplateInput) => { - const engine = getTemplateEngine(); + const engine = bundle.templateEngine; return engine.updateTemplate(id, data); }); safeHandle('templates:delete', async (_, id: string, options?: { force?: boolean }) => { - const engine = getTemplateEngine(); + const engine = bundle.templateEngine; return engine.deleteTemplate(id, options); }); safeHandle('templates:get', async (_, id: string) => { - const engine = getTemplateEngine(); + const engine = bundle.templateEngine; return engine.getTemplate(id); }); safeHandle('templates:getAll', async () => { - const engine = getTemplateEngine(); + const engine = bundle.templateEngine; return engine.getAllTemplates(); }); safeHandle('templates:getEnabledByKind', async (_, kind: string) => { - const engine = getTemplateEngine(); + const engine = bundle.templateEngine; return engine.getEnabledTemplatesByKind(kind as 'post' | 'list' | 'not-found' | 'partial'); }); safeHandle('templates:validate', async (_, content: string) => { - const engine = getTemplateEngine(); + const engine = bundle.templateEngine; return engine.validateTemplate(content); }); safeHandle('templates:rebuildFromFiles', async () => { - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const project = await projectEngine.getActiveProject(); - const engine = getTemplateEngine(); + const engine = bundle.templateEngine; if (project) { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); engine.setProjectContext(project.id, dataDir); @@ -867,69 +863,69 @@ export function registerIpcHandlers(): void { // ============ Task Handlers ============ safeHandle('tasks:getAll', async () => { - return taskManager.getAllTasks(); + return bundle.taskManager.getAllTasks(); }); safeHandle('tasks:getRunning', async () => { - return taskManager.getRunningTasks(); + return bundle.taskManager.getRunningTasks(); }); safeHandle('tasks:cancel', async (_, taskId: string) => { - return taskManager.cancelTask(taskId); + return bundle.taskManager.cancelTask(taskId); }); safeHandle('tasks:clearCompleted', async () => { - return taskManager.clearCompletedTasks(); + return bundle.taskManager.clearCompletedTasks(); }); // ============ Sync Handlers (git operations via GitApiAdapter) ============ safeHandle('sync:checkAvailability', async () => { - return getGitApiAdapter().checkAvailability(); + return bundle.gitApiAdapter.checkAvailability(); }); safeHandle('sync:getRepoState', async () => { - return getGitApiAdapter().getRepoState(); + return bundle.gitApiAdapter.getRepoState(); }); safeHandle('sync:getStatus', async () => { - return getGitApiAdapter().getStatus(); + return bundle.gitApiAdapter.getStatus(); }); safeHandle('sync:getHistory', async (_, limit?: number) => { - return getGitApiAdapter().getHistory(limit); + return bundle.gitApiAdapter.getHistory(limit); }); safeHandle('sync:getRemoteState', async () => { - return getGitApiAdapter().getRemoteState(); + return bundle.gitApiAdapter.getRemoteState(); }); safeHandle('sync:fetch', async () => { - return getGitApiAdapter().fetch(); + return bundle.gitApiAdapter.fetch(); }); safeHandle('sync:pull', async () => { - return getGitApiAdapter().pull(); + return bundle.gitApiAdapter.pull(); }); safeHandle('sync:push', async () => { - return getGitApiAdapter().push(); + return bundle.gitApiAdapter.push(); }); safeHandle('sync:commitAll', async (_, message: string) => { - return getGitApiAdapter().commitAll(message); + return bundle.gitApiAdapter.commitAll(message); }); // ============ App Handlers ============ safeHandle('app:getDataPaths', async () => { // Get paths for the active project - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const activeProject = await projectEngine.getActiveProject(); const projectId = activeProject?.id || 'default'; const paths = projectEngine.getProjectPaths(projectId, activeProject?.dataPath); return { - database: getDatabase().getDataPaths().database, + database: getDatabase().getDbPath(), posts: paths.posts, media: paths.media, }; @@ -951,7 +947,7 @@ export function registerIpcHandlers(): void { }); safeHandle('app:getDefaultProjectPath', async (_, projectId: string) => { - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; return projectEngine.getDefaultProjectBaseDir(projectId); }); @@ -1019,8 +1015,8 @@ export function registerIpcHandlers(): void { } if (typedAction === 'openDataFolder') { - const paths = getDatabase().getDataPaths(); - await shell.openPath(path.dirname(paths.database)); + const dbPath = getDatabase().getDbPath(); + await shell.openPath(path.dirname(dbPath)); return; } @@ -1043,8 +1039,8 @@ export function registerIpcHandlers(): void { // ============ Meta Handlers ============ - const ensureMetaContext = async (engine: ReturnType) => { - const projectEngine = getProjectEngine(); + const ensureMetaContext = async (engine: MetaEngine) => { + const projectEngine = bundle.projectEngine; const activeProject = await projectEngine.getActiveProject(); if (!activeProject) { return; @@ -1054,7 +1050,7 @@ export function registerIpcHandlers(): void { engine.setProjectContext(activeProject.id, dataDir); }; - const ensureMetaReady = async (engine: ReturnType) => { + const ensureMetaReady = async (engine: MetaEngine) => { await ensureMetaContext(engine); if (!engine.isInitialized()) { await engine.syncOnStartup(); @@ -1062,8 +1058,8 @@ export function registerIpcHandlers(): void { }; safeHandle('menu:get', async () => { - const projectEngine = getProjectEngine(); - const menuEngine = getMenuEngine(); + const projectEngine = bundle.projectEngine; + const menuEngine = bundle.menuEngine; const project = await projectEngine.getActiveProject(); if (!project) { @@ -1076,8 +1072,8 @@ export function registerIpcHandlers(): void { }); safeHandle('menu:save', async (_, menu: MenuDocument) => { - const projectEngine = getProjectEngine(); - const menuEngine = getMenuEngine(); + const projectEngine = bundle.projectEngine; + const menuEngine = bundle.menuEngine; const project = await projectEngine.getActiveProject(); if (!project) { @@ -1090,47 +1086,47 @@ export function registerIpcHandlers(): void { }); safeHandle('meta:getTags', async () => { - const engine = getMetaEngine(); + const engine = bundle.metaEngine; await ensureMetaReady(engine); return engine.getTags(); }); safeHandle('meta:getCategories', async () => { - const engine = getMetaEngine(); + const engine = bundle.metaEngine; await ensureMetaReady(engine); return engine.getCategories(); }); safeHandle('meta:addTag', async (_, tag: string) => { - const engine = getMetaEngine(); + const engine = bundle.metaEngine; await ensureMetaReady(engine); await engine.addTag(tag); return engine.getTags(); }); safeHandle('meta:removeTag', async (_, tag: string) => { - const engine = getMetaEngine(); + const engine = bundle.metaEngine; await ensureMetaReady(engine); await engine.removeTag(tag); return engine.getTags(); }); safeHandle('meta:addCategory', async (_, category: string) => { - const engine = getMetaEngine(); + const engine = bundle.metaEngine; await ensureMetaReady(engine); await engine.addCategory(category); return engine.getCategories(); }); safeHandle('meta:removeCategory', async (_, category: string) => { - const engine = getMetaEngine(); + const engine = bundle.metaEngine; await ensureMetaReady(engine); await engine.removeCategory(category); return engine.getCategories(); }); safeHandle('meta:syncOnStartup', async () => { - const engine = getMetaEngine(); + const engine = bundle.metaEngine; await ensureMetaContext(engine); await engine.syncOnStartup(); return { @@ -1141,39 +1137,39 @@ export function registerIpcHandlers(): void { }); safeHandle('meta:getProjectMetadata', async () => { - const engine = getMetaEngine(); + const engine = bundle.metaEngine; await ensureMetaReady(engine); return engine.getProjectMetadata(); }); safeHandle('meta:setProjectMetadata', async (_, metadata: { name: string; description?: string }) => { - const engine = getMetaEngine(); + const engine = bundle.metaEngine; await ensureMetaContext(engine); await engine.setProjectMetadata(metadata); return engine.getProjectMetadata(); }); 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(); + const engine = bundle.metaEngine; await ensureMetaContext(engine); await engine.updateProjectMetadata(updates); return engine.getProjectMetadata(); }); safeHandle('meta:getPublishingPreferences', async () => { - const engine = getMetaEngine(); + const engine = bundle.metaEngine; await ensureMetaReady(engine); return engine.getPublishingPreferences(); }); safeHandle('meta:setPublishingPreferences', async (_, prefs: { sshHost: string; sshUser: string; sshRemotePath: string; sshMode: 'scp' | 'rsync' }) => { - const engine = getMetaEngine(); + const engine = bundle.metaEngine; await ensureMetaContext(engine); await engine.setPublishingPreferences(prefs); }); safeHandle('meta:clearPublishingPreferences', async () => { - const engine = getMetaEngine(); + const engine = bundle.metaEngine; await ensureMetaContext(engine); await engine.clearPublishingPreferences(); }); @@ -1181,114 +1177,114 @@ export function registerIpcHandlers(): void { // ============ Tag Management Handlers ============ safeHandle('tags:getAll', async () => { - const engine = getTagEngine(); + const engine = bundle.tagEngine; return engine.getAllTags(); }); safeHandle('tags:getWithCounts', async () => { - const engine = getTagEngine(); + const engine = bundle.tagEngine; return engine.getTagsWithCounts(); }); safeHandle('tags:get', async (_, id: string) => { - const engine = getTagEngine(); + const engine = bundle.tagEngine; return engine.getTag(id); }); safeHandle('tags:getByName', async (_, name: string) => { - const engine = getTagEngine(); + const engine = bundle.tagEngine; return engine.getTagByName(name); }); safeHandle('tags:create', async (_, data: { name: string; color?: string }) => { - const engine = getTagEngine(); + const engine = bundle.tagEngine; return engine.createTag(data); }); safeHandle('tags:update', async (_, id: string, data: { name?: string; color?: string | null; postTemplateSlug?: string | null }) => { - const engine = getTagEngine(); + const engine = bundle.tagEngine; return engine.updateTag(id, data); }); safeHandle('tags:delete', async (_, id: string) => { - const engine = getTagEngine(); + const engine = bundle.tagEngine; return engine.deleteTag(id); }); safeHandle('tags:merge', async (_, sourceTagIds: string[], targetTagId: string) => { - const engine = getTagEngine(); + const engine = bundle.tagEngine; return engine.mergeTags(sourceTagIds, targetTagId); }); safeHandle('tags:rename', async (_, id: string, newName: string) => { - const engine = getTagEngine(); + const engine = bundle.tagEngine; return engine.renameTag(id, newName); }); safeHandle('tags:getPostsWithTag', async (_, tagId: string) => { - const engine = getTagEngine(); + const engine = bundle.tagEngine; return engine.getPostsWithTag(tagId); }); safeHandle('tags:syncFromPosts', async () => { - const engine = getTagEngine(); + const engine = bundle.tagEngine; return engine.syncTagsFromPosts(); }); // ============ Post-Media Link Handlers ============ safeHandle('postMedia:link', async (_, postId: string, mediaId: string) => { - const engine = getPostMediaEngine(); + const engine = bundle.postMediaEngine; return engine.linkMediaToPost(postId, mediaId); }); safeHandle('postMedia:unlink', async (_, postId: string, mediaId: string) => { - const engine = getPostMediaEngine(); + const engine = bundle.postMediaEngine; return engine.unlinkMediaFromPost(postId, mediaId); }); safeHandle('postMedia:linkMany', async (_, postId: string, mediaIds: string[]) => { - const engine = getPostMediaEngine(); + const engine = bundle.postMediaEngine; return engine.linkManyToPost(postId, mediaIds); }); safeHandle('postMedia:unlinkMany', async (_, postId: string, mediaIds: string[]) => { - const engine = getPostMediaEngine(); + const engine = bundle.postMediaEngine; return engine.unlinkManyFromPost(postId, mediaIds); }); safeHandle('postMedia:getForPost', async (_, postId: string) => { - const engine = getPostMediaEngine(); + const engine = bundle.postMediaEngine; return engine.getLinkedMediaForPost(postId); }); safeHandle('postMedia:getForMedia', async (_, mediaId: string) => { - const engine = getPostMediaEngine(); + const engine = bundle.postMediaEngine; return engine.getLinkedPostsForMedia(mediaId); }); safeHandle('postMedia:getMediaDataForPost', async (_, postId: string) => { - const engine = getPostMediaEngine(); + const engine = bundle.postMediaEngine; return engine.getLinkedMediaDataForPost(postId); }); safeHandle('postMedia:reorder', async (_, postId: string, mediaIds: string[]) => { - const engine = getPostMediaEngine(); + const engine = bundle.postMediaEngine; return engine.reorderMediaForPost(postId, mediaIds); }); safeHandle('postMedia:isLinked', async (_, postId: string, mediaId: string) => { - const engine = getPostMediaEngine(); + const engine = bundle.postMediaEngine; return engine.isMediaLinkedToPost(postId, mediaId); }); safeHandle('postMedia:import', async (_, postId: string, filePath: string) => { - const engine = getPostMediaEngine(); + const engine = bundle.postMediaEngine; return engine.importMediaForPost(postId, filePath); }); safeHandle('postMedia:rebuild', async () => { - const engine = getPostMediaEngine(); + const engine = bundle.postMediaEngine; return engine.rebuildFromSidecars(); }); @@ -1329,7 +1325,7 @@ export function registerIpcHandlers(): void { emitImportProgress('Loading project data...', `Found ${wxrData.posts.length} posts, ${wxrData.media.length} media`); const analysisEngine = new ImportAnalysisEngine(); - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const activeProject = await projectEngine.getActiveProject(); if (activeProject) { analysisEngine.setProjectContext(activeProject.id); @@ -1363,7 +1359,7 @@ export function registerIpcHandlers(): void { emitImportProgress('Loading project data...', `Found ${wxrData.posts.length} posts, ${wxrData.media.length} media`); const analysisEngine = new ImportAnalysisEngine(); - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const activeProject = await projectEngine.getActiveProject(); if (activeProject) { analysisEngine.setProjectContext(activeProject.id); @@ -1421,7 +1417,7 @@ export function registerIpcHandlers(): void { const report = JSON.parse(reportJson) as import('../engine/ImportAnalysisEngine').ImportAnalysisReport; // Set up project context - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const activeProject = await projectEngine.getActiveProject(); // Calculate total items for ETA @@ -1442,14 +1438,19 @@ export function registerIpcHandlers(): void { id: taskId, name: `Import from ${report.site.title || 'WordPress'}`, execute: async (onProgress: (progress: number, message: string) => void) => { - const executionEngine = new ImportExecutionEngine(); + const executionEngine = new ImportExecutionEngine({ + tagEngine: bundle.tagEngine, + postEngine: bundle.postEngine, + mediaEngine: bundle.mediaEngine, + postMediaEngine: bundle.postMediaEngine, + }); if (activeProject) { executionEngine.setProjectContext(activeProject.id, activeProject.dataPath); } // Get default author from project settings - const metaEngine = getMetaEngine(); + const metaEngine = bundle.metaEngine; const projectMetadata = await metaEngine.getProjectMetadata(); const defaultAuthor = projectMetadata?.defaultAuthor; @@ -1500,7 +1501,7 @@ export function registerIpcHandlers(): void { }; // Run the task - this returns immediately with a promise - const resultPromise = taskManager.runTask(task); + const resultPromise = bundle.taskManager.runTask(task); // Return task ID so UI can track it return { taskId, totalItems }; @@ -1511,7 +1512,7 @@ export function registerIpcHandlers(): void { safeHandle('importDefinitions:create', async (_, name?: string) => { const { ImportDefinitionEngine } = await import('../engine/ImportDefinitionEngine'); const engine = new ImportDefinitionEngine(); - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const activeProject = await projectEngine.getActiveProject(); if (activeProject) { engine.setProjectContext(activeProject.id); @@ -1522,7 +1523,7 @@ export function registerIpcHandlers(): void { safeHandle('importDefinitions:get', async (_, id: string) => { const { ImportDefinitionEngine } = await import('../engine/ImportDefinitionEngine'); const engine = new ImportDefinitionEngine(); - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const activeProject = await projectEngine.getActiveProject(); if (activeProject) { engine.setProjectContext(activeProject.id); @@ -1533,7 +1534,7 @@ export function registerIpcHandlers(): void { safeHandle('importDefinitions:getAll', async () => { const { ImportDefinitionEngine } = await import('../engine/ImportDefinitionEngine'); const engine = new ImportDefinitionEngine(); - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const activeProject = await projectEngine.getActiveProject(); if (activeProject) { engine.setProjectContext(activeProject.id); @@ -1544,7 +1545,7 @@ export function registerIpcHandlers(): void { safeHandle('importDefinitions:update', async (event, id: string, updates: any) => { const { ImportDefinitionEngine } = await import('../engine/ImportDefinitionEngine'); const engine = new ImportDefinitionEngine(); - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const activeProject = await projectEngine.getActiveProject(); if (activeProject) { engine.setProjectContext(activeProject.id); @@ -1560,7 +1561,7 @@ export function registerIpcHandlers(): void { safeHandle('importDefinitions:delete', async (_, id: string) => { const { ImportDefinitionEngine } = await import('../engine/ImportDefinitionEngine'); const engine = new ImportDefinitionEngine(); - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const activeProject = await projectEngine.getActiveProject(); if (activeProject) { engine.setProjectContext(activeProject.id); @@ -1568,9 +1569,9 @@ export function registerIpcHandlers(): void { return engine.deleteDefinition(id); }); - registerMetadataDiffHandlers(safeHandle); - registerBlogHandlers(safeHandle); - registerPublishHandlers(safeHandle); + registerMetadataDiffHandlers(safeHandle, bundle); + registerBlogHandlers(safeHandle, bundle); + registerPublishHandlers(safeHandle, bundle); // ============ MCP Config Handlers ============ @@ -1579,7 +1580,7 @@ export function registerIpcHandlers(): void { const engine = new MCPAgentConfigEngine({ homeDir: require('os').homedir(), platform: process.platform, - mcpUrl: buildMcpUrl(), + mcpUrl: buildMcpUrl(bundle), }); return engine.getAgents(); }); @@ -1589,7 +1590,7 @@ export function registerIpcHandlers(): void { const engine = new MCPAgentConfigEngine({ homeDir: require('os').homedir(), platform: process.platform, - mcpUrl: buildMcpUrl(), + mcpUrl: buildMcpUrl(bundle), }); return engine.addToConfig(agentId as import('../engine/MCPAgentConfigEngine').MCPAgentId); }); @@ -1599,15 +1600,14 @@ export function registerIpcHandlers(): void { const engine = new MCPAgentConfigEngine({ homeDir: require('os').homedir(), platform: process.platform, - mcpUrl: buildMcpUrl(), + mcpUrl: buildMcpUrl(bundle), }); return engine.isConfigured(agentId as import('../engine/MCPAgentConfigEngine').MCPAgentId); }); safeHandle('mcp:getPort', async () => { try { - const { getMCPServer } = await import('../engine/MCPServer'); - return getMCPServer().getPort(); + return bundle.mcpServer.getPort(); } catch { return null; } @@ -1620,13 +1620,13 @@ export function registerIpcHandlers(): void { * Separated from registerIpcHandlers() so that handler registration can happen * synchronously before any async work, eliminating startup race conditions. */ -export function registerEventForwarding(): void { - const postEngine = getPostEngine(); - const mediaEngine = getMediaEngine(); - const projectEngine = getProjectEngine(); - const metaEngine = getMetaEngine(); - const tagEngine = getTagEngine(); - const postMediaEngine = getPostMediaEngine(); +export function registerEventForwarding(bundle: EngineBundle): void { + const postEngine = bundle.postEngine; + const mediaEngine = bundle.mediaEngine; + const projectEngine = bundle.projectEngine; + const metaEngine = bundle.metaEngine; + const tagEngine = bundle.tagEngine; + const postMediaEngine = bundle.postMediaEngine; const forwardEvent = (eventName: string) => { return (...args: unknown[]) => { @@ -1671,19 +1671,19 @@ export function registerEventForwarding(): void { postMediaEngine.on('mediaReordered', forwardEvent('postMedia:reordered')); postMediaEngine.on('rebuilt', forwardEvent('postMedia:rebuilt')); - taskManager.on('taskCreated', forwardEvent('task:created')); - taskManager.on('taskStarted', forwardEvent('task:started')); - taskManager.on('taskProgress', forwardEvent('task:progress')); - taskManager.on('taskCompleted', forwardEvent('task:completed')); - taskManager.on('taskFailed', forwardEvent('task:failed')); + bundle.taskManager.on('taskCreated', forwardEvent('task:created')); + bundle.taskManager.on('taskStarted', forwardEvent('task:started')); + bundle.taskManager.on('taskProgress', forwardEvent('task:progress')); + bundle.taskManager.on('taskCompleted', forwardEvent('task:completed')); + bundle.taskManager.on('taskFailed', forwardEvent('task:failed')); - const scriptEngine = getScriptEngine(); + const scriptEngine = bundle.scriptEngine; scriptEngine.on('scriptCreated', forwardEvent('script:created')); scriptEngine.on('scriptUpdated', forwardEvent('script:updated')); scriptEngine.on('scriptDeleted', forwardEvent('script:deleted')); scriptEngine.on('scriptsRebuilt', forwardEvent('scripts:rebuilt')); - const templateEngine = getTemplateEngine(); + const templateEngine = bundle.templateEngine; templateEngine.on('templateCreated', forwardEvent('template:created')); templateEngine.on('templateUpdated', forwardEvent('template:updated')); templateEngine.on('templateDeleted', forwardEvent('template:deleted')); diff --git a/src/main/ipc/metadataDiffHandlers.ts b/src/main/ipc/metadataDiffHandlers.ts index 10dbc5f..bf3a2f5 100644 --- a/src/main/ipc/metadataDiffHandlers.ts +++ b/src/main/ipc/metadataDiffHandlers.ts @@ -1,13 +1,11 @@ -import { getProjectEngine } from '../engine/ProjectEngine'; -import { taskManager } from '../engine/TaskManager'; +import type { EngineBundle } from '../engine/EngineBundle'; type SafeHandle = (channel: string, handler: (...args: any[]) => Promise) => void; -export function registerMetadataDiffHandlers(safeHandle: SafeHandle): void { +export function registerMetadataDiffHandlers(safeHandle: SafeHandle, bundle: EngineBundle): void { safeHandle('metadataDiff:getStats', async () => { - const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine'); - const engine = getMetadataDiffEngine(); - const projectEngine = getProjectEngine(); + const engine = bundle.metadataDiffEngine; + const projectEngine = bundle.projectEngine; const activeProject = await projectEngine.getActiveProject(); if (activeProject) { engine.setProjectContext(activeProject.id); @@ -16,16 +14,15 @@ export function registerMetadataDiffHandlers(safeHandle: SafeHandle): void { }); safeHandle('metadataDiff:scan', async () => { - const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine'); - const engine = getMetadataDiffEngine(); - const projectEngine = getProjectEngine(); + const engine = bundle.metadataDiffEngine; + const projectEngine = bundle.projectEngine; const activeProject = await projectEngine.getActiveProject(); if (activeProject) { engine.setProjectContext(activeProject.id); } const taskId = `metadata-diff-scan-${Date.now()}`; - return taskManager.runTask({ + return bundle.taskManager.runTask({ id: taskId, name: 'Scanning for metadata differences', execute: async (onProgress) => { @@ -38,9 +35,8 @@ export function registerMetadataDiffHandlers(safeHandle: SafeHandle): void { }); safeHandle('metadataDiff:syncDbToFile', async (_, postIds: string[], groupLabel: string) => { - const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine'); - const engine = getMetadataDiffEngine(); - const projectEngine = getProjectEngine(); + const engine = bundle.metadataDiffEngine; + const projectEngine = bundle.projectEngine; const activeProject = await projectEngine.getActiveProject(); if (activeProject) { engine.setProjectContext(activeProject.id); @@ -49,9 +45,8 @@ export function registerMetadataDiffHandlers(safeHandle: SafeHandle): void { }); safeHandle('metadataDiff:syncFileToDb', async (_, postIds: string[], field: string, groupLabel: string) => { - const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine'); - const engine = getMetadataDiffEngine(); - const projectEngine = getProjectEngine(); + const engine = bundle.metadataDiffEngine; + const projectEngine = bundle.projectEngine; const activeProject = await projectEngine.getActiveProject(); if (activeProject) { engine.setProjectContext(activeProject.id); diff --git a/src/main/ipc/publishHandlers.ts b/src/main/ipc/publishHandlers.ts index d7db765..f242dd3 100644 --- a/src/main/ipc/publishHandlers.ts +++ b/src/main/ipc/publishHandlers.ts @@ -1,18 +1,17 @@ -import { getProjectEngine } from '../engine/ProjectEngine'; -import { getPublishEngine, type PublishCredentials } from '../engine/PublishEngine'; -import { taskManager } from '../engine/TaskManager'; +import type { PublishCredentials } from '../engine/PublishEngine'; +import type { EngineBundle } from '../engine/EngineBundle'; type SafeHandle = (channel: string, handler: (...args: any[]) => Promise) => void; -export function registerPublishHandlers(safeHandle: SafeHandle): void { +export function registerPublishHandlers(safeHandle: SafeHandle, bundle: EngineBundle): void { safeHandle('publish:uploadSite', async (_event: unknown, credentials: PublishCredentials) => { - const projectEngine = getProjectEngine(); + const projectEngine = bundle.projectEngine; const project = await projectEngine.getActiveProject(); if (!project) { throw new Error('No active project'); } - const publishEngine = getPublishEngine(); + const publishEngine = bundle.publishEngine; publishEngine.setProjectContext(project.id, project.dataPath!); const ts = Date.now(); @@ -20,7 +19,7 @@ export function registerPublishHandlers(safeHandle: SafeHandle): void { const groupName = 'Site Publishing'; // Launch three parallel tasks, one per directory - const htmlTask = taskManager.runTask({ + const htmlTask = bundle.taskManager.runTask({ id: `publish-html-${ts}`, name: 'Upload HTML', groupId, @@ -28,7 +27,7 @@ export function registerPublishHandlers(safeHandle: SafeHandle): void { execute: (onProgress) => publishEngine.uploadHtml(credentials, onProgress), }); - const thumbsTask = taskManager.runTask({ + const thumbsTask = bundle.taskManager.runTask({ id: `publish-thumbnails-${ts}`, name: 'Upload Thumbnails', groupId, @@ -36,7 +35,7 @@ export function registerPublishHandlers(safeHandle: SafeHandle): void { execute: (onProgress) => publishEngine.uploadThumbnails(credentials, onProgress), }); - const mediaTask = taskManager.runTask({ + const mediaTask = bundle.taskManager.runTask({ id: `publish-media-${ts}`, name: 'Upload Media', groupId, diff --git a/src/main/main.ts b/src/main/main.ts index 2ef5730..1521ffe 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -5,24 +5,59 @@ import { getDatabase } from './database'; import { registerIpcHandlers, registerEventForwarding, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc'; import { media } from './database/schema'; import { eq } from 'drizzle-orm'; -import { getMediaEngine } from './engine/MediaEngine'; -import { getPostEngine } from './engine/PostEngine'; -import { getMetaEngine } from './engine/MetaEngine'; -import { getTemplateEngine } from './engine/TemplateEngine'; -import { getScriptEngine } from './engine/ScriptEngine'; -import { getPostMediaEngine } from './engine/PostMediaEngine'; -import { getTagEngine } from './engine/TagEngine'; -import { getBlogmarkTransformService } from './engine/BlogmarkTransformService'; +import { MediaEngine } from './engine/MediaEngine'; +import { PostEngine } from './engine/PostEngine'; +import { MetaEngine } from './engine/MetaEngine'; +import { MenuEngine } from './engine/MenuEngine'; +import { TemplateEngine } from './engine/TemplateEngine'; +import { ScriptEngine } from './engine/ScriptEngine'; +import { PostMediaEngine } from './engine/PostMediaEngine'; +import { TagEngine } from './engine/TagEngine'; +import { ProjectEngine } from './engine/ProjectEngine'; +import { GitEngine } from './engine/GitEngine'; +import { GitApiAdapter } from './engine/GitApiAdapter'; +import { BlogGenerationEngine } from './engine/BlogGenerationEngine'; +import { BlogmarkTransformService } from './engine/BlogmarkTransformService'; +import { PublishEngine } from './engine/PublishEngine'; +import { MetadataDiffEngine } from './engine/MetadataDiffEngine'; +import { MCPServer } from './engine/MCPServer'; +import { taskManager } from './engine/TaskManager'; +import { BlogmarkPythonWorkerRuntime } from './engine/BlogmarkPythonWorkerRuntime'; +import { PythonMacroWorkerRuntime } from './engine/PythonMacroWorkerRuntime'; +import { AppApiAdapter } from './engine/AppApiAdapter'; +import { PublishApiAdapter } from './engine/PublishApiAdapter'; +import { NoopNotifier } from './engine/CliNotifier'; +import { NotificationWatcher } from './engine/NotificationWatcher'; +import { setEngineBundle } from './engine/mainProcessPythonApiInvoker'; +import type { EngineBundle } from './engine/EngineBundle'; import { PreviewServer } from './engine/PreviewServer'; -import { getMCPServer } from './engine/MCPServer'; import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_GROUPS, APP_MENU_ITEM_IDS, type AppMenuAction, type AppMenuItemDefinition } from './shared/menuCommands'; import { resolveUiLanguageFromSystemLocale, translateMenu } from './shared/i18n'; import { buildBlogmarkMarkdownLink, extractBlogmarkPayloadFromDeepLink, normalizeBlogmarkCategory } from './shared/blogmark'; let mainWindow: BrowserWindow | null = null; let previewServer: PreviewServer | null = null; +let notificationWatcher: NotificationWatcher | null = null; let activePreviewPostId: string | null = null; let appInitialized = false; +let bundle: EngineBundle | null = null; + +function buildPreviewServerDeps() { + const b = bundle!; + return { + postEngine: b.postEngine, + mediaEngine: b.mediaEngine, + postMediaEngine: b.postMediaEngine, + settingsEngine: b.metaEngine, + menuEngine: b.menuEngine, + getActiveProjectContext: async () => { + const project = await b.projectEngine.getActiveProject(); + if (!project) throw new Error('No active project'); + const dataDir = b.projectEngine.getDataDir(project.id, project.dataPath); + return { projectId: project.id, dataDir, projectName: project.name }; + }, + }; +} let blogmarkQueue: string[] = []; let blogmarkQueueProcessing = false; let pendingBlogmarkCreatedEvents: unknown[] = []; @@ -310,7 +345,7 @@ function createWindow(): void { async function openPreviewInBrowser(): Promise { if (!previewServer) { - previewServer = new PreviewServer(); + previewServer = new PreviewServer(buildPreviewServerDeps()); } await previewServer.start(PREVIEW_SERVER_PORT); @@ -337,7 +372,7 @@ async function openActivePostPreviewInBrowser(): Promise { return; } - const postEngine = getPostEngine(); + const postEngine = bundle!.postEngine; const post = await postEngine.getPost(activePreviewPostId); if (!post) { setPreviewPostMenuEnabled(false); @@ -345,7 +380,7 @@ async function openActivePostPreviewInBrowser(): Promise { } if (!previewServer) { - previewServer = new PreviewServer(); + previewServer = new PreviewServer(buildPreviewServerDeps()); } await previewServer.start(PREVIEW_SERVER_PORT); @@ -355,7 +390,7 @@ async function openActivePostPreviewInBrowser(): Promise { async function startPreviewServerOnAppStart(): Promise { if (!previewServer) { - previewServer = new PreviewServer(); + previewServer = new PreviewServer(buildPreviewServerDeps()); } await previewServer.start(PREVIEW_SERVER_PORT); @@ -391,10 +426,10 @@ async function processBlogmarkDeepLink(rawDeepLink: string): Promise { return; } - const metadata = await getMetaEngine().getProjectMetadata(); + const metadata = await bundle!.metaEngine.getProjectMetadata(); const preferredCategory = normalizeBlogmarkCategory((metadata as { blogmarkCategory?: unknown } | null)?.blogmarkCategory); - const transformService = getBlogmarkTransformService(); + const transformService = bundle!.blogmarkTransformService; const transformResult = await transformService.applyTransforms({ post: { title: payload.title, @@ -408,7 +443,7 @@ async function processBlogmarkDeepLink(rawDeepLink: string): Promise { }, }); - const createdPost = await getPostEngine().createPost({ + const createdPost = await bundle!.postEngine.createPost({ title: transformResult.post.title, content: transformResult.post.content, tags: transformResult.post.tags, @@ -475,8 +510,7 @@ function registerBlogmarkProtocolClient(): void { async function initializeActiveProjectContext(): Promise { try { - const { getProjectEngine } = await import('./engine/ProjectEngine'); - const projectEngine = getProjectEngine(); + const projectEngine = bundle!.projectEngine; const project = await projectEngine.getActiveProject(); if (!project) { @@ -484,15 +518,15 @@ async function initializeActiveProjectContext(): Promise { } const dataDir = projectEngine.getDataDir(project.id, project.dataPath); - const postEngine = getPostEngine() as { + const postEngine = bundle!.postEngine as { setProjectContext?: (projectId: string, dataDir?: string) => void; setSearchLanguage?: (language: string) => void; }; - const mediaEngine = getMediaEngine() as { + const mediaEngine = bundle!.mediaEngine as { setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void; setSearchLanguage?: (language: string) => void; }; - const metaEngine = getMetaEngine() as { + const metaEngine = bundle!.metaEngine as { setProjectContext?: (projectId: string, dataDir?: string) => void; syncOnStartup?: () => Promise; getProjectMetadata?: () => Promise<{ mainLanguage?: string } | null>; @@ -502,7 +536,7 @@ async function initializeActiveProjectContext(): Promise { mediaEngine.setProjectContext?.(project.id, dataDir, dataDir); metaEngine.setProjectContext?.(project.id, dataDir); - const templateEngine = getTemplateEngine() as { + const templateEngine = bundle!.templateEngine as { setProjectContext?: (projectId: string, dataDir?: string) => void; }; templateEngine.setProjectContext?.(project.id, dataDir); @@ -546,8 +580,8 @@ function createApplicationMenu(): Menu { } if (action === 'openDataFolder') { - const paths = getDatabase().getDataPaths(); - void shell.openPath(path.dirname(paths.database)); + const dbPath = getDatabase().getDbPath(); + void shell.openPath(path.dirname(dbPath)); return; } @@ -717,7 +751,7 @@ async function initialize(): Promise { // Register IPC handlers immediately (synchronous) so they are available // before any async work. This eliminates race conditions where the renderer // calls handlers before the database is ready. - registerIpcHandlers(); + registerIpcHandlers(bundle!); // Initialize database const db = getDatabase(); @@ -725,7 +759,7 @@ async function initialize(): Promise { // Now that the database is ready, register event forwarding from engines // to the renderer (engines need DB access at registration time). - registerEventForwarding(); + registerEventForwarding(bundle!); // Register custom protocol for serving media files // URLs like bds-media://media-id will be resolved to the actual file @@ -787,7 +821,7 @@ async function initialize(): Promise { const url = new URL(request.url); const mediaId = url.hostname; - const engine = getMediaEngine(); + const engine = bundle!.mediaEngine; const thumbnails = await engine.getThumbnailPaths(mediaId); if (thumbnails.small) { @@ -837,7 +871,7 @@ async function initialize(): Promise { }); // Initialize and register chat handlers - initializeChatHandlers(() => mainWindow); + initializeChatHandlers(() => mainWindow, bundle!); registerChatHandlers(); } @@ -867,6 +901,61 @@ app.on('open-url', (event, deepLink) => { // App lifecycle app.whenReady().then(async () => { + // Construct all engines and build EngineBundle before any initialization + const noopNotifier = new NoopNotifier(); + const projectEngine = new ProjectEngine(); + const metaEngine = new MetaEngine(); + const menuEngine = new MenuEngine(); + const mediaEngine = new MediaEngine(noopNotifier); + const postEngine = new PostEngine({ notifier: noopNotifier, mediaEngine }); + const postMediaEngine = new PostMediaEngine(mediaEngine); + const tagEngine = new TagEngine(postEngine); + const scriptEngine = new ScriptEngine(noopNotifier); + const templateEngine = new TemplateEngine(noopNotifier); + const metadataDiffEngine = new MetadataDiffEngine(postEngine); + const publishEngine = new PublishEngine(); + const gitEngine = new GitEngine(); + const gitApiAdapter = new GitApiAdapter(gitEngine, projectEngine); + const blogGenerationEngine = new BlogGenerationEngine(postEngine, mediaEngine, postMediaEngine); + const blogmarkPythonWorkerRuntime = new BlogmarkPythonWorkerRuntime(); + const pythonMacroWorkerRuntime = new PythonMacroWorkerRuntime(); + const blogmarkTransformService = new BlogmarkTransformService({ scriptEngine, metaEngine, blogmarkWorkerRuntime: blogmarkPythonWorkerRuntime }); + const appApiAdapter = new AppApiAdapter(projectEngine); + const publishApiAdapter = new PublishApiAdapter(projectEngine, publishEngine, taskManager); + const mcpServer = new MCPServer({ + postEngine, + mediaEngine, + scriptEngine, + templateEngine, + metaEngine, + postMediaEngine, + tagEngine, + }); + bundle = { + postEngine, + mediaEngine, + scriptEngine, + templateEngine, + metaEngine, + menuEngine, + tagEngine, + postMediaEngine, + projectEngine, + gitEngine, + gitApiAdapter, + blogGenerationEngine, + publishEngine, + metadataDiffEngine, + taskManager, + blogmarkTransformService, + mcpServer, + blogmarkPythonWorkerRuntime, + pythonMacroWorkerRuntime, + publishApiAdapter, + appApiAdapter, + }; + setEngineBundle(bundle); + await initialize(); const activeProjectContextReady = initializeActiveProjectContext(); registerBlogmarkProtocolClient(); @@ -876,21 +965,30 @@ app.whenReady().then(async () => { console.error('Failed to start preview server on app startup:', error); } try { - const mcpServer = getMCPServer({ - getPostEngine: () => getPostEngine(), - getMediaEngine: () => getMediaEngine(), - getScriptEngine: () => getScriptEngine(), - getTemplateEngine: () => getTemplateEngine(), - getMetaEngine: () => getMetaEngine(), - getPostMediaEngine: () => getPostMediaEngine(), - getTagEngine: () => getTagEngine(), - }); await mcpServer.start(MCP_SERVER_PORT); } catch (error) { console.error('Failed to start MCP server on app startup:', error); } createWindow(); + // Start NotificationWatcher after window is created (watcher needs mainWindow). + if (mainWindow) { + const db = getDatabase(); + notificationWatcher = new NotificationWatcher( + db.getDbPath(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db.getLocal() as any, + { + post: bundle.postEngine, + media: bundle.mediaEngine, + script: bundle.scriptEngine, + template: bundle.templateEngine, + }, + mainWindow, + ); + notificationWatcher.start(); + } + await activeProjectContextReady; appInitialized = true; @@ -914,6 +1012,10 @@ app.on('window-all-closed', () => { }); app.on('before-quit', async () => { + // Stop the notification watcher first to avoid processing events during shutdown. + notificationWatcher?.stop(); + notificationWatcher = null; + // Cleanup chat resources await cleanupChatHandlers(); @@ -923,8 +1025,7 @@ app.on('before-quit', async () => { } try { - const mcpServer = getMCPServer(); - await mcpServer.cleanup(); + await bundle?.mcpServer.cleanup(); } catch (error) { console.error('Failed to cleanup MCP server:', error); } diff --git a/src/main/preload.ts b/src/main/preload.ts index fae8e23..8a382d3 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -383,6 +383,12 @@ export const electronAPI: ElectronAPI = { ipcRenderer.once(channel, (_event, ...args) => callback(...args)); }, + onEntityChanged: (callback: (payload: import('./shared/electronApi').EntityChangedPayload) => void) => { + const subscription = (_event: Electron.IpcRendererEvent, payload: import('./shared/electronApi').EntityChangedPayload) => callback(payload); + ipcRenderer.on('entity:changed', subscription); + return () => ipcRenderer.removeListener('entity:changed', subscription); + }, + mcp: { getAgents: () => ipcRenderer.invoke('mcp:getAgents'), addToAgentConfig: (agentId: string) => ipcRenderer.invoke('mcp:addToAgentConfig', agentId), diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 36e5af0..6322404 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -1,5 +1,12 @@ // Type definitions for the Electron API exposed via preload +/** Payload emitted when the CLI mutates an entity (via db_notifications). */ +export interface EntityChangedPayload { + entity: 'post' | 'media' | 'script' | 'template'; + entityId: string; + action: 'created' | 'updated' | 'deleted'; +} + export interface ImportExecuteResult { taskId: string; totalItems: number; @@ -836,6 +843,8 @@ export interface ElectronAPI { }; on: (channel: string, callback: (...args: unknown[]) => void) => () => void; once: (channel: string, callback: (...args: unknown[]) => void) => void; + /** Subscribe to entity-changed events fired by the CLI NotificationWatcher. */ + onEntityChanged: (callback: (payload: EntityChangedPayload) => void) => () => void; mcp: { getAgents: () => Promise>; addToAgentConfig: (agentId: string) => Promise<{ success: boolean; configPath: string; error?: string }>; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 53af98c..fa5302a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -585,6 +585,39 @@ const App: React.FC = () => { }; }, []); + // Subscribe to entity:changed events fired by the CLI NotificationWatcher. + // When the CLI mutates posts or media while the app is open, refresh the + // affected entry in the local store so the UI stays in sync. + useEffect(() => { + const unsub = window.electronAPI?.onEntityChanged(async ({ entity, entityId, action }) => { + if (entity === 'post') { + if (action === 'deleted') { + removePost(entityId); + useAppStore.getState().closeTab(entityId); + } else { + const post = await window.electronAPI?.posts.get(entityId); + if (post) { + const p = post as PostData; + action === 'created' ? addPost(p) : updatePost(p.id, p); + } + } + } else if (entity === 'media') { + if (action === 'deleted') { + removeMedia(entityId); + } else { + const media = await window.electronAPI?.media.get(entityId); + if (media) { + const m = media as MediaData; + action === 'created' ? addMedia(m) : updateMedia(m.id, m); + } + } + } + // script and template entities have no cached store state — they are + // loaded on demand and will reflect CLI changes on next navigation. + }); + return () => unsub?.(); + }, [addPost, updatePost, removePost, addMedia, updateMedia, removeMedia]); + const { sidebarVisible, assistantSidebarVisible } = useAppStore(); return ( diff --git a/tests/engine/AppApiAdapter.test.ts b/tests/engine/AppApiAdapter.test.ts index 4c24074..5c63f17 100644 --- a/tests/engine/AppApiAdapter.test.ts +++ b/tests/engine/AppApiAdapter.test.ts @@ -8,7 +8,7 @@ const { mockProjectEngine, mockDatabase, mockReadFile } = vi.hoisted(() => ({ getDefaultProjectBaseDir: vi.fn().mockResolvedValue('/home/user/bDS/p1'), }, mockDatabase: { - getDataPaths: vi.fn().mockReturnValue({ database: '/data/bds.db' }), + getDbPath: vi.fn(() => '/data/bds.db'), }, mockReadFile: vi.fn(), })); @@ -38,8 +38,7 @@ describe('AppApiAdapter', () => { mockProjectEngine.getActiveProject.mockResolvedValue({ id: 'p1', dataPath: '/projects/blog' }); mockProjectEngine.getProjectPaths.mockReturnValue({ posts: '/projects/blog/posts', media: '/projects/blog/media' }); mockProjectEngine.getDefaultProjectBaseDir.mockResolvedValue('/home/user/bDS/p1'); - mockDatabase.getDataPaths.mockReturnValue({ database: '/data/bds.db' }); - adapter = new AppApiAdapter(); + adapter = new AppApiAdapter(mockProjectEngine as any); }); it('getDataPaths returns database, posts, and media paths', async () => { diff --git a/tests/engine/BlogGenerationEngine.test.ts b/tests/engine/BlogGenerationEngine.test.ts index 82a2bf2..1102589 100644 --- a/tests/engine/BlogGenerationEngine.test.ts +++ b/tests/engine/BlogGenerationEngine.test.ts @@ -154,6 +154,7 @@ describe('BlogGenerationEngine', () => { let tempDir: string; let mockPostEngine: any; let mockMediaEngine: any; + let mockPostMediaEngine: any; beforeEach(async () => { vi.clearAllMocks(); @@ -165,6 +166,8 @@ describe('BlogGenerationEngine', () => { mockPostEngine = __mockPostEngine; const { __mockMediaEngine } = await import('../../src/main/engine/MediaEngine') as any; mockMediaEngine = __mockMediaEngine; + const { __mockPostMediaEngine } = await import('../../src/main/engine/PostMediaEngine') as any; + mockPostMediaEngine = __mockPostMediaEngine; }); afterEach(async () => { @@ -210,7 +213,7 @@ describe('BlogGenerationEngine', () => { ) { setupPosts(posts); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); const onProgress = vi.fn(); return engine.generate({ projectId: 'test', @@ -726,7 +729,7 @@ describe('BlogGenerationEngine', () => { }); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); const result = await engine.generate({ projectId: 'test', projectName: 'Test Blog', @@ -759,7 +762,7 @@ describe('BlogGenerationEngine', () => { setupPosts(posts); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); await engine.generate({ projectId: 'test', projectName: 'Test Blog', @@ -789,7 +792,7 @@ describe('BlogGenerationEngine', () => { setupPosts(posts); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); await engine.generate({ projectId: 'test', @@ -816,7 +819,7 @@ describe('BlogGenerationEngine', () => { setupPosts(posts); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); const onProgress = vi.fn(); await engine.generate({ @@ -848,7 +851,7 @@ describe('BlogGenerationEngine', () => { setupPosts(posts); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); const onProgress = vi.fn(); await engine.generate({ @@ -878,7 +881,7 @@ describe('BlogGenerationEngine', () => { const canonicalPathSpy = vi.spyOn(pageRendererModule, 'buildCanonicalPostPath'); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); await engine.generate({ projectId: 'test', @@ -902,7 +905,7 @@ describe('BlogGenerationEngine', () => { setupPosts(posts); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); await engine.generate({ projectId: 'test', @@ -933,7 +936,7 @@ describe('BlogGenerationEngine', () => { const filterSpy = vi.spyOn(Array.prototype, 'filter'); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); await engine.generate({ projectId: 'test', @@ -975,7 +978,7 @@ describe('BlogGenerationEngine', () => { await writeFile(path.join(tempDir, 'html', 'stale', 'index.html'), 'stale', 'utf-8'); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); const report = await engine.validateSite({ projectId: 'test', @@ -1006,7 +1009,7 @@ describe('BlogGenerationEngine', () => { setupPosts([post]); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); await engine.generate({ projectId: 'test', @@ -1049,7 +1052,7 @@ describe('BlogGenerationEngine', () => { setupPosts([post]); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); await engine.generate({ projectId: 'test', @@ -1112,7 +1115,7 @@ describe('BlogGenerationEngine', () => { await writeFile(path.join(tempDir, 'html', 'obsolete', 'deep', 'index.html'), 'obsolete', 'utf-8'); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); const report = await engine.validateSite({ projectId: 'test', @@ -1158,7 +1161,7 @@ describe('BlogGenerationEngine', () => { setupPosts(posts); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); await engine.generate({ projectId: 'test', @@ -1237,7 +1240,7 @@ describe('BlogGenerationEngine', () => { await writeFile(path.join(tempDir, 'html', 'stale', 'index.html'), 'stale', 'utf-8'); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); const generateSpy = vi.spyOn(engine, 'generate'); @@ -1273,7 +1276,7 @@ describe('BlogGenerationEngine', () => { setupPosts(posts); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); await engine.applyValidation({ projectId: 'test', @@ -1303,7 +1306,7 @@ describe('BlogGenerationEngine', () => { setupPosts(posts); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); await engine.applyValidation({ projectId: 'test', @@ -1335,7 +1338,7 @@ describe('BlogGenerationEngine', () => { setupPosts(posts); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); await engine.applyValidation({ projectId: 'test', @@ -1371,7 +1374,7 @@ describe('BlogGenerationEngine', () => { await writeFile(path.join(tempDir, 'html', 'index.html'), 'stale-root', 'utf-8'); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); await engine.applyValidation({ projectId: 'test', @@ -1401,7 +1404,7 @@ describe('BlogGenerationEngine', () => { const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); const { PageRenderer } = await import('../../src/main/engine/PageRenderer'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); const renderPostListSpy = vi.spyOn(PageRenderer.prototype, 'renderPostList'); @@ -1454,7 +1457,7 @@ describe('BlogGenerationEngine', () => { setupPosts(posts); const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); await engine.applyValidation({ projectId: 'test', @@ -1497,7 +1500,7 @@ describe('BlogGenerationEngine', () => { const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); const { PageRenderer } = await import('../../src/main/engine/PageRenderer'); - const engine = new BlogGenerationEngine(); + const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine); const renderPostListSpy = vi.spyOn(PageRenderer.prototype, 'renderPostList'); diff --git a/tests/engine/GitApiAdapter.test.ts b/tests/engine/GitApiAdapter.test.ts index 358ad43..def0827 100644 --- a/tests/engine/GitApiAdapter.test.ts +++ b/tests/engine/GitApiAdapter.test.ts @@ -31,7 +31,7 @@ describe('GitApiAdapter', () => { beforeEach(() => { vi.clearAllMocks(); - adapter = new GitApiAdapter(); + adapter = new GitApiAdapter(mockGitEngine as any, mockProjectEngine as any); }); it('checkAvailability delegates directly (no projectPath)', async () => { diff --git a/tests/engine/ImportExecutionEngine.e2e.test.ts b/tests/engine/ImportExecutionEngine.e2e.test.ts index 9f7c985..a0b718a 100644 --- a/tests/engine/ImportExecutionEngine.e2e.test.ts +++ b/tests/engine/ImportExecutionEngine.e2e.test.ts @@ -233,7 +233,12 @@ describe('ImportExecutionEngine E2E Tests', () => { vi.clearAllMocks(); // Create engine instance - engine = new ImportExecutionEngine(); + engine = new ImportExecutionEngine({ + tagEngine: mockTagEngine as any, + postEngine: mockPostEngine as any, + mediaEngine: mockMediaEngine as any, + postMediaEngine: mockPostMediaEngine as any, + }); engine.setProjectContext('test-project', '/mock/test/data'); // Parse the WXR content (mocked readFile will return our pre-loaded content) diff --git a/tests/engine/ImportExecutionEngine.test.ts b/tests/engine/ImportExecutionEngine.test.ts index 5298608..9ce3a81 100644 --- a/tests/engine/ImportExecutionEngine.test.ts +++ b/tests/engine/ImportExecutionEngine.test.ts @@ -108,6 +108,17 @@ const mockMediaEngine = { updateMedia: vi.fn().mockResolvedValue({}), }; +// Mock the PostMediaEngine +const mockPostMediaEngine = { + setProjectContext: vi.fn(), + linkMediaToPost: vi.fn().mockResolvedValue(undefined), + getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]), +}; + +vi.mock('../../src/main/engine/PostMediaEngine', () => ({ + PostMediaEngine: vi.fn(() => mockPostMediaEngine), +})); + vi.mock('../../src/main/engine/MediaEngine', () => ({ getMediaEngine: vi.fn(() => mockMediaEngine), })); @@ -275,7 +286,12 @@ describe('ImportExecutionEngine', () => { insertedPosts.length = 0; insertedMedia.length = 0; updatedPosts.length = 0; - engine = new ImportExecutionEngine(); + engine = new ImportExecutionEngine({ + tagEngine: mockTagEngine as any, + postEngine: mockPostEngine as any, + mediaEngine: mockMediaEngine as any, + postMediaEngine: mockPostMediaEngine as any, + }); engine.setProjectContext('test-project', '/mock/project/data'); }); diff --git a/tests/engine/MCPConfigEngine.test.ts b/tests/engine/MCPConfigEngine.test.ts index 93933ac..6b5fd04 100644 --- a/tests/engine/MCPConfigEngine.test.ts +++ b/tests/engine/MCPConfigEngine.test.ts @@ -29,7 +29,7 @@ describe('MCPAgentConfigEngine', () => { let engine: MCPAgentConfigEngine; beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); engine = new MCPAgentConfigEngine({ homeDir: '/home/testuser', platform: 'darwin', @@ -40,9 +40,10 @@ describe('MCPAgentConfigEngine', () => { describe('getAgents', () => { it('returns all supported agent definitions', () => { const agents = engine.getAgents(); - expect(agents).toHaveLength(4); + expect(agents).toHaveLength(5); const ids = agents.map((a) => a.id); expect(ids).toContain('claude-code'); + expect(ids).toContain('claude-desktop'); expect(ids).toContain('github-copilot'); expect(ids).toContain('gemini-cli'); expect(ids).toContain('opencode'); @@ -336,4 +337,160 @@ describe('MCPAgentConfigEngine', () => { expect(written.mcpServers.bDS.url).toBe('http://127.0.0.1:9999/mcp'); }); }); + + describe('claude-desktop', () => { + let desktopEngine: MCPAgentConfigEngine; + + beforeEach(() => { + desktopEngine = new MCPAgentConfigEngine({ + homeDir: '/home/testuser', + platform: 'darwin', + mcpUrl: 'http://127.0.0.1:4124/mcp', + execPath: '/Applications/Blogging Desktop Server.app/Contents/MacOS/Blogging Desktop Server', + scriptPath: '/Applications/Blogging Desktop Server.app/Contents/Resources/bds-mcp.cjs', + }); + }); + + it('includes claude-desktop in getAgents()', () => { + const agents = desktopEngine.getAgents(); + expect(agents.map((a) => a.id)).toContain('claude-desktop'); + }); + + it('returns correct config path for claude-desktop on macOS', () => { + expect(desktopEngine.getConfigPath('claude-desktop')).toBe( + '/home/testuser/Library/Application Support/Claude/claude_desktop_config.json', + ); + }); + + it('returns correct config path for claude-desktop on Windows', () => { + const winEngine = new MCPAgentConfigEngine({ + homeDir: 'C:\\Users\\testuser', + platform: 'win32', + mcpUrl: 'http://127.0.0.1:4124/mcp', + execPath: 'C:\\path\\to\\app.exe', + scriptPath: 'C:\\path\\to\\bds-mcp.cjs', + }); + const configPath = winEngine.getConfigPath('claude-desktop'); + // On Windows path.join uses backslashes; on macOS it uses forward slashes + // so normalise for cross-platform CI + const normalised = configPath.replace(/[\\/]/g, '/'); + expect(normalised).toBe( + 'C:/Users/testuser/AppData/Roaming/Claude/claude_desktop_config.json', + ); + }); + + it('returns correct config path for claude-desktop on Linux', () => { + const linuxEngine = new MCPAgentConfigEngine({ + homeDir: '/home/user', + platform: 'linux', + mcpUrl: 'http://127.0.0.1:4124/mcp', + }); + expect(linuxEngine.getConfigPath('claude-desktop')).toBe( + '/home/user/.config/Claude/claude_desktop_config.json', + ); + }); + + it('adds stdio entry with command/args/env to claude_desktop_config.json', () => { + mockExistsSync.mockReturnValue(false); + + const result = desktopEngine.addToConfig('claude-desktop'); + + expect(result.success).toBe(true); + const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); + expect(written.mcpServers.bDS).toEqual({ + command: '/Applications/Blogging Desktop Server.app/Contents/MacOS/Blogging Desktop Server', + args: ['/Applications/Blogging Desktop Server.app/Contents/Resources/bds-mcp.cjs'], + env: { ELECTRON_RUN_AS_NODE: '1' }, + }); + }); + + it('throws descriptive error if execPath/scriptPath missing for claude-desktop', () => { + const noPathEngine = new MCPAgentConfigEngine({ + homeDir: '/home/testuser', + platform: 'darwin', + mcpUrl: 'http://127.0.0.1:4124/mcp', + }); + mockExistsSync.mockReturnValue(false); + + const result = noPathEngine.addToConfig('claude-desktop'); + expect(result.success).toBe(false); + expect(result.error).toContain('execPath'); + }); + }); + + describe('removeFromConfig', () => { + it('removes bDS entry from config and returns success', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + mcpServers: { + bDS: { type: 'http', url: 'http://127.0.0.1:4124/mcp' }, + other: { type: 'http', url: 'http://other' }, + }, + }), + ); + + const result = engine.removeFromConfig('claude-code'); + + expect(result.success).toBe(true); + const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); + expect(written.mcpServers.bDS).toBeUndefined(); + expect(written.mcpServers.other).toBeDefined(); + }); + + it('removes the mcpServers key entirely when bDS was the only entry', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + mcpServers: { bDS: { type: 'http', url: 'http://127.0.0.1:4124/mcp' } }, + }), + ); + + engine.removeFromConfig('claude-code'); + + const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); + expect(written.mcpServers).toBeUndefined(); + }); + + it('no-ops gracefully when file does not exist', () => { + mockExistsSync.mockReturnValue(false); + + const result = engine.removeFromConfig('claude-code'); + + expect(result.success).toBe(true); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it('no-ops gracefully when bDS entry is not in config', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ mcpServers: { other: {} } })); + + const result = engine.removeFromConfig('claude-code'); + + expect(result.success).toBe(true); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it('uses the servers key for github-copilot', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ servers: { bDS: { type: 'http', url: 'x' } } }), + ); + + const result = engine.removeFromConfig('github-copilot'); + + expect(result.success).toBe(true); + const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); + expect(written.servers).toBeUndefined(); + }); + + it('returns success with configPath', () => { + mockExistsSync.mockReturnValue(false); + + const result = engine.removeFromConfig('claude-code'); + + expect(result.success).toBe(true); + expect(result.configPath).toBe('/home/testuser/.claude.json'); + }); + }); }); diff --git a/tests/engine/MCPServer.test.ts b/tests/engine/MCPServer.test.ts index e0d92ab..4d8a26f 100644 --- a/tests/engine/MCPServer.test.ts +++ b/tests/engine/MCPServer.test.ts @@ -60,22 +60,34 @@ function createMockMediaEngine() { function createMockScriptEngine() { return { - createScript: vi.fn().mockResolvedValue({ + createDraftScript: vi.fn().mockResolvedValue({ id: 'script-1', title: 'Test', slug: 'test', kind: 'macro', entrypoint: 'main.py', content: '', enabled: true, version: 1, filePath: '/test', createdAt: new Date(), updatedAt: new Date(), }), + publishScript: vi.fn().mockResolvedValue({ + id: 'script-1', title: 'Test', slug: 'test', kind: 'macro', + entrypoint: 'main.py', content: '', enabled: true, version: 1, + filePath: '/test', createdAt: new Date(), updatedAt: new Date(), + }), + deleteDraftScript: vi.fn().mockResolvedValue(true), validateScript: vi.fn().mockResolvedValue({ valid: true, errors: [] }), }; } function createMockTemplateEngine() { return { - createTemplate: vi.fn().mockResolvedValue({ + createDraftTemplate: vi.fn().mockResolvedValue({ id: 'tpl-1', title: 'Test', slug: 'test', kind: 'post', enabled: true, version: 1, filePath: '/test', content: '', createdAt: new Date(), updatedAt: new Date(), }), + publishTemplate: vi.fn().mockResolvedValue({ + id: 'tpl-1', title: 'Test', slug: 'test', kind: 'post', + enabled: true, version: 1, filePath: '/test', content: '', + createdAt: new Date(), updatedAt: new Date(), + }), + deleteDraftTemplate: vi.fn().mockResolvedValue(true), validateTemplate: vi.fn().mockResolvedValue({ valid: true, errors: [] }), }; } @@ -110,13 +122,13 @@ function createDependencies() { const mockTagEngine = createMockTagEngine(); const deps: MCPServerDependencies = { - getPostEngine: () => mockPostEngine, - getMediaEngine: () => mockMediaEngine, - getScriptEngine: () => mockScriptEngine, - getTemplateEngine: () => mockTemplateEngine, - getMetaEngine: () => mockMetaEngine, - getPostMediaEngine: () => mockPostMediaEngine, - getTagEngine: () => mockTagEngine, + postEngine: mockPostEngine, + mediaEngine: mockMediaEngine, + scriptEngine: mockScriptEngine, + templateEngine: mockTemplateEngine, + metaEngine: mockMetaEngine, + postMediaEngine: mockPostMediaEngine, + tagEngine: mockTagEngine, }; return { deps, mockPostEngine, mockMediaEngine, mockScriptEngine, mockTemplateEngine, mockMetaEngine, mockPostMediaEngine, mockTagEngine }; @@ -358,25 +370,21 @@ describe('MCPServer', () => { it('accepts a proposeScript proposal by creating script', async () => { const proposalId = server.proposalStore.create('proposeScript', { - title: 'My Script', kind: 'macro', content: 'print("hello")', + scriptId: 'script-1', }); const result = await server.acceptProposal(proposalId); expect(result.success).toBe(true); - expect(mockScriptEngine.createScript).toHaveBeenCalledWith({ - title: 'My Script', kind: 'macro', content: 'print("hello")', - }); + expect(mockScriptEngine.publishScript).toHaveBeenCalledWith('script-1'); expect(server.proposalStore.get(proposalId)).toBeUndefined(); }); it('accepts a proposeTemplate proposal by creating template', async () => { const proposalId = server.proposalStore.create('proposeTemplate', { - title: 'My Template', kind: 'post', content: '

{{ title }}

', + templateId: 'tpl-1', }); const result = await server.acceptProposal(proposalId); expect(result.success).toBe(true); - expect(mockTemplateEngine.createTemplate).toHaveBeenCalledWith({ - title: 'My Template', kind: 'post', content: '

{{ title }}

', - }); + expect(mockTemplateEngine.publishTemplate).toHaveBeenCalledWith('tpl-1'); }); it('accepts a proposeMediaMetadata proposal by updating media', async () => { @@ -415,9 +423,10 @@ describe('MCPServer', () => { }); it('discards a proposeScript proposal by removing from store', async () => { - const proposalId = server.proposalStore.create('proposeScript', { title: 'Script' }); + const proposalId = server.proposalStore.create('proposeScript', { scriptId: 'script-1' }); const result = await server.discardProposal(proposalId); expect(result.success).toBe(true); + expect(mockScriptEngine.deleteDraftScript).toHaveBeenCalledWith('script-1'); expect(server.proposalStore.get(proposalId)).toBeUndefined(); }); @@ -830,7 +839,10 @@ describe('MCPServer', () => { const proposal = server.proposalStore.get(parsed.proposalId); expect(proposal).toBeDefined(); expect(proposal!.type).toBe('proposeScript'); - expect(proposal!.data.content).toBe('print("hi")'); + expect(mockScriptEngine.createDraftScript).toHaveBeenCalledWith({ + title: 'My Script', kind: 'macro', content: 'print("hi")', entrypoint: undefined, + }); + expect(proposal!.data.scriptId).toBe('script-1'); }); it('propose_script calls validateScript and includes validation result in preview', async () => { diff --git a/tests/engine/MetadataDiffEngine.test.ts b/tests/engine/MetadataDiffEngine.test.ts index 7e8d040..a556818 100644 --- a/tests/engine/MetadataDiffEngine.test.ts +++ b/tests/engine/MetadataDiffEngine.test.ts @@ -173,7 +173,7 @@ describe('MetadataDiffEngine', () => { mockAllPostsRows = []; mockSyncPublishedPostFile.mockClear(); resetMockCounters(); - engine = new MetadataDiffEngine(); + engine = new MetadataDiffEngine({ syncPublishedPostFile: mockSyncPublishedPostFile } as any); engine.setProjectContext('test-project'); }); diff --git a/tests/engine/NotificationWatcher.test.ts b/tests/engine/NotificationWatcher.test.ts new file mode 100644 index 0000000..620cd87 --- /dev/null +++ b/tests/engine/NotificationWatcher.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type MockedFunction } from 'vitest'; + +// ── Chokidar mock ──────────────────────────────────────────────────────────── + +interface MockFSWatcher { + on: MockedFunction<(event: string, handler: () => void) => MockFSWatcher>; + close: MockedFunction<() => Promise>; +} + +let mockWatcher: MockFSWatcher; +let capturedWatchPaths: string[] = []; +let capturedWatchOptions: Record = {}; + +vi.mock('chokidar', () => ({ + default: { + watch: (paths: string[], options: Record) => { + capturedWatchPaths = paths; + capturedWatchOptions = options; + return mockWatcher; + }, + }, +})); + +// ── Imports (after mocks) ──────────────────────────────────────────────────── + +import { + NotificationWatcher, + type WatchableEngines, +} from '../../src/main/engine/NotificationWatcher'; + +// ── DB mock helpers ───────────────────────────────────────────────────────── + +type MockDb = { + select: MockedFunction<() => { + from: (table: unknown) => { where: MockedFunction<() => Promise> }; + }>; + update: MockedFunction<(table: unknown) => { + set: (values: unknown) => { where: MockedFunction<() => Promise> }; + }>; + delete: MockedFunction<(table: unknown) => { where: MockedFunction<() => Promise> }>; +}; + +function makeSelectChain(rows: unknown[]): ReturnType { + const whereSelect = vi.fn().mockResolvedValue(rows); + return { + from: (_table: unknown) => ({ where: whereSelect }), + }; +} + +function makeUpdateChain(): ReturnType { + const whereUpdate = vi.fn().mockResolvedValue(undefined); + return { + set: (_values: unknown) => ({ where: whereUpdate }), + }; +} + +function makeDeleteChain(): ReturnType { + return { where: vi.fn().mockResolvedValue(undefined) }; +} + +// ── Test suite ─────────────────────────────────────────────────────────────── + +describe('NotificationWatcher', () => { + const DB_PATH = '/home/user/.config/bDS/bds.db'; + + let db: MockDb; + let engines: WatchableEngines; + let mockSend: MockedFunction<(channel: string, payload: unknown) => void>; + let mainWindow: { webContents: { send: typeof mockSend } }; + let watcher: NotificationWatcher; + + beforeEach(() => { + vi.useFakeTimers(); + + mockWatcher = { + on: vi.fn().mockReturnThis(), + close: vi.fn().mockResolvedValue(undefined), + }; + + capturedWatchPaths = []; + capturedWatchOptions = {}; + + db = { + select: vi.fn().mockReturnValue(makeSelectChain([])), + update: vi.fn().mockReturnValue(makeUpdateChain()), + delete: vi.fn().mockReturnValue(makeDeleteChain()), + }; + + engines = { + post: { invalidate: vi.fn() }, + media: { invalidate: vi.fn() }, + script: { invalidate: vi.fn() }, + template: { invalidate: vi.fn() }, + }; + + mockSend = vi.fn(); + mainWindow = { webContents: { send: mockSend } }; + + watcher = new NotificationWatcher(DB_PATH, db as any, engines, mainWindow as any, 100); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ── start() ─────────────────────────────────────────────────────────────── + + describe('start()', () => { + it('watches both db and wal paths', () => { + watcher.start(); + expect(capturedWatchPaths).toEqual([DB_PATH, `${DB_PATH}-wal`]); + }); + + it('sets persistent:false and ignoreInitial:true', () => { + watcher.start(); + expect(capturedWatchOptions.persistent).toBe(false); + expect(capturedWatchOptions.ignoreInitial).toBe(true); + }); + + it('registers change and add handlers', () => { + watcher.start(); + const events = mockWatcher.on.mock.calls.map((c) => c[0]); + expect(events).toContain('change'); + expect(events).toContain('add'); + }); + + it('debounces rapid file-change events', async () => { + watcher.start(); + const changeHandler = mockWatcher.on.mock.calls.find((c) => c[0] === 'change')![1]; + + db.select.mockReturnValue(makeSelectChain([])); + + changeHandler(); + changeHandler(); + changeHandler(); + + expect(db.select).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(100); + + // Only one process() call despite three change events + expect(db.select).toHaveBeenCalledTimes(1); + }); + }); + + // ── process() ───────────────────────────────────────────────────────────── + + describe('process()', () => { + async function triggerProcess(rows: unknown[] = []): Promise { + db.select.mockReturnValue(makeSelectChain(rows)); + watcher.start(); + const changeHandler = mockWatcher.on.mock.calls.find((c) => c[0] === 'change')![1]; + changeHandler(); + await vi.advanceTimersByTimeAsync(100); + } + + it('queries db_notifications for unprocessed CLI rows', async () => { + await triggerProcess([]); + expect(db.select).toHaveBeenCalledTimes(1); + }); + + it('calls invalidate on the matching engine for each row', async () => { + const rows = [ + { id: 1, entity: 'post', entityId: 'p1', action: 'created', fromCli: 1, seenAt: null, createdAt: Date.now() }, + { id: 2, entity: 'media', entityId: 'm1', action: 'updated', fromCli: 1, seenAt: null, createdAt: Date.now() }, + ]; + await triggerProcess(rows); + + expect(engines.post.invalidate).toHaveBeenCalledWith('p1'); + expect(engines.media.invalidate).toHaveBeenCalledWith('m1'); + }); + + it('sends entity:changed IPC event for each row', async () => { + const rows = [ + { id: 1, entity: 'post', entityId: 'p1', action: 'created', fromCli: 1, seenAt: null, createdAt: Date.now() }, + { id: 2, entity: 'script', entityId: 's1', action: 'deleted', fromCli: 1, seenAt: null, createdAt: Date.now() }, + ]; + await triggerProcess(rows); + + expect(mockSend).toHaveBeenCalledWith('entity:changed', { + entity: 'post', + entityId: 'p1', + action: 'created', + }); + expect(mockSend).toHaveBeenCalledWith('entity:changed', { + entity: 'script', + entityId: 's1', + action: 'deleted', + }); + }); + + it('stamps seenAt on each processed row', async () => { + const rows = [ + { id: 42, entity: 'post', entityId: 'p1', action: 'updated', fromCli: 1, seenAt: null, createdAt: Date.now() }, + ]; + db.select.mockReturnValue(makeSelectChain(rows)); + const updateChain = makeUpdateChain(); + db.update.mockReturnValue(updateChain); + + watcher.start(); + const changeHandler = mockWatcher.on.mock.calls.find((c) => c[0] === 'change')![1]; + changeHandler(); + await vi.advanceTimersByTimeAsync(100); + + expect(db.update).toHaveBeenCalledTimes(1); + }); + + it('prunes old seen rows (>1h) and old unprocessed rows (>24h)', async () => { + await triggerProcess([]); + // delete is called twice: once for seenAt > 1h, once for unprocessed > 24h + expect(db.delete).toHaveBeenCalledTimes(2); + }); + + it('skips unknown entity types gracefully', async () => { + const rows = [ + { id: 1, entity: 'unknown_entity', entityId: 'x1', action: 'created', fromCli: 1, seenAt: null, createdAt: Date.now() }, + ]; + await expect(triggerProcess(rows)).resolves.not.toThrow(); + // No IPC send for unknown entities, but the watcher finishes without error + }); + }); + + // ── stop() ──────────────────────────────────────────────────────────────── + + describe('stop()', () => { + it('closes the file watcher', () => { + watcher.start(); + watcher.stop(); + expect(mockWatcher.close).toHaveBeenCalled(); + }); + + it('cancels a pending debounce timer', async () => { + watcher.start(); + const changeHandler = mockWatcher.on.mock.calls.find((c) => c[0] === 'change')![1]; + changeHandler(); + + watcher.stop(); + await vi.advanceTimersByTimeAsync(200); + + // process() must NOT have run after stop + expect(db.select).not.toHaveBeenCalled(); + }); + + it('does not throw if called before start()', () => { + expect(() => watcher.stop()).not.toThrow(); + }); + }); +}); diff --git a/tests/engine/PostMediaEngine.test.ts b/tests/engine/PostMediaEngine.test.ts index feb73a4..c164ec4 100644 --- a/tests/engine/PostMediaEngine.test.ts +++ b/tests/engine/PostMediaEngine.test.ts @@ -31,6 +31,14 @@ const mockUpdateMedia = vi.fn(); const mockGetAllMedia = vi.fn(); const mockImportMedia = vi.fn(); +// Aggregated mock MediaEngine object for constructor injection +const mockMediaEngineForPostMedia = { + getMedia: mockGetMedia, + updateMedia: mockUpdateMedia, + getAllMedia: mockGetAllMedia, + importMedia: mockImportMedia, +}; + // Mock MediaEngine vi.mock('../../src/main/engine/MediaEngine', () => ({ getMediaEngine: vi.fn(() => ({ @@ -144,7 +152,7 @@ describe('PostMediaEngine', () => { mockGetAllMedia.mockResolvedValue([]); mockImportMedia.mockResolvedValue({ id: 'imported-media-id' }); - engine = new PostMediaEngine(); + engine = new PostMediaEngine(mockMediaEngineForPostMedia as any); engine.setProjectContext('test-project'); }); diff --git a/tests/engine/PreviewServer.test.ts b/tests/engine/PreviewServer.test.ts index a915bb9..701b677 100644 --- a/tests/engine/PreviewServer.test.ts +++ b/tests/engine/PreviewServer.test.ts @@ -159,6 +159,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -200,6 +203,8 @@ describe('PreviewServer', () => { }, ], }), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -242,6 +247,8 @@ describe('PreviewServer', () => { { id: 'dev', title: 'Dev', kind: 'category-archive', categoryName: 'news', children: [] }, ], }), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -278,6 +285,8 @@ describe('PreviewServer', () => { { id: 'news', title: 'news', kind: 'category-archive', categoryName: 'news', children: [] }, ], }), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -293,6 +302,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine([makePost()]), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -369,6 +381,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine([makePost()]), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default', dataDir: tempDir ?? undefined }), }); @@ -397,6 +412,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine([postWithCode]), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -432,6 +450,7 @@ describe('PreviewServer', () => { postMediaEngine, settingsEngine: settingsEngine as any, menuEngine, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default', dataDir: '/tmp/default' }), }); @@ -474,6 +493,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -520,6 +542,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -557,6 +582,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine([publishedPost, draftPost]), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -589,6 +617,9 @@ describe('PreviewServer', () => { }; }, } as any, + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -618,6 +649,9 @@ describe('PreviewServer', () => { }; }, } as any, + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -645,6 +679,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -665,6 +702,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine([matchingDay, sameMonth, sameYear, differentYear]), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -696,6 +736,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -728,6 +771,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine([post]), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -753,6 +799,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine([post]), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -766,6 +815,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine([makePost({ content: '```js\nconst line = "x".repeat(1000);\n```' })]), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -789,6 +841,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine([post]), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -829,6 +884,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine([post]), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default', dataDir: tempDir || undefined }), }); @@ -904,6 +962,9 @@ describe('PreviewServer', () => { }; }, }, + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -933,6 +994,9 @@ describe('PreviewServer', () => { }; }, }, + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -959,6 +1023,9 @@ describe('PreviewServer', () => { }; }, }, + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -988,6 +1055,9 @@ describe('PreviewServer', () => { }; }, }, + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1015,6 +1085,9 @@ describe('PreviewServer', () => { return { description: 'Beschreibung', maxPostsPerPage: 2 }; }, }, + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1043,6 +1116,9 @@ describe('PreviewServer', () => { return { description: 'Beschreibung', maxPostsPerPage: 2 }; }, }, + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1075,6 +1151,9 @@ describe('PreviewServer', () => { }; }, } as any, + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1111,6 +1190,9 @@ describe('PreviewServer', () => { }; }, } as any, + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1147,6 +1229,9 @@ describe('PreviewServer', () => { }; }, } as any, + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1166,6 +1251,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine([tagged, categorized, page, regular]), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1215,6 +1303,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine([tagDayOneA, tagDayOneB, tagDayTwo]), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1247,6 +1338,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1302,6 +1396,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: makeSettings(7), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1326,6 +1423,9 @@ describe('PreviewServer', () => { }; }, }, + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1353,6 +1453,9 @@ describe('PreviewServer', () => { }; }, }, + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1381,6 +1484,9 @@ describe('PreviewServer', () => { : null; }, } as any, + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1401,6 +1507,9 @@ describe('PreviewServer', () => { return null; }, }, + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default', dataDir: '/tmp/default', @@ -1465,7 +1574,9 @@ describe('PreviewServer', () => { createdAt: new Date('2025-02-03T10:00:00.000Z'), }, ]) as any, + postMediaEngine: makePostMediaEngine({}) as any, settingsEngine: makeSettings(50), + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1495,6 +1606,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine([post]), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1546,7 +1660,11 @@ describe('PreviewServer', () => { linkedPostIds: [], } as any, ]) as any, + postMediaEngine: makePostMediaEngine({ + 'macro-1': [{ media: { id: 'media-1' } }, { media: { id: 'media-2' } }], + }) as any, settingsEngine: makeSettings(50), + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1587,6 +1705,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine([post]), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1624,6 +1745,7 @@ describe('PreviewServer', () => { 'macro-junction-1': [{ media: { id: 'junction-media-1' } }], }) as any, settingsEngine: makeSettings(50), + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), } as any); @@ -1646,6 +1768,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine([makePost()]), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default', dataDir: tempDir!, @@ -1691,6 +1816,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: engine, settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1738,6 +1866,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: engine, settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1790,6 +1921,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: engine, settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); @@ -1805,6 +1939,9 @@ describe('PreviewServer', () => { server = new PreviewServer({ postEngine: makeEngine([makePost()]), settingsEngine: makeSettings(50), + mediaEngine: makeMediaEngine([]) as any, + postMediaEngine: makePostMediaEngine({}) as any, + menuEngine: makeMenuEngine({ items: [] }) as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); diff --git a/tests/engine/PublishApiAdapter.test.ts b/tests/engine/PublishApiAdapter.test.ts index ebc8eca..869b040 100644 --- a/tests/engine/PublishApiAdapter.test.ts +++ b/tests/engine/PublishApiAdapter.test.ts @@ -43,7 +43,7 @@ describe('PublishApiAdapter', () => { mockTaskManager.runTask.mockImplementation((opts: { execute: (onProgress: () => void) => Promise }) => { return opts.execute(() => {}); }); - adapter = new PublishApiAdapter(); + adapter = new PublishApiAdapter(mockProjectEngine as any, mockPublishEngine as any, mockTaskManager as any); }); it('sets project context before uploading', async () => { diff --git a/tests/engine/PublishEngine.test.ts b/tests/engine/PublishEngine.test.ts index df750a3..513df48 100644 --- a/tests/engine/PublishEngine.test.ts +++ b/tests/engine/PublishEngine.test.ts @@ -99,13 +99,6 @@ describe('PublishEngine', () => { }); describe('constructor and project context', () => { - it('should be instantiated via getPublishEngine singleton', async () => { - const { getPublishEngine } = await import('../../src/main/engine/PublishEngine'); - const e1 = getPublishEngine(); - const e2 = getPublishEngine(); - expect(e1).toBe(e2); - }); - it('should throw if no project context is set', async () => { const noContextEngine = new PublishEngine(); await expect( diff --git a/tests/engine/TagEngine.test.ts b/tests/engine/TagEngine.test.ts index 5ace9f2..66723d0 100644 --- a/tests/engine/TagEngine.test.ts +++ b/tests/engine/TagEngine.test.ts @@ -157,7 +157,7 @@ describe('TagEngine', () => { mockSelectDataDefault = []; mockPostEngine.syncPublishedPostFile.mockClear(); resetMockCounters(); - tagEngine = new TagEngine(); + tagEngine = new TagEngine(mockPostEngine as any); }); afterEach(() => { diff --git a/tests/engine/WxrReferenceComparison.e2e.test.ts b/tests/engine/WxrReferenceComparison.e2e.test.ts index 809abd3..38314a1 100644 --- a/tests/engine/WxrReferenceComparison.e2e.test.ts +++ b/tests/engine/WxrReferenceComparison.e2e.test.ts @@ -351,7 +351,12 @@ describe('WXR Reference Comparison E2E Tests', () => { vi.clearAllMocks(); // Create engine instances - executionEngine = new ImportExecutionEngine(); + executionEngine = new ImportExecutionEngine({ + tagEngine: mockTagEngine as any, + postEngine: mockPostEngine as any, + mediaEngine: mockMediaEngine as any, + postMediaEngine: mockPostMediaEngine as any, + }); executionEngine.setProjectContext('test-project', '/mock/test/data'); analysisEngine = new ImportAnalysisEngine(); diff --git a/tests/ipc/chatHandlers.test.ts b/tests/ipc/chatHandlers.test.ts index 808b7d1..0cbd25c 100644 --- a/tests/ipc/chatHandlers.test.ts +++ b/tests/ipc/chatHandlers.test.ts @@ -102,7 +102,12 @@ describe('chatHandlers', () => { it('streams sendMessage callbacks through main window events', async () => { const mod = await import('../../src/main/ipc/chatHandlers'); - mod.initializeChatHandlers(() => mainWindowMock as never); + const mockBundle = { + postEngine: {}, + mediaEngine: {}, + postMediaEngine: {}, + }; + mod.initializeChatHandlers(() => mainWindowMock as never, mockBundle as any); mod.registerChatHandlers(); const handler = registeredHandlers.get('chat:sendMessage'); diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index cebab7a..a34e800 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -152,6 +152,7 @@ const mockPostMediaEngine = { linkManyToPost: vi.fn(), unlinkManyFromPost: vi.fn(), getLinkedMediaForPost: vi.fn(), + getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]), getLinkedPostsForMedia: vi.fn(), reorderMediaForPost: vi.fn(), isMediaLinkedToPost: vi.fn(), @@ -257,6 +258,7 @@ const mockDatabase = { posts: '/mock/data/posts', media: '/mock/data/media', })), + getDbPath: vi.fn(() => '/mock/data/bds.db'), }; // Mock engine modules @@ -350,16 +352,49 @@ async function invokeHandlerWithEvent(event: any, channel: string, ...args: any[ } describe('IPC Handlers', () => { + + const mockBundle: Record = { + postEngine: mockPostEngine, + mediaEngine: mockMediaEngine, + projectEngine: mockProjectEngine, + metaEngine: mockMetaEngine, + tagEngine: mockTagEngine, + menuEngine: mockMenuEngine, + postMediaEngine: mockPostMediaEngine, + scriptEngine: mockScriptEngine, + templateEngine: mockTemplateEngine, + gitEngine: mockGitEngine, + gitApiAdapter: {}, + taskManager: mockTaskManager, + blogGenerationEngine: null, // set in beforeEach + publishEngine: { setProjectContext: vi.fn(), uploadHtml: vi.fn(), uploadThumbnails: vi.fn(), uploadMedia: vi.fn() }, + metadataDiffEngine: { setProjectContext: vi.fn(), comparePostMetadata: vi.fn(), scanAllPublishedPosts: vi.fn(), syncDbToFile: vi.fn(), syncFileToDb: vi.fn(), groupDifferencesByField: vi.fn() }, + blogmarkTransformService: {}, + mcpServer: { getPort: vi.fn(() => 4124), startCli: vi.fn(), cleanup: vi.fn() }, + blogmarkPythonWorkerRuntime: {}, + pythonMacroWorkerRuntime: {}, + publishApiAdapter: {}, + appApiAdapter: {}, + }; + beforeEach(async () => { // Clear all mocks vi.clearAllMocks(); registeredHandlers.clear(); mockGeneratedFileHashStore.clear(); resetMockCounters(); + + // Create a real BlogGenerationEngine with mock engines for blog handler tests + const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); + mockBundle.blogGenerationEngine = new BlogGenerationEngine( + mockPostEngine as any, + mockMediaEngine as any, + mockPostMediaEngine as any, + ); // Import and register handlers fresh for each test const { registerIpcHandlers } = await import('../../src/main/ipc/handlers'); - registerIpcHandlers(); + registerIpcHandlers(mockBundle as any); }); afterEach(() => { diff --git a/vite.config.cli.ts b/vite.config.cli.ts new file mode 100644 index 0000000..d3d97bf --- /dev/null +++ b/vite.config.cli.ts @@ -0,0 +1,70 @@ +/** + * Vite build config for the standalone CLI bundle. + * + * Produces: dist/cli/bds-mcp.cjs + * Target: Node.js (same version as Electron's Node) + * Format: CommonJS (required by ELECTRON_RUN_AS_NODE) + * Strategy: Bundle all first-party code; externalize native modules and + * packages whose platform-specific binaries cannot be inlined. + */ + +import { defineConfig } from 'vite'; +import { resolve } from 'path'; + +// Packages that contain native binaries or platform-specific build outputs +// that must be resolved at runtime from the app bundle. +const EXTERNALS = [ + 'electron', + '@libsql/client', + '@libsql/linux-x64-gnu', + '@libsql/linux-arm64-gnu', + '@libsql/darwin-x64', + '@libsql/darwin-arm64', + '@libsql/win32-x64-msvc', + '@libsql/win32-arm64-msvc', + 'chokidar', + 'fsevents', + // Node built-ins (already externalized by Vite's 'node' target, but explicit + // listing ensures they survive any future config change) + 'path', + 'fs', + 'os', + 'crypto', + 'child_process', + 'net', + 'tls', + 'http', + 'https', + 'stream', + 'util', + 'events', + 'assert', + 'url', + 'zlib', + 'buffer', + 'dgram', + 'dns', +]; + +export default defineConfig({ + build: { + target: 'node18', + outDir: 'dist/cli', + emptyOutDir: true, + ssr: resolve(__dirname, 'src/cli/bds-mcp.ts'), + rollupOptions: { + input: resolve(__dirname, 'src/cli/bds-mcp.ts'), + external: EXTERNALS, + output: { + format: 'cjs', + // Ensure the output file is named bds-mcp.cjs + entryFileNames: 'bds-mcp.cjs', + // Preserve __dirname for path resolution at runtime + interop: 'auto', + }, + }, + // Source maps help with debugging; keep them external to avoid inflating file size. + sourcemap: process.env.NODE_ENV === 'development' ? 'inline' : false, + minify: false, // readable stack traces in production + }, +});