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(ls -la /Users/gb/Projects/bDS/*.md)",
|
||||||
"Bash(grep -n \"templateSlug\\\\|postTemplateSlug\" /Users/gb/Projects/bDS/src/main/engine/*.ts)",
|
"Bash(grep -n \"templateSlug\\\\|postTemplateSlug\" /Users/gb/Projects/bDS/src/main/engine/*.ts)",
|
||||||
"Bash(npm test -- tests/renderer/i18nLocaleCompleteness.test.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,
|
"when": 1772213213016,
|
||||||
"tag": "0006_yummy_scorpion",
|
"tag": "0006_yummy_scorpion",
|
||||||
"breakpoints": true
|
"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 .",
|
"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": "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 .",
|
"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",
|
"icons:generate": "node scripts/regenerate-icons.mjs",
|
||||||
"package": "npm run icons:generate && npm run build && electron-builder --dir",
|
"package": "npm run icons:generate && npm run build && electron-builder --dir",
|
||||||
"dist": "npm run icons:generate && npm run build && electron-builder",
|
"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:win": "npm run icons:generate && npm run build && electron-builder --win",
|
||||||
"dist:linux": "npm run icons:generate && npm run build && electron-builder --linux",
|
"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: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",
|
"build:renderer": "node ./node_modules/vite/bin/vite.js build",
|
||||||
"start:prod": "node ./node_modules/electron/cli.js .",
|
"start:prod": "node ./node_modules/electron/cli.js .",
|
||||||
"start:dev": "cross-env NODE_ENV=development node ./node_modules/electron/cli.js .",
|
"start:dev": "cross-env NODE_ENV=development node ./node_modules/electron/cli.js .",
|
||||||
@@ -137,6 +138,10 @@
|
|||||||
"!**/._*"
|
"!**/._*"
|
||||||
],
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "dist/cli/bds-mcp.cjs",
|
||||||
|
"to": "bds-mcp.cjs"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "drizzle",
|
"from": "drizzle",
|
||||||
"to": "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 { eq, sql } from 'drizzle-orm';
|
||||||
import * as schema from './schema';
|
import * as schema from './schema';
|
||||||
import { projects } from './schema';
|
import { projects } from './schema';
|
||||||
import { app } from 'electron';
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
||||||
export interface DatabaseConfig {
|
export interface DatabaseConnectionConfig {
|
||||||
localPath: string;
|
/** 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>;
|
type DrizzleDB = ReturnType<typeof drizzle>;
|
||||||
@@ -17,31 +25,27 @@ type DrizzleDB = ReturnType<typeof drizzle>;
|
|||||||
export class DatabaseConnection {
|
export class DatabaseConnection {
|
||||||
private localDb: DrizzleDB | null = null;
|
private localDb: DrizzleDB | null = null;
|
||||||
private localClient: Client | 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;
|
private _closing = false;
|
||||||
|
|
||||||
constructor(config?: Partial<DatabaseConfig>) {
|
constructor(config: DatabaseConnectionConfig) {
|
||||||
const userDataPath = app.getPath('userData');
|
this.dbPath = config.dbPath;
|
||||||
|
this.migrationsFolder = config.migrationsFolder;
|
||||||
this.config = {
|
this.dataDirs = config.dataDirs ?? [];
|
||||||
localPath: config?.localPath || path.join(userDataPath, 'bds.db'),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ensure user data directory exists
|
// Ensure the directory containing the DB file exists.
|
||||||
const dataDir = path.dirname(this.config.localPath);
|
const dataDir = path.dirname(this.dbPath);
|
||||||
if (!fs.existsSync(dataDir)) {
|
if (!fs.existsSync(dataDir)) {
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure posts and media directories exist
|
// Ensure caller-supplied extra directories exist.
|
||||||
const postsDir = path.join(userDataPath, 'posts');
|
for (const dir of this.dataDirs) {
|
||||||
const mediaDir = path.join(userDataPath, 'media');
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
if (!fs.existsSync(postsDir)) {
|
}
|
||||||
fs.mkdirSync(postsDir, { recursive: true });
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(mediaDir)) {
|
|
||||||
fs.mkdirSync(mediaDir, { recursive: true });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,12 +56,55 @@ export class DatabaseConnection {
|
|||||||
|
|
||||||
// Use file: URL for local SQLite database via libsql
|
// Use file: URL for local SQLite database via libsql
|
||||||
this.localClient = createClient({
|
this.localClient = createClient({
|
||||||
url: `file:${this.config.localPath}`,
|
url: `file:${this.dbPath}`,
|
||||||
});
|
});
|
||||||
this.localDb = drizzle(this.localClient, { schema });
|
this.localDb = drizzle(this.localClient, { schema });
|
||||||
|
|
||||||
// Run migrations
|
// Enable WAL mode and set synchronous=NORMAL for better concurrency and
|
||||||
await this.runMigrations();
|
// 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;
|
return this.localDb;
|
||||||
}
|
}
|
||||||
@@ -79,6 +126,11 @@ export class DatabaseConnection {
|
|||||||
return this.localClient;
|
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> {
|
async getActiveProject(): Promise<{ id: string; name: string; slug: string } | null> {
|
||||||
if (!this.localDb) return null;
|
if (!this.localDb) return null;
|
||||||
const rows = await this.localDb
|
const rows = await this.localDb
|
||||||
@@ -103,57 +155,6 @@ export class DatabaseConnection {
|
|||||||
.where(eq(projects.id, projectId));
|
.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> {
|
async close(): Promise<void> {
|
||||||
this._closing = true;
|
this._closing = true;
|
||||||
if (this.localClient) {
|
if (this.localClient) {
|
||||||
@@ -162,28 +163,26 @@ export class DatabaseConnection {
|
|||||||
this.localDb = null;
|
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;
|
let dbConnection: DatabaseConnection | null = null;
|
||||||
|
|
||||||
export function getDatabase(): DatabaseConnection {
|
export function getDatabase(): DatabaseConnection {
|
||||||
if (!dbConnection) {
|
if (!dbConnection) {
|
||||||
dbConnection = new DatabaseConnection();
|
throw new Error(
|
||||||
|
'DatabaseConnection has not been initialised. ' +
|
||||||
|
'Call initDatabase() before calling getDatabase().',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return dbConnection;
|
return dbConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initDatabase(config?: Partial<DatabaseConfig>): DatabaseConnection {
|
export function initDatabase(config: DatabaseConnectionConfig): DatabaseConnection {
|
||||||
dbConnection = new DatabaseConnection(config);
|
dbConnection = new DatabaseConnection(config);
|
||||||
return dbConnection;
|
return dbConnection;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,9 @@ export const scripts = sqliteTable('scripts', {
|
|||||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||||
version: integer('version').notNull().default(1),
|
version: integer('version').notNull().default(1),
|
||||||
filePath: text('file_path').notNull(),
|
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(),
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
@@ -181,6 +184,9 @@ export const templates = sqliteTable('templates', {
|
|||||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||||
version: integer('version').notNull().default(1),
|
version: integer('version').notNull().default(1),
|
||||||
filePath: text('file_path').notNull(),
|
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(),
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
@@ -188,6 +194,18 @@ export const templates = sqliteTable('templates', {
|
|||||||
projectSlugIdx: uniqueIndex('templates_project_slug_idx').on(table.projectId, table.slug),
|
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
|
// Types for TypeScript
|
||||||
export type Project = typeof projects.$inferSelect;
|
export type Project = typeof projects.$inferSelect;
|
||||||
export type NewProject = typeof projects.$inferInsert;
|
export type NewProject = typeof projects.$inferInsert;
|
||||||
@@ -215,3 +233,5 @@ export type Script = typeof scripts.$inferSelect;
|
|||||||
export type NewScript = typeof scripts.$inferInsert;
|
export type NewScript = typeof scripts.$inferInsert;
|
||||||
export type Template = typeof templates.$inferSelect;
|
export type Template = typeof templates.$inferSelect;
|
||||||
export type NewTemplate = typeof templates.$inferInsert;
|
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 path from 'path';
|
||||||
import * as fsPromises from 'fs/promises';
|
import * as fsPromises from 'fs/promises';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import { getProjectEngine } from './ProjectEngine';
|
import type { ProjectEngine } from './ProjectEngine';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,13 +9,13 @@ import { getDatabase } from '../database';
|
|||||||
* Provides safe, read-only app methods without requiring Electron UI facilities.
|
* Provides safe, read-only app methods without requiring Electron UI facilities.
|
||||||
*/
|
*/
|
||||||
export class AppApiAdapter {
|
export class AppApiAdapter {
|
||||||
|
constructor(private readonly projectEngine: ProjectEngine) {}
|
||||||
async getDataPaths(): Promise<{ database: string; posts: string; media: string }> {
|
async getDataPaths(): Promise<{ database: string; posts: string; media: string }> {
|
||||||
const projectEngine = getProjectEngine();
|
const activeProject = await this.projectEngine.getActiveProject();
|
||||||
const activeProject = await projectEngine.getActiveProject();
|
|
||||||
const projectId = activeProject?.id || 'default';
|
const projectId = activeProject?.id || 'default';
|
||||||
const paths = projectEngine.getProjectPaths(projectId, activeProject?.dataPath);
|
const paths = this.projectEngine.getProjectPaths(projectId, activeProject?.dataPath);
|
||||||
return {
|
return {
|
||||||
database: getDatabase().getDataPaths().database,
|
database: getDatabase().getDbPath(),
|
||||||
posts: paths.posts,
|
posts: paths.posts,
|
||||||
media: paths.media,
|
media: paths.media,
|
||||||
};
|
};
|
||||||
@@ -26,7 +26,7 @@ export class AppApiAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getDefaultProjectPath(projectId: string): Promise<string> {
|
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> {
|
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 path from 'path';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import { getPostEngine, type PostData } from './PostEngine';
|
import type { PostEngine, PostData } from './PostEngine';
|
||||||
import { getMediaEngine, type MediaData } from './MediaEngine';
|
import type { MediaEngine, MediaData } from './MediaEngine';
|
||||||
import { getPostMediaEngine } from './PostMediaEngine';
|
import type { PostMediaEngine } from './PostMediaEngine';
|
||||||
import {
|
import {
|
||||||
PageRenderer,
|
PageRenderer,
|
||||||
buildTemplateMenuItems,
|
buildTemplateMenuItems,
|
||||||
@@ -195,9 +195,15 @@ function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class BlogGenerationEngine {
|
export class BlogGenerationEngine {
|
||||||
private readonly postEngine = getPostEngine();
|
private readonly postEngine: PostEngine;
|
||||||
private readonly mediaEngine = getMediaEngine();
|
private readonly mediaEngine: MediaEngine;
|
||||||
private readonly postMediaEngine = getPostMediaEngine();
|
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> {
|
async generate(options: BlogGenerationOptions, onProgress: (progress: number, message?: string) => void): Promise<BlogGenerationResult> {
|
||||||
onProgress(0, 'Loading posts...');
|
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 { z } from 'zod';
|
||||||
import { getScriptEngine } from './ScriptEngine';
|
import type { BlogmarkPythonWorkerRuntime } from './BlogmarkPythonWorkerRuntime';
|
||||||
import { getMetaEngine } from './MetaEngine';
|
import type { ScriptEngine, ScriptData } from './ScriptEngine';
|
||||||
import { getBlogmarkPythonWorkerRuntime } from './BlogmarkPythonWorkerRuntime';
|
import type { MetaEngine } from './MetaEngine';
|
||||||
|
|
||||||
const transformPostSchema = z.object({
|
const transformPostSchema = z.object({
|
||||||
title: z.string().trim().min(1),
|
title: z.string().trim().min(1),
|
||||||
@@ -63,11 +63,7 @@ const MAX_TOASTS_PER_SCRIPT = 5;
|
|||||||
const MAX_TOASTS_TOTAL = 20;
|
const MAX_TOASTS_TOTAL = 20;
|
||||||
const MAX_TOAST_LENGTH = 300;
|
const MAX_TOAST_LENGTH = 300;
|
||||||
|
|
||||||
const scriptEngineBackedProvider: BlogmarkTransformScriptProvider = {
|
// Note: scriptEngineBackedProvider removed — ScriptEngine is injected via constructor dep.
|
||||||
async getScripts() {
|
|
||||||
return getScriptEngine().getAllScripts();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function toTimestamp(value: Date | string): number {
|
function toTimestamp(value: Date | string): number {
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
@@ -163,8 +159,8 @@ function resolvePythonRuntimeMode(value: unknown): PythonRuntimeMode {
|
|||||||
return 'webworker';
|
return 'webworker';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getConfiguredPythonRuntimeMode(): Promise<PythonRuntimeMode> {
|
async function getConfiguredPythonRuntimeModeFromEngine(metaEngine: MetaEngine): Promise<PythonRuntimeMode> {
|
||||||
const metadata = await getMetaEngine().getProjectMetadata();
|
const metadata = await metaEngine.getProjectMetadata();
|
||||||
return resolvePythonRuntimeMode((metadata as { pythonRuntimeMode?: unknown } | null)?.pythonRuntimeMode);
|
return resolvePythonRuntimeMode((metadata as { pythonRuntimeMode?: unknown } | null)?.pythonRuntimeMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,8 +235,10 @@ json.dumps(_result)
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PythonWorkerBlogmarkTransformExecutor implements BlogmarkTransformExecutor {
|
class PythonWorkerBlogmarkTransformExecutor implements BlogmarkTransformExecutor {
|
||||||
|
constructor(private readonly runtime: BlogmarkPythonWorkerRuntime) {}
|
||||||
|
|
||||||
async runTransform(script: BlogmarkTransformScriptRecord, input: BlogmarkTransformInput): Promise<unknown> {
|
async runTransform(script: BlogmarkTransformScriptRecord, input: BlogmarkTransformInput): Promise<unknown> {
|
||||||
return getBlogmarkPythonWorkerRuntime().executeTransform({
|
return this.runtime.executeTransform({
|
||||||
scriptContent: script.content,
|
scriptContent: script.content,
|
||||||
entrypoint: resolveTransformEntrypoint(script.entrypoint),
|
entrypoint: resolveTransformEntrypoint(script.entrypoint),
|
||||||
payloadJson: JSON.stringify(input),
|
payloadJson: JSON.stringify(input),
|
||||||
@@ -249,12 +247,14 @@ class PythonWorkerBlogmarkTransformExecutor implements BlogmarkTransformExecutor
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mainThreadExecutor = new PythonBlogmarkTransformExecutor();
|
const mainThreadExecutor = new PythonBlogmarkTransformExecutor();
|
||||||
const workerExecutor = new PythonWorkerBlogmarkTransformExecutor();
|
|
||||||
|
|
||||||
export class BlogmarkTransformService {
|
export class BlogmarkTransformService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dependencies: {
|
private readonly dependencies: {
|
||||||
provider?: BlogmarkTransformScriptProvider;
|
provider?: BlogmarkTransformScriptProvider;
|
||||||
|
scriptEngine?: ScriptEngine;
|
||||||
|
metaEngine?: MetaEngine;
|
||||||
|
blogmarkWorkerRuntime?: BlogmarkPythonWorkerRuntime;
|
||||||
executor?: BlogmarkTransformExecutor;
|
executor?: BlogmarkTransformExecutor;
|
||||||
resolvePythonRuntimeMode?: () => Promise<PythonRuntimeMode>;
|
resolvePythonRuntimeMode?: () => Promise<PythonRuntimeMode>;
|
||||||
executors?: Partial<Record<PythonRuntimeMode, BlogmarkTransformExecutor>>;
|
executors?: Partial<Record<PythonRuntimeMode, BlogmarkTransformExecutor>>;
|
||||||
@@ -268,7 +268,10 @@ export class BlogmarkTransformService {
|
|||||||
post: parsedInput,
|
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 executor = this.dependencies.executor ?? await this.resolveExecutorForConfiguredRuntime();
|
||||||
|
|
||||||
const scripts = await provider.getScripts();
|
const scripts = await provider.getScripts();
|
||||||
@@ -337,7 +340,10 @@ export class BlogmarkTransformService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async resolveExecutorForConfiguredRuntime(): Promise<BlogmarkTransformExecutor> {
|
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 mode = await resolveMode();
|
||||||
const executors = this.dependencies.executors ?? {};
|
const executors = this.dependencies.executors ?? {};
|
||||||
|
|
||||||
@@ -345,16 +351,12 @@ export class BlogmarkTransformService {
|
|||||||
return executors['main-thread'] ?? mainThreadExecutor;
|
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;
|
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 type { GitEngine, GitAvailability, RepoState, GitStatusDto, GitHistoryEntry, GitRemoteStateDto, GitActionResult } from './GitEngine';
|
||||||
import { getProjectEngine } from './ProjectEngine';
|
import type { ProjectEngine } from './ProjectEngine';
|
||||||
import type {
|
|
||||||
GitAvailability,
|
|
||||||
RepoState,
|
|
||||||
GitStatusDto,
|
|
||||||
GitHistoryEntry,
|
|
||||||
GitRemoteStateDto,
|
|
||||||
GitActionResult,
|
|
||||||
} from './GitEngine';
|
|
||||||
|
|
||||||
export type { GitAvailability, RepoState, GitStatusDto, GitHistoryEntry, GitRemoteStateDto, GitActionResult };
|
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.
|
* don't need to pass it.
|
||||||
*/
|
*/
|
||||||
export class GitApiAdapter {
|
export class GitApiAdapter {
|
||||||
|
constructor(
|
||||||
|
private readonly gitEngine: GitEngine,
|
||||||
|
private readonly projectEngine: ProjectEngine,
|
||||||
|
) {}
|
||||||
|
|
||||||
private async resolveProjectPath(): Promise<string> {
|
private async resolveProjectPath(): Promise<string> {
|
||||||
const project = await getProjectEngine().getActiveProject();
|
const project = await this.projectEngine.getActiveProject();
|
||||||
if (!project?.dataPath) {
|
if (!project?.dataPath) {
|
||||||
throw new Error('No active project with a data path');
|
throw new Error('No active project with a data path');
|
||||||
}
|
}
|
||||||
@@ -26,55 +23,47 @@ export class GitApiAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkAvailability(): Promise<GitAvailability> {
|
async checkAvailability(): Promise<GitAvailability> {
|
||||||
return getGitEngine().checkAvailability();
|
return this.gitEngine.checkAvailability();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRepoState(): Promise<RepoState> {
|
async getRepoState(): Promise<RepoState> {
|
||||||
const projectPath = await this.resolveProjectPath();
|
const projectPath = await this.resolveProjectPath();
|
||||||
return getGitEngine().getRepoState(projectPath);
|
return this.gitEngine.getRepoState(projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStatus(): Promise<GitStatusDto> {
|
async getStatus(): Promise<GitStatusDto> {
|
||||||
const projectPath = await this.resolveProjectPath();
|
const projectPath = await this.resolveProjectPath();
|
||||||
return getGitEngine().getStatus(projectPath);
|
return this.gitEngine.getStatus(projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHistory(limit?: number): Promise<GitHistoryEntry[]> {
|
async getHistory(limit?: number): Promise<GitHistoryEntry[]> {
|
||||||
const projectPath = await this.resolveProjectPath();
|
const projectPath = await this.resolveProjectPath();
|
||||||
return getGitEngine().getHistory(projectPath, limit);
|
return this.gitEngine.getHistory(projectPath, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRemoteState(): Promise<GitRemoteStateDto> {
|
async getRemoteState(): Promise<GitRemoteStateDto> {
|
||||||
const projectPath = await this.resolveProjectPath();
|
const projectPath = await this.resolveProjectPath();
|
||||||
return getGitEngine().getRemoteState(projectPath);
|
return this.gitEngine.getRemoteState(projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetch(): Promise<GitActionResult> {
|
async fetch(): Promise<GitActionResult> {
|
||||||
const projectPath = await this.resolveProjectPath();
|
const projectPath = await this.resolveProjectPath();
|
||||||
return getGitEngine().fetch(projectPath);
|
return this.gitEngine.fetch(projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async pull(): Promise<GitActionResult> {
|
async pull(): Promise<GitActionResult> {
|
||||||
const projectPath = await this.resolveProjectPath();
|
const projectPath = await this.resolveProjectPath();
|
||||||
return getGitEngine().pull(projectPath);
|
return this.gitEngine.pull(projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async push(): Promise<GitActionResult> {
|
async push(): Promise<GitActionResult> {
|
||||||
const projectPath = await this.resolveProjectPath();
|
const projectPath = await this.resolveProjectPath();
|
||||||
return getGitEngine().push(projectPath);
|
return this.gitEngine.push(projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async commitAll(message: string): Promise<GitActionResult> {
|
async commitAll(message: string): Promise<GitActionResult> {
|
||||||
const projectPath = await this.resolveProjectPath();
|
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';
|
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 {
|
export class GitEngine {
|
||||||
private readonly markdownExtensions = new Set(['.md', '.markdown', '.mdx']);
|
private readonly markdownExtensions = new Set(['.md', '.markdown', '.mdx']);
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import TurndownService from 'turndown';
|
|||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { posts, media, NewPost, NewMedia } from '../database/schema';
|
import { posts, media, NewPost, NewMedia } from '../database/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { getTagEngine } from './TagEngine';
|
import type { TagEngine } from './TagEngine';
|
||||||
import { getPostEngine, PostData } from './PostEngine';
|
import type { PostEngine, PostData } from './PostEngine';
|
||||||
import { getMediaEngine, MediaData } from './MediaEngine';
|
import type { MediaEngine, MediaData } from './MediaEngine';
|
||||||
import { getPostMediaEngine } from './PostMediaEngine';
|
import type { PostMediaEngine } from './PostMediaEngine';
|
||||||
import type {
|
import type {
|
||||||
ImportAnalysisReport,
|
ImportAnalysisReport,
|
||||||
AnalyzedPost,
|
AnalyzedPost,
|
||||||
@@ -71,14 +71,29 @@ export interface ImportExecutionResult {
|
|||||||
// Regex to match WordPress shortcodes: [macroname ...] but NOT [[macroname ...]]
|
// Regex to match WordPress shortcodes: [macroname ...] but NOT [[macroname ...]]
|
||||||
const WP_SHORTCODE_REGEX = /(?<!\[)\[(\w+)([^\]]*?)(?:\s*\/)?\](?!\])/g;
|
const WP_SHORTCODE_REGEX = /(?<!\[)\[(\w+)([^\]]*?)(?:\s*\/)?\](?!\])/g;
|
||||||
|
|
||||||
|
export interface ImportExecutionDeps {
|
||||||
|
tagEngine: TagEngine;
|
||||||
|
postEngine: PostEngine;
|
||||||
|
mediaEngine: MediaEngine;
|
||||||
|
postMediaEngine: PostMediaEngine;
|
||||||
|
}
|
||||||
|
|
||||||
export class ImportExecutionEngine extends EventEmitter {
|
export class ImportExecutionEngine extends EventEmitter {
|
||||||
private currentProjectId: string = 'default';
|
private currentProjectId: string = 'default';
|
||||||
private dataDir: string | null = null;
|
private dataDir: string | null = null;
|
||||||
private turndown: TurndownService;
|
private turndown: TurndownService;
|
||||||
private siteBaseUrl: string | null = null; // Base URL for media URL conversion
|
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();
|
super();
|
||||||
|
this.tagEngine = deps.tagEngine;
|
||||||
|
this.postEngine = deps.postEngine;
|
||||||
|
this.mediaEngine = deps.mediaEngine;
|
||||||
|
this.postMediaEngine = deps.postMediaEngine;
|
||||||
this.turndown = new TurndownService({
|
this.turndown = new TurndownService({
|
||||||
headingStyle: 'atx',
|
headingStyle: 'atx',
|
||||||
codeBlockStyle: 'fenced',
|
codeBlockStyle: 'fenced',
|
||||||
@@ -329,7 +344,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
|||||||
result: ImportExecutionResult,
|
result: ImportExecutionResult,
|
||||||
progress: (phase: string, current: number, total: number, detail?: string) => void
|
progress: (phase: string, current: number, total: number, detail?: string) => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const tagEngine = getTagEngine();
|
const tagEngine = this.tagEngine;
|
||||||
tagEngine.setProjectContext(this.currentProjectId);
|
tagEngine.setProjectContext(this.currentProjectId);
|
||||||
|
|
||||||
let current = 0;
|
let current = 0;
|
||||||
@@ -459,7 +474,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
|||||||
result: ImportExecutionResult,
|
result: ImportExecutionResult,
|
||||||
options: ImportExecutionOptions
|
options: ImportExecutionOptions
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const postEngine = getPostEngine();
|
const postEngine = this.postEngine;
|
||||||
|
|
||||||
if (resolution === 'overwrite') {
|
if (resolution === 'overwrite') {
|
||||||
// Update the existing post with new content and set to draft for review
|
// Update the existing post with new content and set to draft for review
|
||||||
@@ -493,7 +508,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const wxrPost = analyzed.wxrPost;
|
const wxrPost = analyzed.wxrPost;
|
||||||
const db = getDatabase().getLocal();
|
const db = getDatabase().getLocal();
|
||||||
const postEngine = getPostEngine();
|
const postEngine = this.postEngine;
|
||||||
|
|
||||||
// Convert Vimeo iframes to [[vimeo]] macros BEFORE markdown conversion
|
// Convert Vimeo iframes to [[vimeo]] macros BEFORE markdown conversion
|
||||||
const contentWithVimeo = this.convertVimeoIframes(wxrPost.content);
|
const contentWithVimeo = this.convertVimeoIframes(wxrPost.content);
|
||||||
@@ -640,7 +655,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
|||||||
await db.insert(posts).values(dbPost);
|
await db.insert(posts).values(dbPost);
|
||||||
|
|
||||||
// Update FTS index
|
// Update FTS index
|
||||||
const postEngine = getPostEngine();
|
const postEngine = this.postEngine;
|
||||||
await postEngine.updateFTSIndex(postData);
|
await postEngine.updateFTSIndex(postData);
|
||||||
|
|
||||||
// Track wpId to postId mapping
|
// Track wpId to postId mapping
|
||||||
@@ -774,7 +789,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
|||||||
const createdAt = this.toDate(wxrMedia.pubDate) || new Date();
|
const createdAt = this.toDate(wxrMedia.pubDate) || new Date();
|
||||||
|
|
||||||
// Import the media file
|
// Import the media file
|
||||||
const mediaEngine = getMediaEngine();
|
const mediaEngine = this.mediaEngine;
|
||||||
const importedMedia = await mediaEngine.importMedia(sourcePath, {
|
const importedMedia = await mediaEngine.importMedia(sourcePath, {
|
||||||
title: wxrMedia.title || undefined,
|
title: wxrMedia.title || undefined,
|
||||||
alt: wxrMedia.description || undefined,
|
alt: wxrMedia.description || undefined,
|
||||||
@@ -788,7 +803,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
|||||||
|
|
||||||
// Link media to posts in the postMedia table
|
// Link media to posts in the postMedia table
|
||||||
if (linkedPostIds.length > 0) {
|
if (linkedPostIds.length > 0) {
|
||||||
const postMediaEngine = getPostMediaEngine();
|
const postMediaEngine = this.postMediaEngine;
|
||||||
postMediaEngine.setProjectContext(this.currentProjectId);
|
postMediaEngine.setProjectContext(this.currentProjectId);
|
||||||
for (const postId of linkedPostIds) {
|
for (const postId of linkedPostIds) {
|
||||||
await postMediaEngine.linkMediaToPost(postId, importedMedia.id);
|
await postMediaEngine.linkMediaToPost(postId, importedMedia.id);
|
||||||
@@ -824,7 +839,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaEngine = getMediaEngine();
|
const mediaEngine = this.mediaEngine;
|
||||||
|
|
||||||
// Replace the file on disk and update size/checksum/dimensions in database
|
// Replace the file on disk and update size/checksum/dimensions in database
|
||||||
await mediaEngine.replaceMediaFile(existingMediaId, sourcePath);
|
await mediaEngine.replaceMediaFile(existingMediaId, sourcePath);
|
||||||
@@ -847,7 +862,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
|||||||
|
|
||||||
// Link media to posts in the postMedia table if needed
|
// Link media to posts in the postMedia table if needed
|
||||||
if (linkedPostIds.length > 0) {
|
if (linkedPostIds.length > 0) {
|
||||||
const postMediaEngine = getPostMediaEngine();
|
const postMediaEngine = this.postMediaEngine;
|
||||||
postMediaEngine.setProjectContext(this.currentProjectId);
|
postMediaEngine.setProjectContext(this.currentProjectId);
|
||||||
for (const postId of linkedPostIds) {
|
for (const postId of linkedPostIds) {
|
||||||
await postMediaEngine.linkMediaToPost(postId, existingMediaId);
|
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 ─────────────────────────────────────────────────────
|
// ── 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 {
|
export interface AgentDefinition {
|
||||||
id: MCPAgentId;
|
id: MCPAgentId;
|
||||||
@@ -28,12 +28,17 @@ export interface MCPAgentConfigOptions {
|
|||||||
homeDir: string;
|
homeDir: string;
|
||||||
platform: NodeJS.Platform;
|
platform: NodeJS.Platform;
|
||||||
mcpUrl: string;
|
mcpUrl: string;
|
||||||
|
/** Required when agentId is 'claude-desktop'; unused otherwise. */
|
||||||
|
execPath?: string;
|
||||||
|
/** Required when agentId is 'claude-desktop'; unused otherwise. */
|
||||||
|
scriptPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Agent definitions ────────────────────────────────────────────────
|
// ── Agent definitions ────────────────────────────────────────────────
|
||||||
|
|
||||||
const AGENTS: AgentDefinition[] = [
|
const AGENTS: AgentDefinition[] = [
|
||||||
{ id: 'claude-code', label: 'Claude Code' },
|
{ id: 'claude-code', label: 'Claude Code' },
|
||||||
|
{ id: 'claude-desktop', label: 'Claude Desktop' },
|
||||||
{ id: 'github-copilot', label: 'GitHub Copilot' },
|
{ id: 'github-copilot', label: 'GitHub Copilot' },
|
||||||
{ id: 'gemini-cli', label: 'Gemini CLI' },
|
{ id: 'gemini-cli', label: 'Gemini CLI' },
|
||||||
{ id: 'opencode', label: 'OpenCode' },
|
{ id: 'opencode', label: 'OpenCode' },
|
||||||
@@ -47,11 +52,15 @@ export class MCPAgentConfigEngine {
|
|||||||
private readonly homeDir: string;
|
private readonly homeDir: string;
|
||||||
private readonly platform: NodeJS.Platform;
|
private readonly platform: NodeJS.Platform;
|
||||||
private readonly mcpUrl: string;
|
private readonly mcpUrl: string;
|
||||||
|
private readonly execPath?: string;
|
||||||
|
private readonly scriptPath?: string;
|
||||||
|
|
||||||
constructor(opts: MCPAgentConfigOptions) {
|
constructor(opts: MCPAgentConfigOptions) {
|
||||||
this.homeDir = opts.homeDir;
|
this.homeDir = opts.homeDir;
|
||||||
this.platform = opts.platform;
|
this.platform = opts.platform;
|
||||||
this.mcpUrl = opts.mcpUrl;
|
this.mcpUrl = opts.mcpUrl;
|
||||||
|
this.execPath = opts.execPath;
|
||||||
|
this.scriptPath = opts.scriptPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return the list of supported agent definitions. */
|
/** Return the list of supported agent definitions. */
|
||||||
@@ -64,6 +73,8 @@ export class MCPAgentConfigEngine {
|
|||||||
switch (agentId) {
|
switch (agentId) {
|
||||||
case 'claude-code':
|
case 'claude-code':
|
||||||
return path.join(this.homeDir, '.claude.json');
|
return path.join(this.homeDir, '.claude.json');
|
||||||
|
case 'claude-desktop':
|
||||||
|
return this.claudeDesktopConfigPath();
|
||||||
case 'github-copilot':
|
case 'github-copilot':
|
||||||
return this.vsCodeMcpPath();
|
return this.vsCodeMcpPath();
|
||||||
case 'gemini-cli':
|
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. */
|
/** Read-merge-write the bDS MCP server entry into the agent's config file. */
|
||||||
addToConfig(agentId: MCPAgentId): AgentConfigResult {
|
addToConfig(agentId: MCPAgentId): AgentConfigResult {
|
||||||
const configPath = this.getConfigPath(agentId);
|
const configPath = this.getConfigPath(agentId);
|
||||||
@@ -103,6 +143,16 @@ export class MCPAgentConfigEngine {
|
|||||||
|
|
||||||
// ── Private helpers ──────────────────────────────────────────────
|
// ── 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 {
|
private vsCodeMcpPath(): string {
|
||||||
if (this.platform === 'darwin') {
|
if (this.platform === 'darwin') {
|
||||||
return path.join(this.homeDir, 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
|
return path.join(this.homeDir, 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
|
||||||
@@ -138,6 +188,18 @@ export class MCPAgentConfigEngine {
|
|||||||
switch (agentId) {
|
switch (agentId) {
|
||||||
case 'claude-code':
|
case 'claude-code':
|
||||||
return { type: 'http', url: this.mcpUrl };
|
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':
|
case 'github-copilot':
|
||||||
return { type: 'http', url: this.mcpUrl };
|
return { type: 'http', url: this.mcpUrl };
|
||||||
case 'gemini-cli':
|
case 'gemini-cli':
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
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 { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
import {
|
import {
|
||||||
registerAppTool,
|
registerAppTool,
|
||||||
@@ -82,12 +83,16 @@ interface MediaEngineContract {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ScriptEngineContract {
|
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>;
|
validateScript: (content: string) => Promise<ScriptValidationResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TemplateEngineContract {
|
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>;
|
validateTemplate: (content: string) => Promise<TemplateValidationResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,13 +110,13 @@ interface TagEngineContract {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MCPServerDependencies {
|
export interface MCPServerDependencies {
|
||||||
getPostEngine: () => PostEngineContract;
|
postEngine: PostEngineContract;
|
||||||
getMediaEngine: () => MediaEngineContract;
|
mediaEngine: MediaEngineContract;
|
||||||
getScriptEngine: () => ScriptEngineContract;
|
scriptEngine: ScriptEngineContract;
|
||||||
getTemplateEngine: () => TemplateEngineContract;
|
templateEngine: TemplateEngineContract;
|
||||||
getMetaEngine: () => MetaEngineContract;
|
metaEngine: MetaEngineContract;
|
||||||
getPostMediaEngine: () => PostMediaEngineContract;
|
postMediaEngine: PostMediaEngineContract;
|
||||||
getTagEngine: () => TagEngineContract;
|
tagEngine: TagEngineContract;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,8 +125,8 @@ export interface MCPServerDependencies {
|
|||||||
*/
|
*/
|
||||||
export interface ProposalDataMap {
|
export interface ProposalDataMap {
|
||||||
draftPost: { postId: string };
|
draftPost: { postId: string };
|
||||||
proposeScript: CreateScriptInput;
|
proposeScript: { scriptId: string };
|
||||||
proposeTemplate: CreateTemplateInput;
|
proposeTemplate: { templateId: string };
|
||||||
proposeMediaMetadata: { mediaId: string; changes: Partial<MediaData> };
|
proposeMediaMetadata: { mediaId: string; changes: Partial<MediaData> };
|
||||||
proposePostMetadata: { postId: string; changes: Partial<PostData> };
|
proposePostMetadata: { postId: string; changes: Partial<PostData> };
|
||||||
}
|
}
|
||||||
@@ -139,9 +144,21 @@ export class MCPServer {
|
|||||||
private httpServer: Server | null = null;
|
private httpServer: Server | null = null;
|
||||||
private port: number | null = null;
|
private port: number | null = null;
|
||||||
|
|
||||||
constructor(deps: MCPServerDependencies) {
|
constructor(deps: MCPServerDependencies, opts?: { proposalTtlMs?: number }) {
|
||||||
this.deps = deps;
|
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). */
|
/** Create a fresh McpServer with all tools/resources/prompts registered (stateless — one per request). */
|
||||||
@@ -268,6 +285,16 @@ export class MCPServer {
|
|||||||
return this.port;
|
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 ────────────────────────────────────────────────
|
// ── Accept / Discard ────────────────────────────────────────────────
|
||||||
|
|
||||||
async acceptProposal(proposalId: string): Promise<{ success: boolean; message: string }> {
|
async acceptProposal(proposalId: string): Promise<{ success: boolean; message: string }> {
|
||||||
@@ -280,25 +307,27 @@ export class MCPServer {
|
|||||||
switch (proposal.type) {
|
switch (proposal.type) {
|
||||||
case 'draftPost': {
|
case 'draftPost': {
|
||||||
const { postId } = proposalData<'draftPost'>(proposal);
|
const { postId } = proposalData<'draftPost'>(proposal);
|
||||||
await this.deps.getPostEngine().publishPost(postId);
|
await this.deps.postEngine.publishPost(postId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'proposeScript': {
|
case 'proposeScript': {
|
||||||
await this.deps.getScriptEngine().createScript(proposalData<'proposeScript'>(proposal));
|
const { scriptId } = proposalData<'proposeScript'>(proposal);
|
||||||
|
await this.deps.scriptEngine.publishScript(scriptId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'proposeTemplate': {
|
case 'proposeTemplate': {
|
||||||
await this.deps.getTemplateEngine().createTemplate(proposalData<'proposeTemplate'>(proposal));
|
const { templateId } = proposalData<'proposeTemplate'>(proposal);
|
||||||
|
await this.deps.templateEngine.publishTemplate(templateId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'proposeMediaMetadata': {
|
case 'proposeMediaMetadata': {
|
||||||
const { mediaId, changes } = proposalData<'proposeMediaMetadata'>(proposal);
|
const { mediaId, changes } = proposalData<'proposeMediaMetadata'>(proposal);
|
||||||
await this.deps.getMediaEngine().updateMedia(mediaId, changes);
|
await this.deps.mediaEngine.updateMedia(mediaId, changes);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'proposePostMetadata': {
|
case 'proposePostMetadata': {
|
||||||
const { postId, changes } = proposalData<'proposePostMetadata'>(proposal);
|
const { postId, changes } = proposalData<'proposePostMetadata'>(proposal);
|
||||||
await this.deps.getPostEngine().updatePost(postId, changes);
|
await this.deps.postEngine.updatePost(postId, changes);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,7 +347,13 @@ export class MCPServer {
|
|||||||
try {
|
try {
|
||||||
if (proposal.type === 'draftPost') {
|
if (proposal.type === 'draftPost') {
|
||||||
const { postId } = proposalData<'draftPost'>(proposal);
|
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);
|
this.proposalStore.remove(proposalId);
|
||||||
return { success: true, message: `Proposal ${proposalId} discarded.` };
|
return { success: true, message: `Proposal ${proposalId} discarded.` };
|
||||||
@@ -331,7 +366,7 @@ export class MCPServer {
|
|||||||
|
|
||||||
private registerResources(server: McpServer): void {
|
private registerResources(server: McpServer): void {
|
||||||
server.registerResource('posts', 'bds://posts', { description: 'All blog posts (first page)' }, async () => {
|
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 };
|
const response: Record<string, unknown> = { ...result };
|
||||||
if (result.hasMore) {
|
if (result.hasMore) {
|
||||||
response.nextCursor = encodeCursor(DEFAULT_PAGE_SIZE);
|
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 () => {
|
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 items = allMedia.slice(0, DEFAULT_PAGE_SIZE);
|
||||||
const total = allMedia.length;
|
const total = allMedia.length;
|
||||||
const hasMore = DEFAULT_PAGE_SIZE < total;
|
const hasMore = DEFAULT_PAGE_SIZE < total;
|
||||||
@@ -352,17 +387,17 @@ export class MCPServer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
server.registerResource('tags', 'bds://tags', { description: 'Tags with post counts' }, async () => {
|
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) }] };
|
return { contents: [{ uri: 'bds://tags', mimeType: 'application/json', text: JSON.stringify(result) }] };
|
||||||
});
|
});
|
||||||
|
|
||||||
server.registerResource('categories', 'bds://categories', { description: 'Categories with post counts' }, async () => {
|
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) }] };
|
return { contents: [{ uri: 'bds://categories', mimeType: 'application/json', text: JSON.stringify(result) }] };
|
||||||
});
|
});
|
||||||
|
|
||||||
server.registerResource('stats', 'bds://stats', { description: 'Blog statistics' }, async () => {
|
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) }] };
|
return { contents: [{ uri: 'bds://stats', mimeType: 'application/json', text: JSON.stringify(result) }] };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -371,7 +406,7 @@ export class MCPServer {
|
|||||||
// ── Pagination templates ──
|
// ── 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 }) => {
|
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 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 };
|
const response: Record<string, unknown> = { ...result };
|
||||||
if (result.hasMore) {
|
if (result.hasMore) {
|
||||||
response.nextCursor = encodeCursor(offset + DEFAULT_PAGE_SIZE);
|
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 }) => {
|
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 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 items = allMedia.slice(offset, offset + DEFAULT_PAGE_SIZE);
|
||||||
const total = allMedia.length;
|
const total = allMedia.length;
|
||||||
const hasMore = offset + DEFAULT_PAGE_SIZE < total;
|
const hasMore = offset + DEFAULT_PAGE_SIZE < total;
|
||||||
@@ -394,42 +429,42 @@ export class MCPServer {
|
|||||||
|
|
||||||
// ── Entity templates ──
|
// ── Entity templates ──
|
||||||
server.registerResource('post', new ResourceTemplate('bds://posts/{id}', { list: undefined }), { description: 'A single post by ID' }, async (uri, { id }) => {
|
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) }] };
|
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 }) => {
|
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) }] };
|
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 }) => {
|
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) }] };
|
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 }) => {
|
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) }] };
|
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 }) => {
|
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) }] };
|
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 }) => {
|
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) }] };
|
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 }) => {
|
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 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/')) {
|
if (!media || !media.mimeType.startsWith('image/')) {
|
||||||
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'Not an image or media not found' }] };
|
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) {
|
if (!dataUrl) {
|
||||||
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'Thumbnail not available' }] };
|
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'Thumbnail not available' }] };
|
||||||
}
|
}
|
||||||
@@ -464,7 +499,7 @@ export class MCPServer {
|
|||||||
|
|
||||||
if (args.query && !hasFilters) {
|
if (args.query && !hasFilters) {
|
||||||
// Pure text search — use FTS
|
// 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);
|
const paginated = results.slice(offset, offset + limit);
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] };
|
||||||
}
|
}
|
||||||
@@ -479,14 +514,14 @@ export class MCPServer {
|
|||||||
|
|
||||||
if (args.query && hasFilters) {
|
if (args.query && hasFilters) {
|
||||||
// FTS + structural filters: single SQL JOIN query, ranked by FTS score
|
// 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 },
|
args.query, filter, { offset, limit },
|
||||||
);
|
);
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter-only query (no text search)
|
// 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);
|
const paginated = results.slice(offset, offset + limit);
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] };
|
||||||
});
|
});
|
||||||
@@ -509,7 +544,7 @@ export class MCPServer {
|
|||||||
_meta: { ui: { resourceUri: 'ui://bds/review-post' } },
|
_meta: { ui: { resourceUri: 'ui://bds/review-post' } },
|
||||||
}, async (args: { title: string; content: string; excerpt?: string; tags?: string[]; categories?: string[]; author?: string }) => {
|
}, async (args: { title: string; content: string; excerpt?: string; tags?: string[]; categories?: string[]; author?: string }) => {
|
||||||
try {
|
try {
|
||||||
const post = await this.deps.getPostEngine().createPost({
|
const post = await this.deps.postEngine.createPost({
|
||||||
title: args.title,
|
title: args.title,
|
||||||
content: args.content,
|
content: args.content,
|
||||||
excerpt: args.excerpt,
|
excerpt: args.excerpt,
|
||||||
@@ -543,13 +578,14 @@ export class MCPServer {
|
|||||||
annotations: { readOnlyHint: false, destructiveHint: false },
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||||
_meta: { ui: { resourceUri: 'ui://bds/review-script' } },
|
_meta: { ui: { resourceUri: 'ui://bds/review-script' } },
|
||||||
}, async (args: { title: string; kind: 'macro' | 'utility' | 'transform'; content: string; entrypoint?: string }) => {
|
}, async (args: { title: string; kind: 'macro' | 'utility' | 'transform'; content: string; entrypoint?: string }) => {
|
||||||
const validation = await this.deps.getScriptEngine().validateScript(args.content);
|
const validation = await this.deps.scriptEngine.validateScript(args.content);
|
||||||
const proposalId = this.proposalStore.create('proposeScript', {
|
const draft = await this.deps.scriptEngine.createDraftScript({
|
||||||
title: args.title,
|
title: args.title,
|
||||||
kind: args.kind,
|
kind: args.kind,
|
||||||
content: args.content,
|
content: args.content,
|
||||||
entrypoint: args.entrypoint,
|
entrypoint: args.entrypoint,
|
||||||
});
|
});
|
||||||
|
const proposalId = this.proposalStore.create('proposeScript', { scriptId: draft.id });
|
||||||
return {
|
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 } }) }],
|
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 },
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||||
_meta: { ui: { resourceUri: 'ui://bds/review-template' } },
|
_meta: { ui: { resourceUri: 'ui://bds/review-template' } },
|
||||||
}, async (args: { title: string; kind: 'post' | 'list' | 'not-found' | 'partial'; content: string }) => {
|
}, async (args: { title: string; kind: 'post' | 'list' | 'not-found' | 'partial'; content: string }) => {
|
||||||
const validation = await this.deps.getTemplateEngine().validateTemplate(args.content);
|
const validation = await this.deps.templateEngine.validateTemplate(args.content);
|
||||||
const proposalId = this.proposalStore.create('proposeTemplate', {
|
const draft = await this.deps.templateEngine.createDraftTemplate({
|
||||||
title: args.title,
|
title: args.title,
|
||||||
kind: args.kind,
|
kind: args.kind,
|
||||||
content: args.content,
|
content: args.content,
|
||||||
});
|
});
|
||||||
|
const proposalId = this.proposalStore.create('proposeTemplate', { templateId: draft.id });
|
||||||
return {
|
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 } }) }],
|
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[] }) => {
|
}, async (args: { mediaId: string; alt?: string; caption?: string; title?: string; tags?: string[] }) => {
|
||||||
try {
|
try {
|
||||||
const { mediaId, ...changes } = args;
|
const { mediaId, ...changes } = args;
|
||||||
const current = await this.deps.getMediaEngine().getMedia(mediaId);
|
const current = await this.deps.mediaEngine.getMedia(mediaId);
|
||||||
if (!current) {
|
if (!current) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Media item ${mediaId} not found.` }) }],
|
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[] }) => {
|
}, async (args: { postId: string; title?: string; excerpt?: string; tags?: string[]; categories?: string[] }) => {
|
||||||
try {
|
try {
|
||||||
const { postId, ...changes } = args;
|
const { postId, ...changes } = args;
|
||||||
const current = await this.deps.getPostEngine().getPost(postId);
|
const current = await this.deps.postEngine.getPost(postId);
|
||||||
if (!current) {
|
if (!current) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Post ${postId} not found.` }) }],
|
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 { getDatabase } from '../database';
|
||||||
import { media, Media, NewMedia, postMedia } from '../database/schema';
|
import { media, Media, NewMedia, postMedia } from '../database/schema';
|
||||||
import { stemText, stemQuery, SupportedLanguage } from './stemmer';
|
import { stemText, stemQuery, SupportedLanguage } from './stemmer';
|
||||||
|
import { CliNotifier, NoopNotifier } from './CliNotifier';
|
||||||
|
|
||||||
// Thumbnail sizes
|
// Thumbnail sizes
|
||||||
const 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 dataDir: string | null = null; // For media files (may be external)
|
||||||
private internalDir: string | null = null; // For thumbnails (always local)
|
private internalDir: string | null = null; // For thumbnails (always local)
|
||||||
private searchLanguage: SupportedLanguage = 'english';
|
private searchLanguage: SupportedLanguage = 'english';
|
||||||
|
private readonly notifier: CliNotifier;
|
||||||
|
|
||||||
constructor() {
|
constructor(notifier: CliNotifier = new NoopNotifier()) {
|
||||||
super();
|
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.
|
* Set the language used for full-text search stemming.
|
||||||
*/
|
*/
|
||||||
@@ -582,6 +588,7 @@ export class MediaEngine extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.emit('mediaImported', mediaData);
|
this.emit('mediaImported', mediaData);
|
||||||
|
await this.notifier.notify('media', mediaData.id, 'created');
|
||||||
return mediaData;
|
return mediaData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,6 +635,7 @@ export class MediaEngine extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.emit('mediaUpdated', updated);
|
this.emit('mediaUpdated', updated);
|
||||||
|
await this.notifier.notify('media', id, 'updated');
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,6 +746,7 @@ export class MediaEngine extends EventEmitter {
|
|||||||
await this.deleteFTSIndex(id);
|
await this.deleteFTSIndex(id);
|
||||||
|
|
||||||
this.emit('mediaDeleted', id);
|
this.emit('mediaDeleted', id);
|
||||||
|
await this.notifier.notify('media', id, 'deleted');
|
||||||
return true;
|
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 { getDatabase } from '../database';
|
||||||
import { posts, media } from '../database/schema';
|
import { posts, media } from '../database/schema';
|
||||||
import { readPostFile, PostFileData } from './postFileUtils';
|
import { readPostFile, PostFileData } from './postFileUtils';
|
||||||
import { getPostEngine } from './PostEngine';
|
|
||||||
import { taskManager } from './TaskManager';
|
import { taskManager } from './TaskManager';
|
||||||
|
import type { PostEngine } from './PostEngine';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A difference in a specific metadata field
|
* A difference in a specific metadata field
|
||||||
@@ -77,6 +77,10 @@ export interface TableStats {
|
|||||||
export class MetadataDiffEngine extends EventEmitter {
|
export class MetadataDiffEngine extends EventEmitter {
|
||||||
private currentProjectId = 'default';
|
private currentProjectId = 'default';
|
||||||
|
|
||||||
|
constructor(private readonly postEngine?: PostEngine) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
private async runSyncLoop(
|
private async runSyncLoop(
|
||||||
postIds: string[],
|
postIds: string[],
|
||||||
onProgress: ((percent: number, message: string) => void) | undefined,
|
onProgress: ((percent: number, message: string) => void) | undefined,
|
||||||
@@ -363,7 +367,8 @@ export class MetadataDiffEngine extends EventEmitter {
|
|||||||
postIds: string[],
|
postIds: string[],
|
||||||
onProgress?: (percent: number, message: string) => void
|
onProgress?: (percent: number, message: string) => void
|
||||||
): Promise<{ success: number; failed: number }> {
|
): Promise<{ success: number; failed: number }> {
|
||||||
const postEngine = getPostEngine();
|
const postEngine = this.postEngine;
|
||||||
|
if (!postEngine) throw new Error('MetadataDiffEngine: postEngine not injected');
|
||||||
return this.runSyncLoop(
|
return this.runSyncLoop(
|
||||||
postIds,
|
postIds,
|
||||||
onProgress,
|
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 { ChatEngine } from './ChatEngine';
|
||||||
import { PostEngine, type PostData } from './PostEngine';
|
import { PostEngine, type PostData } from './PostEngine';
|
||||||
import { MediaEngine, type MediaData } from './MediaEngine';
|
import { MediaEngine, type MediaData } from './MediaEngine';
|
||||||
import { getPostMediaEngine } from './PostMediaEngine';
|
import type { PostMediaEngine } from './PostMediaEngine';
|
||||||
import { isRenderTool, generateFromToolCall } from '../a2ui/generator';
|
import { isRenderTool, generateFromToolCall } from '../a2ui/generator';
|
||||||
import type { A2UIServerMessage } from '../a2ui/types';
|
import type { A2UIServerMessage } from '../a2ui/types';
|
||||||
|
|
||||||
@@ -155,6 +155,7 @@ export class OpenCodeManager {
|
|||||||
private chatEngine: ChatEngine;
|
private chatEngine: ChatEngine;
|
||||||
private postEngine: PostEngine;
|
private postEngine: PostEngine;
|
||||||
private mediaEngine: MediaEngine;
|
private mediaEngine: MediaEngine;
|
||||||
|
private postMediaEngine: PostMediaEngine;
|
||||||
private getMainWindow: () => BrowserWindow | null;
|
private getMainWindow: () => BrowserWindow | null;
|
||||||
private apiKey: string = '';
|
private apiKey: string = '';
|
||||||
private abortControllers: Map<string, AbortController> = new Map();
|
private abortControllers: Map<string, AbortController> = new Map();
|
||||||
@@ -172,11 +173,13 @@ export class OpenCodeManager {
|
|||||||
chatEngine: ChatEngine,
|
chatEngine: ChatEngine,
|
||||||
postEngine: PostEngine,
|
postEngine: PostEngine,
|
||||||
mediaEngine: MediaEngine,
|
mediaEngine: MediaEngine,
|
||||||
|
postMediaEngine: PostMediaEngine,
|
||||||
getMainWindow: () => BrowserWindow | null
|
getMainWindow: () => BrowserWindow | null
|
||||||
) {
|
) {
|
||||||
this.chatEngine = chatEngine;
|
this.chatEngine = chatEngine;
|
||||||
this.postEngine = postEngine;
|
this.postEngine = postEngine;
|
||||||
this.mediaEngine = mediaEngine;
|
this.mediaEngine = mediaEngine;
|
||||||
|
this.postMediaEngine = postMediaEngine;
|
||||||
this.getMainWindow = getMainWindow;
|
this.getMainWindow = getMainWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1522,7 +1525,7 @@ export class OpenCodeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'get_post_media': {
|
case 'get_post_media': {
|
||||||
const postMediaEngine = getPostMediaEngine();
|
const postMediaEngine = this.postMediaEngine;
|
||||||
const linkedMedia = await postMediaEngine.getLinkedMediaDataForPost(args.postId as string);
|
const linkedMedia = await postMediaEngine.getLinkedMediaDataForPost(args.postId as string);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -1544,7 +1547,7 @@ export class OpenCodeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'get_media_posts': {
|
case 'get_media_posts': {
|
||||||
const postMediaEngine = getPostMediaEngine();
|
const postMediaEngine = this.postMediaEngine;
|
||||||
const linkedPosts = await postMediaEngine.getLinkedPostsForMedia(args.mediaId as string);
|
const linkedPosts = await postMediaEngine.getLinkedPostsForMedia(args.mediaId as string);
|
||||||
|
|
||||||
// Fetch full post data for each linked post
|
// 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 { taskManager, Task } from './TaskManager';
|
||||||
import { stemText, stemQuery, SupportedLanguage } from './stemmer';
|
import { stemText, stemQuery, SupportedLanguage } from './stemmer';
|
||||||
import { readPostFile as readPostFileShared, type PostFileData } from './postFileUtils';
|
import { readPostFile as readPostFileShared, type PostFileData } from './postFileUtils';
|
||||||
|
import { CliNotifier, NoopNotifier } from './CliNotifier';
|
||||||
|
import type { MediaEngine } from './MediaEngine';
|
||||||
|
|
||||||
export interface PostData {
|
export interface PostData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -90,11 +92,18 @@ export interface PublishedPostReconcileResult {
|
|||||||
export class PostEngine extends EventEmitter {
|
export class PostEngine extends EventEmitter {
|
||||||
private currentProjectId: string = 'default';
|
private currentProjectId: string = 'default';
|
||||||
private searchLanguage: SupportedLanguage = 'english';
|
private searchLanguage: SupportedLanguage = 'english';
|
||||||
|
private readonly notifier: CliNotifier;
|
||||||
|
private readonly mediaEngine: MediaEngine | undefined;
|
||||||
|
|
||||||
constructor() {
|
constructor(opts: { notifier?: CliNotifier; mediaEngine?: MediaEngine } = {}) {
|
||||||
super();
|
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.
|
* Set the language used for full-text search stemming.
|
||||||
* Affects both indexing and query processing.
|
* Affects both indexing and query processing.
|
||||||
@@ -419,6 +428,7 @@ export class PostEngine extends EventEmitter {
|
|||||||
await this.updateFTSIndex(post);
|
await this.updateFTSIndex(post);
|
||||||
|
|
||||||
this.emit('postCreated', post);
|
this.emit('postCreated', post);
|
||||||
|
await this.notifier.notify('post', post.id, 'created');
|
||||||
return post;
|
return post;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,6 +498,7 @@ export class PostEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.emit('postUpdated', updated);
|
this.emit('postUpdated', updated);
|
||||||
|
await this.notifier.notify('post', id, 'updated');
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,20 +526,31 @@ export class PostEngine extends EventEmitter {
|
|||||||
|
|
||||||
// Delete post-media links and update media sidecars
|
// Delete post-media links and update media sidecars
|
||||||
const { postMedia } = await import('../database/schema');
|
const { postMedia } = await import('../database/schema');
|
||||||
const { getMediaEngine } = await import('./MediaEngine');
|
|
||||||
const linkedMediaResult = await db.select().from(postMedia).where(eq(postMedia.postId, id));
|
const linkedMediaResult = await db.select().from(postMedia).where(eq(postMedia.postId, id));
|
||||||
const linkedMedia = Array.isArray(linkedMediaResult) ? linkedMediaResult : [];
|
const linkedMedia = Array.isArray(linkedMediaResult) ? linkedMediaResult : [];
|
||||||
|
|
||||||
// Remove this post from each linked media's sidecar
|
// Remove this post from each linked media's sidecar.
|
||||||
const mediaEngine = getMediaEngine();
|
// Requires mediaEngine to be injected at construction time.
|
||||||
for (const link of linkedMedia) {
|
if (linkedMedia.length > 0 && this.mediaEngine) {
|
||||||
const media = await mediaEngine.getMedia(link.mediaId);
|
for (const link of linkedMedia) {
|
||||||
if (media && media.linkedPostIds) {
|
const media = await this.mediaEngine.getMedia(link.mediaId);
|
||||||
const updatedLinkedPostIds = media.linkedPostIds.filter(pid => pid !== id);
|
if (media && media.linkedPostIds) {
|
||||||
await mediaEngine.updateMedia(link.mediaId, { linkedPostIds: updatedLinkedPostIds });
|
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
|
// Delete post-media junction entries
|
||||||
await db.delete(postMedia).where(eq(postMedia.postId, id));
|
await db.delete(postMedia).where(eq(postMedia.postId, id));
|
||||||
|
|
||||||
@@ -539,6 +561,7 @@ export class PostEngine extends EventEmitter {
|
|||||||
await this.deleteFTSIndex(id);
|
await this.deleteFTSIndex(id);
|
||||||
|
|
||||||
this.emit('postDeleted', id);
|
this.emit('postDeleted', id);
|
||||||
|
await this.notifier.notify('post', id, 'deleted');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1198,6 +1221,7 @@ export class PostEngine extends EventEmitter {
|
|||||||
await this.updatePostLinks(id, published.content);
|
await this.updatePostLinks(id, published.content);
|
||||||
|
|
||||||
this.emit('postUpdated', published);
|
this.emit('postUpdated', published);
|
||||||
|
await this.notifier.notify('post', id, 'updated');
|
||||||
return published;
|
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 { eq, and, asc } from 'drizzle-orm';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { postMedia, PostMediaLink, NewPostMediaLink } from '../database/schema';
|
import { postMedia, PostMediaLink, NewPostMediaLink } from '../database/schema';
|
||||||
import { getMediaEngine, MediaData } from './MediaEngine';
|
import type { MediaEngine, MediaData } from './MediaEngine';
|
||||||
|
|
||||||
export interface PostMediaLinkData {
|
export interface PostMediaLinkData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,13 +25,12 @@ export interface PostMediaLinkData {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance — removed in favour of explicit construction (see EngineBundle)
|
||||||
let postMediaEngineInstance: PostMediaEngine | null = null;
|
|
||||||
|
|
||||||
export class PostMediaEngine extends EventEmitter {
|
export class PostMediaEngine extends EventEmitter {
|
||||||
private currentProjectId: string = 'default';
|
private currentProjectId: string = 'default';
|
||||||
|
|
||||||
constructor() {
|
constructor(private readonly mediaEngine: MediaEngine) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ export class PostMediaEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async addPostToMediaSidecar(mediaId: string, postId: string): Promise<void> {
|
private async addPostToMediaSidecar(mediaId: string, postId: string): Promise<void> {
|
||||||
const media = await getMediaEngine().getMedia(mediaId);
|
const media = await this.mediaEngine.getMedia(mediaId);
|
||||||
if (!media) {
|
if (!media) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -54,19 +53,19 @@ export class PostMediaEngine extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await getMediaEngine().updateMedia(mediaId, {
|
await this.mediaEngine.updateMedia(mediaId, {
|
||||||
linkedPostIds: [...linkedPostIds, postId],
|
linkedPostIds: [...linkedPostIds, postId],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async removePostFromMediaSidecar(mediaId: string, postId: string): Promise<void> {
|
private async removePostFromMediaSidecar(mediaId: string, postId: string): Promise<void> {
|
||||||
const media = await getMediaEngine().getMedia(mediaId);
|
const media = await this.mediaEngine.getMedia(mediaId);
|
||||||
if (!media) {
|
if (!media) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const linkedPostIds = (media.linkedPostIds || []).filter(id => id !== postId);
|
const linkedPostIds = (media.linkedPostIds || []).filter(id => id !== postId);
|
||||||
await getMediaEngine().updateMedia(mediaId, { linkedPostIds });
|
await this.mediaEngine.updateMedia(mediaId, { linkedPostIds });
|
||||||
}
|
}
|
||||||
|
|
||||||
private createLinkData(link: NewPostMediaLink): PostMediaLinkData {
|
private createLinkData(link: NewPostMediaLink): PostMediaLinkData {
|
||||||
@@ -319,7 +318,7 @@ export class PostMediaEngine extends EventEmitter {
|
|||||||
await db.delete(postMedia).where(eq(postMedia.projectId, this.currentProjectId));
|
await db.delete(postMedia).where(eq(postMedia.projectId, this.currentProjectId));
|
||||||
|
|
||||||
// Get all media with their linkedPostIds
|
// Get all media with their linkedPostIds
|
||||||
const allMedia = await getMediaEngine().getAllMedia();
|
const allMedia = await this.mediaEngine.getAllMedia();
|
||||||
|
|
||||||
let linksCreated = 0;
|
let linksCreated = 0;
|
||||||
for (const media of allMedia) {
|
for (const media of allMedia) {
|
||||||
@@ -352,7 +351,7 @@ export class PostMediaEngine extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
async importMediaForPost(postId: string, sourcePath: string): Promise<PostMediaLinkData> {
|
async importMediaForPost(postId: string, sourcePath: string): Promise<PostMediaLinkData> {
|
||||||
// Import the media file
|
// Import the media file
|
||||||
const importedMedia = await getMediaEngine().importMedia(sourcePath);
|
const importedMedia = await this.mediaEngine.importMedia(sourcePath);
|
||||||
|
|
||||||
// Link it to the post
|
// Link it to the post
|
||||||
return this.linkMediaToPost(postId, importedMedia.id);
|
return this.linkMediaToPost(postId, importedMedia.id);
|
||||||
@@ -366,7 +365,7 @@ export class PostMediaEngine extends EventEmitter {
|
|||||||
|
|
||||||
const result: Array<PostMediaLinkData & { media: MediaData }> = [];
|
const result: Array<PostMediaLinkData & { media: MediaData }> = [];
|
||||||
for (const link of links) {
|
for (const link of links) {
|
||||||
const media = await getMediaEngine().getMedia(link.mediaId);
|
const media = await this.mediaEngine.getMedia(link.mediaId);
|
||||||
if (media) {
|
if (media) {
|
||||||
result.push({ ...link, 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 { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { getMetaEngine, type CategoryMetadata, type ProjectMetadata } from './MetaEngine';
|
import { type CategoryMetadata, type ProjectMetadata } from './MetaEngine';
|
||||||
import { getMediaEngine, type MediaData } from './MediaEngine';
|
import { type MediaData } from './MediaEngine';
|
||||||
import { getMenuEngine, type MenuDocument } from './MenuEngine';
|
import { type MenuDocument } from './MenuEngine';
|
||||||
import { getPostMediaEngine } from './PostMediaEngine';
|
import { type PostData, type PostFilter } from './PostEngine';
|
||||||
import { getPostEngine, type PostData, type PostFilter } from './PostEngine';
|
|
||||||
import { getProjectEngine } from './ProjectEngine';
|
|
||||||
import {
|
import {
|
||||||
PageRenderer,
|
PageRenderer,
|
||||||
PREVIEW_ASSETS,
|
PREVIEW_ASSETS,
|
||||||
@@ -21,9 +19,6 @@ import {
|
|||||||
type PostMediaEngineContract,
|
type PostMediaEngineContract,
|
||||||
type PythonMacroRendererContract,
|
type PythonMacroRendererContract,
|
||||||
} from './PageRenderer';
|
} from './PageRenderer';
|
||||||
import { getScriptEngine } from './ScriptEngine';
|
|
||||||
import { getTemplateEngine } from './TemplateEngine';
|
|
||||||
import { getPythonMacroWorkerRuntime } from './PythonMacroWorkerRuntime';
|
|
||||||
import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes';
|
import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes';
|
||||||
import { renderRouteWithSharedContext } from './SharedRouteRenderer';
|
import { renderRouteWithSharedContext } from './SharedRouteRenderer';
|
||||||
import {
|
import {
|
||||||
@@ -71,6 +66,7 @@ interface PreviewServerDependencies {
|
|||||||
menuEngine: MenuEngineContract;
|
menuEngine: MenuEngineContract;
|
||||||
getActiveProjectContext: () => Promise<ActiveProjectContext>;
|
getActiveProjectContext: () => Promise<ActiveProjectContext>;
|
||||||
userTemplatesDir?: string;
|
userTemplatesDir?: string;
|
||||||
|
macroRenderer?: PythonMacroRendererContract;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SerializedTag {
|
interface SerializedTag {
|
||||||
@@ -91,29 +87,24 @@ export class PreviewServer {
|
|||||||
private port: number | null = null;
|
private port: number | null = null;
|
||||||
|
|
||||||
constructor(dependencies?: Partial<PreviewServerDependencies>) {
|
constructor(dependencies?: Partial<PreviewServerDependencies>) {
|
||||||
this.postEngine = dependencies?.postEngine ?? getPostEngine();
|
if (!dependencies?.postEngine) throw new Error('PreviewServer: postEngine not provided');
|
||||||
this.mediaEngine = dependencies?.mediaEngine ?? getMediaEngine();
|
if (!dependencies?.mediaEngine) throw new Error('PreviewServer: mediaEngine not provided');
|
||||||
this.postMediaEngine = dependencies?.postMediaEngine ?? getPostMediaEngine();
|
if (!dependencies?.postMediaEngine) throw new Error('PreviewServer: postMediaEngine not provided');
|
||||||
this.settingsEngine = dependencies?.settingsEngine ?? getMetaEngine();
|
if (!dependencies?.settingsEngine) throw new Error('PreviewServer: settingsEngine not provided');
|
||||||
this.menuEngine = dependencies?.menuEngine ?? getMenuEngine();
|
if (!dependencies?.menuEngine) throw new Error('PreviewServer: menuEngine not provided');
|
||||||
this.getActiveProjectContext = dependencies?.getActiveProjectContext ?? (async () => {
|
if (!dependencies?.getActiveProjectContext) throw new Error('PreviewServer: getActiveProjectContext not provided');
|
||||||
const projectEngine = getProjectEngine();
|
this.postEngine = dependencies.postEngine;
|
||||||
const activeProject = await projectEngine.getActiveProject();
|
this.mediaEngine = dependencies.mediaEngine;
|
||||||
const projectId = activeProject?.id ?? 'default';
|
this.postMediaEngine = dependencies.postMediaEngine;
|
||||||
const dataDir = projectEngine.getDataDir(projectId, activeProject?.dataPath);
|
this.settingsEngine = dependencies.settingsEngine;
|
||||||
return {
|
this.menuEngine = dependencies.menuEngine;
|
||||||
projectId,
|
this.getActiveProjectContext = dependencies.getActiveProjectContext;
|
||||||
dataDir,
|
|
||||||
projectName: activeProject?.name,
|
|
||||||
projectDescription: activeProject?.description ?? undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
this.pageRenderer = new PageRenderer(
|
this.pageRenderer = new PageRenderer(
|
||||||
this.mediaEngine,
|
this.mediaEngine,
|
||||||
this.postMediaEngine,
|
this.postMediaEngine,
|
||||||
this.postEngine,
|
this.postEngine,
|
||||||
buildPythonMacroRenderer(),
|
dependencies.macroRenderer ?? buildNoopMacroRenderer(),
|
||||||
dependencies?.userTemplatesDir ?? getTemplateEngine().getTemplatesDirectory(),
|
dependencies.userTemplatesDir,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,20 +653,13 @@ export class PreviewServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPythonMacroRenderer(): PythonMacroRendererContract {
|
function buildNoopMacroRenderer(): PythonMacroRendererContract {
|
||||||
return {
|
return {
|
||||||
async getEnabledMacroScripts() {
|
async getEnabledMacroScripts() {
|
||||||
const scripts = await getScriptEngine().getEnabledMacroScripts();
|
return [];
|
||||||
return scripts.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
slug: s.slug,
|
|
||||||
entrypoint: s.entrypoint,
|
|
||||||
content: s.content,
|
|
||||||
version: s.version,
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
async renderMacro(params) {
|
async renderMacro() {
|
||||||
return getPythonMacroWorkerRuntime().renderMacro(params);
|
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 {
|
export class ProposalStore {
|
||||||
private readonly proposals = new Map<string, Proposal>();
|
private readonly proposals = new Map<string, Proposal>();
|
||||||
private readonly ttlMs: number;
|
private readonly ttlMs: number;
|
||||||
|
private readonly onExpiry: ((proposal: Proposal) => void) | undefined;
|
||||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
constructor(ttlMs: number = DEFAULT_TTL_MS) {
|
constructor(ttlMs?: number, onExpiry?: (proposal: Proposal) => void) {
|
||||||
this.ttlMs = ttlMs;
|
this.ttlMs = ttlMs ?? DEFAULT_TTL_MS;
|
||||||
|
this.onExpiry = onExpiry;
|
||||||
this.cleanupInterval = setInterval(() => this.cleanup(), this.ttlMs);
|
this.cleanupInterval = setInterval(() => this.cleanup(), this.ttlMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ export class ProposalStore {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [id, proposal] of this.proposals) {
|
for (const [id, proposal] of this.proposals) {
|
||||||
if (now - proposal.createdAt > this.ttlMs) {
|
if (now - proposal.createdAt > this.ttlMs) {
|
||||||
|
this.onExpiry?.(proposal);
|
||||||
this.proposals.delete(id);
|
this.proposals.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { getProjectEngine } from './ProjectEngine';
|
import type { ProjectEngine } from './ProjectEngine';
|
||||||
import { getPublishEngine, type PublishCredentials } from './PublishEngine';
|
import type { PublishEngine, PublishCredentials } from './PublishEngine';
|
||||||
import { taskManager } from './TaskManager';
|
import type { TaskManager } from './TaskManager';
|
||||||
|
|
||||||
|
export type { PublishCredentials };
|
||||||
|
|
||||||
export interface PublishSiteResult {
|
export interface PublishSiteResult {
|
||||||
htmlFilesUploaded: number;
|
htmlFilesUploaded: number;
|
||||||
@@ -15,41 +17,46 @@ export interface PublishSiteResult {
|
|||||||
* context, launches three parallel upload tasks, and returns aggregate results.
|
* context, launches three parallel upload tasks, and returns aggregate results.
|
||||||
*/
|
*/
|
||||||
export class PublishApiAdapter {
|
export class PublishApiAdapter {
|
||||||
|
constructor(
|
||||||
|
private readonly projectEngine: ProjectEngine,
|
||||||
|
private readonly publishEngine: PublishEngine,
|
||||||
|
private readonly taskManager: TaskManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
async uploadSite(credentials: PublishCredentials): Promise<PublishSiteResult> {
|
async uploadSite(credentials: PublishCredentials): Promise<PublishSiteResult> {
|
||||||
const project = await getProjectEngine().getActiveProject();
|
const project = await this.projectEngine.getActiveProject();
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error('No active project');
|
throw new Error('No active project');
|
||||||
}
|
}
|
||||||
|
|
||||||
const publishEngine = getPublishEngine();
|
this.publishEngine.setProjectContext(project.id, project.dataPath!);
|
||||||
publishEngine.setProjectContext(project.id, project.dataPath!);
|
|
||||||
|
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
const groupId = `publish-${ts}`;
|
const groupId = `publish-${ts}`;
|
||||||
const groupName = 'Site Publishing';
|
const groupName = 'Site Publishing';
|
||||||
|
|
||||||
const htmlTask = taskManager.runTask({
|
const htmlTask = this.taskManager.runTask({
|
||||||
id: `publish-html-${ts}`,
|
id: `publish-html-${ts}`,
|
||||||
name: 'Upload HTML',
|
name: 'Upload HTML',
|
||||||
groupId,
|
groupId,
|
||||||
groupName,
|
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}`,
|
id: `publish-thumbnails-${ts}`,
|
||||||
name: 'Upload Thumbnails',
|
name: 'Upload Thumbnails',
|
||||||
groupId,
|
groupId,
|
||||||
groupName,
|
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}`,
|
id: `publish-media-${ts}`,
|
||||||
name: 'Upload Media',
|
name: 'Upload Media',
|
||||||
groupId,
|
groupId,
|
||||||
groupName,
|
groupName,
|
||||||
execute: (onProgress) => publishEngine.uploadMedia(credentials, onProgress),
|
execute: (onProgress) => this.publishEngine.uploadMedia(credentials, onProgress),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [html, thumbnails, media] = await Promise.all([htmlTask, thumbsTask, mediaTask]);
|
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 { and, desc, eq } from 'drizzle-orm';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { scripts, type NewScript, type Script } from '../database/schema';
|
import { scripts, type NewScript, type Script } from '../database/schema';
|
||||||
|
import { CliNotifier, NoopNotifier } from './CliNotifier';
|
||||||
|
|
||||||
export type ScriptKind = 'macro' | 'utility' | 'transform';
|
export type ScriptKind = 'macro' | 'utility' | 'transform';
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ export interface ScriptData {
|
|||||||
version: number;
|
version: number;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
status: 'draft' | 'published';
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -81,6 +83,15 @@ interface ParsedScriptFile {
|
|||||||
export class ScriptEngine extends EventEmitter {
|
export class ScriptEngine extends EventEmitter {
|
||||||
private currentProjectId = 'default';
|
private currentProjectId = 'default';
|
||||||
private dataDir: string | null = null;
|
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 {
|
setProjectContext(projectId: string, dataDir?: string): void {
|
||||||
this.currentProjectId = projectId;
|
this.currentProjectId = projectId;
|
||||||
@@ -159,6 +170,7 @@ export class ScriptEngine extends EventEmitter {
|
|||||||
|
|
||||||
const created = await this.toScriptData(row as Script);
|
const created = await this.toScriptData(row as Script);
|
||||||
this.emit('scriptCreated', created);
|
this.emit('scriptCreated', created);
|
||||||
|
await this.notifier.notify('script', created.id, 'created');
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +239,7 @@ export class ScriptEngine extends EventEmitter {
|
|||||||
|
|
||||||
const updated = await this.toScriptData(updatedRow);
|
const updated = await this.toScriptData(updatedRow);
|
||||||
this.emit('scriptUpdated', updated);
|
this.emit('scriptUpdated', updated);
|
||||||
|
await this.notifier.notify('script', updated.id, 'updated');
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +263,7 @@ export class ScriptEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.emit('scriptDeleted', id);
|
this.emit('scriptDeleted', id);
|
||||||
|
await this.notifier.notify('script', id, 'deleted');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,7 +512,10 @@ export class ScriptEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async toScriptData(row: Script): Promise<ScriptData> {
|
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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -511,6 +528,7 @@ export class ScriptEngine extends EventEmitter {
|
|||||||
version: row.version,
|
version: row.version,
|
||||||
filePath: row.filePath,
|
filePath: row.filePath,
|
||||||
content,
|
content,
|
||||||
|
status: (row.status as 'draft' | 'published') ?? 'published',
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
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> {
|
private async readScriptBody(filePath: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const rawContent = await fs.readFile(filePath, 'utf-8');
|
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 { getDatabase } from '../database';
|
||||||
import { tags, posts } from '../database/schema';
|
import { tags, posts } from '../database/schema';
|
||||||
import { taskManager } from './TaskManager';
|
import { taskManager } from './TaskManager';
|
||||||
import { getPostEngine } from './PostEngine';
|
|
||||||
import { normalizeTaxonomyTerm, normalizeNonEmptyTaxonomyTerm } from './taxonomyUtils';
|
import { normalizeTaxonomyTerm, normalizeNonEmptyTaxonomyTerm } from './taxonomyUtils';
|
||||||
|
import type { PostEngine } from './PostEngine';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tag data stored in the database
|
* Tag data stored in the database
|
||||||
@@ -85,18 +85,7 @@ export interface SyncTagsResult {
|
|||||||
added: string[];
|
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
|
* Validate hex color format
|
||||||
@@ -128,7 +117,7 @@ export class TagEngine extends EventEmitter {
|
|||||||
private currentProjectId: string = 'default';
|
private currentProjectId: string = 'default';
|
||||||
private dataDir: string | null = null; // Custom data directory (null = use internal userData)
|
private dataDir: string | null = null; // Custom data directory (null = use internal userData)
|
||||||
|
|
||||||
constructor() {
|
constructor(private readonly postEngine?: PostEngine) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +178,9 @@ export class TagEngine extends EventEmitter {
|
|||||||
})
|
})
|
||||||
.where(eq(posts.id, postId));
|
.where(eq(posts.id, postId));
|
||||||
|
|
||||||
await getPostEngine().syncPublishedPostFile(postId);
|
if (this.postEngine) {
|
||||||
|
await this.postEngine.syncPublishedPostFile(postId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateMatchingPosts(
|
private async updateMatchingPosts(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { and, desc, eq } from 'drizzle-orm';
|
|||||||
import { Liquid } from 'liquidjs';
|
import { Liquid } from 'liquidjs';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { posts, tags, templates, type NewTemplate, type Template } from '../database/schema';
|
import { posts, tags, templates, type NewTemplate, type Template } from '../database/schema';
|
||||||
|
import { CliNotifier, NoopNotifier } from './CliNotifier';
|
||||||
|
|
||||||
export type TemplateKind = 'post' | 'list' | 'not-found' | 'partial';
|
export type TemplateKind = 'post' | 'list' | 'not-found' | 'partial';
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ export interface TemplateData {
|
|||||||
version: number;
|
version: number;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
status: 'draft' | 'published';
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -83,6 +85,15 @@ interface ParsedTemplateFile {
|
|||||||
export class TemplateEngine extends EventEmitter {
|
export class TemplateEngine extends EventEmitter {
|
||||||
private currentProjectId = 'default';
|
private currentProjectId = 'default';
|
||||||
private dataDir: string | null = null;
|
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 {
|
setProjectContext(projectId: string, dataDir?: string): void {
|
||||||
this.currentProjectId = projectId;
|
this.currentProjectId = projectId;
|
||||||
@@ -125,6 +136,7 @@ export class TemplateEngine extends EventEmitter {
|
|||||||
|
|
||||||
const created = await this.toTemplateData(row as Template);
|
const created = await this.toTemplateData(row as Template);
|
||||||
this.emit('templateCreated', created);
|
this.emit('templateCreated', created);
|
||||||
|
await this.notifier.notify('template', created.id, 'created');
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +234,7 @@ export class TemplateEngine extends EventEmitter {
|
|||||||
|
|
||||||
const updated = await this.toTemplateData(updatedRow);
|
const updated = await this.toTemplateData(updatedRow);
|
||||||
this.emit('templateUpdated', updated);
|
this.emit('templateUpdated', updated);
|
||||||
|
await this.notifier.notify('template', updated.id, 'updated');
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +295,7 @@ export class TemplateEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.emit('templateDeleted', id);
|
this.emit('templateDeleted', id);
|
||||||
|
await this.notifier.notify('template', id, 'deleted');
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,7 +552,10 @@ export class TemplateEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async toTemplateData(row: Template): Promise<TemplateData> {
|
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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -550,11 +567,84 @@ export class TemplateEngine extends EventEmitter {
|
|||||||
version: row.version,
|
version: row.version,
|
||||||
filePath: row.filePath,
|
filePath: row.filePath,
|
||||||
content,
|
content,
|
||||||
|
status: (row.status as 'draft' | 'published') ?? 'published',
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
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 {
|
private getDataDir(): string {
|
||||||
if (this.dataDir) {
|
if (this.dataDir) {
|
||||||
return 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 { 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 { PostEngine, type PostData, type PostFilter, type SearchResult, type PaginatedResult, type PaginationOptions } from './PostEngine';
|
||||||
export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine';
|
export { MediaEngine, type MediaData } from './MediaEngine';
|
||||||
export { PostMediaEngine, getPostMediaEngine, postMediaEngine, type PostMediaLinkData } from './PostMediaEngine';
|
export { PostMediaEngine, type PostMediaLinkData } from './PostMediaEngine';
|
||||||
export { ProjectEngine, getProjectEngine, type ProjectData } from './ProjectEngine';
|
export { ProjectEngine, type ProjectData } from './ProjectEngine';
|
||||||
export { MetaEngine, getMetaEngine, type ProjectMetadata, DEFAULT_CATEGORIES } from './MetaEngine';
|
export { MetaEngine, type ProjectMetadata, DEFAULT_CATEGORIES } from './MetaEngine';
|
||||||
export {
|
export {
|
||||||
TagEngine,
|
TagEngine,
|
||||||
getTagEngine,
|
|
||||||
type TagData,
|
type TagData,
|
||||||
type TagWithCount,
|
type TagWithCount,
|
||||||
type CreateTagInput,
|
type CreateTagInput,
|
||||||
@@ -66,7 +65,6 @@ export {
|
|||||||
} from './postFileUtils';
|
} from './postFileUtils';
|
||||||
export {
|
export {
|
||||||
MetadataDiffEngine,
|
MetadataDiffEngine,
|
||||||
getMetadataDiffEngine,
|
|
||||||
type PostMetadataDiff,
|
type PostMetadataDiff,
|
||||||
type DiffGroup,
|
type DiffGroup,
|
||||||
type DiffField,
|
type DiffField,
|
||||||
@@ -75,7 +73,6 @@ export {
|
|||||||
} from './MetadataDiffEngine';
|
} from './MetadataDiffEngine';
|
||||||
export {
|
export {
|
||||||
GitEngine,
|
GitEngine,
|
||||||
getGitEngine,
|
|
||||||
type GitAvailability,
|
type GitAvailability,
|
||||||
type RepoState,
|
type RepoState,
|
||||||
type GitStatusDto,
|
type GitStatusDto,
|
||||||
@@ -88,21 +85,18 @@ export {
|
|||||||
} from './GitEngine';
|
} from './GitEngine';
|
||||||
export {
|
export {
|
||||||
BlogGenerationEngine,
|
BlogGenerationEngine,
|
||||||
getBlogGenerationEngine,
|
|
||||||
resolvePublicBaseUrl,
|
resolvePublicBaseUrl,
|
||||||
type BlogGenerationOptions,
|
type BlogGenerationOptions,
|
||||||
type BlogGenerationResult,
|
type BlogGenerationResult,
|
||||||
} from './BlogGenerationEngine';
|
} from './BlogGenerationEngine';
|
||||||
export {
|
export {
|
||||||
MenuEngine,
|
MenuEngine,
|
||||||
getMenuEngine,
|
|
||||||
type MenuItemData,
|
type MenuItemData,
|
||||||
type MenuDocument,
|
type MenuDocument,
|
||||||
type MenuItemKind,
|
type MenuItemKind,
|
||||||
} from './MenuEngine';
|
} from './MenuEngine';
|
||||||
export {
|
export {
|
||||||
ScriptEngine,
|
ScriptEngine,
|
||||||
getScriptEngine,
|
|
||||||
type ScriptData,
|
type ScriptData,
|
||||||
type ScriptKind,
|
type ScriptKind,
|
||||||
type CreateScriptInput,
|
type CreateScriptInput,
|
||||||
@@ -110,7 +104,6 @@ export {
|
|||||||
} from './ScriptEngine';
|
} from './ScriptEngine';
|
||||||
export {
|
export {
|
||||||
PublishEngine,
|
PublishEngine,
|
||||||
getPublishEngine,
|
|
||||||
type PublishCredentials,
|
type PublishCredentials,
|
||||||
type DirectoryUploadResult,
|
type DirectoryUploadResult,
|
||||||
} from './PublishEngine';
|
} from './PublishEngine';
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import { getPythonApiMethodContract } from '../shared/pythonApiContractV1';
|
import { getPythonApiMethodContract } from '../shared/pythonApiContractV1';
|
||||||
import type { PythonApiParamContractV1 } 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> {
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
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>;
|
type EngineGetter = () => Record<string, (...args: unknown[]) => unknown>;
|
||||||
|
|
||||||
export const ENGINE_MAP: Record<string, EngineGetter> = {
|
export const ENGINE_MAP: Record<string, EngineGetter> = {
|
||||||
posts: () => {
|
posts: () => requireBundle().postEngine as any,
|
||||||
const { getPostEngine } = require('../engine/PostEngine');
|
media: () => requireBundle().mediaEngine as any,
|
||||||
return getPostEngine();
|
projects: () => requireBundle().projectEngine as any,
|
||||||
},
|
meta: () => requireBundle().metaEngine as any,
|
||||||
media: () => {
|
tags: () => requireBundle().tagEngine as any,
|
||||||
const { getMediaEngine } = require('../engine/MediaEngine');
|
scripts: () => requireBundle().scriptEngine as any,
|
||||||
return getMediaEngine();
|
templates: () => requireBundle().templateEngine as any,
|
||||||
},
|
tasks: () => requireBundle().taskManager as any,
|
||||||
projects: () => {
|
sync: () => requireBundle().gitApiAdapter as any,
|
||||||
const { getProjectEngine } = require('../engine/ProjectEngine');
|
publish: () => requireBundle().publishApiAdapter as any,
|
||||||
return getProjectEngine();
|
app: () => requireBundle().appApiAdapter as any,
|
||||||
},
|
|
||||||
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();
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map API method names to engine method names where they differ
|
// Map API method names to engine method names where they differ
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import { dialog } from 'electron';
|
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 {
|
import {
|
||||||
getBlogGenerationEngine,
|
|
||||||
resolvePublicBaseUrl,
|
resolvePublicBaseUrl,
|
||||||
type BlogGenerationResult,
|
type BlogGenerationResult,
|
||||||
type BlogGenerationSection,
|
type BlogGenerationSection,
|
||||||
@@ -15,17 +7,18 @@ import {
|
|||||||
type SiteValidationReport,
|
type SiteValidationReport,
|
||||||
} from '../engine/BlogGenerationEngine';
|
} from '../engine/BlogGenerationEngine';
|
||||||
import { resolvePageTitle } from '../engine/PageRenderer';
|
import { resolvePageTitle } from '../engine/PageRenderer';
|
||||||
|
import type { EngineBundle } from '../engine/EngineBundle';
|
||||||
|
|
||||||
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
|
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 resolveBlogGenerationBaseOptions = async (): Promise<BlogGenerationOptions> => {
|
||||||
const projectEngine = getProjectEngine();
|
const projectEngine = bundle.projectEngine;
|
||||||
const postEngine = getPostEngine();
|
const postEngine = bundle.postEngine;
|
||||||
const metaEngine = getMetaEngine();
|
const metaEngine = bundle.metaEngine;
|
||||||
const mediaEngine = getMediaEngine();
|
const mediaEngine = bundle.mediaEngine;
|
||||||
const postMediaEngine = getPostMediaEngine();
|
const postMediaEngine = bundle.postMediaEngine;
|
||||||
const menuEngine = getMenuEngine();
|
const menuEngine = bundle.menuEngine;
|
||||||
|
|
||||||
const project = await projectEngine.getActiveProject();
|
const project = await projectEngine.getActiveProject();
|
||||||
if (!project) {
|
if (!project) {
|
||||||
@@ -76,7 +69,7 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
safeHandle('blog:generateSitemap', async () => {
|
safeHandle('blog:generateSitemap', async () => {
|
||||||
const blogGenerationEngine = getBlogGenerationEngine();
|
const blogGenerationEngine = bundle.blogGenerationEngine;
|
||||||
const baseOptions = await resolveBlogGenerationBaseOptions();
|
const baseOptions = await resolveBlogGenerationBaseOptions();
|
||||||
|
|
||||||
const taskTimestamp = Date.now();
|
const taskTimestamp = Date.now();
|
||||||
@@ -88,7 +81,7 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
|
|||||||
taskName: string,
|
taskName: string,
|
||||||
taskIdPrefix: string,
|
taskIdPrefix: string,
|
||||||
): Promise<BlogGenerationResult> => {
|
): Promise<BlogGenerationResult> => {
|
||||||
return taskManager.runTask({
|
return bundle.taskManager.runTask({
|
||||||
id: `${taskIdPrefix}-${taskTimestamp}`,
|
id: `${taskIdPrefix}-${taskTimestamp}`,
|
||||||
name: taskName,
|
name: taskName,
|
||||||
groupId: taskGroupId,
|
groupId: taskGroupId,
|
||||||
@@ -137,11 +130,11 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('blog:validateSite', async () => {
|
safeHandle('blog:validateSite', async () => {
|
||||||
const blogGenerationEngine = getBlogGenerationEngine();
|
const blogGenerationEngine = bundle.blogGenerationEngine;
|
||||||
const baseOptions = await resolveBlogGenerationBaseOptions();
|
const baseOptions = await resolveBlogGenerationBaseOptions();
|
||||||
|
|
||||||
const taskTimestamp = Date.now();
|
const taskTimestamp = Date.now();
|
||||||
return taskManager.runTask({
|
return bundle.taskManager.runTask({
|
||||||
id: `site-validate-${taskTimestamp}`,
|
id: `site-validate-${taskTimestamp}`,
|
||||||
name: 'Validate Site',
|
name: 'Validate Site',
|
||||||
execute: async (onProgress) => {
|
execute: async (onProgress) => {
|
||||||
@@ -153,11 +146,11 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('blog:regenerateCalendar', async () => {
|
safeHandle('blog:regenerateCalendar', async () => {
|
||||||
const blogGenerationEngine = getBlogGenerationEngine();
|
const blogGenerationEngine = bundle.blogGenerationEngine;
|
||||||
const baseOptions = await resolveBlogGenerationBaseOptions();
|
const baseOptions = await resolveBlogGenerationBaseOptions();
|
||||||
|
|
||||||
const taskTimestamp = Date.now();
|
const taskTimestamp = Date.now();
|
||||||
return taskManager.runTask({
|
return bundle.taskManager.runTask({
|
||||||
id: `site-calendar-regenerate-${taskTimestamp}`,
|
id: `site-calendar-regenerate-${taskTimestamp}`,
|
||||||
name: 'Regenerate Calendar',
|
name: 'Regenerate Calendar',
|
||||||
execute: async (onProgress) => {
|
execute: async (onProgress) => {
|
||||||
@@ -169,11 +162,11 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('blog:applyValidation', async (_event, report: SiteValidationReport) => {
|
safeHandle('blog:applyValidation', async (_event, report: SiteValidationReport) => {
|
||||||
const blogGenerationEngine = getBlogGenerationEngine();
|
const blogGenerationEngine = bundle.blogGenerationEngine;
|
||||||
const baseOptions = await resolveBlogGenerationBaseOptions();
|
const baseOptions = await resolveBlogGenerationBaseOptions();
|
||||||
|
|
||||||
const taskTimestamp = Date.now();
|
const taskTimestamp = Date.now();
|
||||||
return taskManager.runTask({
|
return bundle.taskManager.runTask({
|
||||||
id: `site-validate-apply-${taskTimestamp}`,
|
id: `site-validate-apply-${taskTimestamp}`,
|
||||||
name: 'Apply Site Validation',
|
name: 'Apply Site Validation',
|
||||||
execute: async (onProgress) => {
|
execute: async (onProgress) => {
|
||||||
|
|||||||
@@ -5,20 +5,21 @@
|
|||||||
import { ipcMain, BrowserWindow } from 'electron';
|
import { ipcMain, BrowserWindow } from 'electron';
|
||||||
import { ChatEngine } from '../engine/ChatEngine';
|
import { ChatEngine } from '../engine/ChatEngine';
|
||||||
import { OpenCodeManager } from '../engine/OpenCodeManager';
|
import { OpenCodeManager } from '../engine/OpenCodeManager';
|
||||||
import { getPostEngine } from '../engine/PostEngine';
|
|
||||||
import { getMediaEngine } from '../engine/MediaEngine';
|
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
|
import type { EngineBundle } from '../engine/EngineBundle';
|
||||||
|
|
||||||
let chatEngine: ChatEngine | null = null;
|
let chatEngine: ChatEngine | null = null;
|
||||||
let openCodeManager: OpenCodeManager | null = null;
|
let openCodeManager: OpenCodeManager | null = null;
|
||||||
let openCodeManagerInitPromise: Promise<void> | null = null;
|
let openCodeManagerInitPromise: Promise<void> | null = null;
|
||||||
let mainWindowGetter: (() => BrowserWindow | null) | null = null;
|
let mainWindowGetter: (() => BrowserWindow | null) | null = null;
|
||||||
|
let engineBundle: EngineBundle | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize chat handlers with the main window reference
|
* 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;
|
mainWindowGetter = getMainWindow;
|
||||||
|
engineBundle = bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,8 +41,9 @@ async function getOpenCodeManager(): Promise<OpenCodeManager> {
|
|||||||
if (!openCodeManager) {
|
if (!openCodeManager) {
|
||||||
openCodeManager = new OpenCodeManager(
|
openCodeManager = new OpenCodeManager(
|
||||||
getChatEngine(),
|
getChatEngine(),
|
||||||
getPostEngine(),
|
engineBundle!.postEngine,
|
||||||
getMediaEngine(),
|
engineBundle!.mediaEngine,
|
||||||
|
engineBundle!.postMediaEngine,
|
||||||
() => mainWindowGetter?.() || null
|
() => mainWindowGetter?.() || null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,11 @@
|
|||||||
import { getProjectEngine } from '../engine/ProjectEngine';
|
import type { EngineBundle } from '../engine/EngineBundle';
|
||||||
import { taskManager } from '../engine/TaskManager';
|
|
||||||
|
|
||||||
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
|
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 () => {
|
safeHandle('metadataDiff:getStats', async () => {
|
||||||
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
|
const engine = bundle.metadataDiffEngine;
|
||||||
const engine = getMetadataDiffEngine();
|
const projectEngine = bundle.projectEngine;
|
||||||
const projectEngine = getProjectEngine();
|
|
||||||
const activeProject = await projectEngine.getActiveProject();
|
const activeProject = await projectEngine.getActiveProject();
|
||||||
if (activeProject) {
|
if (activeProject) {
|
||||||
engine.setProjectContext(activeProject.id);
|
engine.setProjectContext(activeProject.id);
|
||||||
@@ -16,16 +14,15 @@ export function registerMetadataDiffHandlers(safeHandle: SafeHandle): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('metadataDiff:scan', async () => {
|
safeHandle('metadataDiff:scan', async () => {
|
||||||
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
|
const engine = bundle.metadataDiffEngine;
|
||||||
const engine = getMetadataDiffEngine();
|
const projectEngine = bundle.projectEngine;
|
||||||
const projectEngine = getProjectEngine();
|
|
||||||
const activeProject = await projectEngine.getActiveProject();
|
const activeProject = await projectEngine.getActiveProject();
|
||||||
if (activeProject) {
|
if (activeProject) {
|
||||||
engine.setProjectContext(activeProject.id);
|
engine.setProjectContext(activeProject.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskId = `metadata-diff-scan-${Date.now()}`;
|
const taskId = `metadata-diff-scan-${Date.now()}`;
|
||||||
return taskManager.runTask({
|
return bundle.taskManager.runTask({
|
||||||
id: taskId,
|
id: taskId,
|
||||||
name: 'Scanning for metadata differences',
|
name: 'Scanning for metadata differences',
|
||||||
execute: async (onProgress) => {
|
execute: async (onProgress) => {
|
||||||
@@ -38,9 +35,8 @@ export function registerMetadataDiffHandlers(safeHandle: SafeHandle): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('metadataDiff:syncDbToFile', async (_, postIds: string[], groupLabel: string) => {
|
safeHandle('metadataDiff:syncDbToFile', async (_, postIds: string[], groupLabel: string) => {
|
||||||
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
|
const engine = bundle.metadataDiffEngine;
|
||||||
const engine = getMetadataDiffEngine();
|
const projectEngine = bundle.projectEngine;
|
||||||
const projectEngine = getProjectEngine();
|
|
||||||
const activeProject = await projectEngine.getActiveProject();
|
const activeProject = await projectEngine.getActiveProject();
|
||||||
if (activeProject) {
|
if (activeProject) {
|
||||||
engine.setProjectContext(activeProject.id);
|
engine.setProjectContext(activeProject.id);
|
||||||
@@ -49,9 +45,8 @@ export function registerMetadataDiffHandlers(safeHandle: SafeHandle): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('metadataDiff:syncFileToDb', async (_, postIds: string[], field: string, groupLabel: string) => {
|
safeHandle('metadataDiff:syncFileToDb', async (_, postIds: string[], field: string, groupLabel: string) => {
|
||||||
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
|
const engine = bundle.metadataDiffEngine;
|
||||||
const engine = getMetadataDiffEngine();
|
const projectEngine = bundle.projectEngine;
|
||||||
const projectEngine = getProjectEngine();
|
|
||||||
const activeProject = await projectEngine.getActiveProject();
|
const activeProject = await projectEngine.getActiveProject();
|
||||||
if (activeProject) {
|
if (activeProject) {
|
||||||
engine.setProjectContext(activeProject.id);
|
engine.setProjectContext(activeProject.id);
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { getProjectEngine } from '../engine/ProjectEngine';
|
import type { PublishCredentials } from '../engine/PublishEngine';
|
||||||
import { getPublishEngine, type PublishCredentials } from '../engine/PublishEngine';
|
import type { EngineBundle } from '../engine/EngineBundle';
|
||||||
import { taskManager } from '../engine/TaskManager';
|
|
||||||
|
|
||||||
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
|
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) => {
|
safeHandle('publish:uploadSite', async (_event: unknown, credentials: PublishCredentials) => {
|
||||||
const projectEngine = getProjectEngine();
|
const projectEngine = bundle.projectEngine;
|
||||||
const project = await projectEngine.getActiveProject();
|
const project = await projectEngine.getActiveProject();
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error('No active project');
|
throw new Error('No active project');
|
||||||
}
|
}
|
||||||
|
|
||||||
const publishEngine = getPublishEngine();
|
const publishEngine = bundle.publishEngine;
|
||||||
publishEngine.setProjectContext(project.id, project.dataPath!);
|
publishEngine.setProjectContext(project.id, project.dataPath!);
|
||||||
|
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
@@ -20,7 +19,7 @@ export function registerPublishHandlers(safeHandle: SafeHandle): void {
|
|||||||
const groupName = 'Site Publishing';
|
const groupName = 'Site Publishing';
|
||||||
|
|
||||||
// Launch three parallel tasks, one per directory
|
// Launch three parallel tasks, one per directory
|
||||||
const htmlTask = taskManager.runTask({
|
const htmlTask = bundle.taskManager.runTask({
|
||||||
id: `publish-html-${ts}`,
|
id: `publish-html-${ts}`,
|
||||||
name: 'Upload HTML',
|
name: 'Upload HTML',
|
||||||
groupId,
|
groupId,
|
||||||
@@ -28,7 +27,7 @@ export function registerPublishHandlers(safeHandle: SafeHandle): void {
|
|||||||
execute: (onProgress) => publishEngine.uploadHtml(credentials, onProgress),
|
execute: (onProgress) => publishEngine.uploadHtml(credentials, onProgress),
|
||||||
});
|
});
|
||||||
|
|
||||||
const thumbsTask = taskManager.runTask({
|
const thumbsTask = bundle.taskManager.runTask({
|
||||||
id: `publish-thumbnails-${ts}`,
|
id: `publish-thumbnails-${ts}`,
|
||||||
name: 'Upload Thumbnails',
|
name: 'Upload Thumbnails',
|
||||||
groupId,
|
groupId,
|
||||||
@@ -36,7 +35,7 @@ export function registerPublishHandlers(safeHandle: SafeHandle): void {
|
|||||||
execute: (onProgress) => publishEngine.uploadThumbnails(credentials, onProgress),
|
execute: (onProgress) => publishEngine.uploadThumbnails(credentials, onProgress),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mediaTask = taskManager.runTask({
|
const mediaTask = bundle.taskManager.runTask({
|
||||||
id: `publish-media-${ts}`,
|
id: `publish-media-${ts}`,
|
||||||
name: 'Upload Media',
|
name: 'Upload Media',
|
||||||
groupId,
|
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 { registerIpcHandlers, registerEventForwarding, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc';
|
||||||
import { media } from './database/schema';
|
import { media } from './database/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { getMediaEngine } from './engine/MediaEngine';
|
import { MediaEngine } from './engine/MediaEngine';
|
||||||
import { getPostEngine } from './engine/PostEngine';
|
import { PostEngine } from './engine/PostEngine';
|
||||||
import { getMetaEngine } from './engine/MetaEngine';
|
import { MetaEngine } from './engine/MetaEngine';
|
||||||
import { getTemplateEngine } from './engine/TemplateEngine';
|
import { MenuEngine } from './engine/MenuEngine';
|
||||||
import { getScriptEngine } from './engine/ScriptEngine';
|
import { TemplateEngine } from './engine/TemplateEngine';
|
||||||
import { getPostMediaEngine } from './engine/PostMediaEngine';
|
import { ScriptEngine } from './engine/ScriptEngine';
|
||||||
import { getTagEngine } from './engine/TagEngine';
|
import { PostMediaEngine } from './engine/PostMediaEngine';
|
||||||
import { getBlogmarkTransformService } from './engine/BlogmarkTransformService';
|
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 { 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 { 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 { resolveUiLanguageFromSystemLocale, translateMenu } from './shared/i18n';
|
||||||
import { buildBlogmarkMarkdownLink, extractBlogmarkPayloadFromDeepLink, normalizeBlogmarkCategory } from './shared/blogmark';
|
import { buildBlogmarkMarkdownLink, extractBlogmarkPayloadFromDeepLink, normalizeBlogmarkCategory } from './shared/blogmark';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let previewServer: PreviewServer | null = null;
|
let previewServer: PreviewServer | null = null;
|
||||||
|
let notificationWatcher: NotificationWatcher | null = null;
|
||||||
let activePreviewPostId: string | null = null;
|
let activePreviewPostId: string | null = null;
|
||||||
let appInitialized = false;
|
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 blogmarkQueue: string[] = [];
|
||||||
let blogmarkQueueProcessing = false;
|
let blogmarkQueueProcessing = false;
|
||||||
let pendingBlogmarkCreatedEvents: unknown[] = [];
|
let pendingBlogmarkCreatedEvents: unknown[] = [];
|
||||||
@@ -310,7 +345,7 @@ function createWindow(): void {
|
|||||||
|
|
||||||
async function openPreviewInBrowser(): Promise<void> {
|
async function openPreviewInBrowser(): Promise<void> {
|
||||||
if (!previewServer) {
|
if (!previewServer) {
|
||||||
previewServer = new PreviewServer();
|
previewServer = new PreviewServer(buildPreviewServerDeps());
|
||||||
}
|
}
|
||||||
|
|
||||||
await previewServer.start(PREVIEW_SERVER_PORT);
|
await previewServer.start(PREVIEW_SERVER_PORT);
|
||||||
@@ -337,7 +372,7 @@ async function openActivePostPreviewInBrowser(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const postEngine = getPostEngine();
|
const postEngine = bundle!.postEngine;
|
||||||
const post = await postEngine.getPost(activePreviewPostId);
|
const post = await postEngine.getPost(activePreviewPostId);
|
||||||
if (!post) {
|
if (!post) {
|
||||||
setPreviewPostMenuEnabled(false);
|
setPreviewPostMenuEnabled(false);
|
||||||
@@ -345,7 +380,7 @@ async function openActivePostPreviewInBrowser(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!previewServer) {
|
if (!previewServer) {
|
||||||
previewServer = new PreviewServer();
|
previewServer = new PreviewServer(buildPreviewServerDeps());
|
||||||
}
|
}
|
||||||
|
|
||||||
await previewServer.start(PREVIEW_SERVER_PORT);
|
await previewServer.start(PREVIEW_SERVER_PORT);
|
||||||
@@ -355,7 +390,7 @@ async function openActivePostPreviewInBrowser(): Promise<void> {
|
|||||||
|
|
||||||
async function startPreviewServerOnAppStart(): Promise<void> {
|
async function startPreviewServerOnAppStart(): Promise<void> {
|
||||||
if (!previewServer) {
|
if (!previewServer) {
|
||||||
previewServer = new PreviewServer();
|
previewServer = new PreviewServer(buildPreviewServerDeps());
|
||||||
}
|
}
|
||||||
|
|
||||||
await previewServer.start(PREVIEW_SERVER_PORT);
|
await previewServer.start(PREVIEW_SERVER_PORT);
|
||||||
@@ -391,10 +426,10 @@ async function processBlogmarkDeepLink(rawDeepLink: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = await getMetaEngine().getProjectMetadata();
|
const metadata = await bundle!.metaEngine.getProjectMetadata();
|
||||||
const preferredCategory = normalizeBlogmarkCategory((metadata as { blogmarkCategory?: unknown } | null)?.blogmarkCategory);
|
const preferredCategory = normalizeBlogmarkCategory((metadata as { blogmarkCategory?: unknown } | null)?.blogmarkCategory);
|
||||||
|
|
||||||
const transformService = getBlogmarkTransformService();
|
const transformService = bundle!.blogmarkTransformService;
|
||||||
const transformResult = await transformService.applyTransforms({
|
const transformResult = await transformService.applyTransforms({
|
||||||
post: {
|
post: {
|
||||||
title: payload.title,
|
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,
|
title: transformResult.post.title,
|
||||||
content: transformResult.post.content,
|
content: transformResult.post.content,
|
||||||
tags: transformResult.post.tags,
|
tags: transformResult.post.tags,
|
||||||
@@ -475,8 +510,7 @@ function registerBlogmarkProtocolClient(): void {
|
|||||||
|
|
||||||
async function initializeActiveProjectContext(): Promise<void> {
|
async function initializeActiveProjectContext(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { getProjectEngine } = await import('./engine/ProjectEngine');
|
const projectEngine = bundle!.projectEngine;
|
||||||
const projectEngine = getProjectEngine();
|
|
||||||
const project = await projectEngine.getActiveProject();
|
const project = await projectEngine.getActiveProject();
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
@@ -484,15 +518,15 @@ async function initializeActiveProjectContext(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
||||||
const postEngine = getPostEngine() as {
|
const postEngine = bundle!.postEngine as {
|
||||||
setProjectContext?: (projectId: string, dataDir?: string) => void;
|
setProjectContext?: (projectId: string, dataDir?: string) => void;
|
||||||
setSearchLanguage?: (language: string) => void;
|
setSearchLanguage?: (language: string) => void;
|
||||||
};
|
};
|
||||||
const mediaEngine = getMediaEngine() as {
|
const mediaEngine = bundle!.mediaEngine as {
|
||||||
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
|
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
|
||||||
setSearchLanguage?: (language: string) => void;
|
setSearchLanguage?: (language: string) => void;
|
||||||
};
|
};
|
||||||
const metaEngine = getMetaEngine() as {
|
const metaEngine = bundle!.metaEngine as {
|
||||||
setProjectContext?: (projectId: string, dataDir?: string) => void;
|
setProjectContext?: (projectId: string, dataDir?: string) => void;
|
||||||
syncOnStartup?: () => Promise<void>;
|
syncOnStartup?: () => Promise<void>;
|
||||||
getProjectMetadata?: () => Promise<{ mainLanguage?: string } | null>;
|
getProjectMetadata?: () => Promise<{ mainLanguage?: string } | null>;
|
||||||
@@ -502,7 +536,7 @@ async function initializeActiveProjectContext(): Promise<void> {
|
|||||||
mediaEngine.setProjectContext?.(project.id, dataDir, dataDir);
|
mediaEngine.setProjectContext?.(project.id, dataDir, dataDir);
|
||||||
metaEngine.setProjectContext?.(project.id, dataDir);
|
metaEngine.setProjectContext?.(project.id, dataDir);
|
||||||
|
|
||||||
const templateEngine = getTemplateEngine() as {
|
const templateEngine = bundle!.templateEngine as {
|
||||||
setProjectContext?: (projectId: string, dataDir?: string) => void;
|
setProjectContext?: (projectId: string, dataDir?: string) => void;
|
||||||
};
|
};
|
||||||
templateEngine.setProjectContext?.(project.id, dataDir);
|
templateEngine.setProjectContext?.(project.id, dataDir);
|
||||||
@@ -546,8 +580,8 @@ function createApplicationMenu(): Menu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'openDataFolder') {
|
if (action === 'openDataFolder') {
|
||||||
const paths = getDatabase().getDataPaths();
|
const dbPath = getDatabase().getDbPath();
|
||||||
void shell.openPath(path.dirname(paths.database));
|
void shell.openPath(path.dirname(dbPath));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,7 +751,7 @@ async function initialize(): Promise<void> {
|
|||||||
// Register IPC handlers immediately (synchronous) so they are available
|
// Register IPC handlers immediately (synchronous) so they are available
|
||||||
// before any async work. This eliminates race conditions where the renderer
|
// before any async work. This eliminates race conditions where the renderer
|
||||||
// calls handlers before the database is ready.
|
// calls handlers before the database is ready.
|
||||||
registerIpcHandlers();
|
registerIpcHandlers(bundle!);
|
||||||
|
|
||||||
// Initialize database
|
// Initialize database
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
@@ -725,7 +759,7 @@ async function initialize(): Promise<void> {
|
|||||||
|
|
||||||
// Now that the database is ready, register event forwarding from engines
|
// Now that the database is ready, register event forwarding from engines
|
||||||
// to the renderer (engines need DB access at registration time).
|
// to the renderer (engines need DB access at registration time).
|
||||||
registerEventForwarding();
|
registerEventForwarding(bundle!);
|
||||||
|
|
||||||
// Register custom protocol for serving media files
|
// Register custom protocol for serving media files
|
||||||
// URLs like bds-media://media-id will be resolved to the actual file
|
// 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 url = new URL(request.url);
|
||||||
const mediaId = url.hostname;
|
const mediaId = url.hostname;
|
||||||
|
|
||||||
const engine = getMediaEngine();
|
const engine = bundle!.mediaEngine;
|
||||||
const thumbnails = await engine.getThumbnailPaths(mediaId);
|
const thumbnails = await engine.getThumbnailPaths(mediaId);
|
||||||
|
|
||||||
if (thumbnails.small) {
|
if (thumbnails.small) {
|
||||||
@@ -837,7 +871,7 @@ async function initialize(): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Initialize and register chat handlers
|
// Initialize and register chat handlers
|
||||||
initializeChatHandlers(() => mainWindow);
|
initializeChatHandlers(() => mainWindow, bundle!);
|
||||||
registerChatHandlers();
|
registerChatHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -867,6 +901,61 @@ app.on('open-url', (event, deepLink) => {
|
|||||||
|
|
||||||
// App lifecycle
|
// App lifecycle
|
||||||
app.whenReady().then(async () => {
|
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();
|
await initialize();
|
||||||
const activeProjectContextReady = initializeActiveProjectContext();
|
const activeProjectContextReady = initializeActiveProjectContext();
|
||||||
registerBlogmarkProtocolClient();
|
registerBlogmarkProtocolClient();
|
||||||
@@ -876,21 +965,30 @@ app.whenReady().then(async () => {
|
|||||||
console.error('Failed to start preview server on app startup:', error);
|
console.error('Failed to start preview server on app startup:', error);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const mcpServer = getMCPServer({
|
|
||||||
getPostEngine: () => getPostEngine(),
|
|
||||||
getMediaEngine: () => getMediaEngine(),
|
|
||||||
getScriptEngine: () => getScriptEngine(),
|
|
||||||
getTemplateEngine: () => getTemplateEngine(),
|
|
||||||
getMetaEngine: () => getMetaEngine(),
|
|
||||||
getPostMediaEngine: () => getPostMediaEngine(),
|
|
||||||
getTagEngine: () => getTagEngine(),
|
|
||||||
});
|
|
||||||
await mcpServer.start(MCP_SERVER_PORT);
|
await mcpServer.start(MCP_SERVER_PORT);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start MCP server on app startup:', error);
|
console.error('Failed to start MCP server on app startup:', error);
|
||||||
}
|
}
|
||||||
createWindow();
|
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;
|
await activeProjectContextReady;
|
||||||
appInitialized = true;
|
appInitialized = true;
|
||||||
|
|
||||||
@@ -914,6 +1012,10 @@ app.on('window-all-closed', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.on('before-quit', async () => {
|
app.on('before-quit', async () => {
|
||||||
|
// Stop the notification watcher first to avoid processing events during shutdown.
|
||||||
|
notificationWatcher?.stop();
|
||||||
|
notificationWatcher = null;
|
||||||
|
|
||||||
// Cleanup chat resources
|
// Cleanup chat resources
|
||||||
await cleanupChatHandlers();
|
await cleanupChatHandlers();
|
||||||
|
|
||||||
@@ -923,8 +1025,7 @@ app.on('before-quit', async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mcpServer = getMCPServer();
|
await bundle?.mcpServer.cleanup();
|
||||||
await mcpServer.cleanup();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to cleanup MCP server:', error);
|
console.error('Failed to cleanup MCP server:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,6 +383,12 @@ export const electronAPI: ElectronAPI = {
|
|||||||
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
|
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: {
|
mcp: {
|
||||||
getAgents: () => ipcRenderer.invoke('mcp:getAgents'),
|
getAgents: () => ipcRenderer.invoke('mcp:getAgents'),
|
||||||
addToAgentConfig: (agentId: string) => ipcRenderer.invoke('mcp:addToAgentConfig', agentId),
|
addToAgentConfig: (agentId: string) => ipcRenderer.invoke('mcp:addToAgentConfig', agentId),
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
// Type definitions for the Electron API exposed via preload
|
// 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 {
|
export interface ImportExecuteResult {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
@@ -836,6 +843,8 @@ export interface ElectronAPI {
|
|||||||
};
|
};
|
||||||
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
|
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
|
||||||
once: (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: {
|
mcp: {
|
||||||
getAgents: () => Promise<Array<{ id: string; label: string }>>;
|
getAgents: () => Promise<Array<{ id: string; label: string }>>;
|
||||||
addToAgentConfig: (agentId: string) => Promise<{ success: boolean; configPath: string; error?: 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();
|
const { sidebarVisible, assistantSidebarVisible } = useAppStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const { mockProjectEngine, mockDatabase, mockReadFile } = vi.hoisted(() => ({
|
|||||||
getDefaultProjectBaseDir: vi.fn().mockResolvedValue('/home/user/bDS/p1'),
|
getDefaultProjectBaseDir: vi.fn().mockResolvedValue('/home/user/bDS/p1'),
|
||||||
},
|
},
|
||||||
mockDatabase: {
|
mockDatabase: {
|
||||||
getDataPaths: vi.fn().mockReturnValue({ database: '/data/bds.db' }),
|
getDbPath: vi.fn(() => '/data/bds.db'),
|
||||||
},
|
},
|
||||||
mockReadFile: vi.fn(),
|
mockReadFile: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -38,8 +38,7 @@ describe('AppApiAdapter', () => {
|
|||||||
mockProjectEngine.getActiveProject.mockResolvedValue({ id: 'p1', dataPath: '/projects/blog' });
|
mockProjectEngine.getActiveProject.mockResolvedValue({ id: 'p1', dataPath: '/projects/blog' });
|
||||||
mockProjectEngine.getProjectPaths.mockReturnValue({ posts: '/projects/blog/posts', media: '/projects/blog/media' });
|
mockProjectEngine.getProjectPaths.mockReturnValue({ posts: '/projects/blog/posts', media: '/projects/blog/media' });
|
||||||
mockProjectEngine.getDefaultProjectBaseDir.mockResolvedValue('/home/user/bDS/p1');
|
mockProjectEngine.getDefaultProjectBaseDir.mockResolvedValue('/home/user/bDS/p1');
|
||||||
mockDatabase.getDataPaths.mockReturnValue({ database: '/data/bds.db' });
|
adapter = new AppApiAdapter(mockProjectEngine as any);
|
||||||
adapter = new AppApiAdapter();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getDataPaths returns database, posts, and media paths', async () => {
|
it('getDataPaths returns database, posts, and media paths', async () => {
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
let mockPostEngine: any;
|
let mockPostEngine: any;
|
||||||
let mockMediaEngine: any;
|
let mockMediaEngine: any;
|
||||||
|
let mockPostMediaEngine: any;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -165,6 +166,8 @@ describe('BlogGenerationEngine', () => {
|
|||||||
mockPostEngine = __mockPostEngine;
|
mockPostEngine = __mockPostEngine;
|
||||||
const { __mockMediaEngine } = await import('../../src/main/engine/MediaEngine') as any;
|
const { __mockMediaEngine } = await import('../../src/main/engine/MediaEngine') as any;
|
||||||
mockMediaEngine = __mockMediaEngine;
|
mockMediaEngine = __mockMediaEngine;
|
||||||
|
const { __mockPostMediaEngine } = await import('../../src/main/engine/PostMediaEngine') as any;
|
||||||
|
mockPostMediaEngine = __mockPostMediaEngine;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -210,7 +213,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
) {
|
) {
|
||||||
setupPosts(posts);
|
setupPosts(posts);
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
const onProgress = vi.fn();
|
const onProgress = vi.fn();
|
||||||
return engine.generate({
|
return engine.generate({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
@@ -726,7 +729,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/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({
|
const result = await engine.generate({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
projectName: 'Test Blog',
|
projectName: 'Test Blog',
|
||||||
@@ -759,7 +762,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
setupPosts(posts);
|
setupPosts(posts);
|
||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
await engine.generate({
|
await engine.generate({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
projectName: 'Test Blog',
|
projectName: 'Test Blog',
|
||||||
@@ -789,7 +792,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
setupPosts(posts);
|
setupPosts(posts);
|
||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
|
|
||||||
await engine.generate({
|
await engine.generate({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
@@ -816,7 +819,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
setupPosts(posts);
|
setupPosts(posts);
|
||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
const onProgress = vi.fn();
|
const onProgress = vi.fn();
|
||||||
|
|
||||||
await engine.generate({
|
await engine.generate({
|
||||||
@@ -848,7 +851,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
setupPosts(posts);
|
setupPosts(posts);
|
||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
const onProgress = vi.fn();
|
const onProgress = vi.fn();
|
||||||
|
|
||||||
await engine.generate({
|
await engine.generate({
|
||||||
@@ -878,7 +881,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
const canonicalPathSpy = vi.spyOn(pageRendererModule, 'buildCanonicalPostPath');
|
const canonicalPathSpy = vi.spyOn(pageRendererModule, 'buildCanonicalPostPath');
|
||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
|
|
||||||
await engine.generate({
|
await engine.generate({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
@@ -902,7 +905,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
setupPosts(posts);
|
setupPosts(posts);
|
||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
|
|
||||||
await engine.generate({
|
await engine.generate({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
@@ -933,7 +936,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
|
|
||||||
const filterSpy = vi.spyOn(Array.prototype, 'filter');
|
const filterSpy = vi.spyOn(Array.prototype, 'filter');
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
|
|
||||||
await engine.generate({
|
await engine.generate({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
@@ -975,7 +978,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
await writeFile(path.join(tempDir, 'html', 'stale', 'index.html'), '<html>stale</html>', 'utf-8');
|
await writeFile(path.join(tempDir, 'html', 'stale', 'index.html'), '<html>stale</html>', 'utf-8');
|
||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
|
|
||||||
const report = await engine.validateSite({
|
const report = await engine.validateSite({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
@@ -1006,7 +1009,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
setupPosts([post]);
|
setupPosts([post]);
|
||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
|
|
||||||
await engine.generate({
|
await engine.generate({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
@@ -1049,7 +1052,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
setupPosts([post]);
|
setupPosts([post]);
|
||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
|
|
||||||
await engine.generate({
|
await engine.generate({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
@@ -1112,7 +1115,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
await writeFile(path.join(tempDir, 'html', 'obsolete', 'deep', 'index.html'), '<html>obsolete</html>', 'utf-8');
|
await writeFile(path.join(tempDir, 'html', 'obsolete', 'deep', 'index.html'), '<html>obsolete</html>', 'utf-8');
|
||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
|
|
||||||
const report = await engine.validateSite({
|
const report = await engine.validateSite({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
@@ -1158,7 +1161,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
setupPosts(posts);
|
setupPosts(posts);
|
||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
|
|
||||||
await engine.generate({
|
await engine.generate({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
@@ -1237,7 +1240,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
await writeFile(path.join(tempDir, 'html', 'stale', 'index.html'), '<html>stale</html>', 'utf-8');
|
await writeFile(path.join(tempDir, 'html', 'stale', 'index.html'), '<html>stale</html>', 'utf-8');
|
||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
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');
|
const generateSpy = vi.spyOn(engine, 'generate');
|
||||||
|
|
||||||
@@ -1273,7 +1276,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
setupPosts(posts);
|
setupPosts(posts);
|
||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
|
|
||||||
await engine.applyValidation({
|
await engine.applyValidation({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
@@ -1303,7 +1306,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
setupPosts(posts);
|
setupPosts(posts);
|
||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
|
|
||||||
await engine.applyValidation({
|
await engine.applyValidation({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
@@ -1335,7 +1338,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
setupPosts(posts);
|
setupPosts(posts);
|
||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
|
|
||||||
await engine.applyValidation({
|
await engine.applyValidation({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
@@ -1371,7 +1374,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
await writeFile(path.join(tempDir, 'html', 'index.html'), '<html><body>stale-root</body></html>', 'utf-8');
|
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 { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
|
|
||||||
await engine.applyValidation({
|
await engine.applyValidation({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
@@ -1401,7 +1404,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const { PageRenderer } = await import('../../src/main/engine/PageRenderer');
|
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');
|
const renderPostListSpy = vi.spyOn(PageRenderer.prototype, 'renderPostList');
|
||||||
|
|
||||||
@@ -1454,7 +1457,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
setupPosts(posts);
|
setupPosts(posts);
|
||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||||
|
|
||||||
await engine.applyValidation({
|
await engine.applyValidation({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
@@ -1497,7 +1500,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
|
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const { PageRenderer } = await import('../../src/main/engine/PageRenderer');
|
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');
|
const renderPostListSpy = vi.spyOn(PageRenderer.prototype, 'renderPostList');
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe('GitApiAdapter', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
adapter = new GitApiAdapter();
|
adapter = new GitApiAdapter(mockGitEngine as any, mockProjectEngine as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('checkAvailability delegates directly (no projectPath)', async () => {
|
it('checkAvailability delegates directly (no projectPath)', async () => {
|
||||||
|
|||||||
@@ -233,7 +233,12 @@ describe('ImportExecutionEngine E2E Tests', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
// Create engine instance
|
// 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');
|
engine.setProjectContext('test-project', '/mock/test/data');
|
||||||
|
|
||||||
// Parse the WXR content (mocked readFile will return our pre-loaded content)
|
// Parse the WXR content (mocked readFile will return our pre-loaded content)
|
||||||
|
|||||||
@@ -108,6 +108,17 @@ const mockMediaEngine = {
|
|||||||
updateMedia: vi.fn().mockResolvedValue({}),
|
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', () => ({
|
vi.mock('../../src/main/engine/MediaEngine', () => ({
|
||||||
getMediaEngine: vi.fn(() => mockMediaEngine),
|
getMediaEngine: vi.fn(() => mockMediaEngine),
|
||||||
}));
|
}));
|
||||||
@@ -275,7 +286,12 @@ describe('ImportExecutionEngine', () => {
|
|||||||
insertedPosts.length = 0;
|
insertedPosts.length = 0;
|
||||||
insertedMedia.length = 0;
|
insertedMedia.length = 0;
|
||||||
updatedPosts.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');
|
engine.setProjectContext('test-project', '/mock/project/data');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ describe('MCPAgentConfigEngine', () => {
|
|||||||
let engine: MCPAgentConfigEngine;
|
let engine: MCPAgentConfigEngine;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.resetAllMocks();
|
||||||
engine = new MCPAgentConfigEngine({
|
engine = new MCPAgentConfigEngine({
|
||||||
homeDir: '/home/testuser',
|
homeDir: '/home/testuser',
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
@@ -40,9 +40,10 @@ describe('MCPAgentConfigEngine', () => {
|
|||||||
describe('getAgents', () => {
|
describe('getAgents', () => {
|
||||||
it('returns all supported agent definitions', () => {
|
it('returns all supported agent definitions', () => {
|
||||||
const agents = engine.getAgents();
|
const agents = engine.getAgents();
|
||||||
expect(agents).toHaveLength(4);
|
expect(agents).toHaveLength(5);
|
||||||
const ids = agents.map((a) => a.id);
|
const ids = agents.map((a) => a.id);
|
||||||
expect(ids).toContain('claude-code');
|
expect(ids).toContain('claude-code');
|
||||||
|
expect(ids).toContain('claude-desktop');
|
||||||
expect(ids).toContain('github-copilot');
|
expect(ids).toContain('github-copilot');
|
||||||
expect(ids).toContain('gemini-cli');
|
expect(ids).toContain('gemini-cli');
|
||||||
expect(ids).toContain('opencode');
|
expect(ids).toContain('opencode');
|
||||||
@@ -336,4 +337,160 @@ describe('MCPAgentConfigEngine', () => {
|
|||||||
expect(written.mcpServers.bDS.url).toBe('http://127.0.0.1:9999/mcp');
|
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() {
|
function createMockScriptEngine() {
|
||||||
return {
|
return {
|
||||||
createScript: vi.fn().mockResolvedValue({
|
createDraftScript: vi.fn().mockResolvedValue({
|
||||||
id: 'script-1', title: 'Test', slug: 'test', kind: 'macro',
|
id: 'script-1', title: 'Test', slug: 'test', kind: 'macro',
|
||||||
entrypoint: 'main.py', content: '', enabled: true, version: 1,
|
entrypoint: 'main.py', content: '', enabled: true, version: 1,
|
||||||
filePath: '/test', createdAt: new Date(), updatedAt: new Date(),
|
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: [] }),
|
validateScript: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMockTemplateEngine() {
|
function createMockTemplateEngine() {
|
||||||
return {
|
return {
|
||||||
createTemplate: vi.fn().mockResolvedValue({
|
createDraftTemplate: vi.fn().mockResolvedValue({
|
||||||
id: 'tpl-1', title: 'Test', slug: 'test', kind: 'post',
|
id: 'tpl-1', title: 'Test', slug: 'test', kind: 'post',
|
||||||
enabled: true, version: 1, filePath: '/test', content: '',
|
enabled: true, version: 1, filePath: '/test', content: '',
|
||||||
createdAt: new Date(), updatedAt: new Date(),
|
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: [] }),
|
validateTemplate: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -110,13 +122,13 @@ function createDependencies() {
|
|||||||
const mockTagEngine = createMockTagEngine();
|
const mockTagEngine = createMockTagEngine();
|
||||||
|
|
||||||
const deps: MCPServerDependencies = {
|
const deps: MCPServerDependencies = {
|
||||||
getPostEngine: () => mockPostEngine,
|
postEngine: mockPostEngine,
|
||||||
getMediaEngine: () => mockMediaEngine,
|
mediaEngine: mockMediaEngine,
|
||||||
getScriptEngine: () => mockScriptEngine,
|
scriptEngine: mockScriptEngine,
|
||||||
getTemplateEngine: () => mockTemplateEngine,
|
templateEngine: mockTemplateEngine,
|
||||||
getMetaEngine: () => mockMetaEngine,
|
metaEngine: mockMetaEngine,
|
||||||
getPostMediaEngine: () => mockPostMediaEngine,
|
postMediaEngine: mockPostMediaEngine,
|
||||||
getTagEngine: () => mockTagEngine,
|
tagEngine: mockTagEngine,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { deps, mockPostEngine, mockMediaEngine, mockScriptEngine, mockTemplateEngine, mockMetaEngine, mockPostMediaEngine, 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 () => {
|
it('accepts a proposeScript proposal by creating script', async () => {
|
||||||
const proposalId = server.proposalStore.create('proposeScript', {
|
const proposalId = server.proposalStore.create('proposeScript', {
|
||||||
title: 'My Script', kind: 'macro', content: 'print("hello")',
|
scriptId: 'script-1',
|
||||||
});
|
});
|
||||||
const result = await server.acceptProposal(proposalId);
|
const result = await server.acceptProposal(proposalId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(mockScriptEngine.createScript).toHaveBeenCalledWith({
|
expect(mockScriptEngine.publishScript).toHaveBeenCalledWith('script-1');
|
||||||
title: 'My Script', kind: 'macro', content: 'print("hello")',
|
|
||||||
});
|
|
||||||
expect(server.proposalStore.get(proposalId)).toBeUndefined();
|
expect(server.proposalStore.get(proposalId)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts a proposeTemplate proposal by creating template', async () => {
|
it('accepts a proposeTemplate proposal by creating template', async () => {
|
||||||
const proposalId = server.proposalStore.create('proposeTemplate', {
|
const proposalId = server.proposalStore.create('proposeTemplate', {
|
||||||
title: 'My Template', kind: 'post', content: '<h1>{{ title }}</h1>',
|
templateId: 'tpl-1',
|
||||||
});
|
});
|
||||||
const result = await server.acceptProposal(proposalId);
|
const result = await server.acceptProposal(proposalId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(mockTemplateEngine.createTemplate).toHaveBeenCalledWith({
|
expect(mockTemplateEngine.publishTemplate).toHaveBeenCalledWith('tpl-1');
|
||||||
title: 'My Template', kind: 'post', content: '<h1>{{ title }}</h1>',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts a proposeMediaMetadata proposal by updating media', async () => {
|
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 () => {
|
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);
|
const result = await server.discardProposal(proposalId);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockScriptEngine.deleteDraftScript).toHaveBeenCalledWith('script-1');
|
||||||
expect(server.proposalStore.get(proposalId)).toBeUndefined();
|
expect(server.proposalStore.get(proposalId)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -830,7 +839,10 @@ describe('MCPServer', () => {
|
|||||||
const proposal = server.proposalStore.get(parsed.proposalId);
|
const proposal = server.proposalStore.get(parsed.proposalId);
|
||||||
expect(proposal).toBeDefined();
|
expect(proposal).toBeDefined();
|
||||||
expect(proposal!.type).toBe('proposeScript');
|
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 () => {
|
it('propose_script calls validateScript and includes validation result in preview', async () => {
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ describe('MetadataDiffEngine', () => {
|
|||||||
mockAllPostsRows = [];
|
mockAllPostsRows = [];
|
||||||
mockSyncPublishedPostFile.mockClear();
|
mockSyncPublishedPostFile.mockClear();
|
||||||
resetMockCounters();
|
resetMockCounters();
|
||||||
engine = new MetadataDiffEngine();
|
engine = new MetadataDiffEngine({ syncPublishedPostFile: mockSyncPublishedPostFile } as any);
|
||||||
engine.setProjectContext('test-project');
|
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 mockGetAllMedia = vi.fn();
|
||||||
const mockImportMedia = 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
|
// Mock MediaEngine
|
||||||
vi.mock('../../src/main/engine/MediaEngine', () => ({
|
vi.mock('../../src/main/engine/MediaEngine', () => ({
|
||||||
getMediaEngine: vi.fn(() => ({
|
getMediaEngine: vi.fn(() => ({
|
||||||
@@ -144,7 +152,7 @@ describe('PostMediaEngine', () => {
|
|||||||
mockGetAllMedia.mockResolvedValue([]);
|
mockGetAllMedia.mockResolvedValue([]);
|
||||||
mockImportMedia.mockResolvedValue({ id: 'imported-media-id' });
|
mockImportMedia.mockResolvedValue({ id: 'imported-media-id' });
|
||||||
|
|
||||||
engine = new PostMediaEngine();
|
engine = new PostMediaEngine(mockMediaEngineForPostMedia as any);
|
||||||
engine.setProjectContext('test-project');
|
engine.setProjectContext('test-project');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine(posts),
|
postEngine: makeEngine(posts),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,6 +203,8 @@ describe('PreviewServer', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -242,6 +247,8 @@ describe('PreviewServer', () => {
|
|||||||
{ id: 'dev', title: 'Dev', kind: 'category-archive', categoryName: 'news', children: [] },
|
{ id: 'dev', title: 'Dev', kind: 'category-archive', categoryName: 'news', children: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -278,6 +285,8 @@ describe('PreviewServer', () => {
|
|||||||
{ id: 'news', title: 'news', kind: 'category-archive', categoryName: 'news', children: [] },
|
{ id: 'news', title: 'news', kind: 'category-archive', categoryName: 'news', children: [] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -293,6 +302,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine([makePost()]),
|
postEngine: makeEngine([makePost()]),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -369,6 +381,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine([makePost()]),
|
postEngine: makeEngine([makePost()]),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: tempDir ?? undefined }),
|
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: tempDir ?? undefined }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -397,6 +412,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine([postWithCode]),
|
postEngine: makeEngine([postWithCode]),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -432,6 +450,7 @@ describe('PreviewServer', () => {
|
|||||||
postMediaEngine,
|
postMediaEngine,
|
||||||
settingsEngine: settingsEngine as any,
|
settingsEngine: settingsEngine as any,
|
||||||
menuEngine,
|
menuEngine,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: '/tmp/default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: '/tmp/default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -474,6 +493,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine(posts),
|
postEngine: makeEngine(posts),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -520,6 +542,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine(posts),
|
postEngine: makeEngine(posts),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -557,6 +582,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine([publishedPost, draftPost]),
|
postEngine: makeEngine([publishedPost, draftPost]),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -589,6 +617,9 @@ describe('PreviewServer', () => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
} as any,
|
} as any,
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -618,6 +649,9 @@ describe('PreviewServer', () => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
} as any,
|
} as any,
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -645,6 +679,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine(posts),
|
postEngine: makeEngine(posts),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -665,6 +702,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine([matchingDay, sameMonth, sameYear, differentYear]),
|
postEngine: makeEngine([matchingDay, sameMonth, sameYear, differentYear]),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -696,6 +736,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine(posts),
|
postEngine: makeEngine(posts),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -728,6 +771,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine([post]),
|
postEngine: makeEngine([post]),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -753,6 +799,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine([post]),
|
postEngine: makeEngine([post]),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -766,6 +815,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine([makePost({ content: '```js\nconst line = "x".repeat(1000);\n```' })]),
|
postEngine: makeEngine([makePost({ content: '```js\nconst line = "x".repeat(1000);\n```' })]),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -789,6 +841,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine([post]),
|
postEngine: makeEngine([post]),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -829,6 +884,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine([post]),
|
postEngine: makeEngine([post]),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: tempDir || undefined }),
|
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' }),
|
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' }),
|
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' }),
|
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' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1015,6 +1085,9 @@ describe('PreviewServer', () => {
|
|||||||
return { description: 'Beschreibung', maxPostsPerPage: 2 };
|
return { description: 'Beschreibung', maxPostsPerPage: 2 };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1043,6 +1116,9 @@ describe('PreviewServer', () => {
|
|||||||
return { description: 'Beschreibung', maxPostsPerPage: 2 };
|
return { description: 'Beschreibung', maxPostsPerPage: 2 };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1075,6 +1151,9 @@ describe('PreviewServer', () => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
} as any,
|
} as any,
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1111,6 +1190,9 @@ describe('PreviewServer', () => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
} as any,
|
} as any,
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1147,6 +1229,9 @@ describe('PreviewServer', () => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
} as any,
|
} as any,
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1166,6 +1251,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine([tagged, categorized, page, regular]),
|
postEngine: makeEngine([tagged, categorized, page, regular]),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1215,6 +1303,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine([tagDayOneA, tagDayOneB, tagDayTwo]),
|
postEngine: makeEngine([tagDayOneA, tagDayOneB, tagDayTwo]),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1247,6 +1338,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine(posts),
|
postEngine: makeEngine(posts),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1302,6 +1396,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine(posts),
|
postEngine: makeEngine(posts),
|
||||||
settingsEngine: makeSettings(7),
|
settingsEngine: makeSettings(7),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
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' }),
|
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' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1381,6 +1484,9 @@ describe('PreviewServer', () => {
|
|||||||
: null;
|
: null;
|
||||||
},
|
},
|
||||||
} as any,
|
} as any,
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1401,6 +1507,9 @@ describe('PreviewServer', () => {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({
|
getActiveProjectContext: async () => ({
|
||||||
projectId: 'default',
|
projectId: 'default',
|
||||||
dataDir: '/tmp/default',
|
dataDir: '/tmp/default',
|
||||||
@@ -1465,7 +1574,9 @@ describe('PreviewServer', () => {
|
|||||||
createdAt: new Date('2025-02-03T10:00:00.000Z'),
|
createdAt: new Date('2025-02-03T10:00:00.000Z'),
|
||||||
},
|
},
|
||||||
]) as any,
|
]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1495,6 +1606,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine([post]),
|
postEngine: makeEngine([post]),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1546,7 +1660,11 @@ describe('PreviewServer', () => {
|
|||||||
linkedPostIds: [],
|
linkedPostIds: [],
|
||||||
} as any,
|
} as any,
|
||||||
]) as any,
|
]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({
|
||||||
|
'macro-1': [{ media: { id: 'media-1' } }, { media: { id: 'media-2' } }],
|
||||||
|
}) as any,
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1587,6 +1705,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine([post]),
|
postEngine: makeEngine([post]),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1624,6 +1745,7 @@ describe('PreviewServer', () => {
|
|||||||
'macro-junction-1': [{ media: { id: 'junction-media-1' } }],
|
'macro-junction-1': [{ media: { id: 'junction-media-1' } }],
|
||||||
}) as any,
|
}) as any,
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
@@ -1646,6 +1768,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine([makePost()]),
|
postEngine: makeEngine([makePost()]),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({
|
getActiveProjectContext: async () => ({
|
||||||
projectId: 'default',
|
projectId: 'default',
|
||||||
dataDir: tempDir!,
|
dataDir: tempDir!,
|
||||||
@@ -1691,6 +1816,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: engine,
|
postEngine: engine,
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1738,6 +1866,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: engine,
|
postEngine: engine,
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1790,6 +1921,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: engine,
|
postEngine: engine,
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1805,6 +1939,9 @@ describe('PreviewServer', () => {
|
|||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine([makePost()]),
|
postEngine: makeEngine([makePost()]),
|
||||||
settingsEngine: makeSettings(50),
|
settingsEngine: makeSettings(50),
|
||||||
|
mediaEngine: makeMediaEngine([]) as any,
|
||||||
|
postMediaEngine: makePostMediaEngine({}) as any,
|
||||||
|
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ describe('PublishApiAdapter', () => {
|
|||||||
mockTaskManager.runTask.mockImplementation((opts: { execute: (onProgress: () => void) => Promise<unknown> }) => {
|
mockTaskManager.runTask.mockImplementation((opts: { execute: (onProgress: () => void) => Promise<unknown> }) => {
|
||||||
return opts.execute(() => {});
|
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 () => {
|
it('sets project context before uploading', async () => {
|
||||||
|
|||||||
@@ -99,13 +99,6 @@ describe('PublishEngine', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('constructor and project context', () => {
|
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 () => {
|
it('should throw if no project context is set', async () => {
|
||||||
const noContextEngine = new PublishEngine();
|
const noContextEngine = new PublishEngine();
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ describe('TagEngine', () => {
|
|||||||
mockSelectDataDefault = [];
|
mockSelectDataDefault = [];
|
||||||
mockPostEngine.syncPublishedPostFile.mockClear();
|
mockPostEngine.syncPublishedPostFile.mockClear();
|
||||||
resetMockCounters();
|
resetMockCounters();
|
||||||
tagEngine = new TagEngine();
|
tagEngine = new TagEngine(mockPostEngine as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -351,7 +351,12 @@ describe('WXR Reference Comparison E2E Tests', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
// Create engine instances
|
// 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');
|
executionEngine.setProjectContext('test-project', '/mock/test/data');
|
||||||
|
|
||||||
analysisEngine = new ImportAnalysisEngine();
|
analysisEngine = new ImportAnalysisEngine();
|
||||||
|
|||||||
@@ -102,7 +102,12 @@ describe('chatHandlers', () => {
|
|||||||
|
|
||||||
it('streams sendMessage callbacks through main window events', async () => {
|
it('streams sendMessage callbacks through main window events', async () => {
|
||||||
const mod = await import('../../src/main/ipc/chatHandlers');
|
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();
|
mod.registerChatHandlers();
|
||||||
|
|
||||||
const handler = registeredHandlers.get('chat:sendMessage');
|
const handler = registeredHandlers.get('chat:sendMessage');
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ const mockPostMediaEngine = {
|
|||||||
linkManyToPost: vi.fn(),
|
linkManyToPost: vi.fn(),
|
||||||
unlinkManyFromPost: vi.fn(),
|
unlinkManyFromPost: vi.fn(),
|
||||||
getLinkedMediaForPost: vi.fn(),
|
getLinkedMediaForPost: vi.fn(),
|
||||||
|
getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]),
|
||||||
getLinkedPostsForMedia: vi.fn(),
|
getLinkedPostsForMedia: vi.fn(),
|
||||||
reorderMediaForPost: vi.fn(),
|
reorderMediaForPost: vi.fn(),
|
||||||
isMediaLinkedToPost: vi.fn(),
|
isMediaLinkedToPost: vi.fn(),
|
||||||
@@ -257,6 +258,7 @@ const mockDatabase = {
|
|||||||
posts: '/mock/data/posts',
|
posts: '/mock/data/posts',
|
||||||
media: '/mock/data/media',
|
media: '/mock/data/media',
|
||||||
})),
|
})),
|
||||||
|
getDbPath: vi.fn(() => '/mock/data/bds.db'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock engine modules
|
// Mock engine modules
|
||||||
@@ -350,16 +352,49 @@ async function invokeHandlerWithEvent(event: any, channel: string, ...args: any[
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('IPC Handlers', () => {
|
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 () => {
|
beforeEach(async () => {
|
||||||
// Clear all mocks
|
// Clear all mocks
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
registeredHandlers.clear();
|
registeredHandlers.clear();
|
||||||
mockGeneratedFileHashStore.clear();
|
mockGeneratedFileHashStore.clear();
|
||||||
resetMockCounters();
|
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
|
// Import and register handlers fresh for each test
|
||||||
const { registerIpcHandlers } = await import('../../src/main/ipc/handlers');
|
const { registerIpcHandlers } = await import('../../src/main/ipc/handlers');
|
||||||
registerIpcHandlers();
|
registerIpcHandlers(mockBundle as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
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