feat: first round of mcp standalone server

This commit is contained in:
2026-02-28 21:23:22 +01:00
parent 1fc2003260
commit c358e1b11c
67 changed files with 3426 additions and 901 deletions

View File

@@ -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<typeof drizzle>;
@@ -17,31 +25,27 @@ type DrizzleDB = ReturnType<typeof drizzle>;
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<DatabaseConfig>) {
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<number>`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<void> {
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<number>`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<void> {
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<DatabaseConfig>): DatabaseConnection {
export function initDatabase(config: DatabaseConnectionConfig): DatabaseConnection {
dbConnection = new DatabaseConnection(config);
return dbConnection;
}

View File

@@ -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;

View File

@@ -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<string> {
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;
}

View File

@@ -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<BlogGenerationResult> {
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;
}

View File

@@ -272,12 +272,3 @@ export class BlogmarkPythonWorkerRuntime {
}
}
let blogmarkPythonWorkerRuntimeInstance: BlogmarkPythonWorkerRuntime | null = null;
export function getBlogmarkPythonWorkerRuntime(): BlogmarkPythonWorkerRuntime {
if (!blogmarkPythonWorkerRuntimeInstance) {
blogmarkPythonWorkerRuntimeInstance = new BlogmarkPythonWorkerRuntime();
}
return blogmarkPythonWorkerRuntimeInstance;
}

View File

@@ -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<PythonRuntimeMode> {
const metadata = await getMetaEngine().getProjectMetadata();
async function getConfiguredPythonRuntimeModeFromEngine(metaEngine: MetaEngine): Promise<PythonRuntimeMode> {
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<unknown> {
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<PythonRuntimeMode>;
executors?: Partial<Record<PythonRuntimeMode, BlogmarkTransformExecutor>>;
@@ -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<ScriptData[]> => 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<BlogmarkTransformExecutor> {
const resolveMode = this.dependencies.resolvePythonRuntimeMode ?? getConfiguredPythonRuntimeMode;
const resolveMode = this.dependencies.resolvePythonRuntimeMode
?? (this.dependencies.metaEngine
? () => getConfiguredPythonRuntimeModeFromEngine(this.dependencies.metaEngine!)
: () => Promise.resolve<PythonRuntimeMode>('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;
}

View File

@@ -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<void>;
}
// ── 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<void> {
// intentional no-op
}
}
// ── DbNotifier ────────────────────────────────────────────────────────────────
type DrizzleInsertable = {
insert: (table: typeof dbNotifications) => {
values: (row: typeof dbNotifications.$inferInsert) => Promise<unknown>;
};
};
/**
* 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<void> {
await this.db.insert(dbNotifications).values({
entity,
entityId: id,
action,
fromCli: 1,
seenAt: null,
createdAt: Date.now(),
});
}
}

View File

@@ -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;
}

View File

@@ -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<string> {
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<GitAvailability> {
return getGitEngine().checkAvailability();
return this.gitEngine.checkAvailability();
}
async getRepoState(): Promise<RepoState> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().getRepoState(projectPath);
return this.gitEngine.getRepoState(projectPath);
}
async getStatus(): Promise<GitStatusDto> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().getStatus(projectPath);
return this.gitEngine.getStatus(projectPath);
}
async getHistory(limit?: number): Promise<GitHistoryEntry[]> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().getHistory(projectPath, limit);
return this.gitEngine.getHistory(projectPath, limit);
}
async getRemoteState(): Promise<GitRemoteStateDto> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().getRemoteState(projectPath);
return this.gitEngine.getRemoteState(projectPath);
}
async fetch(): Promise<GitActionResult> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().fetch(projectPath);
return this.gitEngine.fetch(projectPath);
}
async pull(): Promise<GitActionResult> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().pull(projectPath);
return this.gitEngine.pull(projectPath);
}
async push(): Promise<GitActionResult> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().push(projectPath);
return this.gitEngine.push(projectPath);
}
async commitAll(message: string): Promise<GitActionResult> {
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;
}

View File

@@ -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']);

View File

@@ -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 = /(?<!\[)\[(\w+)([^\]]*?)(?:\s*\/)?\](?!\])/g;
export interface ImportExecutionDeps {
tagEngine: TagEngine;
postEngine: PostEngine;
mediaEngine: MediaEngine;
postMediaEngine: PostMediaEngine;
}
export class ImportExecutionEngine extends EventEmitter {
private currentProjectId: string = 'default';
private dataDir: string | null = null;
private turndown: TurndownService;
private siteBaseUrl: string | null = null; // Base URL for media URL conversion
private readonly tagEngine: TagEngine;
private readonly postEngine: PostEngine;
private readonly mediaEngine: MediaEngine;
private readonly postMediaEngine: PostMediaEngine;
constructor() {
constructor(deps: ImportExecutionDeps) {
super();
this.tagEngine = deps.tagEngine;
this.postEngine = deps.postEngine;
this.mediaEngine = deps.mediaEngine;
this.postMediaEngine = deps.postMediaEngine;
this.turndown = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
@@ -329,7 +344,7 @@ export class ImportExecutionEngine extends EventEmitter {
result: ImportExecutionResult,
progress: (phase: string, current: number, total: number, detail?: string) => void
): Promise<void> {
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<boolean> {
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<boolean> {
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;
}

View File

@@ -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<string, unknown> | undefined) ?? {};
if (!(SERVER_NAME in currentServers)) {
return { success: true, configPath };
}
const { [SERVER_NAME]: _removed, ...remainingServers } = currentServers;
const updated: Record<string, unknown> = { ...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':

View File

@@ -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<ScriptData>;
createDraftScript: (input: CreateScriptInput) => Promise<ScriptData>;
publishScript: (id: string) => Promise<ScriptData | null>;
deleteDraftScript: (id: string) => Promise<boolean>;
validateScript: (content: string) => Promise<ScriptValidationResult>;
}
interface TemplateEngineContract {
createTemplate: (input: CreateTemplateInput) => Promise<TemplateData>;
createDraftTemplate: (input: CreateTemplateInput) => Promise<TemplateData>;
publishTemplate: (id: string) => Promise<TemplateData | null>;
deleteDraftTemplate: (id: string) => Promise<boolean>;
validateTemplate: (content: string) => Promise<TemplateValidationResult>;
}
@@ -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<MediaData> };
proposePostMetadata: { postId: string; changes: Partial<PostData> };
}
@@ -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<void> {
const server = this.createMcpServer();
const transport = new StdioServerTransport();
await server.connect(transport);
await new Promise<void>((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<string, unknown> = { ...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<string, unknown> = { ...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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<Record<string, WatchableEngine>>;
// 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<typeof dbNotifications.$inferInsert>) => {
where: (condition: unknown) => Promise<void>;
};
};
delete: (table: typeof dbNotifications) => {
where: (condition: unknown) => Promise<void>;
};
};
// ── NotificationWatcher ──────────────────────────────────────────────────────
export class NotificationWatcher {
private watcher: FSWatcher | null = null;
private debounceTimer: ReturnType<typeof setTimeout> | 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<void> {
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;
}
}
}

View File

@@ -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<string, AbortController> = 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

View File

@@ -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;
}

View File

@@ -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<void> {
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<void> {
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<PostMediaLinkData> {
// 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<PostMediaLinkData & { media: MediaData }> = [];
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();

View File

@@ -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<ActiveProjectContext>;
userTemplatesDir?: string;
macroRenderer?: PythonMacroRendererContract;
}
interface SerializedTag {
@@ -91,29 +87,24 @@ export class PreviewServer {
private port: number | null = null;
constructor(dependencies?: Partial<PreviewServerDependencies>) {
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');
},
};
}

View File

@@ -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;
}

View File

@@ -19,10 +19,12 @@ const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 minutes
export class ProposalStore {
private readonly proposals = new Map<string, Proposal>();
private readonly ttlMs: number;
private readonly onExpiry: ((proposal: Proposal) => void) | undefined;
private cleanupInterval: ReturnType<typeof setInterval> | 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);
}
}

View File

@@ -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<PublishSiteResult> {
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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<ScriptData> {
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<ScriptData> {
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<ScriptData | null> {
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<boolean> {
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<string> {
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;
}

View File

@@ -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(

View File

@@ -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<TemplateData> {
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<TemplateData> {
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<TemplateData | null> {
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<boolean> {
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;
}

View File

@@ -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';

View File

@@ -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<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -66,50 +82,17 @@ function validateParamValue(methodName: string, param: PythonApiParamContractV1,
type EngineGetter = () => Record<string, (...args: unknown[]) => unknown>;
export const ENGINE_MAP: Record<string, EngineGetter> = {
posts: () => {
const { getPostEngine } = require('../engine/PostEngine');
return getPostEngine();
},
media: () => {
const { getMediaEngine } = require('../engine/MediaEngine');
return getMediaEngine();
},
projects: () => {
const { getProjectEngine } = require('../engine/ProjectEngine');
return getProjectEngine();
},
meta: () => {
const { getMetaEngine } = require('../engine/MetaEngine');
return getMetaEngine();
},
tags: () => {
const { getTagEngine } = require('../engine/TagEngine');
return getTagEngine();
},
scripts: () => {
const { getScriptEngine } = require('../engine/ScriptEngine');
return getScriptEngine();
},
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

View File

@@ -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<any>) => void;
export function registerBlogHandlers(safeHandle: SafeHandle): void {
export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundle): void {
const resolveBlogGenerationBaseOptions = async (): Promise<BlogGenerationOptions> => {
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<BlogGenerationResult> => {
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) => {

View File

@@ -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<void> | 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<OpenCodeManager> {
if (!openCodeManager) {
openCodeManager = new OpenCodeManager(
getChatEngine(),
getPostEngine(),
getMediaEngine(),
engineBundle!.postEngine,
engineBundle!.mediaEngine,
engineBundle!.postMediaEngine,
() => mainWindowGetter?.() || null
);

File diff suppressed because it is too large Load Diff

View File

@@ -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<any>) => 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);

View File

@@ -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<any>) => 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,

View File

@@ -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<void> {
if (!previewServer) {
previewServer = new PreviewServer();
previewServer = new PreviewServer(buildPreviewServerDeps());
}
await previewServer.start(PREVIEW_SERVER_PORT);
@@ -337,7 +372,7 @@ async function openActivePostPreviewInBrowser(): Promise<void> {
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<void> {
}
if (!previewServer) {
previewServer = new PreviewServer();
previewServer = new PreviewServer(buildPreviewServerDeps());
}
await previewServer.start(PREVIEW_SERVER_PORT);
@@ -355,7 +390,7 @@ async function openActivePostPreviewInBrowser(): Promise<void> {
async function startPreviewServerOnAppStart(): Promise<void> {
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<void> {
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<void> {
},
});
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<void> {
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<void> {
}
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<void>;
getProjectMetadata?: () => Promise<{ mainLanguage?: string } | null>;
@@ -502,7 +536,7 @@ async function initializeActiveProjectContext(): Promise<void> {
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<void> {
// 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<void> {
// 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<void> {
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<void> {
});
// 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);
}

View File

@@ -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),

View File

@@ -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<Array<{ id: string; label: string }>>;
addToAgentConfig: (agentId: string) => Promise<{ success: boolean; configPath: string; error?: string }>;