feat: first round of mcp standalone server
This commit is contained in:
@@ -16,7 +16,8 @@
|
||||
"Bash(ls -la /Users/gb/Projects/bDS/*.md)",
|
||||
"Bash(grep -n \"templateSlug\\\\|postTemplateSlug\" /Users/gb/Projects/bDS/src/main/engine/*.ts)",
|
||||
"Bash(npm test -- tests/renderer/i18nLocaleCompleteness.test.ts)",
|
||||
"WebFetch(domain:ricmac.org)"
|
||||
"WebFetch(domain:ricmac.org)",
|
||||
"WebFetch(domain:docs.mistral.ai)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
14
drizzle/0007_closed_sabretooth.sql
Normal file
14
drizzle/0007_closed_sabretooth.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE `db_notifications` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`entity` text NOT NULL,
|
||||
`entity_id` text NOT NULL,
|
||||
`action` text NOT NULL,
|
||||
`from_cli` integer DEFAULT 1 NOT NULL,
|
||||
`seen_at` integer,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `scripts` ADD `status` text DEFAULT 'published' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `scripts` ADD `content` text;--> statement-breakpoint
|
||||
ALTER TABLE `templates` ADD `status` text DEFAULT 'published' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `templates` ADD `content` text;
|
||||
1109
drizzle/meta/0007_snapshot.json
Normal file
1109
drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
||||
"when": 1772213213016,
|
||||
"tag": "0006_yummy_scorpion",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1772301340810,
|
||||
"tag": "0007_closed_sabretooth",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
"dev:electron": "wait-on http://localhost:5173 && cross-env NODE_ENV=development node ./node_modules/electron/cli.js .",
|
||||
"start": "concurrently --kill-others \"npm run dev:renderer\" \"npm run start:electron\"",
|
||||
"start:electron": "wait-on http://localhost:5173 && cross-env NODE_ENV=development node ./node_modules/electron/cli.js .",
|
||||
"build": "npm run lint && npm run db:generate && npm run build:main && npm run build:renderer",
|
||||
"build": "npm run lint && npm run db:generate && npm run build:main && npm run build:cli && npm run build:renderer",
|
||||
"icons:generate": "node scripts/regenerate-icons.mjs",
|
||||
"package": "npm run icons:generate && npm run build && electron-builder --dir",
|
||||
"dist": "npm run icons:generate && npm run build && electron-builder",
|
||||
@@ -19,6 +19,7 @@
|
||||
"dist:win": "npm run icons:generate && npm run build && electron-builder --win",
|
||||
"dist:linux": "npm run icons:generate && npm run build && electron-builder --linux",
|
||||
"build:main": "node ./node_modules/typescript/bin/tsc -p tsconfig.main.json",
|
||||
"build:cli": "node ./node_modules/vite/bin/vite.js build --config vite.config.cli.ts",
|
||||
"build:renderer": "node ./node_modules/vite/bin/vite.js build",
|
||||
"start:prod": "node ./node_modules/electron/cli.js .",
|
||||
"start:dev": "cross-env NODE_ENV=development node ./node_modules/electron/cli.js .",
|
||||
@@ -137,6 +138,10 @@
|
||||
"!**/._*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "dist/cli/bds-mcp.cjs",
|
||||
"to": "bds-mcp.cjs"
|
||||
},
|
||||
{
|
||||
"from": "drizzle",
|
||||
"to": "drizzle"
|
||||
|
||||
33
scripts/fix-ipc-handlers.mjs
Normal file
33
scripts/fix-ipc-handlers.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
|
||||
const files = [
|
||||
'src/main/ipc/handlers.ts',
|
||||
'src/main/ipc/blogHandlers.ts',
|
||||
'src/main/ipc/publishHandlers.ts',
|
||||
'src/main/ipc/chatHandlers.ts',
|
||||
'src/main/ipc/metadataDiffHandlers.ts',
|
||||
];
|
||||
|
||||
const replacements = [
|
||||
[/getPostEngine\(\)/g, 'bundle.postEngine'],
|
||||
[/getMediaEngine\(\)/g, 'bundle.mediaEngine'],
|
||||
[/getProjectEngine\(\)/g, 'bundle.projectEngine'],
|
||||
[/getMetaEngine\(\)/g, 'bundle.metaEngine'],
|
||||
[/getMenuEngine\(\)/g, 'bundle.menuEngine'],
|
||||
[/getTagEngine\(\)/g, 'bundle.tagEngine'],
|
||||
[/getScriptEngine\(\)/g, 'bundle.scriptEngine'],
|
||||
[/getTemplateEngine\(\)/g, 'bundle.templateEngine'],
|
||||
[/getGitEngine\(\)/g, 'bundle.gitEngine'],
|
||||
[/getBlogGenerationEngine\(\)/g, 'bundle.blogGenerationEngine'],
|
||||
[/getPublishEngine\(\)/g, 'bundle.publishEngine'],
|
||||
[/getMetadataDiffEngine\(\)/g, 'bundle.metadataDiffEngine'],
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
let content = readFileSync(file, 'utf8');
|
||||
for (const [pattern, replacement] of replacements) {
|
||||
content = content.replace(pattern, replacement);
|
||||
}
|
||||
writeFileSync(file, content, 'utf8');
|
||||
console.log(`Updated: ${file}`);
|
||||
}
|
||||
109
src/cli/bds-mcp.ts
Normal file
109
src/cli/bds-mcp.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* bds-mcp — standalone MCP server for Blogging Desktop Server.
|
||||
*
|
||||
* Launched by coding agents (Claude Desktop, etc.) via:
|
||||
* ELECTRON_RUN_AS_NODE=1 /path/to/app.app/Contents/MacOS/app bds-mcp.cjs
|
||||
*
|
||||
* No `electron` imports in this file. Engine modules may import from
|
||||
* `electron` — those imports are satisfied because we run as ELECTRON_RUN_AS_NODE.
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { platformConfigPath } from './platform';
|
||||
import { initDatabase } from '../main/database/connection';
|
||||
import { projects } from '../main/database/schema';
|
||||
import { DbNotifier } from '../main/engine/CliNotifier';
|
||||
import { PostEngine } from '../main/engine/PostEngine';
|
||||
import { MediaEngine } from '../main/engine/MediaEngine';
|
||||
import { PostMediaEngine } from '../main/engine/PostMediaEngine';
|
||||
import { TagEngine } from '../main/engine/TagEngine';
|
||||
import { ScriptEngine } from '../main/engine/ScriptEngine';
|
||||
import { TemplateEngine } from '../main/engine/TemplateEngine';
|
||||
import { MetaEngine } from '../main/engine/MetaEngine';
|
||||
import { MCPServer } from '../main/engine/MCPServer';
|
||||
|
||||
// ── Bootstrap ────────────────────────────────────────────────────────────────
|
||||
|
||||
const userData = platformConfigPath();
|
||||
const dbPath = path.join(userData, 'bds.db');
|
||||
|
||||
// __dirname points to Contents/Resources/ in the packaged app (bds-mcp.cjs
|
||||
// is placed there by extraResources). The drizzle/ migrations folder is also
|
||||
// shipped to Contents/Resources/drizzle/ via extraResources.
|
||||
const migrationsFolder = path.join(__dirname, 'drizzle');
|
||||
|
||||
const db = initDatabase({ dbPath, migrationsFolder });
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// 1. Open + migrate the local database.
|
||||
await db.initializeLocal();
|
||||
|
||||
const localDb = db.getLocal();
|
||||
|
||||
// 2. Verify an active project exists.
|
||||
const activeProject = await localDb
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.isActive, true))
|
||||
.get();
|
||||
|
||||
if (!activeProject) {
|
||||
process.stderr.write(
|
||||
'[bds-mcp] No active project found. Open the Blogging Desktop Server app ' +
|
||||
'and ensure at least one project is active.\n',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Construct engines with the DbNotifier so every mutation writes a
|
||||
// db_notifications row that the running Electron app can pick up.
|
||||
const notifier = new DbNotifier(localDb as any);
|
||||
|
||||
const mediaEngine = new MediaEngine(notifier);
|
||||
const postEngine = new PostEngine({ notifier, mediaEngine });
|
||||
const postMediaEngine = new PostMediaEngine(mediaEngine);
|
||||
const tagEngine = new TagEngine(postEngine);
|
||||
const scriptEngine = new ScriptEngine(notifier);
|
||||
const templateEngine = new TemplateEngine(notifier);
|
||||
const metaEngine = new MetaEngine();
|
||||
|
||||
// 4. Create the MCP server with an 8-hour proposal TTL (CLI sessions can
|
||||
// last overnight).
|
||||
const mcpServer = new MCPServer(
|
||||
{
|
||||
postEngine,
|
||||
mediaEngine,
|
||||
postMediaEngine,
|
||||
scriptEngine,
|
||||
templateEngine,
|
||||
metaEngine,
|
||||
tagEngine,
|
||||
},
|
||||
{ proposalTtlMs: 8 * 60 * 60 * 1000 },
|
||||
);
|
||||
|
||||
// 5. Graceful shutdown — two paths, no racing.
|
||||
// process.exit() makes this non-reentrant even without explicit guards.
|
||||
async function shutdown(): Promise<never> {
|
||||
await mcpServer.cleanup();
|
||||
await db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Signal handlers own interruption; registered before startCli() so they
|
||||
// are active during the entire session.
|
||||
process.once('SIGTERM', shutdown);
|
||||
process.once('SIGINT', shutdown);
|
||||
|
||||
// 6. Start the MCP stdio server and block until stdin closes.
|
||||
await mcpServer.startCli();
|
||||
|
||||
// 7. Normal exit path: stdin closed → startCli() resolved.
|
||||
await shutdown();
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
process.stderr.write(`[bds-mcp] Fatal error: ${String(err)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
31
src/cli/platform.ts
Normal file
31
src/cli/platform.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Pure-Node helper to resolve the same path as Electron's
|
||||
* `app.getPath('userData')` without loading Electron.
|
||||
*
|
||||
* | Platform | Path |
|
||||
* |----------|------------------------------------------------|
|
||||
* | macOS | ~/Library/Application Support/<appName> |
|
||||
* | Windows | %APPDATA%\<appName> |
|
||||
* | Linux | ~/.config/<appName> |
|
||||
*/
|
||||
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const APP_NAME = 'Blogging Desktop Server';
|
||||
|
||||
export function platformConfigPath(): string {
|
||||
const home = os.homedir();
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === 'darwin') {
|
||||
return path.join(home, 'Library', 'Application Support', APP_NAME);
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
const appData = process.env['APPDATA'] ?? path.join(home, 'AppData', 'Roaming');
|
||||
return path.join(appData, APP_NAME);
|
||||
}
|
||||
// Linux and others
|
||||
const configDir = process.env['XDG_CONFIG_HOME'] ?? path.join(home, '.config');
|
||||
return path.join(configDir, APP_NAME);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -272,12 +272,3 @@ export class BlogmarkPythonWorkerRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
let blogmarkPythonWorkerRuntimeInstance: BlogmarkPythonWorkerRuntime | null = null;
|
||||
|
||||
export function getBlogmarkPythonWorkerRuntime(): BlogmarkPythonWorkerRuntime {
|
||||
if (!blogmarkPythonWorkerRuntimeInstance) {
|
||||
blogmarkPythonWorkerRuntimeInstance = new BlogmarkPythonWorkerRuntime();
|
||||
}
|
||||
|
||||
return blogmarkPythonWorkerRuntimeInstance;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
55
src/main/engine/CliNotifier.ts
Normal file
55
src/main/engine/CliNotifier.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
55
src/main/engine/EngineBundle.ts
Normal file
55
src/main/engine/EngineBundle.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
125
src/main/engine/NotificationWatcher.ts
Normal file
125
src/main/engine/NotificationWatcher.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
179
src/main/main.ts
179
src/main/main.ts
@@ -5,24 +5,59 @@ import { getDatabase } from './database';
|
||||
import { registerIpcHandlers, registerEventForwarding, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc';
|
||||
import { media } from './database/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getMediaEngine } from './engine/MediaEngine';
|
||||
import { getPostEngine } from './engine/PostEngine';
|
||||
import { getMetaEngine } from './engine/MetaEngine';
|
||||
import { getTemplateEngine } from './engine/TemplateEngine';
|
||||
import { getScriptEngine } from './engine/ScriptEngine';
|
||||
import { getPostMediaEngine } from './engine/PostMediaEngine';
|
||||
import { getTagEngine } from './engine/TagEngine';
|
||||
import { getBlogmarkTransformService } from './engine/BlogmarkTransformService';
|
||||
import { MediaEngine } from './engine/MediaEngine';
|
||||
import { PostEngine } from './engine/PostEngine';
|
||||
import { MetaEngine } from './engine/MetaEngine';
|
||||
import { MenuEngine } from './engine/MenuEngine';
|
||||
import { TemplateEngine } from './engine/TemplateEngine';
|
||||
import { ScriptEngine } from './engine/ScriptEngine';
|
||||
import { PostMediaEngine } from './engine/PostMediaEngine';
|
||||
import { TagEngine } from './engine/TagEngine';
|
||||
import { ProjectEngine } from './engine/ProjectEngine';
|
||||
import { GitEngine } from './engine/GitEngine';
|
||||
import { GitApiAdapter } from './engine/GitApiAdapter';
|
||||
import { BlogGenerationEngine } from './engine/BlogGenerationEngine';
|
||||
import { BlogmarkTransformService } from './engine/BlogmarkTransformService';
|
||||
import { PublishEngine } from './engine/PublishEngine';
|
||||
import { MetadataDiffEngine } from './engine/MetadataDiffEngine';
|
||||
import { MCPServer } from './engine/MCPServer';
|
||||
import { taskManager } from './engine/TaskManager';
|
||||
import { BlogmarkPythonWorkerRuntime } from './engine/BlogmarkPythonWorkerRuntime';
|
||||
import { PythonMacroWorkerRuntime } from './engine/PythonMacroWorkerRuntime';
|
||||
import { AppApiAdapter } from './engine/AppApiAdapter';
|
||||
import { PublishApiAdapter } from './engine/PublishApiAdapter';
|
||||
import { NoopNotifier } from './engine/CliNotifier';
|
||||
import { NotificationWatcher } from './engine/NotificationWatcher';
|
||||
import { setEngineBundle } from './engine/mainProcessPythonApiInvoker';
|
||||
import type { EngineBundle } from './engine/EngineBundle';
|
||||
import { PreviewServer } from './engine/PreviewServer';
|
||||
import { getMCPServer } from './engine/MCPServer';
|
||||
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_GROUPS, APP_MENU_ITEM_IDS, type AppMenuAction, type AppMenuItemDefinition } from './shared/menuCommands';
|
||||
import { resolveUiLanguageFromSystemLocale, translateMenu } from './shared/i18n';
|
||||
import { buildBlogmarkMarkdownLink, extractBlogmarkPayloadFromDeepLink, normalizeBlogmarkCategory } from './shared/blogmark';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let previewServer: PreviewServer | null = null;
|
||||
let notificationWatcher: NotificationWatcher | null = null;
|
||||
let activePreviewPostId: string | null = null;
|
||||
let appInitialized = false;
|
||||
let bundle: EngineBundle | null = null;
|
||||
|
||||
function buildPreviewServerDeps() {
|
||||
const b = bundle!;
|
||||
return {
|
||||
postEngine: b.postEngine,
|
||||
mediaEngine: b.mediaEngine,
|
||||
postMediaEngine: b.postMediaEngine,
|
||||
settingsEngine: b.metaEngine,
|
||||
menuEngine: b.menuEngine,
|
||||
getActiveProjectContext: async () => {
|
||||
const project = await b.projectEngine.getActiveProject();
|
||||
if (!project) throw new Error('No active project');
|
||||
const dataDir = b.projectEngine.getDataDir(project.id, project.dataPath);
|
||||
return { projectId: project.id, dataDir, projectName: project.name };
|
||||
},
|
||||
};
|
||||
}
|
||||
let blogmarkQueue: string[] = [];
|
||||
let blogmarkQueueProcessing = false;
|
||||
let pendingBlogmarkCreatedEvents: unknown[] = [];
|
||||
@@ -310,7 +345,7 @@ function createWindow(): void {
|
||||
|
||||
async function openPreviewInBrowser(): Promise<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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -585,6 +585,39 @@ const App: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Subscribe to entity:changed events fired by the CLI NotificationWatcher.
|
||||
// When the CLI mutates posts or media while the app is open, refresh the
|
||||
// affected entry in the local store so the UI stays in sync.
|
||||
useEffect(() => {
|
||||
const unsub = window.electronAPI?.onEntityChanged(async ({ entity, entityId, action }) => {
|
||||
if (entity === 'post') {
|
||||
if (action === 'deleted') {
|
||||
removePost(entityId);
|
||||
useAppStore.getState().closeTab(entityId);
|
||||
} else {
|
||||
const post = await window.electronAPI?.posts.get(entityId);
|
||||
if (post) {
|
||||
const p = post as PostData;
|
||||
action === 'created' ? addPost(p) : updatePost(p.id, p);
|
||||
}
|
||||
}
|
||||
} else if (entity === 'media') {
|
||||
if (action === 'deleted') {
|
||||
removeMedia(entityId);
|
||||
} else {
|
||||
const media = await window.electronAPI?.media.get(entityId);
|
||||
if (media) {
|
||||
const m = media as MediaData;
|
||||
action === 'created' ? addMedia(m) : updateMedia(m.id, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
// script and template entities have no cached store state — they are
|
||||
// loaded on demand and will reflect CLI changes on next navigation.
|
||||
});
|
||||
return () => unsub?.();
|
||||
}, [addPost, updatePost, removePost, addMedia, updateMedia, removeMedia]);
|
||||
|
||||
const { sidebarVisible, assistantSidebarVisible } = useAppStore();
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ const { mockProjectEngine, mockDatabase, mockReadFile } = vi.hoisted(() => ({
|
||||
getDefaultProjectBaseDir: vi.fn().mockResolvedValue('/home/user/bDS/p1'),
|
||||
},
|
||||
mockDatabase: {
|
||||
getDataPaths: vi.fn().mockReturnValue({ database: '/data/bds.db' }),
|
||||
getDbPath: vi.fn(() => '/data/bds.db'),
|
||||
},
|
||||
mockReadFile: vi.fn(),
|
||||
}));
|
||||
@@ -38,8 +38,7 @@ describe('AppApiAdapter', () => {
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue({ id: 'p1', dataPath: '/projects/blog' });
|
||||
mockProjectEngine.getProjectPaths.mockReturnValue({ posts: '/projects/blog/posts', media: '/projects/blog/media' });
|
||||
mockProjectEngine.getDefaultProjectBaseDir.mockResolvedValue('/home/user/bDS/p1');
|
||||
mockDatabase.getDataPaths.mockReturnValue({ database: '/data/bds.db' });
|
||||
adapter = new AppApiAdapter();
|
||||
adapter = new AppApiAdapter(mockProjectEngine as any);
|
||||
});
|
||||
|
||||
it('getDataPaths returns database, posts, and media paths', async () => {
|
||||
|
||||
@@ -154,6 +154,7 @@ describe('BlogGenerationEngine', () => {
|
||||
let tempDir: string;
|
||||
let mockPostEngine: any;
|
||||
let mockMediaEngine: any;
|
||||
let mockPostMediaEngine: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
@@ -165,6 +166,8 @@ describe('BlogGenerationEngine', () => {
|
||||
mockPostEngine = __mockPostEngine;
|
||||
const { __mockMediaEngine } = await import('../../src/main/engine/MediaEngine') as any;
|
||||
mockMediaEngine = __mockMediaEngine;
|
||||
const { __mockPostMediaEngine } = await import('../../src/main/engine/PostMediaEngine') as any;
|
||||
mockPostMediaEngine = __mockPostMediaEngine;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -210,7 +213,7 @@ describe('BlogGenerationEngine', () => {
|
||||
) {
|
||||
setupPosts(posts);
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
const onProgress = vi.fn();
|
||||
return engine.generate({
|
||||
projectId: 'test',
|
||||
@@ -726,7 +729,7 @@ describe('BlogGenerationEngine', () => {
|
||||
});
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
const result = await engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
@@ -759,7 +762,7 @@ describe('BlogGenerationEngine', () => {
|
||||
setupPosts(posts);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
@@ -789,7 +792,7 @@ describe('BlogGenerationEngine', () => {
|
||||
setupPosts(posts);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
@@ -816,7 +819,7 @@ describe('BlogGenerationEngine', () => {
|
||||
setupPosts(posts);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
const onProgress = vi.fn();
|
||||
|
||||
await engine.generate({
|
||||
@@ -848,7 +851,7 @@ describe('BlogGenerationEngine', () => {
|
||||
setupPosts(posts);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
const onProgress = vi.fn();
|
||||
|
||||
await engine.generate({
|
||||
@@ -878,7 +881,7 @@ describe('BlogGenerationEngine', () => {
|
||||
const canonicalPathSpy = vi.spyOn(pageRendererModule, 'buildCanonicalPostPath');
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
@@ -902,7 +905,7 @@ describe('BlogGenerationEngine', () => {
|
||||
setupPosts(posts);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
@@ -933,7 +936,7 @@ describe('BlogGenerationEngine', () => {
|
||||
|
||||
const filterSpy = vi.spyOn(Array.prototype, 'filter');
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
@@ -975,7 +978,7 @@ describe('BlogGenerationEngine', () => {
|
||||
await writeFile(path.join(tempDir, 'html', 'stale', 'index.html'), '<html>stale</html>', 'utf-8');
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
const report = await engine.validateSite({
|
||||
projectId: 'test',
|
||||
@@ -1006,7 +1009,7 @@ describe('BlogGenerationEngine', () => {
|
||||
setupPosts([post]);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
@@ -1049,7 +1052,7 @@ describe('BlogGenerationEngine', () => {
|
||||
setupPosts([post]);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
@@ -1112,7 +1115,7 @@ describe('BlogGenerationEngine', () => {
|
||||
await writeFile(path.join(tempDir, 'html', 'obsolete', 'deep', 'index.html'), '<html>obsolete</html>', 'utf-8');
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
const report = await engine.validateSite({
|
||||
projectId: 'test',
|
||||
@@ -1158,7 +1161,7 @@ describe('BlogGenerationEngine', () => {
|
||||
setupPosts(posts);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
@@ -1237,7 +1240,7 @@ describe('BlogGenerationEngine', () => {
|
||||
await writeFile(path.join(tempDir, 'html', 'stale', 'index.html'), '<html>stale</html>', 'utf-8');
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
const generateSpy = vi.spyOn(engine, 'generate');
|
||||
|
||||
@@ -1273,7 +1276,7 @@ describe('BlogGenerationEngine', () => {
|
||||
setupPosts(posts);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.applyValidation({
|
||||
projectId: 'test',
|
||||
@@ -1303,7 +1306,7 @@ describe('BlogGenerationEngine', () => {
|
||||
setupPosts(posts);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.applyValidation({
|
||||
projectId: 'test',
|
||||
@@ -1335,7 +1338,7 @@ describe('BlogGenerationEngine', () => {
|
||||
setupPosts(posts);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.applyValidation({
|
||||
projectId: 'test',
|
||||
@@ -1371,7 +1374,7 @@ describe('BlogGenerationEngine', () => {
|
||||
await writeFile(path.join(tempDir, 'html', 'index.html'), '<html><body>stale-root</body></html>', 'utf-8');
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.applyValidation({
|
||||
projectId: 'test',
|
||||
@@ -1401,7 +1404,7 @@ describe('BlogGenerationEngine', () => {
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const { PageRenderer } = await import('../../src/main/engine/PageRenderer');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
const renderPostListSpy = vi.spyOn(PageRenderer.prototype, 'renderPostList');
|
||||
|
||||
@@ -1454,7 +1457,7 @@ describe('BlogGenerationEngine', () => {
|
||||
setupPosts(posts);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.applyValidation({
|
||||
projectId: 'test',
|
||||
@@ -1497,7 +1500,7 @@ describe('BlogGenerationEngine', () => {
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const { PageRenderer } = await import('../../src/main/engine/PageRenderer');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
const renderPostListSpy = vi.spyOn(PageRenderer.prototype, 'renderPostList');
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('GitApiAdapter', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
adapter = new GitApiAdapter();
|
||||
adapter = new GitApiAdapter(mockGitEngine as any, mockProjectEngine as any);
|
||||
});
|
||||
|
||||
it('checkAvailability delegates directly (no projectPath)', async () => {
|
||||
|
||||
@@ -233,7 +233,12 @@ describe('ImportExecutionEngine E2E Tests', () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create engine instance
|
||||
engine = new ImportExecutionEngine();
|
||||
engine = new ImportExecutionEngine({
|
||||
tagEngine: mockTagEngine as any,
|
||||
postEngine: mockPostEngine as any,
|
||||
mediaEngine: mockMediaEngine as any,
|
||||
postMediaEngine: mockPostMediaEngine as any,
|
||||
});
|
||||
engine.setProjectContext('test-project', '/mock/test/data');
|
||||
|
||||
// Parse the WXR content (mocked readFile will return our pre-loaded content)
|
||||
|
||||
@@ -108,6 +108,17 @@ const mockMediaEngine = {
|
||||
updateMedia: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
|
||||
// Mock the PostMediaEngine
|
||||
const mockPostMediaEngine = {
|
||||
setProjectContext: vi.fn(),
|
||||
linkMediaToPost: vi.fn().mockResolvedValue(undefined),
|
||||
getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
vi.mock('../../src/main/engine/PostMediaEngine', () => ({
|
||||
PostMediaEngine: vi.fn(() => mockPostMediaEngine),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/MediaEngine', () => ({
|
||||
getMediaEngine: vi.fn(() => mockMediaEngine),
|
||||
}));
|
||||
@@ -275,7 +286,12 @@ describe('ImportExecutionEngine', () => {
|
||||
insertedPosts.length = 0;
|
||||
insertedMedia.length = 0;
|
||||
updatedPosts.length = 0;
|
||||
engine = new ImportExecutionEngine();
|
||||
engine = new ImportExecutionEngine({
|
||||
tagEngine: mockTagEngine as any,
|
||||
postEngine: mockPostEngine as any,
|
||||
mediaEngine: mockMediaEngine as any,
|
||||
postMediaEngine: mockPostMediaEngine as any,
|
||||
});
|
||||
engine.setProjectContext('test-project', '/mock/project/data');
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('MCPAgentConfigEngine', () => {
|
||||
let engine: MCPAgentConfigEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
engine = new MCPAgentConfigEngine({
|
||||
homeDir: '/home/testuser',
|
||||
platform: 'darwin',
|
||||
@@ -40,9 +40,10 @@ describe('MCPAgentConfigEngine', () => {
|
||||
describe('getAgents', () => {
|
||||
it('returns all supported agent definitions', () => {
|
||||
const agents = engine.getAgents();
|
||||
expect(agents).toHaveLength(4);
|
||||
expect(agents).toHaveLength(5);
|
||||
const ids = agents.map((a) => a.id);
|
||||
expect(ids).toContain('claude-code');
|
||||
expect(ids).toContain('claude-desktop');
|
||||
expect(ids).toContain('github-copilot');
|
||||
expect(ids).toContain('gemini-cli');
|
||||
expect(ids).toContain('opencode');
|
||||
@@ -336,4 +337,160 @@ describe('MCPAgentConfigEngine', () => {
|
||||
expect(written.mcpServers.bDS.url).toBe('http://127.0.0.1:9999/mcp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('claude-desktop', () => {
|
||||
let desktopEngine: MCPAgentConfigEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
desktopEngine = new MCPAgentConfigEngine({
|
||||
homeDir: '/home/testuser',
|
||||
platform: 'darwin',
|
||||
mcpUrl: 'http://127.0.0.1:4124/mcp',
|
||||
execPath: '/Applications/Blogging Desktop Server.app/Contents/MacOS/Blogging Desktop Server',
|
||||
scriptPath: '/Applications/Blogging Desktop Server.app/Contents/Resources/bds-mcp.cjs',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes claude-desktop in getAgents()', () => {
|
||||
const agents = desktopEngine.getAgents();
|
||||
expect(agents.map((a) => a.id)).toContain('claude-desktop');
|
||||
});
|
||||
|
||||
it('returns correct config path for claude-desktop on macOS', () => {
|
||||
expect(desktopEngine.getConfigPath('claude-desktop')).toBe(
|
||||
'/home/testuser/Library/Application Support/Claude/claude_desktop_config.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns correct config path for claude-desktop on Windows', () => {
|
||||
const winEngine = new MCPAgentConfigEngine({
|
||||
homeDir: 'C:\\Users\\testuser',
|
||||
platform: 'win32',
|
||||
mcpUrl: 'http://127.0.0.1:4124/mcp',
|
||||
execPath: 'C:\\path\\to\\app.exe',
|
||||
scriptPath: 'C:\\path\\to\\bds-mcp.cjs',
|
||||
});
|
||||
const configPath = winEngine.getConfigPath('claude-desktop');
|
||||
// On Windows path.join uses backslashes; on macOS it uses forward slashes
|
||||
// so normalise for cross-platform CI
|
||||
const normalised = configPath.replace(/[\\/]/g, '/');
|
||||
expect(normalised).toBe(
|
||||
'C:/Users/testuser/AppData/Roaming/Claude/claude_desktop_config.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns correct config path for claude-desktop on Linux', () => {
|
||||
const linuxEngine = new MCPAgentConfigEngine({
|
||||
homeDir: '/home/user',
|
||||
platform: 'linux',
|
||||
mcpUrl: 'http://127.0.0.1:4124/mcp',
|
||||
});
|
||||
expect(linuxEngine.getConfigPath('claude-desktop')).toBe(
|
||||
'/home/user/.config/Claude/claude_desktop_config.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds stdio entry with command/args/env to claude_desktop_config.json', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const result = desktopEngine.addToConfig('claude-desktop');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||
expect(written.mcpServers.bDS).toEqual({
|
||||
command: '/Applications/Blogging Desktop Server.app/Contents/MacOS/Blogging Desktop Server',
|
||||
args: ['/Applications/Blogging Desktop Server.app/Contents/Resources/bds-mcp.cjs'],
|
||||
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws descriptive error if execPath/scriptPath missing for claude-desktop', () => {
|
||||
const noPathEngine = new MCPAgentConfigEngine({
|
||||
homeDir: '/home/testuser',
|
||||
platform: 'darwin',
|
||||
mcpUrl: 'http://127.0.0.1:4124/mcp',
|
||||
});
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const result = noPathEngine.addToConfig('claude-desktop');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('execPath');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFromConfig', () => {
|
||||
it('removes bDS entry from config and returns success', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
bDS: { type: 'http', url: 'http://127.0.0.1:4124/mcp' },
|
||||
other: { type: 'http', url: 'http://other' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = engine.removeFromConfig('claude-code');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||
expect(written.mcpServers.bDS).toBeUndefined();
|
||||
expect(written.mcpServers.other).toBeDefined();
|
||||
});
|
||||
|
||||
it('removes the mcpServers key entirely when bDS was the only entry', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
mcpServers: { bDS: { type: 'http', url: 'http://127.0.0.1:4124/mcp' } },
|
||||
}),
|
||||
);
|
||||
|
||||
engine.removeFromConfig('claude-code');
|
||||
|
||||
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||
expect(written.mcpServers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('no-ops gracefully when file does not exist', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const result = engine.removeFromConfig('claude-code');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockWriteFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('no-ops gracefully when bDS entry is not in config', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({ mcpServers: { other: {} } }));
|
||||
|
||||
const result = engine.removeFromConfig('claude-code');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockWriteFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the servers key for github-copilot', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({ servers: { bDS: { type: 'http', url: 'x' } } }),
|
||||
);
|
||||
|
||||
const result = engine.removeFromConfig('github-copilot');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||
expect(written.servers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns success with configPath', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const result = engine.removeFromConfig('claude-code');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.configPath).toBe('/home/testuser/.claude.json');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,22 +60,34 @@ function createMockMediaEngine() {
|
||||
|
||||
function createMockScriptEngine() {
|
||||
return {
|
||||
createScript: vi.fn().mockResolvedValue({
|
||||
createDraftScript: vi.fn().mockResolvedValue({
|
||||
id: 'script-1', title: 'Test', slug: 'test', kind: 'macro',
|
||||
entrypoint: 'main.py', content: '', enabled: true, version: 1,
|
||||
filePath: '/test', createdAt: new Date(), updatedAt: new Date(),
|
||||
}),
|
||||
publishScript: vi.fn().mockResolvedValue({
|
||||
id: 'script-1', title: 'Test', slug: 'test', kind: 'macro',
|
||||
entrypoint: 'main.py', content: '', enabled: true, version: 1,
|
||||
filePath: '/test', createdAt: new Date(), updatedAt: new Date(),
|
||||
}),
|
||||
deleteDraftScript: vi.fn().mockResolvedValue(true),
|
||||
validateScript: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockTemplateEngine() {
|
||||
return {
|
||||
createTemplate: vi.fn().mockResolvedValue({
|
||||
createDraftTemplate: vi.fn().mockResolvedValue({
|
||||
id: 'tpl-1', title: 'Test', slug: 'test', kind: 'post',
|
||||
enabled: true, version: 1, filePath: '/test', content: '',
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
}),
|
||||
publishTemplate: vi.fn().mockResolvedValue({
|
||||
id: 'tpl-1', title: 'Test', slug: 'test', kind: 'post',
|
||||
enabled: true, version: 1, filePath: '/test', content: '',
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
}),
|
||||
deleteDraftTemplate: vi.fn().mockResolvedValue(true),
|
||||
validateTemplate: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
|
||||
};
|
||||
}
|
||||
@@ -110,13 +122,13 @@ function createDependencies() {
|
||||
const mockTagEngine = createMockTagEngine();
|
||||
|
||||
const deps: MCPServerDependencies = {
|
||||
getPostEngine: () => mockPostEngine,
|
||||
getMediaEngine: () => mockMediaEngine,
|
||||
getScriptEngine: () => mockScriptEngine,
|
||||
getTemplateEngine: () => mockTemplateEngine,
|
||||
getMetaEngine: () => mockMetaEngine,
|
||||
getPostMediaEngine: () => mockPostMediaEngine,
|
||||
getTagEngine: () => mockTagEngine,
|
||||
postEngine: mockPostEngine,
|
||||
mediaEngine: mockMediaEngine,
|
||||
scriptEngine: mockScriptEngine,
|
||||
templateEngine: mockTemplateEngine,
|
||||
metaEngine: mockMetaEngine,
|
||||
postMediaEngine: mockPostMediaEngine,
|
||||
tagEngine: mockTagEngine,
|
||||
};
|
||||
|
||||
return { deps, mockPostEngine, mockMediaEngine, mockScriptEngine, mockTemplateEngine, mockMetaEngine, mockPostMediaEngine, mockTagEngine };
|
||||
@@ -358,25 +370,21 @@ describe('MCPServer', () => {
|
||||
|
||||
it('accepts a proposeScript proposal by creating script', async () => {
|
||||
const proposalId = server.proposalStore.create('proposeScript', {
|
||||
title: 'My Script', kind: 'macro', content: 'print("hello")',
|
||||
scriptId: 'script-1',
|
||||
});
|
||||
const result = await server.acceptProposal(proposalId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockScriptEngine.createScript).toHaveBeenCalledWith({
|
||||
title: 'My Script', kind: 'macro', content: 'print("hello")',
|
||||
});
|
||||
expect(mockScriptEngine.publishScript).toHaveBeenCalledWith('script-1');
|
||||
expect(server.proposalStore.get(proposalId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a proposeTemplate proposal by creating template', async () => {
|
||||
const proposalId = server.proposalStore.create('proposeTemplate', {
|
||||
title: 'My Template', kind: 'post', content: '<h1>{{ title }}</h1>',
|
||||
templateId: 'tpl-1',
|
||||
});
|
||||
const result = await server.acceptProposal(proposalId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockTemplateEngine.createTemplate).toHaveBeenCalledWith({
|
||||
title: 'My Template', kind: 'post', content: '<h1>{{ title }}</h1>',
|
||||
});
|
||||
expect(mockTemplateEngine.publishTemplate).toHaveBeenCalledWith('tpl-1');
|
||||
});
|
||||
|
||||
it('accepts a proposeMediaMetadata proposal by updating media', async () => {
|
||||
@@ -415,9 +423,10 @@ describe('MCPServer', () => {
|
||||
});
|
||||
|
||||
it('discards a proposeScript proposal by removing from store', async () => {
|
||||
const proposalId = server.proposalStore.create('proposeScript', { title: 'Script' });
|
||||
const proposalId = server.proposalStore.create('proposeScript', { scriptId: 'script-1' });
|
||||
const result = await server.discardProposal(proposalId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockScriptEngine.deleteDraftScript).toHaveBeenCalledWith('script-1');
|
||||
expect(server.proposalStore.get(proposalId)).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -830,7 +839,10 @@ describe('MCPServer', () => {
|
||||
const proposal = server.proposalStore.get(parsed.proposalId);
|
||||
expect(proposal).toBeDefined();
|
||||
expect(proposal!.type).toBe('proposeScript');
|
||||
expect(proposal!.data.content).toBe('print("hi")');
|
||||
expect(mockScriptEngine.createDraftScript).toHaveBeenCalledWith({
|
||||
title: 'My Script', kind: 'macro', content: 'print("hi")', entrypoint: undefined,
|
||||
});
|
||||
expect(proposal!.data.scriptId).toBe('script-1');
|
||||
});
|
||||
|
||||
it('propose_script calls validateScript and includes validation result in preview', async () => {
|
||||
|
||||
@@ -173,7 +173,7 @@ describe('MetadataDiffEngine', () => {
|
||||
mockAllPostsRows = [];
|
||||
mockSyncPublishedPostFile.mockClear();
|
||||
resetMockCounters();
|
||||
engine = new MetadataDiffEngine();
|
||||
engine = new MetadataDiffEngine({ syncPublishedPostFile: mockSyncPublishedPostFile } as any);
|
||||
engine.setProjectContext('test-project');
|
||||
});
|
||||
|
||||
|
||||
248
tests/engine/NotificationWatcher.test.ts
Normal file
248
tests/engine/NotificationWatcher.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type MockedFunction } from 'vitest';
|
||||
|
||||
// ── Chokidar mock ────────────────────────────────────────────────────────────
|
||||
|
||||
interface MockFSWatcher {
|
||||
on: MockedFunction<(event: string, handler: () => void) => MockFSWatcher>;
|
||||
close: MockedFunction<() => Promise<void>>;
|
||||
}
|
||||
|
||||
let mockWatcher: MockFSWatcher;
|
||||
let capturedWatchPaths: string[] = [];
|
||||
let capturedWatchOptions: Record<string, unknown> = {};
|
||||
|
||||
vi.mock('chokidar', () => ({
|
||||
default: {
|
||||
watch: (paths: string[], options: Record<string, unknown>) => {
|
||||
capturedWatchPaths = paths;
|
||||
capturedWatchOptions = options;
|
||||
return mockWatcher;
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Imports (after mocks) ────────────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
NotificationWatcher,
|
||||
type WatchableEngines,
|
||||
} from '../../src/main/engine/NotificationWatcher';
|
||||
|
||||
// ── DB mock helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
type MockDb = {
|
||||
select: MockedFunction<() => {
|
||||
from: (table: unknown) => { where: MockedFunction<() => Promise<unknown[]>> };
|
||||
}>;
|
||||
update: MockedFunction<(table: unknown) => {
|
||||
set: (values: unknown) => { where: MockedFunction<() => Promise<void>> };
|
||||
}>;
|
||||
delete: MockedFunction<(table: unknown) => { where: MockedFunction<() => Promise<void>> }>;
|
||||
};
|
||||
|
||||
function makeSelectChain(rows: unknown[]): ReturnType<MockDb['select']> {
|
||||
const whereSelect = vi.fn().mockResolvedValue(rows);
|
||||
return {
|
||||
from: (_table: unknown) => ({ where: whereSelect }),
|
||||
};
|
||||
}
|
||||
|
||||
function makeUpdateChain(): ReturnType<MockDb['update']> {
|
||||
const whereUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
return {
|
||||
set: (_values: unknown) => ({ where: whereUpdate }),
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeleteChain(): ReturnType<MockDb['delete']> {
|
||||
return { where: vi.fn().mockResolvedValue(undefined) };
|
||||
}
|
||||
|
||||
// ── Test suite ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('NotificationWatcher', () => {
|
||||
const DB_PATH = '/home/user/.config/bDS/bds.db';
|
||||
|
||||
let db: MockDb;
|
||||
let engines: WatchableEngines;
|
||||
let mockSend: MockedFunction<(channel: string, payload: unknown) => void>;
|
||||
let mainWindow: { webContents: { send: typeof mockSend } };
|
||||
let watcher: NotificationWatcher;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
mockWatcher = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
capturedWatchPaths = [];
|
||||
capturedWatchOptions = {};
|
||||
|
||||
db = {
|
||||
select: vi.fn().mockReturnValue(makeSelectChain([])),
|
||||
update: vi.fn().mockReturnValue(makeUpdateChain()),
|
||||
delete: vi.fn().mockReturnValue(makeDeleteChain()),
|
||||
};
|
||||
|
||||
engines = {
|
||||
post: { invalidate: vi.fn() },
|
||||
media: { invalidate: vi.fn() },
|
||||
script: { invalidate: vi.fn() },
|
||||
template: { invalidate: vi.fn() },
|
||||
};
|
||||
|
||||
mockSend = vi.fn();
|
||||
mainWindow = { webContents: { send: mockSend } };
|
||||
|
||||
watcher = new NotificationWatcher(DB_PATH, db as any, engines, mainWindow as any, 100);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── start() ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('start()', () => {
|
||||
it('watches both db and wal paths', () => {
|
||||
watcher.start();
|
||||
expect(capturedWatchPaths).toEqual([DB_PATH, `${DB_PATH}-wal`]);
|
||||
});
|
||||
|
||||
it('sets persistent:false and ignoreInitial:true', () => {
|
||||
watcher.start();
|
||||
expect(capturedWatchOptions.persistent).toBe(false);
|
||||
expect(capturedWatchOptions.ignoreInitial).toBe(true);
|
||||
});
|
||||
|
||||
it('registers change and add handlers', () => {
|
||||
watcher.start();
|
||||
const events = mockWatcher.on.mock.calls.map((c) => c[0]);
|
||||
expect(events).toContain('change');
|
||||
expect(events).toContain('add');
|
||||
});
|
||||
|
||||
it('debounces rapid file-change events', async () => {
|
||||
watcher.start();
|
||||
const changeHandler = mockWatcher.on.mock.calls.find((c) => c[0] === 'change')![1];
|
||||
|
||||
db.select.mockReturnValue(makeSelectChain([]));
|
||||
|
||||
changeHandler();
|
||||
changeHandler();
|
||||
changeHandler();
|
||||
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Only one process() call despite three change events
|
||||
expect(db.select).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── process() ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('process()', () => {
|
||||
async function triggerProcess(rows: unknown[] = []): Promise<void> {
|
||||
db.select.mockReturnValue(makeSelectChain(rows));
|
||||
watcher.start();
|
||||
const changeHandler = mockWatcher.on.mock.calls.find((c) => c[0] === 'change')![1];
|
||||
changeHandler();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
}
|
||||
|
||||
it('queries db_notifications for unprocessed CLI rows', async () => {
|
||||
await triggerProcess([]);
|
||||
expect(db.select).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls invalidate on the matching engine for each row', async () => {
|
||||
const rows = [
|
||||
{ id: 1, entity: 'post', entityId: 'p1', action: 'created', fromCli: 1, seenAt: null, createdAt: Date.now() },
|
||||
{ id: 2, entity: 'media', entityId: 'm1', action: 'updated', fromCli: 1, seenAt: null, createdAt: Date.now() },
|
||||
];
|
||||
await triggerProcess(rows);
|
||||
|
||||
expect(engines.post.invalidate).toHaveBeenCalledWith('p1');
|
||||
expect(engines.media.invalidate).toHaveBeenCalledWith('m1');
|
||||
});
|
||||
|
||||
it('sends entity:changed IPC event for each row', async () => {
|
||||
const rows = [
|
||||
{ id: 1, entity: 'post', entityId: 'p1', action: 'created', fromCli: 1, seenAt: null, createdAt: Date.now() },
|
||||
{ id: 2, entity: 'script', entityId: 's1', action: 'deleted', fromCli: 1, seenAt: null, createdAt: Date.now() },
|
||||
];
|
||||
await triggerProcess(rows);
|
||||
|
||||
expect(mockSend).toHaveBeenCalledWith('entity:changed', {
|
||||
entity: 'post',
|
||||
entityId: 'p1',
|
||||
action: 'created',
|
||||
});
|
||||
expect(mockSend).toHaveBeenCalledWith('entity:changed', {
|
||||
entity: 'script',
|
||||
entityId: 's1',
|
||||
action: 'deleted',
|
||||
});
|
||||
});
|
||||
|
||||
it('stamps seenAt on each processed row', async () => {
|
||||
const rows = [
|
||||
{ id: 42, entity: 'post', entityId: 'p1', action: 'updated', fromCli: 1, seenAt: null, createdAt: Date.now() },
|
||||
];
|
||||
db.select.mockReturnValue(makeSelectChain(rows));
|
||||
const updateChain = makeUpdateChain();
|
||||
db.update.mockReturnValue(updateChain);
|
||||
|
||||
watcher.start();
|
||||
const changeHandler = mockWatcher.on.mock.calls.find((c) => c[0] === 'change')![1];
|
||||
changeHandler();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(db.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prunes old seen rows (>1h) and old unprocessed rows (>24h)', async () => {
|
||||
await triggerProcess([]);
|
||||
// delete is called twice: once for seenAt > 1h, once for unprocessed > 24h
|
||||
expect(db.delete).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('skips unknown entity types gracefully', async () => {
|
||||
const rows = [
|
||||
{ id: 1, entity: 'unknown_entity', entityId: 'x1', action: 'created', fromCli: 1, seenAt: null, createdAt: Date.now() },
|
||||
];
|
||||
await expect(triggerProcess(rows)).resolves.not.toThrow();
|
||||
// No IPC send for unknown entities, but the watcher finishes without error
|
||||
});
|
||||
});
|
||||
|
||||
// ── stop() ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('stop()', () => {
|
||||
it('closes the file watcher', () => {
|
||||
watcher.start();
|
||||
watcher.stop();
|
||||
expect(mockWatcher.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels a pending debounce timer', async () => {
|
||||
watcher.start();
|
||||
const changeHandler = mockWatcher.on.mock.calls.find((c) => c[0] === 'change')![1];
|
||||
changeHandler();
|
||||
|
||||
watcher.stop();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
// process() must NOT have run after stop
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not throw if called before start()', () => {
|
||||
expect(() => watcher.stop()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,14 @@ const mockUpdateMedia = vi.fn();
|
||||
const mockGetAllMedia = vi.fn();
|
||||
const mockImportMedia = vi.fn();
|
||||
|
||||
// Aggregated mock MediaEngine object for constructor injection
|
||||
const mockMediaEngineForPostMedia = {
|
||||
getMedia: mockGetMedia,
|
||||
updateMedia: mockUpdateMedia,
|
||||
getAllMedia: mockGetAllMedia,
|
||||
importMedia: mockImportMedia,
|
||||
};
|
||||
|
||||
// Mock MediaEngine
|
||||
vi.mock('../../src/main/engine/MediaEngine', () => ({
|
||||
getMediaEngine: vi.fn(() => ({
|
||||
@@ -144,7 +152,7 @@ describe('PostMediaEngine', () => {
|
||||
mockGetAllMedia.mockResolvedValue([]);
|
||||
mockImportMedia.mockResolvedValue({ id: 'imported-media-id' });
|
||||
|
||||
engine = new PostMediaEngine();
|
||||
engine = new PostMediaEngine(mockMediaEngineForPostMedia as any);
|
||||
engine.setProjectContext('test-project');
|
||||
});
|
||||
|
||||
|
||||
@@ -159,6 +159,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine(posts),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -200,6 +203,8 @@ describe('PreviewServer', () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -242,6 +247,8 @@ describe('PreviewServer', () => {
|
||||
{ id: 'dev', title: 'Dev', kind: 'category-archive', categoryName: 'news', children: [] },
|
||||
],
|
||||
}),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -278,6 +285,8 @@ describe('PreviewServer', () => {
|
||||
{ id: 'news', title: 'news', kind: 'category-archive', categoryName: 'news', children: [] },
|
||||
],
|
||||
}),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -293,6 +302,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([makePost()]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -369,6 +381,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([makePost()]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: tempDir ?? undefined }),
|
||||
});
|
||||
|
||||
@@ -397,6 +412,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([postWithCode]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -432,6 +450,7 @@ describe('PreviewServer', () => {
|
||||
postMediaEngine,
|
||||
settingsEngine: settingsEngine as any,
|
||||
menuEngine,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: '/tmp/default' }),
|
||||
});
|
||||
|
||||
@@ -474,6 +493,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine(posts),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -520,6 +542,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine(posts),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -557,6 +582,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([publishedPost, draftPost]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -589,6 +617,9 @@ describe('PreviewServer', () => {
|
||||
};
|
||||
},
|
||||
} as any,
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -618,6 +649,9 @@ describe('PreviewServer', () => {
|
||||
};
|
||||
},
|
||||
} as any,
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -645,6 +679,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine(posts),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -665,6 +702,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([matchingDay, sameMonth, sameYear, differentYear]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -696,6 +736,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine(posts),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -728,6 +771,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([post]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -753,6 +799,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([post]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -766,6 +815,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([makePost({ content: '```js\nconst line = "x".repeat(1000);\n```' })]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -789,6 +841,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([post]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -829,6 +884,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([post]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: tempDir || undefined }),
|
||||
});
|
||||
|
||||
@@ -904,6 +962,9 @@ describe('PreviewServer', () => {
|
||||
};
|
||||
},
|
||||
},
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -933,6 +994,9 @@ describe('PreviewServer', () => {
|
||||
};
|
||||
},
|
||||
},
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -959,6 +1023,9 @@ describe('PreviewServer', () => {
|
||||
};
|
||||
},
|
||||
},
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -988,6 +1055,9 @@ describe('PreviewServer', () => {
|
||||
};
|
||||
},
|
||||
},
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1015,6 +1085,9 @@ describe('PreviewServer', () => {
|
||||
return { description: 'Beschreibung', maxPostsPerPage: 2 };
|
||||
},
|
||||
},
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1043,6 +1116,9 @@ describe('PreviewServer', () => {
|
||||
return { description: 'Beschreibung', maxPostsPerPage: 2 };
|
||||
},
|
||||
},
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1075,6 +1151,9 @@ describe('PreviewServer', () => {
|
||||
};
|
||||
},
|
||||
} as any,
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1111,6 +1190,9 @@ describe('PreviewServer', () => {
|
||||
};
|
||||
},
|
||||
} as any,
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1147,6 +1229,9 @@ describe('PreviewServer', () => {
|
||||
};
|
||||
},
|
||||
} as any,
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1166,6 +1251,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([tagged, categorized, page, regular]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1215,6 +1303,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([tagDayOneA, tagDayOneB, tagDayTwo]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1247,6 +1338,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine(posts),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1302,6 +1396,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine(posts),
|
||||
settingsEngine: makeSettings(7),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1326,6 +1423,9 @@ describe('PreviewServer', () => {
|
||||
};
|
||||
},
|
||||
},
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1353,6 +1453,9 @@ describe('PreviewServer', () => {
|
||||
};
|
||||
},
|
||||
},
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1381,6 +1484,9 @@ describe('PreviewServer', () => {
|
||||
: null;
|
||||
},
|
||||
} as any,
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1401,6 +1507,9 @@ describe('PreviewServer', () => {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({
|
||||
projectId: 'default',
|
||||
dataDir: '/tmp/default',
|
||||
@@ -1465,7 +1574,9 @@ describe('PreviewServer', () => {
|
||||
createdAt: new Date('2025-02-03T10:00:00.000Z'),
|
||||
},
|
||||
]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
settingsEngine: makeSettings(50),
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1495,6 +1606,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([post]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1546,7 +1660,11 @@ describe('PreviewServer', () => {
|
||||
linkedPostIds: [],
|
||||
} as any,
|
||||
]) as any,
|
||||
postMediaEngine: makePostMediaEngine({
|
||||
'macro-1': [{ media: { id: 'media-1' } }, { media: { id: 'media-2' } }],
|
||||
}) as any,
|
||||
settingsEngine: makeSettings(50),
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1587,6 +1705,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([post]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1624,6 +1745,7 @@ describe('PreviewServer', () => {
|
||||
'macro-junction-1': [{ media: { id: 'junction-media-1' } }],
|
||||
}) as any,
|
||||
settingsEngine: makeSettings(50),
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
} as any);
|
||||
|
||||
@@ -1646,6 +1768,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([makePost()]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({
|
||||
projectId: 'default',
|
||||
dataDir: tempDir!,
|
||||
@@ -1691,6 +1816,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: engine,
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1738,6 +1866,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: engine,
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1790,6 +1921,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: engine,
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
@@ -1805,6 +1939,9 @@ describe('PreviewServer', () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([makePost()]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('PublishApiAdapter', () => {
|
||||
mockTaskManager.runTask.mockImplementation((opts: { execute: (onProgress: () => void) => Promise<unknown> }) => {
|
||||
return opts.execute(() => {});
|
||||
});
|
||||
adapter = new PublishApiAdapter();
|
||||
adapter = new PublishApiAdapter(mockProjectEngine as any, mockPublishEngine as any, mockTaskManager as any);
|
||||
});
|
||||
|
||||
it('sets project context before uploading', async () => {
|
||||
|
||||
@@ -99,13 +99,6 @@ describe('PublishEngine', () => {
|
||||
});
|
||||
|
||||
describe('constructor and project context', () => {
|
||||
it('should be instantiated via getPublishEngine singleton', async () => {
|
||||
const { getPublishEngine } = await import('../../src/main/engine/PublishEngine');
|
||||
const e1 = getPublishEngine();
|
||||
const e2 = getPublishEngine();
|
||||
expect(e1).toBe(e2);
|
||||
});
|
||||
|
||||
it('should throw if no project context is set', async () => {
|
||||
const noContextEngine = new PublishEngine();
|
||||
await expect(
|
||||
|
||||
@@ -157,7 +157,7 @@ describe('TagEngine', () => {
|
||||
mockSelectDataDefault = [];
|
||||
mockPostEngine.syncPublishedPostFile.mockClear();
|
||||
resetMockCounters();
|
||||
tagEngine = new TagEngine();
|
||||
tagEngine = new TagEngine(mockPostEngine as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -351,7 +351,12 @@ describe('WXR Reference Comparison E2E Tests', () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create engine instances
|
||||
executionEngine = new ImportExecutionEngine();
|
||||
executionEngine = new ImportExecutionEngine({
|
||||
tagEngine: mockTagEngine as any,
|
||||
postEngine: mockPostEngine as any,
|
||||
mediaEngine: mockMediaEngine as any,
|
||||
postMediaEngine: mockPostMediaEngine as any,
|
||||
});
|
||||
executionEngine.setProjectContext('test-project', '/mock/test/data');
|
||||
|
||||
analysisEngine = new ImportAnalysisEngine();
|
||||
|
||||
@@ -102,7 +102,12 @@ describe('chatHandlers', () => {
|
||||
|
||||
it('streams sendMessage callbacks through main window events', async () => {
|
||||
const mod = await import('../../src/main/ipc/chatHandlers');
|
||||
mod.initializeChatHandlers(() => mainWindowMock as never);
|
||||
const mockBundle = {
|
||||
postEngine: {},
|
||||
mediaEngine: {},
|
||||
postMediaEngine: {},
|
||||
};
|
||||
mod.initializeChatHandlers(() => mainWindowMock as never, mockBundle as any);
|
||||
mod.registerChatHandlers();
|
||||
|
||||
const handler = registeredHandlers.get('chat:sendMessage');
|
||||
|
||||
@@ -152,6 +152,7 @@ const mockPostMediaEngine = {
|
||||
linkManyToPost: vi.fn(),
|
||||
unlinkManyFromPost: vi.fn(),
|
||||
getLinkedMediaForPost: vi.fn(),
|
||||
getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]),
|
||||
getLinkedPostsForMedia: vi.fn(),
|
||||
reorderMediaForPost: vi.fn(),
|
||||
isMediaLinkedToPost: vi.fn(),
|
||||
@@ -257,6 +258,7 @@ const mockDatabase = {
|
||||
posts: '/mock/data/posts',
|
||||
media: '/mock/data/media',
|
||||
})),
|
||||
getDbPath: vi.fn(() => '/mock/data/bds.db'),
|
||||
};
|
||||
|
||||
// Mock engine modules
|
||||
@@ -350,16 +352,49 @@ async function invokeHandlerWithEvent(event: any, channel: string, ...args: any[
|
||||
}
|
||||
|
||||
describe('IPC Handlers', () => {
|
||||
|
||||
const mockBundle: Record<string, any> = {
|
||||
postEngine: mockPostEngine,
|
||||
mediaEngine: mockMediaEngine,
|
||||
projectEngine: mockProjectEngine,
|
||||
metaEngine: mockMetaEngine,
|
||||
tagEngine: mockTagEngine,
|
||||
menuEngine: mockMenuEngine,
|
||||
postMediaEngine: mockPostMediaEngine,
|
||||
scriptEngine: mockScriptEngine,
|
||||
templateEngine: mockTemplateEngine,
|
||||
gitEngine: mockGitEngine,
|
||||
gitApiAdapter: {},
|
||||
taskManager: mockTaskManager,
|
||||
blogGenerationEngine: null, // set in beforeEach
|
||||
publishEngine: { setProjectContext: vi.fn(), uploadHtml: vi.fn(), uploadThumbnails: vi.fn(), uploadMedia: vi.fn() },
|
||||
metadataDiffEngine: { setProjectContext: vi.fn(), comparePostMetadata: vi.fn(), scanAllPublishedPosts: vi.fn(), syncDbToFile: vi.fn(), syncFileToDb: vi.fn(), groupDifferencesByField: vi.fn() },
|
||||
blogmarkTransformService: {},
|
||||
mcpServer: { getPort: vi.fn(() => 4124), startCli: vi.fn(), cleanup: vi.fn() },
|
||||
blogmarkPythonWorkerRuntime: {},
|
||||
pythonMacroWorkerRuntime: {},
|
||||
publishApiAdapter: {},
|
||||
appApiAdapter: {},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks();
|
||||
registeredHandlers.clear();
|
||||
mockGeneratedFileHashStore.clear();
|
||||
resetMockCounters();
|
||||
|
||||
// Create a real BlogGenerationEngine with mock engines for blog handler tests
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
mockBundle.blogGenerationEngine = new BlogGenerationEngine(
|
||||
mockPostEngine as any,
|
||||
mockMediaEngine as any,
|
||||
mockPostMediaEngine as any,
|
||||
);
|
||||
|
||||
// Import and register handlers fresh for each test
|
||||
const { registerIpcHandlers } = await import('../../src/main/ipc/handlers');
|
||||
registerIpcHandlers();
|
||||
registerIpcHandlers(mockBundle as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
70
vite.config.cli.ts
Normal file
70
vite.config.cli.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Vite build config for the standalone CLI bundle.
|
||||
*
|
||||
* Produces: dist/cli/bds-mcp.cjs
|
||||
* Target: Node.js (same version as Electron's Node)
|
||||
* Format: CommonJS (required by ELECTRON_RUN_AS_NODE)
|
||||
* Strategy: Bundle all first-party code; externalize native modules and
|
||||
* packages whose platform-specific binaries cannot be inlined.
|
||||
*/
|
||||
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// Packages that contain native binaries or platform-specific build outputs
|
||||
// that must be resolved at runtime from the app bundle.
|
||||
const EXTERNALS = [
|
||||
'electron',
|
||||
'@libsql/client',
|
||||
'@libsql/linux-x64-gnu',
|
||||
'@libsql/linux-arm64-gnu',
|
||||
'@libsql/darwin-x64',
|
||||
'@libsql/darwin-arm64',
|
||||
'@libsql/win32-x64-msvc',
|
||||
'@libsql/win32-arm64-msvc',
|
||||
'chokidar',
|
||||
'fsevents',
|
||||
// Node built-ins (already externalized by Vite's 'node' target, but explicit
|
||||
// listing ensures they survive any future config change)
|
||||
'path',
|
||||
'fs',
|
||||
'os',
|
||||
'crypto',
|
||||
'child_process',
|
||||
'net',
|
||||
'tls',
|
||||
'http',
|
||||
'https',
|
||||
'stream',
|
||||
'util',
|
||||
'events',
|
||||
'assert',
|
||||
'url',
|
||||
'zlib',
|
||||
'buffer',
|
||||
'dgram',
|
||||
'dns',
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
target: 'node18',
|
||||
outDir: 'dist/cli',
|
||||
emptyOutDir: true,
|
||||
ssr: resolve(__dirname, 'src/cli/bds-mcp.ts'),
|
||||
rollupOptions: {
|
||||
input: resolve(__dirname, 'src/cli/bds-mcp.ts'),
|
||||
external: EXTERNALS,
|
||||
output: {
|
||||
format: 'cjs',
|
||||
// Ensure the output file is named bds-mcp.cjs
|
||||
entryFileNames: 'bds-mcp.cjs',
|
||||
// Preserve __dirname for path resolution at runtime
|
||||
interop: 'auto',
|
||||
},
|
||||
},
|
||||
// Source maps help with debugging; keep them external to avoid inflating file size.
|
||||
sourcemap: process.env.NODE_ENV === 'development' ? 'inline' : false,
|
||||
minify: false, // readable stack traces in production
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user