Merge pull request #25 from rfc1437/feature/mcp-standalone-cli
Feature/mcp standalone cli
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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
125
BDS_SEMANTIC_SIMILARITY.md
Normal file
125
BDS_SEMANTIC_SIMILARITY.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Semantic Similarity in bDS: A Zettelkasten-inspired Feature
|
||||||
|
|
||||||
|
## Concept
|
||||||
|
|
||||||
|
The goal is to surface **thematically related posts** when writing or viewing a post in bDS — not as an authoritative classification, but as an *impulse*: "Have I written something similar before? Where could I explore further?"
|
||||||
|
|
||||||
|
This is inspired by Niklas Luhmann's Zettelkasten method, where the system would surprise its author with unexpected connections. The key difference from Luhmann's academic use case: bDS serves a **personal epistemic network** across diverse topics (programming, board games, social topics, professional interests), not a focused research domain. Cross-domain connections are a feature, not a flaw.
|
||||||
|
|
||||||
|
The algorithm finds the surface. The human finds the depth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Why not full-text search?
|
||||||
|
|
||||||
|
Text search (BM25, LIKE queries) only finds shared words. Semantic similarity finds shared *meaning* — a post about emergent structures in game design can surface next to one about software architecture, even with zero word overlap.
|
||||||
|
|
||||||
|
### Embeddings
|
||||||
|
|
||||||
|
Text is converted into high-dimensional vectors. Similarity becomes geometric proximity. Small, distilled models can do this efficiently without requiring a GPU or external API.
|
||||||
|
|
||||||
|
**Recommended model:** `Xenova/all-MiniLM-L6-v2`
|
||||||
|
- ~90 MB on disk (ONNX format)
|
||||||
|
- ~150–200 MB RAM at runtime
|
||||||
|
- 50–150ms inference time per post on CPU
|
||||||
|
- 384-dimensional vectors
|
||||||
|
- Works well for mixed German/English content
|
||||||
|
- No API key, fully local
|
||||||
|
|
||||||
|
**Lighter alternative:** `all-MiniLM-L4-v2` (~50 MB, minimal quality difference for this use case)
|
||||||
|
|
||||||
|
**Node.js library:** [`@huggingface/transformers`](https://github.com/xenova/transformers.js)
|
||||||
|
- Runs ONNX models natively in Node.js
|
||||||
|
- Downloads and caches model to `~/.cache/huggingface/` on first run
|
||||||
|
- Subsequent runs load from local cache
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Storage: sqlite-vec
|
||||||
|
|
||||||
|
Since bDS already uses SQLite (via Drizzle ORM) as a caching layer, the natural fit is [`sqlite-vec`](https://github.com/asg017/sqlite-vec) — a SQLite extension for vector search by Alex Garcia (the actively maintained successor to `sqlite-vss`).
|
||||||
|
|
||||||
|
**Node.js integration:**
|
||||||
|
```js
|
||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import * as sqliteVec from 'sqlite-vec'
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||||
|
|
||||||
|
const sqlite = new Database('bds.sqlite')
|
||||||
|
sqliteVec.load(sqlite) // must happen before Drizzle init
|
||||||
|
const db = drizzle(sqlite)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Schema** (raw SQL migration, outside Drizzle schema — virtual tables are not supported by Drizzle):
|
||||||
|
```sql
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS post_embeddings
|
||||||
|
USING vec0(
|
||||||
|
post_id TEXT PRIMARY KEY,
|
||||||
|
embedding FLOAT[384]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Similarity query:**
|
||||||
|
```sql
|
||||||
|
SELECT p.id, p.title, e.distance
|
||||||
|
FROM post_embeddings e
|
||||||
|
JOIN posts p ON e.post_id = p.id
|
||||||
|
WHERE e.embedding MATCH ?
|
||||||
|
AND k = 5
|
||||||
|
ORDER BY distance;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration with bDS Hooks
|
||||||
|
|
||||||
|
bDS already has file system hooks that fire when posts change (triggered by external edits, e.g. via git sync across machines). The embedding step fits naturally into the existing cache-update hook:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function onPostChanged(filePath) {
|
||||||
|
const post = parseMarkdownFile(filePath)
|
||||||
|
const embedding = await embedText(post.content) // ~100ms
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
updatePostCache(post) // existing cache logic
|
||||||
|
db.run(sql`
|
||||||
|
INSERT OR REPLACE INTO post_embeddings(post_id, embedding)
|
||||||
|
VALUES (${post.id}, ${serializeVector(embedding)})
|
||||||
|
`)
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Git sync bonus:** On first `git pull` to a new machine, hooks fire for all posts, automatically building the full vector index — no separate setup step needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UX Recommendation
|
||||||
|
|
||||||
|
- Show **3–5 related posts** maximum — enough for an impulse, not so many it becomes a management task
|
||||||
|
- Label them clearly as *"thematically related"*, not *"you should read"*
|
||||||
|
- A low similarity threshold is fine — unexpected connections are often the most valuable
|
||||||
|
- No need for user-facing controls over the algorithm; simplicity serves the use case
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Libraries
|
||||||
|
|
||||||
|
| Purpose | Library | npm |
|
||||||
|
|---|---|---|
|
||||||
|
| Embedding model (local) | Hugging Face Transformers.js | `@huggingface/transformers` |
|
||||||
|
| Vector search in SQLite | sqlite-vec | `sqlite-vec` |
|
||||||
|
| SQLite driver | better-sqlite3 | `better-sqlite3` |
|
||||||
|
| ORM (already in bDS) | Drizzle ORM | `drizzle-orm` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Philosophical Note
|
||||||
|
|
||||||
|
Luhmann's Zettelkasten was monothematic by design — everything fed into a single sociological theory. A personal blog spanning programming, board games, MTG, and everyday life is structurally different. The vector space will reflect that diversity and occasionally bridge domains in ways no intentional tagging system would — which is precisely the point.
|
||||||
|
|
||||||
|
The system is not meant to organize knowledge. It is meant to make existing connections *visible*.
|
||||||
158
MCP_PLAN.md
158
MCP_PLAN.md
@@ -1,158 +0,0 @@
|
|||||||
# Chapter 3: MCP Server — Agent-Assisted Content Creation
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Run a standalone MCP server on its own port (default 4124). External AI agents connect via Streamable HTTP at `/mcp`. Read access is open; writes go through user-reviewable proposals with MCP App review UIs powered by `@modelcontextprotocol/ext-apps`.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- `@modelcontextprotocol/sdk` (v1.27+) — `McpServer`, `StreamableHTTPServerTransport`, `createMcpExpressApp`
|
|
||||||
- `@modelcontextprotocol/ext-apps` (v1.1+) — `registerAppTool`, `registerAppResource`, `RESOURCE_MIME_TYPE` for interactive review UIs rendered inline in compliant hosts (Claude, ChatGPT, VS Code, etc.)
|
|
||||||
- `express` / `cors` — pulled in transitively by the SDK; used by `createMcpExpressApp`
|
|
||||||
|
|
||||||
zod v4 already in project satisfies peer dep requirements.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
PreviewServer (HTTP on 127.0.0.1:4123) ← unchanged
|
|
||||||
└── /* → Blog preview routes
|
|
||||||
|
|
||||||
MCPServer (HTTP on 127.0.0.1:4124) ← NEW, standalone
|
|
||||||
└── /mcp → Streamable HTTP (POST + GET/DELETE)
|
|
||||||
├── tools, resources, prompts
|
|
||||||
└── ui:// resources → MCP App review Views (via ext-apps)
|
|
||||||
```
|
|
||||||
|
|
||||||
The MCP SDK provides `StreamableHTTPServerTransport` for HTTP handling. We use Node's `http.createServer` directly with stateless mode (new `McpServer` per request) to avoid session management complexity and the Express dependency.
|
|
||||||
|
|
||||||
`MCPServer` is a new engine class that owns the `McpServer` factory, tool/resource/prompt registration, and the `ProposalStore`. It runs independently of PreviewServer.
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
|
|
||||||
### Step 1: Install deps
|
|
||||||
```
|
|
||||||
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: ProposalStore (`src/main/engine/ProposalStore.ts`)
|
|
||||||
- `Map<string, Proposal>` with TTL (30 min default)
|
|
||||||
- `create(type, data) → id`, `get(id)`, `remove(id)`, `cleanup()`
|
|
||||||
- Draft posts tracked by mapping proposalId → 'draftPost' (actual data in DB)
|
|
||||||
- Periodic cleanup via `setInterval`
|
|
||||||
|
|
||||||
### Step 3: MCPServer engine (`src/main/engine/MCPServer.ts`)
|
|
||||||
- Constructor: inject engine getters (PostEngine, MediaEngine, ScriptEngine, TemplateEngine, MetaEngine, PostMediaEngine, TagEngine)
|
|
||||||
- `createServer()` → factory that instantiates a fresh `McpServer` and registers all tools/resources/prompts (stateless mode — one per request)
|
|
||||||
- `start(port)` → uses `http.createServer` + `StreamableHTTPServerTransport` in stateless mode, listens on `127.0.0.1:port`, validates Origin header
|
|
||||||
- `stop()` → close HTTP server
|
|
||||||
- `cleanup()` → discard proposals, stop intervals, stop server
|
|
||||||
- Singleton pattern with `getMCPServer()` getter
|
|
||||||
|
|
||||||
### Step 4: Resources (read-only data)
|
|
||||||
Register MCP resources mapping to existing engine methods:
|
|
||||||
|
|
||||||
| Resource URI | Engine Method |
|
|
||||||
|---|---|
|
|
||||||
| `bds://posts` | PostEngine.getAllPosts() |
|
|
||||||
| `bds://posts/{id}` | PostEngine.getPost(id) |
|
|
||||||
| `bds://media` | MediaEngine.getAllMedia() |
|
|
||||||
| `bds://media/{id}` | MediaEngine.getMedia(id) |
|
|
||||||
| `bds://tags` | PostEngine.getTagsWithCounts() |
|
|
||||||
| `bds://categories` | PostEngine.getCategoriesWithCounts() |
|
|
||||||
| `bds://stats` | PostEngine.getBlogStats() + MediaEngine |
|
|
||||||
| `bds://posts/{id}/backlinks` | PostEngine.getLinkedBy(id) |
|
|
||||||
| `bds://posts/{id}/outlinks` | PostEngine.getLinksTo(id) |
|
|
||||||
| `bds://posts/{id}/media` | PostMediaEngine.getLinkedMediaDataForPost(id) |
|
|
||||||
| `bds://media/{id}/posts` | PostMediaEngine.getLinkedPostsForMedia(id) |
|
|
||||||
| `bds://media/{id}/image` | MediaEngine.getThumbnailDataUrl(id, 'medium') |
|
|
||||||
|
|
||||||
### Step 5: Read tools
|
|
||||||
- `search_posts` — annotations: `{ readOnlyHint: true, openWorldHint: false }`
|
|
||||||
- Wraps PostEngine.searchPosts() with filter args (query, category, tags, year/month, offset, limit)
|
|
||||||
|
|
||||||
### Step 6: Proposal tools (with MCP App Views)
|
|
||||||
Each proposal tool uses `registerAppTool` from `@modelcontextprotocol/ext-apps/server` to link the tool to a `ui://` resource. The host renders the review View inline.
|
|
||||||
|
|
||||||
Each review View is a bundled HTML page (built with Vite `vite-plugin-singlefile` into a single self-contained HTML) that uses the `App` class from `@modelcontextprotocol/ext-apps` for bidirectional communication with the host.
|
|
||||||
|
|
||||||
| Tool | Action | `ui://` Resource | Accept | Discard |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `draft_post` | PostEngine.createPost(draft) | `ui://bds/review-post` | publishPost() | deletePost() |
|
|
||||||
| `propose_script` | Store in ProposalStore | `ui://bds/review-script` | ScriptEngine.createScript() | remove from store |
|
|
||||||
| `propose_template` | Store in ProposalStore | `ui://bds/review-template` | TemplateEngine.createTemplate() | remove from store |
|
|
||||||
| `propose_media_metadata` | Store diff in ProposalStore | `ui://bds/review-metadata` | MediaEngine.updateMedia() | remove from store |
|
|
||||||
| `propose_post_metadata` | Store diff in ProposalStore | `ui://bds/review-metadata` | PostEngine.updatePost() | remove from store |
|
|
||||||
|
|
||||||
Resource registration uses `registerAppResource` from `@modelcontextprotocol/ext-apps/server`. Each resource returns a bundled HTML string with `mimeType: RESOURCE_MIME_TYPE`.
|
|
||||||
|
|
||||||
### Step 7: Accept/discard tools
|
|
||||||
- `accept_proposal({ proposalId })` — dispatch by type, commit change
|
|
||||||
- `discard_proposal({ proposalId })` — dispatch by type, clean up
|
|
||||||
- Registered via `registerAppTool` with `visibility: ["app"]` (app-only, hidden from agent LLM)
|
|
||||||
- Annotations: idempotentHint: true
|
|
||||||
|
|
||||||
### Step 8: MCP Prompts
|
|
||||||
- `draft-blog-post(topic?, category?)` — structured prompt guiding agent to read context and draft
|
|
||||||
- `improve-media-metadata(scope)` — guide agent to review media and propose alt/caption
|
|
||||||
- `content-audit(category?)` — guide agent to review posts for quality
|
|
||||||
|
|
||||||
### Step 9: MCP App Review Views (`src/main/mcp-apps/`)
|
|
||||||
Four View HTML pages, each using vanilla JS + `App` class from `@modelcontextprotocol/ext-apps`:
|
|
||||||
|
|
||||||
- `review-post.html` — title, metadata, rendered markdown, word count; accept/discard buttons call `app.callServerTool()` → `accept_proposal`/`discard_proposal`
|
|
||||||
- `review-script.html` — title, syntax-highlighted Python, validation status
|
|
||||||
- `review-template.html` — title, kind badge, syntax-highlighted Liquid, validation
|
|
||||||
- `review-metadata.html` — side-by-side diff (current vs proposed)
|
|
||||||
|
|
||||||
Each View:
|
|
||||||
1. Receives tool result data from host via `app.ontoolresult`
|
|
||||||
2. Renders focused review interface
|
|
||||||
3. On accept/discard: calls `app.callServerTool({ name: 'accept_proposal' | 'discard_proposal', arguments: { proposalId } })`
|
|
||||||
4. Updates UI to show outcome
|
|
||||||
|
|
||||||
Build step: Review Views are inline HTML template strings in `mcp-views.ts` — no separate build step needed.
|
|
||||||
|
|
||||||
### Step 10: Lifecycle in main.ts
|
|
||||||
- MCPServer starts independently alongside PreviewServer during app init
|
|
||||||
- On `before-quit`: call `MCPServer.cleanup()` (stops server + discards proposals)
|
|
||||||
|
|
||||||
### Step 11: Update TODO.md
|
|
||||||
- Remove stdio references per user request
|
|
||||||
- Mark standalone server approach in the doc
|
|
||||||
|
|
||||||
### Step 12: Tests (TDD per CLAUDE.md)
|
|
||||||
Each step above is preceded by failing tests:
|
|
||||||
|
|
||||||
- **ProposalStore**: create/get/remove, TTL expiry, cleanup
|
|
||||||
- **MCPServer tools**: tool definitions (names, schemas, annotations)
|
|
||||||
- **MCPServer resources**: URI resolution, data returned
|
|
||||||
- **MCPServer prompts**: message structure, arguments
|
|
||||||
- **Accept/discard**: draft_post→accept publishes, propose_script→discard removes
|
|
||||||
- **Integration**: HTTP POST to `http://127.0.0.1:{port}/mcp`, tool call, response format
|
|
||||||
- **Review Views**: `registerAppResource` returns valid HTML with `RESOURCE_MIME_TYPE`
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `src/main/engine/ProposalStore.ts` | NEW — in-memory proposal storage |
|
|
||||||
| `src/main/engine/MCPServer.ts` | NEW — standalone MCP server engine |
|
|
||||||
| `src/main/engine/mcp-views.ts` | NEW — inline review View HTML templates |
|
|
||||||
| `tests/engine/mcp-views.test.ts` | NEW |
|
|
||||||
| `src/main/main.ts` | MODIFY — start/cleanup MCPServer |
|
|
||||||
| `tests/engine/ProposalStore.test.ts` | NEW |
|
|
||||||
| `tests/engine/MCPServer.test.ts` | NEW |
|
|
||||||
| `tests/engine/MCPServer.integration.test.ts` | NEW |
|
|
||||||
| `TODO.md` | MODIFY — remove stdio, update transport section |
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
1. `npm test` — all tests pass (new + existing)
|
|
||||||
2. `npm run build` — clean build
|
|
||||||
3. Manual: start app, connect Claude Code or curl to `http://127.0.0.1:4124/mcp` with MCP protocol, verify tool listing and resource reading
|
|
||||||
4. Manual: call `draft_post` tool, verify draft created, review View renders inline in host, accept publishes post
|
|
||||||
|
|
||||||
## Unresolved Questions
|
|
||||||
|
|
||||||
None — scope is clear: standalone HTTP server on port 4124, MCP App Views via `@modelcontextprotocol/ext-apps`, no settings UI.
|
|
||||||
773
MCP_STANDALONE_PLAN.md
Normal file
773
MCP_STANDALONE_PLAN.md
Normal file
@@ -0,0 +1,773 @@
|
|||||||
|
# MCP Standalone CLI Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Ship a bundled CLI tool (`bds-mcp`) alongside the Electron app that:
|
||||||
|
|
||||||
|
- Runs as a self-contained MCP server over **stdio** (for Claude Desktop, VS Code, etc.)
|
||||||
|
- Works entirely against the **SQLite database** — no Electron process required
|
||||||
|
- Signals the running app of changes via a **`db_notifications`** table
|
||||||
|
- Is installed into agent configs by the app's existing agent-config UI (with add **and remove**)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐ ┌────────────────────────────────────┐
|
||||||
|
│ bds-mcp (bundled CLI binary) │ │ bDS Electron app │
|
||||||
|
│ │ │ │
|
||||||
|
│ StdioServerTransport ──────┐ │ │ HTTP MCPServer :4124 │
|
||||||
|
│ │ │ │ ├── same engines │
|
||||||
|
│ │ │ │ └── NotificationWatcher │
|
||||||
|
│ PostEngine ├───┼─────┼──► SQLite (WAL mode, shared) │
|
||||||
|
│ MediaEngine │ │ │ posts / media / scripts … │
|
||||||
|
│ ScriptEngine │ │ │ db_notifications ← new │
|
||||||
|
│ TemplateEngine │ │ └────────────────────────────────────┘
|
||||||
|
│ MCPServer (tools/res) │ │ ▲
|
||||||
|
│ ProposalStore (in-memory) │ │ chokidar(-wal) + 100 ms debounce
|
||||||
|
└─────────────────────────────┘ │ invalidate engines + IPC to renderer
|
||||||
|
│
|
||||||
|
agent (Claude Desktop, VS Code…) ─┘
|
||||||
|
(stdio)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port mapping
|
||||||
|
|
||||||
|
| Context | Port | Transport |
|
||||||
|
|---|---|---|
|
||||||
|
| bDS app running | :4124 | HTTP (existing, unchanged) |
|
||||||
|
| `bds-mcp` CLI | — | stdio |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Changes
|
||||||
|
|
||||||
|
### A — `db_notifications` table (new Drizzle migration)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE db_notifications (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity TEXT NOT NULL, -- 'post' | 'media' | 'script' | 'template'
|
||||||
|
entityId TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL, -- 'created' | 'updated' | 'deleted'
|
||||||
|
fromCli INTEGER NOT NULL DEFAULT 1, -- 1 = written by CLI; reserved for future app→CLI signalling
|
||||||
|
seenAt INTEGER, -- NULL = unprocessed by app
|
||||||
|
createdAt INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI writes a row after every mutation. The app's `NotificationWatcher` queries for
|
||||||
|
`seenAt IS NULL AND fromCli = 1`, invalidates the relevant engine cache, emits IPC events
|
||||||
|
to the renderer, stamps `seenAt`, then **prunes rows in two passes** to prevent unbounded
|
||||||
|
table growth:
|
||||||
|
- rows whose `seenAt` is older than 1 hour (already processed by the app)
|
||||||
|
- rows whose `seenAt IS NULL AND createdAt` is older than 24 hours (stale unprocessed rows
|
||||||
|
written while the app was closed — they will never be read)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B — Draft mode for scripts and templates (new Drizzle migration)
|
||||||
|
|
||||||
|
Scripts and templates currently write the file immediately on create. To allow CLI-created
|
||||||
|
proposals to be durable (survive CLI restarts) without polluting the filesystem until
|
||||||
|
accepted, add the same draft pattern that posts already use.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- scripts
|
||||||
|
ALTER TABLE scripts ADD COLUMN status TEXT NOT NULL DEFAULT 'published'
|
||||||
|
CHECK (status IN ('draft', 'published'));
|
||||||
|
ALTER TABLE scripts ADD COLUMN content TEXT; -- draft body; NULL when on-disk
|
||||||
|
|
||||||
|
-- templates
|
||||||
|
ALTER TABLE templates ADD COLUMN status TEXT NOT NULL DEFAULT 'published'
|
||||||
|
CHECK (status IN ('draft', 'published'));
|
||||||
|
ALTER TABLE templates ADD COLUMN content TEXT; -- draft body; NULL when on-disk
|
||||||
|
```
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
- **`propose_script` / `propose_template`** → `INSERT` with `status: 'draft'`, body in
|
||||||
|
`content`, `filePath: ''` — no file written yet; `ProposalStore` maps
|
||||||
|
`proposalId → rowId`
|
||||||
|
- **`accept_proposal`** → write file to disk, set `status: 'published'`, clear `content`
|
||||||
|
- **`discard_proposal`** → `DELETE` the row (never touched the filesystem)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C0 — Decouple `DatabaseConnection` from Electron
|
||||||
|
|
||||||
|
`src/main/database/connection.ts` currently imports `{ app } from 'electron'` for three
|
||||||
|
things: `app.getPath('userData')`, `app.isPackaged`, and `process.resourcesPath`. The CLI
|
||||||
|
cannot load this file at all without Electron present.
|
||||||
|
|
||||||
|
Refactor `DatabaseConnection` to accept explicit constructor arguments:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DatabaseConnectionConfig {
|
||||||
|
dbPath: string; // absolute path to bds.db
|
||||||
|
migrationsFolder: string; // absolute path to the drizzle/ migrations dir
|
||||||
|
dataDirs?: string[]; // extra directories to mkdir at startup (posts/, media/, etc.)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Four things must move out of `connection.ts` into `main.ts` (or equivalent callers):
|
||||||
|
|
||||||
|
1. `app.getPath('userData')` in the constructor — replaced by the explicit `dbPath`
|
||||||
|
2. `app.isPackaged` + `process.resourcesPath` in `runMigrations()` — replaced by
|
||||||
|
the explicit `migrationsFolder`
|
||||||
|
3. The `posts/` and `media/` `mkdirSync` calls in the constructor — absorbed into
|
||||||
|
the new `dataDirs` option (caller supplies the list)
|
||||||
|
4. **`getDataPaths()`** — this method also calls `app.getPath('userData')` directly and
|
||||||
|
must be removed from `DatabaseConnection` entirely. Move callers to compute data
|
||||||
|
paths themselves (they already have `userData` in scope in `main.ts`).
|
||||||
|
|
||||||
|
The Electron `main.ts` constructs it as today, computing paths via `app.getPath` before
|
||||||
|
passing them in. The CLI uses `platformConfigPath()` and `__dirname`-relative resolution.
|
||||||
|
All Electron imports are removed from `connection.ts` itself.
|
||||||
|
|
||||||
|
**WAL mode and synchronous pragma** — add to `initializeLocal()` immediately after
|
||||||
|
creating the client, before running migrations:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await this.localClient.execute('PRAGMA journal_mode=WAL');
|
||||||
|
await this.localClient.execute('PRAGMA synchronous=NORMAL');
|
||||||
|
```
|
||||||
|
|
||||||
|
This must be done for **both** the app and CLI connections. It ensures the `-wal` file
|
||||||
|
exists (enabling chokidar to watch it) and gives consistent write performance. Note that
|
||||||
|
`PRAGMA journal_mode=WAL` is a one-way change — SQLite persists the mode in the database
|
||||||
|
file itself. Existing users upgrading from a non-WAL build will have their `bds.db`
|
||||||
|
converted automatically on first launch of the new version. This is safe and desirable.
|
||||||
|
|
||||||
|
**Engines also import Electron directly** — `PostEngine`, `MediaEngine`, `ScriptEngine`,
|
||||||
|
`TemplateEngine`, `TagEngine`, `MetaEngine`, `MenuEngine` all call `app.getPath('userData')`
|
||||||
|
directly. These imports are **not** removed as part of C0. When the CLI runs under
|
||||||
|
`ELECTRON_RUN_AS_NODE=1`, the `electron` module is available and `app.getPath('userData')`
|
||||||
|
returns the correct platform path without requiring `app.whenReady()`. Full decoupling of
|
||||||
|
individual engines from Electron is a separate future refactor; it is not a prerequisite
|
||||||
|
for the CLI. The reason this works is that the app name is embedded in the Electron binary,
|
||||||
|
so the path derivation is purely a function of the OS and binary name — no GUI subsystem
|
||||||
|
is needed.
|
||||||
|
|
||||||
|
This is a prerequisite for every CLI section that follows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C — `platformConfigPath()` — userData without Electron (`src/cli/platform.ts`)
|
||||||
|
|
||||||
|
Pure-Node helper to resolve the same path as Electron's `app.getPath('userData')`:
|
||||||
|
|
||||||
|
| Platform | Path |
|
||||||
|
|---|---|
|
||||||
|
| macOS | `~/Library/Application Support/Blogging Desktop Server` |
|
||||||
|
| Windows | `%APPDATA%\Blogging Desktop Server` |
|
||||||
|
| Linux | `~/.config/Blogging Desktop Server` |
|
||||||
|
|
||||||
|
Used by the CLI entrypoint to find the SQLite database without loading Electron.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D — `CliNotifier` interface + explicit engine construction
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CliNotifier {
|
||||||
|
notify(entity: string, id: string, action: 'created' | 'updated' | 'deleted'): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`CliNotifier` has two implementations:
|
||||||
|
- **`NoopNotifier`** — used by the Electron app; all mutations are no-ops
|
||||||
|
- **`DbNotifier`** — used by the CLI; inserts a row into `db_notifications`
|
||||||
|
|
||||||
|
#### Replacing singletons with explicit construction
|
||||||
|
|
||||||
|
The current `getPostEngine()`, `getScriptEngine()`, `getTemplateEngine()`,
|
||||||
|
`getMediaEngine()` etc. are module-level singletons constructed on first access. There is
|
||||||
|
no mechanism to pass constructor-time options (such as a `CliNotifier`) into them, which
|
||||||
|
makes clean DI impossible without global mutation.
|
||||||
|
|
||||||
|
This section replaces those factories with **explicit construction** at the two entry
|
||||||
|
points (`main.ts` for the app, `bds-mcp.ts` for the CLI):
|
||||||
|
|
||||||
|
1. Each engine class gains an optional `notifier?: CliNotifier` constructor parameter
|
||||||
|
(default: `NoopNotifier`). Every mutating method calls `this.notifier.notify(...)` after
|
||||||
|
its DB write.
|
||||||
|
|
||||||
|
2. The module-level singleton variables (`let postEngine: PostEngine | null = null`) and
|
||||||
|
their associated `getPostEngine()` factory functions are **removed** from each engine
|
||||||
|
file.
|
||||||
|
|
||||||
|
3. **`main.ts`** constructs all engines explicitly, passing `NoopNotifier`, then passes
|
||||||
|
them into `registerIpcHandlers()` and `MCPServer` as explicit arguments (already the
|
||||||
|
pattern for `MCPServerDependencies`).
|
||||||
|
|
||||||
|
4. **`src/cli/bds-mcp.ts`** constructs its own engine instances, passing `DbNotifier`,
|
||||||
|
and passes them into `MCPServer`.
|
||||||
|
|
||||||
|
5. **`src/main/ipc/handlers.ts`** `registerIpcHandlers()` signature gains an engine
|
||||||
|
bundle parameter (same shape as `MCPServerDependencies`, or a shared type
|
||||||
|
`EngineBundle`). All `getXxxEngine()` calls inside handlers are replaced by accesses to
|
||||||
|
the injected bundle.
|
||||||
|
|
||||||
|
This is the architecturally sound path: it eliminates hidden global state, makes the
|
||||||
|
engine lifecycle explicit and testable, and removes the need for the global `electron` mock
|
||||||
|
in `tests/setup.ts` for engines that no longer import it.
|
||||||
|
|
||||||
|
> **Scope note:** `ProjectEngine`, `MenuEngine`, `TagEngine`, `MetaEngine`,
|
||||||
|
> `PostMediaEngine`, `GitEngine`, `ImportExecutionEngine`, `TaskManager`, and
|
||||||
|
> `AppApiAdapter` are **not** required by the CLI and do not need `CliNotifier`. They
|
||||||
|
> follow the same explicit-construction refactor only to be consistent (they lose their
|
||||||
|
> singleton factories too), but their `CliNotifier` parameter is omitted.
|
||||||
|
|
||||||
|
**Cross-engine coupling — hidden singleton calls that must be wired up.** Several engines
|
||||||
|
that are not directly called by the CLI still pull other engines through the deleted
|
||||||
|
singleton factories internally:
|
||||||
|
|
||||||
|
| Engine | Internal call |
|
||||||
|
|---|---|
|
||||||
|
| `TagEngine` | `getPostEngine().syncPublishedPostFile()` |
|
||||||
|
| `PostMediaEngine` | `getMediaEngine()` — 8 call sites |
|
||||||
|
| `MetadataDiffEngine` | `getPostEngine()` |
|
||||||
|
| `BlogGenerationEngine` | `getPostEngine()` |
|
||||||
|
| `BlogmarkTransformService` | `getScriptEngine()` |
|
||||||
|
|
||||||
|
Removing the singleton factories without wiring these calls will produce compile errors.
|
||||||
|
Each of these classes must gain a constructor parameter for the engine it depends on, and
|
||||||
|
`main.ts` must inject the already-constructed instance. This is additional — but
|
||||||
|
mechanical — work within this same step.
|
||||||
|
|
||||||
|
**`MCPServerDependencies` shape change.** The current interface uses lazy getter functions
|
||||||
|
(`getPostEngine: () => PostEngineContract`). After engines are constructed explicitly at
|
||||||
|
startup, wrapping them in getters is pointless overhead. The interface should become direct
|
||||||
|
properties (`postEngine: PostEngineContract`). All callers inside `MCPServer.ts` change
|
||||||
|
from `this.deps.getPostEngine().method()` to `this.deps.postEngine.method()`. This is a
|
||||||
|
mechanical find-and-replace across `MCPServer.ts` and `main.ts` — do it as part of step D.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### E — Engine `invalidate()` API
|
||||||
|
|
||||||
|
No engine currently exposes a way to signal that its data is stale. Before
|
||||||
|
`NotificationWatcher` can be implemented, each engine that holds any in-memory state needs
|
||||||
|
an `invalidate(entityId?: string): void` method that clears the relevant cached entries
|
||||||
|
(or the whole cache when `entityId` is omitted), forcing a fresh DB read on the next
|
||||||
|
access. Engines that are already fully stateless (query DB on every call) get a no-op stub
|
||||||
|
so `NotificationWatcher` can call a uniform API.
|
||||||
|
|
||||||
|
Engines to update: `PostEngine`, `MediaEngine`, `ScriptEngine`, `TemplateEngine`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F — `NotificationWatcher` (new engine, app-side only)
|
||||||
|
|
||||||
|
`src/main/engine/NotificationWatcher.ts`
|
||||||
|
|
||||||
|
Uses **chokidar** (already in `dependencies`) to watch both `bds.db` and `bds.db-wal`.
|
||||||
|
This gives sub-100 ms latency with zero boilerplate: chokidar handles missing files (the
|
||||||
|
`-wal` file doesn't exist until the first write after WAL mode is enabled), platform
|
||||||
|
differences (FSEvents on macOS, inotify on Linux, FSWatch on Windows), and
|
||||||
|
`add` events for when the WAL file is created for the first time.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import chokidar, { FSWatcher } from 'chokidar';
|
||||||
|
|
||||||
|
export class NotificationWatcher {
|
||||||
|
private watcher: FSWatcher | null = null;
|
||||||
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dbPath: string, // absolute path to bds.db
|
||||||
|
private readonly db: DrizzleDB,
|
||||||
|
private readonly engines: WatchableEngines,
|
||||||
|
private readonly mainWindow: BrowserWindow,
|
||||||
|
private readonly debounceMs = 100,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
// Watch both the main db file (catches checkpoints) and the WAL file
|
||||||
|
// (catches every write before checkpoint). chokidar handles the case
|
||||||
|
// where bds.db-wal does not yet exist — it fires 'add' when it appears.
|
||||||
|
this.watcher = chokidar.watch(
|
||||||
|
[this.dbPath, `${this.dbPath}-wal`],
|
||||||
|
{
|
||||||
|
persistent: false,
|
||||||
|
ignoreInitial: true, // don't fire on startup for pre-existing files
|
||||||
|
usePolling: false, // rely on native FSEvents/inotify/kqueue
|
||||||
|
awaitWriteFinish: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.watcher.on('change', () => this.schedule());
|
||||||
|
this.watcher.on('add', () => this.schedule()); // WAL created for first time
|
||||||
|
}
|
||||||
|
|
||||||
|
private isProcessing = false;
|
||||||
|
|
||||||
|
private schedule(): void {
|
||||||
|
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||||
|
this.debounceTimer = setTimeout(() => this.process(), this.debounceMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async process(): Promise<void> {
|
||||||
|
// Re-entry guard: seenAt updates trigger chokidar again; skip if already running.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||||
|
this.watcher?.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`WatchableEngines` is a plain record keyed by entity name (`post`, `media`, `script`,
|
||||||
|
`template`) mapping to objects that expose `invalidate()`. Started in `main.ts` alongside
|
||||||
|
`PreviewServer` and `MCPServer`; stopped in the `app.on('before-quit')` handler.
|
||||||
|
|
||||||
|
**The watcher fires on every DB write, not only CLI writes.** chokidar sees all changes to
|
||||||
|
`bds.db` / `bds.db-wal`, including writes the Electron app makes itself (UI post creation,
|
||||||
|
media import, etc.). Each write triggers the 100 ms debounce and then `process()`, which
|
||||||
|
queries `db_notifications` for `seenAt IS NULL AND fromCli = 1`. When no CLI is running
|
||||||
|
that query returns zero rows and the function exits immediately — this is a single cheap
|
||||||
|
indexed SELECT. Do not try to "optimise" the watcher by making it conditional on CLI
|
||||||
|
presence; that would break notification delivery.
|
||||||
|
|
||||||
|
**`seenAt` writes are themselves DB writes** that fire chokidar again. The `isProcessing`
|
||||||
|
flag prevents the resulting re-entrant call from doing any work — it returns immediately
|
||||||
|
and the debounce timer is not rescheduled. This bounds the feedback to one extra no-op
|
||||||
|
debounce per notification batch, not a loop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### G — `MCPServer.startCli()` (extend existing `MCPServer.ts`)
|
||||||
|
|
||||||
|
The App View's `app.callServerTool()` routes through the MCP host (Claude Desktop, VS
|
||||||
|
Code, etc.) over the **same** connection the server is on — it does not make a direct HTTP
|
||||||
|
call to a secondary endpoint. The CLI therefore needs exactly one server and one transport:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async startCli(): Promise<void> {
|
||||||
|
const server = this.createMcpServer();
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
|
||||||
|
// Block until the MCP host closes stdin (normal session end).
|
||||||
|
// Signal-based shutdown is the caller's responsibility: do NOT register
|
||||||
|
// signal handlers here or they will race with the bds-mcp.ts handlers.
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
process.stdin.on('close', resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No secondary HTTP port. No `bds-mcp-base-url` meta tag. The `ProposalStore` is owned by
|
||||||
|
the single `MCPServer` instance as before.
|
||||||
|
|
||||||
|
**App View host compatibility.** The proposal-review HTML (`reviewPostHtml`, etc.) is
|
||||||
|
rendered through the `@modelcontextprotocol/ext-apps` App View protocol. This interactive
|
||||||
|
panel is supported by Claude Desktop and Claude Code. Other MCP hosts (VS Code agent mode,
|
||||||
|
Gemini CLI, OpenCode) may not implement the ext-apps spec and will show the raw HTML as
|
||||||
|
text, or ignore the resource entirely. This is acceptable: `accept_proposal` and
|
||||||
|
`discard_proposal` are plain MCP tool calls that work in any conformant host — the HTML
|
||||||
|
panel is a convenience layer on top, not a requirement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H — `src/cli/bds-mcp.ts` — CLI entrypoint (no Electron imports)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. platformConfigPath() → userData dir
|
||||||
|
2. dbPath = path.join(userData, 'bds.db')
|
||||||
|
3. migrationsFolder = path.join(__dirname, 'drizzle') // Contents/Resources/drizzle/
|
||||||
|
4. new DatabaseConnection({ dbPath, migrationsFolder, dataDirs: [postsDir, mediaDir] })
|
||||||
|
5. await db.initializeLocal() // runs WAL + synchronous pragmas, then migrations
|
||||||
|
6. SELECT active project (isActive = 1); exit with message if none
|
||||||
|
7. instantiate engines with CliNotifier DB writer
|
||||||
|
8. new MCPServer({ postEngine, mediaEngine, … }, { proposalTtlMs: 8 * 60 * 60 * 1000 })
|
||||||
|
9. await mcpServer.startCli() // blocks until stdin closes
|
||||||
|
10. await shutdown() // graceful post-stdin exit
|
||||||
|
```
|
||||||
|
|
||||||
|
`MCPServer` should accept an optional `proposalTtlMs` to override the default 30-minute
|
||||||
|
TTL. CLI sessions can last hours (overnight agent runs), so **8 hours** is the appropriate
|
||||||
|
default. Pass `proposalTtlMs` through to the `ProposalStore` constructor.
|
||||||
|
|
||||||
|
No `electron` imports. Fully standalone Node process.
|
||||||
|
|
||||||
|
**Graceful shutdown** — two paths, no racing:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Called both by signal handlers (forced) and after normal stdin-close (graceful).
|
||||||
|
// process.exit() makes it non-reentrant even without the once guards.
|
||||||
|
async function shutdown(): Promise<never> {
|
||||||
|
await mcpServer.cleanup(); // proposalStore.destroy() + stop() (no-op for stdio, safe)
|
||||||
|
await db.close(); // flushes any in-flight @libsql writes
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal handlers own interruption; registered before startCli() so they are
|
||||||
|
// active during the entire session. Do NOT register signals inside startCli().
|
||||||
|
process.once('SIGTERM', shutdown);
|
||||||
|
process.once('SIGINT', shutdown);
|
||||||
|
|
||||||
|
// Normal path: stdin closes → startCli() resolves → server.close() runs → shutdown()
|
||||||
|
await mcpServer.startCli();
|
||||||
|
await shutdown();
|
||||||
|
```
|
||||||
|
|
||||||
|
When SIGTERM fires, `shutdown()` runs and calls `process.exit(0)` — `startCli()` never
|
||||||
|
resumes, `server.close()` is skipped (safe: the process is exiting anyway). When stdin
|
||||||
|
closes cleanly, `startCli()` resumes, `server.close()` runs first, then `shutdown()`
|
||||||
|
exits. The `once` guards and `process.exit` together make the function non-reentrant.
|
||||||
|
|
||||||
|
**Native module resolution** — When launched as `ELECTRON_RUN_AS_NODE=1`, Electron's
|
||||||
|
ASAR patching is still active, so `require('@libsql/client')` resolves into
|
||||||
|
`app.asar/node_modules`. This is a documented side-effect of `ELECTRON_RUN_AS_NODE` that
|
||||||
|
has been stable since Electron 20. Add an integration test that verifies the module
|
||||||
|
resolves and the DB opens successfully from the bundled path; if a future Electron major
|
||||||
|
breaks this, the test will catch it early.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I — esbuild/Vite build target for CLI bundle
|
||||||
|
|
||||||
|
New build target in `vite.config.ts` (or `package.json` scripts):
|
||||||
|
|
||||||
|
```
|
||||||
|
src/cli/bds-mcp.ts → dist/cli/bds-mcp.cjs
|
||||||
|
target: node, format: cjs, bundle: true
|
||||||
|
```
|
||||||
|
|
||||||
|
The project uses `@libsql/client` with platform-specific native binaries (e.g.
|
||||||
|
`@libsql/darwin-arm64`). These **cannot** be bundled by esbuild. Externalize all
|
||||||
|
`@libsql/*` and `chokidar`-related native packages, and ensure they resolve at runtime
|
||||||
|
from the app bundle (see the ASAR resolution note in section H):
|
||||||
|
|
||||||
|
```
|
||||||
|
externals: ['@libsql/client', '@libsql/darwin-arm64', '@libsql/linux-x64-gnu',
|
||||||
|
'chokidar', 'fsevents', …]
|
||||||
|
```
|
||||||
|
|
||||||
|
The `drizzle/` migrations folder does **not** need to be re-bundled alongside the `.cjs`
|
||||||
|
file — it is already included in `extraResources` (see J). The path resolution
|
||||||
|
`path.join(__dirname, 'drizzle')` resolves correctly when `bds-mcp.cjs` sits at
|
||||||
|
`Contents/Resources/bds-mcp.cjs` and `drizzle/` is at `Contents/Resources/drizzle/`.
|
||||||
|
|
||||||
|
Runs as part of `npm run build` before the Electron build.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### J — electron-builder `extraResources`
|
||||||
|
|
||||||
|
The `{ "from": "drizzle", "to": "drizzle" }` entry **already exists** in `package.json`.
|
||||||
|
Only the CLI bundle itself needs to be added:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"build": {
|
||||||
|
"extraResources": [
|
||||||
|
{ "from": "dist/cli/bds-mcp.cjs", "to": "bds-mcp.cjs" }
|
||||||
|
// { "from": "drizzle", "to": "drizzle" } ← already present, do not duplicate
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`bds-mcp.cjs` lands at `Contents/Resources/bds-mcp.cjs`; `drizzle/` is already at
|
||||||
|
`Contents/Resources/drizzle/`. The CLI migration resolver `path.join(__dirname, 'drizzle')`
|
||||||
|
resolves `Contents/Resources/drizzle/` correctly from that location.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### K — `MCPAgentConfigEngine` — add Claude Desktop + stdio support + remove
|
||||||
|
|
||||||
|
#### New agent type
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type MCPAgentId =
|
||||||
|
| 'claude-code'
|
||||||
|
| 'claude-desktop' // ← NEW (stdio)
|
||||||
|
| 'github-copilot'
|
||||||
|
| 'gemini-cli'
|
||||||
|
| 'opencode';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Extended options
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface MCPAgentConfigOptions {
|
||||||
|
homeDir: string;
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
mcpUrl: string;
|
||||||
|
// stdio agents only — required when agentId is 'claude-desktop', unused otherwise
|
||||||
|
execPath?: string; // process.execPath (packaged) or 'node' (dev)
|
||||||
|
scriptPath?: string; // path to bds-mcp.cjs
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both fields are optional so all existing callers (which only pass `homeDir`, `platform`,
|
||||||
|
`mcpUrl`) continue to compile without changes. `buildEntry('claude-desktop')` asserts
|
||||||
|
both are truthy and throws a descriptive error if called without them — this is a
|
||||||
|
programmer error, not a user error.
|
||||||
|
|
||||||
|
#### Claude Desktop entry
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
case 'claude-desktop':
|
||||||
|
return {
|
||||||
|
command: this.execPath,
|
||||||
|
args: [this.scriptPath],
|
||||||
|
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Config file path:
|
||||||
|
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||||
|
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
||||||
|
- Linux: `~/.config/Claude/claude_desktop_config.json`
|
||||||
|
|
||||||
|
> **Note:** Verify the current OpenCode config schema before implementing the `opencode`
|
||||||
|
> entry — the format has changed between releases. Check the live spec at
|
||||||
|
> https://opencode.ai/docs before writing the `buildEntry` case.
|
||||||
|
|
||||||
|
#### Remove from config (new method)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
removeFromConfig(agentId: MCPAgentId): AgentConfigResult
|
||||||
|
```
|
||||||
|
|
||||||
|
- Reads the config file
|
||||||
|
- Deletes `config[serversKey][SERVER_NAME]`
|
||||||
|
- If `serversKey` object is now empty, removes the key entirely
|
||||||
|
- Writes back; returns `{ success, configPath }`
|
||||||
|
- No-ops gracefully if file or entry does not exist
|
||||||
|
|
||||||
|
This powers **Remove** buttons in the agent-config UI alongside the existing **Add**
|
||||||
|
buttons, so users never have to hunt down config files manually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L — `ScriptEngine` / `TemplateEngine` — draft lifecycle methods
|
||||||
|
|
||||||
|
New methods on each engine:
|
||||||
|
|
||||||
|
> **Also update `ScriptEngineContract` and `TemplateEngineContract` in `MCPServer.ts`.**
|
||||||
|
> The interfaces currently only declare `createScript` / `createTemplate` / `validateScript`
|
||||||
|
> / `validateTemplate`. After this step, `acceptProposal` and `discardProposal` call
|
||||||
|
> `publishScript`, `deleteDraftScript`, `publishTemplate`, `deleteDraftTemplate` — all of
|
||||||
|
> which must be added to the respective contract interfaces, or `MCPServer.ts` will not
|
||||||
|
> compile. Do this as part of the same step.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ScriptEngine
|
||||||
|
createDraftScript(data: CreateScriptInput): Promise<ScriptData> // status: 'draft', no file
|
||||||
|
publishScript(id: string): Promise<ScriptData | null> // write file, status: 'published'
|
||||||
|
deleteDraftScript(id: string): Promise<boolean> // only if status: 'draft'
|
||||||
|
|
||||||
|
// TemplateEngine
|
||||||
|
createDraftTemplate(data: CreateTemplateInput): Promise<TemplateData>
|
||||||
|
publishTemplate(id: string): Promise<TemplateData | null>
|
||||||
|
deleteDraftTemplate(id: string): Promise<boolean>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M — `ProposalStore` — map proposals to DB row IDs
|
||||||
|
|
||||||
|
For `draftPost` proposals the store already maps `proposalId → postId` (the DB row). Extend
|
||||||
|
this consistently:
|
||||||
|
|
||||||
|
- `proposeScript` → maps `proposalId → scriptId` (DB row, `status: 'draft'`)
|
||||||
|
- `proposeTemplate` → maps `proposalId → templateId` (DB row, `status: 'draft'`)
|
||||||
|
- `proposeMediaMetadata` → stays in-memory (no draft DB row needed, metadata diff only)
|
||||||
|
- `proposePostMetadata` → stays in-memory (same reason)
|
||||||
|
|
||||||
|
`accept_proposal` dispatches to `publishScript` / `publishTemplate` / `publishPost` etc.
|
||||||
|
`discard_proposal` dispatches to `deleteDraftScript` / `deleteDraftTemplate` / `deletePost`.
|
||||||
|
|
||||||
|
**Impact on `MCPServer.acceptProposal()` and `discardProposal()`:** the current
|
||||||
|
implementations read the full `CreateScriptInput` / `CreateTemplateInput` payload from the
|
||||||
|
in-memory `ProposalStore` then call `createScript()` / `createTemplate()`. After this
|
||||||
|
change, `ProposalStore` for those types holds only `{ scriptId }` / `{ templateId }`
|
||||||
|
(the DB row ID), and the dispatch must call `publishScript(scriptId)` /
|
||||||
|
`publishTemplate(templateId)` instead. Similarly, `discardProposal` for these types must
|
||||||
|
call `deleteDraftScript(scriptId)` / `deleteDraftTemplate(templateId)` to clean up the DB
|
||||||
|
row — not just `proposalStore.remove()`. These updates to `MCPServer.ts` are part of this
|
||||||
|
step, not a separate one.
|
||||||
|
|
||||||
|
**`ProposalStore` TTL expiry must call back to clean up draft DB rows.** The existing
|
||||||
|
`setInterval` cleanup in `ProposalStore` calls `proposals.delete(id)` in-memory but knows
|
||||||
|
nothing about the `scriptId` / `templateId` DB row it points to. If a `proposeScript` or
|
||||||
|
`proposeTemplate` proposal expires without being accepted or discarded, the `status='draft'`
|
||||||
|
DB row is orphaned forever.
|
||||||
|
|
||||||
|
Fix: add an optional `onExpiry` callback to `ProposalStore`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class ProposalStore {
|
||||||
|
constructor(
|
||||||
|
ttlMs: number = DEFAULT_TTL_MS,
|
||||||
|
private readonly onExpiry?: (proposal: Proposal) => void,
|
||||||
|
) { … }
|
||||||
|
|
||||||
|
cleanup(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [id, proposal] of this.proposals) {
|
||||||
|
if (now - proposal.createdAt > this.ttlMs) {
|
||||||
|
this.onExpiry?.(proposal);
|
||||||
|
this.proposals.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`MCPServer` passes an `onExpiry` handler at construction time that dispatches
|
||||||
|
`deleteDraftScript` / `deleteDraftTemplate` for the relevant proposal types (using the
|
||||||
|
`scriptId` / `templateId` stored in `proposal.data`). `draftPost`, metadata proposals, and
|
||||||
|
all other types are no-ops in the handler. Wire this in step 6 (ProposalStore TTL option)
|
||||||
|
before step 7 (draft lifecycle methods).
|
||||||
|
|
||||||
|
**Known failure mode for in-memory metadata proposals:** if the CLI process is killed after
|
||||||
|
a `propose_media_metadata` or `propose_post_metadata` call but before accept/discard, the
|
||||||
|
`proposalId` no longer exists in any `ProposalStore`. The App View buttons will call
|
||||||
|
`accept_proposal` and receive `{ success: false, message: "Proposal … not found" }` —
|
||||||
|
this is already user-visible in the review UI. No silent corruption occurs; the post or
|
||||||
|
media item is simply unchanged. This is accepted as the trade-off for keeping metadata
|
||||||
|
proposals lightweight (no DB rows, no filesystem side effects).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### N — Renderer: subscribe to `entity:changed` IPC events
|
||||||
|
|
||||||
|
The app's UI must reflect changes the CLI makes while the app is open. `NotificationWatcher`
|
||||||
|
fires `entity:changed` IPC events (section F), but nothing currently handles them in the
|
||||||
|
renderer.
|
||||||
|
|
||||||
|
**`preload.ts`** — expose the channel alongside existing IPC listeners:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
onEntityChanged: (cb: (payload: EntityChangedPayload) => void) =>
|
||||||
|
ipcRenderer.on('entity:changed', (_, payload) => cb(payload)),
|
||||||
|
```
|
||||||
|
|
||||||
|
**Renderer subscription** — in the top-level app component (or a dedicated `IpcListener`
|
||||||
|
component that mounts once), subscribe on mount and unsubscribe on unmount:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = window.api.onEntityChanged(({ entity, entityId, action }) => {
|
||||||
|
// Dispatch store invalidation so the next render fetches fresh data
|
||||||
|
if (entity === 'post') postsStore.getState().invalidate(entityId);
|
||||||
|
if (entity === 'media') mediaStore.getState().invalidate(entityId);
|
||||||
|
if (entity === 'script') scriptsStore.getState().invalidate(entityId);
|
||||||
|
if (entity === 'template') templatesStore.getState().invalidate(entityId);
|
||||||
|
});
|
||||||
|
return () => unsub();
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
Store `invalidate()` methods should clear the cached entry and, if the entity is
|
||||||
|
currently displayed, trigger an immediate refetch — consistent with how other
|
||||||
|
IPC-driven refreshes work in the app.
|
||||||
|
|
||||||
|
**These `invalidate()` methods do not currently exist in any Zustand store.** The
|
||||||
|
renderer's stores today are push-based: the main process fires granular IPC events
|
||||||
|
(`post:created`, `post:updated`, `post:deleted`, etc.) that the renderer applies directly.
|
||||||
|
The `entity:changed` channel carries only `{ entity, entityId, action }` — enough to
|
||||||
|
route, but not the full object. Each store therefore needs:
|
||||||
|
1. An `invalidate(id: string)` action that removes the stale item from local state
|
||||||
|
2. A subsequent IPC fetch call (e.g. `window.electronAPI.posts.get(id)`) to reload it
|
||||||
|
|
||||||
|
This is non-trivial cross-cutting work and should be its own implementation step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Active project in CLI | Use `isActive = 1` from DB; fail fast if none |
|
||||||
|
| Proposal durability | Scripts/templates use DB draft rows; metadata proposals stay in-memory (failure mode documented in M) |
|
||||||
|
| Proposal TTL | 30 min default in app; **8 hours** in CLI (overnight agent sessions) |
|
||||||
|
| App View tool calls in CLI mode | `app.callServerTool()` routes through the MCP host, not a direct HTTP call — no secondary port needed |
|
||||||
|
| Claude Desktop support | stdio via bundled `bds-mcp.cjs` + `ELECTRON_RUN_AS_NODE=1` (entitlements already set) |
|
||||||
|
| Remove from config | New `removeFromConfig()` method + Remove buttons in UI |
|
||||||
|
| DB change detection | **chokidar** on both `bds.db` and `bds.db-wal`; 100 ms debounce; handles missing WAL via `add` events |
|
||||||
|
| WAL mode | Explicit `PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL` in `initializeLocal()` for both app and CLI |
|
||||||
|
| `db_notifications` growth | Rows pruned after `seenAt` is 1 hour old, inside each `process()` call |
|
||||||
|
| DB coupling to Electron | `DatabaseConnection` accepts explicit paths; `getDataPaths()` removed; Electron path resolution stays in `main.ts` |
|
||||||
|
| Engine Electron coupling | Engines retain `app.getPath('userData')` calls — safe under `ELECTRON_RUN_AS_NODE=1`; full decoupling is a future task |
|
||||||
|
| Engine singletons | Replaced with explicit construction at `main.ts` / `bds-mcp.ts`; `getXxxEngine()` factories removed (section D) |
|
||||||
|
| Native module resolution | `ELECTRON_RUN_AS_NODE` inherits Electron ASAR require-hooking; integration test guards against regression |
|
||||||
|
| Signal handling | Signal handlers in `bds-mcp.ts` only; `startCli()` only listens to stdin-close — no competing handlers |
|
||||||
|
| CLI shutdown | `mcpServer.cleanup()` (not direct `proposalStore.destroy()`) then `db.close()` then `process.exit(0)` |
|
||||||
|
| Renderer live updates | Section N: `entity:changed` IPC → store `invalidate()` + refetch; `invalidate()` methods must be added to each store |
|
||||||
|
| Watcher fire rate | chokidar fires on every DB write (app + CLI); `process()` no-ops with a cheap indexed SELECT when no CLI rows are pending |
|
||||||
|
| Watcher re-entry | `isProcessing` flag prevents double-processing when `seenAt` writes re-trigger chokidar |
|
||||||
|
| Stale unprocessed rows | Pruned after 24h (app was closed); processed rows pruned after 1h — both inside the same `process()` call |
|
||||||
|
| Cross-engine coupling | `TagEngine`, `PostMediaEngine`, `MetadataDiffEngine`, `BlogGenerationEngine`, `BlogmarkTransformService` use deleted singleton calls internally; must be given injected deps in step D |
|
||||||
|
| `MCPServerDependencies` shape | Lazy getters (`getPostEngine: () => …`) → direct properties (`postEngine: …`) after step D; mechanical find-and-replace in `MCPServer.ts` and `main.ts` |
|
||||||
|
| `ScriptEngineContract` / `TemplateEngineContract` | Must add draft lifecycle method signatures in step L to avoid compile errors in `MCPServer.ts` |
|
||||||
|
| `ProposalStore` TTL + draft DB rows | `onExpiry` callback lets `MCPServer` clean up `status='draft'` DB rows when a `proposeScript`/`proposeTemplate` proposal expires without accept/discard |
|
||||||
|
| App View host support | Interactive review HTML only renders in hosts implementing `@modelcontextprotocol/ext-apps`; `accept_proposal`/`discard_proposal` work as plain tool calls in all hosts |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order (TDD per AGENTS.md)
|
||||||
|
|
||||||
|
1. **Migrations** (A + B) — schema changes first, all other work builds on them
|
||||||
|
2. **Decouple `DatabaseConnection` from Electron** (C0) — remove `app` import, move `getDataPaths()` to callers, add WAL pragmas; update existing DB tests to pass explicit paths
|
||||||
|
3. **`platformConfigPath()`** (C) — pure function, easy to test
|
||||||
|
4. **Explicit engine construction + `CliNotifier`** (D) — remove all `getXxxEngine()` singletons; add explicit construction in `main.ts`; update `registerIpcHandlers()` signature; add `CliNotifier` constructor arg to the four mutating engines; unit-test `DbNotifier` with mock DB. Also inject deps into the five cross-engine callers (`TagEngine`, `PostMediaEngine`, `MetadataDiffEngine`, `BlogGenerationEngine`, `BlogmarkTransformService`) and change `MCPServerDependencies` from lazy getters to direct properties. This is the largest single step — sequence it early because every later step depends on the new construction pattern.
|
||||||
|
5. **Engine `invalidate()` API** (E) — add and test on each engine before NotificationWatcher
|
||||||
|
6. **`ProposalStore` TTL option + `onExpiry` callback** (M, partial) — add `proposalTtlMs` constructor arg; add `onExpiry?: (proposal: Proposal) => void` callback invoked from `cleanup()` before deletion; extend existing tests; the DB mapping part of M and the `MCPServer` wiring of `onExpiry` comes after L
|
||||||
|
7. **`ScriptEngine`/`TemplateEngine` draft lifecycle** (L) — tests first; produces `publishScript`, `deleteDraftScript`, `publishTemplate`, `deleteDraftTemplate`
|
||||||
|
8. **`ProposalStore` DB mapping + `MCPServer` accept/discard update** (M, remainder) — map `proposeScript`/`proposeTemplate` proposals to DB row IDs; update `acceptProposal()` and `discardProposal()` in `MCPServer.ts` to call publish/delete methods; wire `onExpiry` handler in `MCPServer` constructor to call `deleteDraftScript`/`deleteDraftTemplate` on TTL expiry; extend existing MCPServer accept/discard/expiry tests
|
||||||
|
9. **`NotificationWatcher`** (F) — test with mock DB rows + mock chokidar emitter (emit `'change'` / `'add'` events on a fake `FSWatcher`)
|
||||||
|
10. **`MCPAgentConfigEngine` — `claude-desktop` + `removeFromConfig`** (K) — extend existing tests; `execPath`/`scriptPath` optional; verify opencode format before writing its case
|
||||||
|
11. **`MCPServer.startCli()`** (G) — test with in-process `StdioServerTransport`
|
||||||
|
12. **`src/cli/bds-mcp.ts`** (H) — integration test: spawn process, send `initialize`, assert response, assert clean shutdown on SIGTERM and on stdin-close
|
||||||
|
13. **Build target + `extraResources`** (I + J) — verify `npm run build` produces `dist/cli/bds-mcp.cjs`; `drizzle/` entry already in `extraResources`, only add `bds-mcp.cjs`
|
||||||
|
14. **UI: Remove buttons** — renderer component changes, follows K
|
||||||
|
15. **`NotificationWatcher` wired in `main.ts`** (F, app side) + stopped in `before-quit`
|
||||||
|
16. **Renderer store `invalidate()` methods** — add `invalidate(id)` + IPC refetch action to `postsStore`, `mediaStore`, `scriptsStore`, `templatesStore`; each requires both a store action and a corresponding IPC call to reload the single entity
|
||||||
|
17. **Renderer `entity:changed` subscription** (N) — `preload.ts` channel exposure + component subscription wired to the store `invalidate()` methods from step 16
|
||||||
|
|
||||||
|
Each step: write failing test → implement → green → next.
|
||||||
149
MISTRAL_PLAN.md
Normal file
149
MISTRAL_PLAN.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# Plan: Add Mistral AI as Alternative Chat Provider
|
||||||
|
|
||||||
|
## Context
|
||||||
|
bDS currently routes all AI chat through the OpenCode Zen gateway (`opencode.ai/zen/v1/...`) with two code paths: Anthropic Messages API and OpenAI-compatible. The user wants Mistral AI added as a direct alternative provider with frontier models that support chat completion, tool use, and vision. Mistral's API is OpenAI-compatible (`api.mistral.ai/v1/chat/completions`), making integration straightforward.
|
||||||
|
|
||||||
|
## Target Models
|
||||||
|
- **Mistral Large 3** (`mistral-large-2512`) - frontier, vision, tools, 40k ctx
|
||||||
|
- **Mistral Medium 3.1** (`mistral-medium-2508`) - frontier, vision, tools, 40k ctx
|
||||||
|
- **Mistral Small 3.2** (`mistral-small-2506`) - vision, tools, 128k ctx
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
### 1. `src/main/engine/OpenCodeManager.ts` - Core provider logic
|
||||||
|
|
||||||
|
**A. Add Mistral constants** (near lines 23-25)
|
||||||
|
- `MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'`
|
||||||
|
- `MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models'`
|
||||||
|
|
||||||
|
**B. Add Mistral models to `MODEL_DISPLAY_NAMES`** (lines 28-69)
|
||||||
|
```
|
||||||
|
'mistral-large-2512': 'Mistral Large 3'
|
||||||
|
'mistral-medium-2508': 'Mistral Medium 3.1'
|
||||||
|
'mistral-small-2506': 'Mistral Small 3.2'
|
||||||
|
```
|
||||||
|
|
||||||
|
**C. Update `detectProvider()`** (lines 1839-1845)
|
||||||
|
- Add: `if (id.startsWith('mistral') || id.startsWith('ministral')) return 'mistral';`
|
||||||
|
|
||||||
|
**D. Add Mistral API key storage**
|
||||||
|
- New field: `private mistralApiKey: string = ''`
|
||||||
|
- New methods: `setMistralApiKey()`, `getMistralApiKey()`, `validateMistralApiKey()`
|
||||||
|
- Load on init from settings key `'mistral_api_key'`
|
||||||
|
|
||||||
|
**E. Add Mistral request path in `sendMessage()`**
|
||||||
|
- Route `provider === 'mistral'` to new `sendMistralRequest()` method
|
||||||
|
- Similar to OpenAI path but:
|
||||||
|
- URL: `MISTRAL_API_URL` (direct, not through OpenCode gateway)
|
||||||
|
- Auth: `Authorization: Bearer ${this.mistralApiKey}`
|
||||||
|
- No `tool_choice: "required"` — use `"any"` for Mistral's equivalent
|
||||||
|
- Context budget: 35,000 tokens (vs 150k for OpenCode) for 40k-ctx models, 120k for Small 3.2
|
||||||
|
|
||||||
|
**F. Vision format for Mistral**
|
||||||
|
- In `view_image` tool result handling: use `image_url` format with base64 data URI
|
||||||
|
- Format: `{ type: 'image_url', image_url: { url: 'data:image/webp;base64,...' } }`
|
||||||
|
- Same as OpenAI path (Mistral is compatible here)
|
||||||
|
|
||||||
|
**G. Update `getAvailableModels()`**
|
||||||
|
- When Mistral key is set, include Mistral models in returned list
|
||||||
|
- Add `provider` field to model entries so UI can group them
|
||||||
|
|
||||||
|
**H. Update `analyzeMediaImage()`** (lines 2066-2192)
|
||||||
|
- Support Mistral models for image metadata analysis (title/alt/caption generation)
|
||||||
|
- When a Mistral model is selected, use Mistral vision format (`image_url` with base64 data URI)
|
||||||
|
- Route to `api.mistral.ai` with Mistral API key
|
||||||
|
|
||||||
|
### 2. `src/main/engine/ChatEngine.ts` - Settings persistence
|
||||||
|
|
||||||
|
**A. Add Mistral key helpers**
|
||||||
|
- `getMistralApiKey()` - read from settings table
|
||||||
|
- `setMistralApiKey(key)` - persist to settings table
|
||||||
|
- Settings key: `'mistral_api_key'`
|
||||||
|
|
||||||
|
### 3. `src/main/ipc/chatHandlers.ts` - IPC bridge
|
||||||
|
|
||||||
|
**A. Add Mistral-specific handlers**
|
||||||
|
- `chat:setMistralApiKey` - validate + persist Mistral key
|
||||||
|
- `chat:getMistralApiKey` - return masked key
|
||||||
|
- `chat:validateMistralApiKey` - test key against Mistral API
|
||||||
|
|
||||||
|
**B. Update `chat:getAvailableModels`**
|
||||||
|
- Include Mistral models when Mistral key is configured
|
||||||
|
- Return provider info per model
|
||||||
|
|
||||||
|
**C. Update `chat:checkReady`**
|
||||||
|
- Report readiness for both providers independently
|
||||||
|
|
||||||
|
### 4. `src/main/shared/electronApi.ts` - Type definitions
|
||||||
|
|
||||||
|
**A. Extend `ChatModel` interface**
|
||||||
|
- Add `provider: 'opencode' | 'mistral'` field (already optional, ensure populated)
|
||||||
|
|
||||||
|
**B. Add Mistral IPC methods to `ElectronAPI.chat`**
|
||||||
|
- `setMistralApiKey(key: string)`
|
||||||
|
- `getMistralApiKey()`
|
||||||
|
- `validateMistralApiKey(key: string)`
|
||||||
|
|
||||||
|
### 5. `src/renderer/components/SettingsView.tsx` - UI settings
|
||||||
|
|
||||||
|
**A. Add Mistral API key section**
|
||||||
|
- Separate input field for Mistral API key (below OpenCode key)
|
||||||
|
- Same pattern: masked display, change button, validation on save
|
||||||
|
|
||||||
|
**B. Update model selector**
|
||||||
|
- Group models by provider in dropdown (optgroup: "OpenCode Zen", "Mistral AI")
|
||||||
|
- Show provider badge next to selected model
|
||||||
|
|
||||||
|
### 6. `src/renderer/components/ChatPanel/ChatPanel.tsx` - Chat UI
|
||||||
|
|
||||||
|
**A. Update model selector in chat**
|
||||||
|
- Group by provider in dropdown
|
||||||
|
- Only show models for configured providers
|
||||||
|
|
||||||
|
### 7. Preload/IPC registration
|
||||||
|
|
||||||
|
**A. `src/main/ipc/handlers.ts` or preload**
|
||||||
|
- Register new Mistral IPC channels
|
||||||
|
- Expose in preload bridge
|
||||||
|
|
||||||
|
### 8. MCP Server - `src/main/engine/MCPServer.ts`
|
||||||
|
|
||||||
|
- No changes needed — MCP server exposes tools for external AI agents to call; no bDS-side AI runs during MCP requests
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Tests first (per AGENTS.md)
|
||||||
|
2. Types (`electronApi.ts`)
|
||||||
|
3. Engine (`OpenCodeManager.ts` — constants, detection, key storage, request path)
|
||||||
|
4. Persistence (`ChatEngine.ts` — settings helpers)
|
||||||
|
5. IPC (`chatHandlers.ts` — new handlers)
|
||||||
|
6. UI (`SettingsView.tsx`, `ChatPanel.tsx` — key input, model grouping)
|
||||||
|
7. Build verification
|
||||||
|
|
||||||
|
## Key Differences to Handle
|
||||||
|
|
||||||
|
| Aspect | OpenCode/OpenAI | Mistral |
|
||||||
|
|--------|----------------|---------|
|
||||||
|
| Base URL | `opencode.ai/zen/v1/...` | `api.mistral.ai/v1/...` |
|
||||||
|
| Auth header | `Bearer ${openCodeKey}` | `Bearer ${mistralKey}` |
|
||||||
|
| Tool choice forced | `"required"` | `"any"` |
|
||||||
|
| Parallel tools | not set | `parallel_tool_calls: true` |
|
||||||
|
| Context budget | 150k tokens | 35k (Large/Medium), 120k (Small) |
|
||||||
|
| Vision format | `image_url` block | `image_url` block (same) |
|
||||||
|
| Streaming | SSE deltas | SSE deltas (same) |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Run `npm test` — all existing + new tests pass
|
||||||
|
2. Run `npm run build` — clean build
|
||||||
|
3. Manual: set Mistral API key in Settings, verify validation
|
||||||
|
4. Manual: select Mistral Large 3, send chat message, verify response streams
|
||||||
|
5. Manual: use `view_image` tool in chat with Mistral model, verify vision works
|
||||||
|
6. Manual: verify tool calling works (search_posts, list_posts, etc.)
|
||||||
|
7. Manual: verify OpenCode models still work unchanged
|
||||||
|
|
||||||
|
## Resolved Decisions
|
||||||
|
|
||||||
|
1. **`analyzeMediaImage()`** — Mistral models will be usable for image metadata analysis
|
||||||
|
2. **MCP server** — N/A; MCP server only exposes tools, no bDS-side AI runs during MCP requests
|
||||||
|
3. **Model dropdown grouping** — Use `<optgroup>` labels ("OpenCode Zen", "Mistral AI")
|
||||||
2
TODO.md
2
TODO.md
@@ -256,7 +256,7 @@ In `PageRenderer` and `BlogGenerationEngine`:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. MCP Server — Agent-Assisted Content Creation
|
## 3. MCP Server — Agent-Assisted Content Creation - done
|
||||||
|
|
||||||
### Goal
|
### Goal
|
||||||
|
|
||||||
|
|||||||
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}`);
|
||||||
|
}
|
||||||
146
src/cli/bds-mcp.ts
Normal file
146
src/cli/bds-mcp.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Redirect console to stderr ──────────────────────────────────────────────
|
||||||
|
// MCP stdio transport reserves stdout for JSON-RPC messages. Any stray
|
||||||
|
// console.log from engine code would corrupt the protocol stream, so we
|
||||||
|
// redirect all console output to stderr before importing anything else.
|
||||||
|
const _origLog = console.log;
|
||||||
|
const _origErr = console.error;
|
||||||
|
const _origWarn = console.warn;
|
||||||
|
const _origInfo = console.info;
|
||||||
|
const _origDebug = console.debug;
|
||||||
|
const _write = (args: unknown[]): void => {
|
||||||
|
process.stderr.write(args.map(String).join(' ') + '\n');
|
||||||
|
};
|
||||||
|
console.log = (...args: unknown[]) => _write(args);
|
||||||
|
console.error = (...args: unknown[]) => _write(args);
|
||||||
|
console.warn = (...args: unknown[]) => _write(args);
|
||||||
|
console.info = (...args: unknown[]) => _write(args);
|
||||||
|
console.debug = (...args: unknown[]) => _write(args);
|
||||||
|
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
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, alongside drizzle/).
|
||||||
|
// In development it points to dist/cli/ — drizzle/ lives at the project root.
|
||||||
|
const migrationsFolder = (() => {
|
||||||
|
const adjacent = path.join(__dirname, 'drizzle');
|
||||||
|
if (fs.existsSync(adjacent)) return adjacent;
|
||||||
|
// dev: dist/cli/ → ../../drizzle
|
||||||
|
return 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();
|
||||||
|
|
||||||
|
// 3b. Point every engine at the active project so queries/mutations
|
||||||
|
// target the correct project instead of the hardcoded 'default'.
|
||||||
|
const dataDir = activeProject.dataPath
|
||||||
|
?? path.join(userData, 'projects', activeProject.id);
|
||||||
|
|
||||||
|
postEngine.setProjectContext(activeProject.id, dataDir);
|
||||||
|
mediaEngine.setProjectContext(activeProject.id, dataDir, dataDir);
|
||||||
|
postMediaEngine.setProjectContext(activeProject.id);
|
||||||
|
tagEngine.setProjectContext(activeProject.id, dataDir);
|
||||||
|
scriptEngine.setProjectContext(activeProject.id, dataDir);
|
||||||
|
templateEngine.setProjectContext(activeProject.id, dataDir);
|
||||||
|
metaEngine.setProjectContext(activeProject.id, dataDir);
|
||||||
|
|
||||||
|
// 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.dataDirs = config.dataDirs ?? [];
|
||||||
|
|
||||||
this.config = {
|
// Ensure the directory containing the DB file exists.
|
||||||
localPath: config?.localPath || path.join(userDataPath, 'bds.db'),
|
const dataDir = path.dirname(this.dbPath);
|
||||||
};
|
|
||||||
|
|
||||||
// Ensure user data directory exists
|
|
||||||
const dataDir = path.dirname(this.config.localPath);
|
|
||||||
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,
|
||||||
|
|||||||
189
src/main/main.ts
189
src/main/main.ts
@@ -1,28 +1,63 @@
|
|||||||
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain, protocol, net, shell, screen } from 'electron';
|
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain, protocol, net, shell, screen } from 'electron';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { getDatabase } from './database';
|
import { getDatabase, initDatabase } 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,69 @@ app.on('open-url', (event, deepLink) => {
|
|||||||
|
|
||||||
// App lifecycle
|
// App lifecycle
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
// Initialise the database before constructing any engines.
|
||||||
|
const userData = app.getPath('userData');
|
||||||
|
const migrationsFolder = app.isPackaged
|
||||||
|
? path.join(process.resourcesPath, 'drizzle')
|
||||||
|
: path.join(__dirname, '..', '..', 'drizzle');
|
||||||
|
const db = initDatabase({ dbPath: path.join(userData, 'bds.db'), migrationsFolder });
|
||||||
|
await db.initializeLocal();
|
||||||
|
|
||||||
|
// 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 +973,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 +1020,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 +1033,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,9 +383,16 @@ 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),
|
||||||
|
removeFromAgentConfig: (agentId: string) => ipcRenderer.invoke('mcp:removeFromAgentConfig', agentId),
|
||||||
isConfigured: (agentId: string) => ipcRenderer.invoke('mcp:isConfigured', agentId),
|
isConfigured: (agentId: string) => ipcRenderer.invoke('mcp:isConfigured', agentId),
|
||||||
getPort: () => ipcRenderer.invoke('mcp:getPort'),
|
getPort: () => ipcRenderer.invoke('mcp:getPort'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,9 +843,12 @@ 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 }>;
|
||||||
|
removeFromAgentConfig: (agentId: string) => Promise<{ success: boolean; configPath: string; error?: string }>;
|
||||||
isConfigured: (agentId: string) => Promise<boolean>;
|
isConfigured: (agentId: string) => Promise<boolean>;
|
||||||
getPort: () => Promise<number | null>;
|
getPort: () => Promise<number | null>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ const MCPStatusBadge: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Button to add bDS MCP server to an agent's config. Shows "Configured" if already present. */
|
/** Button to add/remove bDS MCP server to/from an agent's config. */
|
||||||
const MCPAgentButton: React.FC<{ agentId: string; agentLabel: string }> = ({ agentId, agentLabel }) => {
|
const MCPAgentButton: React.FC<{ agentId: string; agentLabel: string }> = ({ agentId, agentLabel }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [configured, setConfigured] = React.useState(false);
|
const [configured, setConfigured] = React.useState(false);
|
||||||
@@ -149,7 +149,30 @@ const MCPAgentButton: React.FC<{ agentId: string; agentLabel: string }> = ({ age
|
|||||||
}, [agentId]);
|
}, [agentId]);
|
||||||
|
|
||||||
if (configured) {
|
if (configured) {
|
||||||
return <span className="badge badge-success">{t('settings.mcp.alreadyConfigured')}</span>;
|
return (
|
||||||
|
<button
|
||||||
|
className="secondary danger"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI?.mcp?.removeFromAgentConfig(agentId);
|
||||||
|
if (result?.success) {
|
||||||
|
showToast.success(t('settings.toast.mcpConfigRemoveSuccess', { agent: agentLabel }));
|
||||||
|
setConfigured(false);
|
||||||
|
} else {
|
||||||
|
showToast.error(t('settings.toast.mcpConfigRemoveFailed', { agent: agentLabel, error: result?.error ?? 'Unknown error' }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast.error(t('settings.toast.mcpConfigRemoveFailed', { agent: agentLabel, error: 'Unexpected error' }));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.mcp.removeFromAgent', { agent: agentLabel })}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1257,6 +1280,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
const renderMCPSettings = () => {
|
const renderMCPSettings = () => {
|
||||||
const agents = [
|
const agents = [
|
||||||
{ 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' },
|
||||||
|
|||||||
@@ -988,8 +988,11 @@
|
|||||||
"settings.mcp.agentsTitle": "Agenten-Konfiguration",
|
"settings.mcp.agentsTitle": "Agenten-Konfiguration",
|
||||||
"settings.mcp.agentsDescription": "Fügen Sie den bDS MCP-Server zur Konfiguration Ihres Programmieragenten hinzu. Vorhandene Einstellungen bleiben erhalten.",
|
"settings.mcp.agentsDescription": "Fügen Sie den bDS MCP-Server zur Konfiguration Ihres Programmieragenten hinzu. Vorhandene Einstellungen bleiben erhalten.",
|
||||||
"settings.mcp.addToAgent": "Zu {agent} hinzufügen",
|
"settings.mcp.addToAgent": "Zu {agent} hinzufügen",
|
||||||
|
"settings.mcp.removeFromAgent": "Aus {agent} entfernen",
|
||||||
"settings.mcp.alreadyConfigured": "Konfiguriert",
|
"settings.mcp.alreadyConfigured": "Konfiguriert",
|
||||||
"settings.toast.mcpConfigSuccess": "bDS MCP-Server zur {agent}-Konfiguration hinzugefügt",
|
"settings.toast.mcpConfigSuccess": "bDS MCP-Server zur {agent}-Konfiguration hinzugefügt",
|
||||||
|
"settings.toast.mcpConfigRemoveSuccess": "bDS MCP-Server aus der {agent}-Konfiguration entfernt",
|
||||||
"settings.toast.mcpConfigFailed": "Konfiguration von {agent} fehlgeschlagen: {error}",
|
"settings.toast.mcpConfigFailed": "Konfiguration von {agent} fehlgeschlagen: {error}",
|
||||||
|
"settings.toast.mcpConfigRemoveFailed": "Entfernen aus {agent} fehlgeschlagen: {error}",
|
||||||
"settings.toast.mcpConfigPath": "Konfiguration geschrieben nach {path}"
|
"settings.toast.mcpConfigPath": "Konfiguration geschrieben nach {path}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -988,8 +988,11 @@
|
|||||||
"settings.mcp.agentsTitle": "Agent Configuration",
|
"settings.mcp.agentsTitle": "Agent Configuration",
|
||||||
"settings.mcp.agentsDescription": "Add the bDS MCP server to your coding agent's configuration. Existing settings are preserved.",
|
"settings.mcp.agentsDescription": "Add the bDS MCP server to your coding agent's configuration. Existing settings are preserved.",
|
||||||
"settings.mcp.addToAgent": "Add to {agent}",
|
"settings.mcp.addToAgent": "Add to {agent}",
|
||||||
|
"settings.mcp.removeFromAgent": "Remove from {agent}",
|
||||||
"settings.mcp.alreadyConfigured": "Configured",
|
"settings.mcp.alreadyConfigured": "Configured",
|
||||||
"settings.toast.mcpConfigSuccess": "bDS MCP server added to {agent} configuration",
|
"settings.toast.mcpConfigSuccess": "bDS MCP server added to {agent} configuration",
|
||||||
|
"settings.toast.mcpConfigRemoveSuccess": "bDS MCP server removed from {agent} configuration",
|
||||||
"settings.toast.mcpConfigFailed": "Failed to configure {agent}: {error}",
|
"settings.toast.mcpConfigFailed": "Failed to configure {agent}: {error}",
|
||||||
|
"settings.toast.mcpConfigRemoveFailed": "Failed to remove from {agent}: {error}",
|
||||||
"settings.toast.mcpConfigPath": "Config written to {path}"
|
"settings.toast.mcpConfigPath": "Config written to {path}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -988,8 +988,11 @@
|
|||||||
"settings.mcp.agentsTitle": "Configuración de agentes",
|
"settings.mcp.agentsTitle": "Configuración de agentes",
|
||||||
"settings.mcp.agentsDescription": "Añada el servidor MCP de bDS a la configuración de su agente de programación. Las configuraciones existentes se conservan.",
|
"settings.mcp.agentsDescription": "Añada el servidor MCP de bDS a la configuración de su agente de programación. Las configuraciones existentes se conservan.",
|
||||||
"settings.mcp.addToAgent": "Añadir a {agent}",
|
"settings.mcp.addToAgent": "Añadir a {agent}",
|
||||||
|
"settings.mcp.removeFromAgent": "Eliminar de {agent}",
|
||||||
"settings.mcp.alreadyConfigured": "Configurado",
|
"settings.mcp.alreadyConfigured": "Configurado",
|
||||||
"settings.toast.mcpConfigSuccess": "Servidor MCP de bDS añadido a la configuración de {agent}",
|
"settings.toast.mcpConfigSuccess": "Servidor MCP de bDS añadido a la configuración de {agent}",
|
||||||
|
"settings.toast.mcpConfigRemoveSuccess": "Servidor MCP de bDS eliminado de la configuración de {agent}",
|
||||||
"settings.toast.mcpConfigFailed": "Error al configurar {agent}: {error}",
|
"settings.toast.mcpConfigFailed": "Error al configurar {agent}: {error}",
|
||||||
|
"settings.toast.mcpConfigRemoveFailed": "Error al eliminar de {agent}: {error}",
|
||||||
"settings.toast.mcpConfigPath": "Configuración escrita en {path}"
|
"settings.toast.mcpConfigPath": "Configuración escrita en {path}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -986,8 +986,11 @@
|
|||||||
"settings.mcp.agentsTitle": "Configuration des agents",
|
"settings.mcp.agentsTitle": "Configuration des agents",
|
||||||
"settings.mcp.agentsDescription": "Ajoutez le serveur MCP bDS à la configuration de votre agent de programmation. Les paramètres existants sont préservés.",
|
"settings.mcp.agentsDescription": "Ajoutez le serveur MCP bDS à la configuration de votre agent de programmation. Les paramètres existants sont préservés.",
|
||||||
"settings.mcp.addToAgent": "Ajouter à {agent}",
|
"settings.mcp.addToAgent": "Ajouter à {agent}",
|
||||||
|
"settings.mcp.removeFromAgent": "Retirer de {agent}",
|
||||||
"settings.mcp.alreadyConfigured": "Configuré",
|
"settings.mcp.alreadyConfigured": "Configuré",
|
||||||
"settings.toast.mcpConfigSuccess": "Serveur MCP bDS ajouté à la configuration de {agent}",
|
"settings.toast.mcpConfigSuccess": "Serveur MCP bDS ajouté à la configuration de {agent}",
|
||||||
|
"settings.toast.mcpConfigRemoveSuccess": "Serveur MCP bDS retiré de la configuration de {agent}",
|
||||||
"settings.toast.mcpConfigFailed": "Échec de la configuration de {agent}: {error}",
|
"settings.toast.mcpConfigFailed": "Échec de la configuration de {agent}: {error}",
|
||||||
|
"settings.toast.mcpConfigRemoveFailed": "Échec du retrait de {agent}: {error}",
|
||||||
"settings.toast.mcpConfigPath": "Configuration écrite dans {path}"
|
"settings.toast.mcpConfigPath": "Configuration écrite dans {path}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -986,8 +986,11 @@
|
|||||||
"settings.mcp.agentsTitle": "Configurazione agenti",
|
"settings.mcp.agentsTitle": "Configurazione agenti",
|
||||||
"settings.mcp.agentsDescription": "Aggiungi il server MCP bDS alla configurazione del tuo agente di programmazione. Le impostazioni esistenti vengono preservate.",
|
"settings.mcp.agentsDescription": "Aggiungi il server MCP bDS alla configurazione del tuo agente di programmazione. Le impostazioni esistenti vengono preservate.",
|
||||||
"settings.mcp.addToAgent": "Aggiungi a {agent}",
|
"settings.mcp.addToAgent": "Aggiungi a {agent}",
|
||||||
|
"settings.mcp.removeFromAgent": "Rimuovi da {agent}",
|
||||||
"settings.mcp.alreadyConfigured": "Configurato",
|
"settings.mcp.alreadyConfigured": "Configurato",
|
||||||
"settings.toast.mcpConfigSuccess": "Server MCP bDS aggiunto alla configurazione di {agent}",
|
"settings.toast.mcpConfigSuccess": "Server MCP bDS aggiunto alla configurazione di {agent}",
|
||||||
|
"settings.toast.mcpConfigRemoveSuccess": "Server MCP bDS rimosso dalla configurazione di {agent}",
|
||||||
"settings.toast.mcpConfigFailed": "Configurazione di {agent} non riuscita: {error}",
|
"settings.toast.mcpConfigFailed": "Configurazione di {agent} non riuscita: {error}",
|
||||||
|
"settings.toast.mcpConfigRemoveFailed": "Rimozione da {agent} non riuscita: {error}",
|
||||||
"settings.toast.mcpConfigPath": "Configurazione scritta in {path}"
|
"settings.toast.mcpConfigPath": "Configurazione scritta in {path}"
|
||||||
}
|
}
|
||||||
|
|||||||
208
tests/cli/bds-mcp-project-context.test.ts
Normal file
208
tests/cli/bds-mcp-project-context.test.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that the MCP standalone CLI calls setProjectContext() on every
|
||||||
|
* engine after resolving the active project from the database.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Spies that each mock-engine instance populates ──────────────────────────
|
||||||
|
|
||||||
|
let postEngineSetProjectContext: ReturnType<typeof vi.fn>;
|
||||||
|
let mediaEngineSetProjectContext: ReturnType<typeof vi.fn>;
|
||||||
|
let postMediaEngineSetProjectContext: ReturnType<typeof vi.fn>;
|
||||||
|
let tagEngineSetProjectContext: ReturnType<typeof vi.fn>;
|
||||||
|
let scriptEngineSetProjectContext: ReturnType<typeof vi.fn>;
|
||||||
|
let templateEngineSetProjectContext: ReturnType<typeof vi.fn>;
|
||||||
|
let metaEngineSetProjectContext: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
function resetSpies(): void {
|
||||||
|
postEngineSetProjectContext = vi.fn();
|
||||||
|
mediaEngineSetProjectContext = vi.fn();
|
||||||
|
postMediaEngineSetProjectContext = vi.fn();
|
||||||
|
tagEngineSetProjectContext = vi.fn();
|
||||||
|
scriptEngineSetProjectContext = vi.fn();
|
||||||
|
templateEngineSetProjectContext = vi.fn();
|
||||||
|
metaEngineSetProjectContext = vi.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active project data ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ACTIVE_PROJECT_CUSTOM_PATH = {
|
||||||
|
id: 'my-blog',
|
||||||
|
name: 'My Blog',
|
||||||
|
slug: 'my-blog',
|
||||||
|
description: 'Test project',
|
||||||
|
dataPath: '/custom/data/path',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIVE_PROJECT_DEFAULT_PATH = {
|
||||||
|
id: 'internal-project',
|
||||||
|
name: 'Internal Project',
|
||||||
|
slug: 'internal-project',
|
||||||
|
description: null,
|
||||||
|
dataPath: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Chainable query mock ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let mockActiveProject: typeof ACTIVE_PROJECT_CUSTOM_PATH | typeof ACTIVE_PROJECT_DEFAULT_PATH = ACTIVE_PROJECT_CUSTOM_PATH;
|
||||||
|
|
||||||
|
function makeMockLocalDb() {
|
||||||
|
return {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
get: vi.fn().mockResolvedValue(mockActiveProject),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hoisted mocks ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('../../src/cli/platform', () => ({
|
||||||
|
platformConfigPath: vi.fn(() => '/tmp/mock-userdata'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/database/connection', () => {
|
||||||
|
const mockDb = {
|
||||||
|
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getLocal: vi.fn(() => makeMockLocalDb()),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
initDatabase: vi.fn(() => mockDb),
|
||||||
|
getDatabase: vi.fn(() => mockDb),
|
||||||
|
DatabaseConnection: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/main/database/schema', () => ({
|
||||||
|
projects: { isActive: 'isActive' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('drizzle-orm', () => ({
|
||||||
|
eq: vi.fn((_col: unknown, _val: unknown) => 'isActive=true'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/engine/CliNotifier', () => ({
|
||||||
|
DbNotifier: vi.fn().mockImplementation(function(this: Record<string, unknown>) { return this; }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/engine/PostEngine', () => ({
|
||||||
|
PostEngine: vi.fn().mockImplementation(function(this: Record<string, unknown>) {
|
||||||
|
this.setProjectContext = (...args: unknown[]) => postEngineSetProjectContext(...args);
|
||||||
|
return this;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/engine/MediaEngine', () => ({
|
||||||
|
MediaEngine: vi.fn().mockImplementation(function(this: Record<string, unknown>) {
|
||||||
|
this.setProjectContext = (...args: unknown[]) => mediaEngineSetProjectContext(...args);
|
||||||
|
return this;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/engine/PostMediaEngine', () => ({
|
||||||
|
PostMediaEngine: vi.fn().mockImplementation(function(this: Record<string, unknown>) {
|
||||||
|
this.setProjectContext = (...args: unknown[]) => postMediaEngineSetProjectContext(...args);
|
||||||
|
return this;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/engine/TagEngine', () => ({
|
||||||
|
TagEngine: vi.fn().mockImplementation(function(this: Record<string, unknown>) {
|
||||||
|
this.setProjectContext = (...args: unknown[]) => tagEngineSetProjectContext(...args);
|
||||||
|
return this;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/engine/ScriptEngine', () => ({
|
||||||
|
ScriptEngine: vi.fn().mockImplementation(function(this: Record<string, unknown>) {
|
||||||
|
this.setProjectContext = (...args: unknown[]) => scriptEngineSetProjectContext(...args);
|
||||||
|
return this;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/engine/TemplateEngine', () => ({
|
||||||
|
TemplateEngine: vi.fn().mockImplementation(function(this: Record<string, unknown>) {
|
||||||
|
this.setProjectContext = (...args: unknown[]) => templateEngineSetProjectContext(...args);
|
||||||
|
return this;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/engine/MetaEngine', () => ({
|
||||||
|
MetaEngine: vi.fn().mockImplementation(function(this: Record<string, unknown>) {
|
||||||
|
this.setProjectContext = (...args: unknown[]) => metaEngineSetProjectContext(...args);
|
||||||
|
return this;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/engine/MCPServer', () => ({
|
||||||
|
MCPServer: vi.fn().mockImplementation(function(this: Record<string, unknown>) {
|
||||||
|
this.startCli = vi.fn().mockResolvedValue(undefined);
|
||||||
|
this.cleanup = vi.fn().mockResolvedValue(undefined);
|
||||||
|
return this;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('bds-mcp project context initialisation', () => {
|
||||||
|
const originalExit = process.exit;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetSpies();
|
||||||
|
// Prevent process.exit from actually killing the test runner
|
||||||
|
process.exit = vi.fn() as never;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.exit = originalExit;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls setProjectContext on all engines with the active project (custom dataPath)', async () => {
|
||||||
|
mockActiveProject = ACTIVE_PROJECT_CUSTOM_PATH;
|
||||||
|
|
||||||
|
await import('../../src/cli/bds-mcp');
|
||||||
|
// Give main() a tick to complete
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
|
const expectedProjectId = 'my-blog';
|
||||||
|
const expectedDataDir = '/custom/data/path';
|
||||||
|
|
||||||
|
expect(postEngineSetProjectContext).toHaveBeenCalledWith(expectedProjectId, expectedDataDir);
|
||||||
|
expect(mediaEngineSetProjectContext).toHaveBeenCalledWith(expectedProjectId, expectedDataDir, expectedDataDir);
|
||||||
|
expect(postMediaEngineSetProjectContext).toHaveBeenCalledWith(expectedProjectId);
|
||||||
|
expect(tagEngineSetProjectContext).toHaveBeenCalledWith(expectedProjectId, expectedDataDir);
|
||||||
|
expect(scriptEngineSetProjectContext).toHaveBeenCalledWith(expectedProjectId, expectedDataDir);
|
||||||
|
expect(templateEngineSetProjectContext).toHaveBeenCalledWith(expectedProjectId, expectedDataDir);
|
||||||
|
expect(metaEngineSetProjectContext).toHaveBeenCalledWith(expectedProjectId, expectedDataDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses internal userData path when project has no custom dataPath', async () => {
|
||||||
|
mockActiveProject = ACTIVE_PROJECT_DEFAULT_PATH;
|
||||||
|
|
||||||
|
await import('../../src/cli/bds-mcp');
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
|
const expectedDataDir = '/tmp/mock-userdata/projects/internal-project';
|
||||||
|
|
||||||
|
expect(postEngineSetProjectContext).toHaveBeenCalledWith('internal-project', expectedDataDir);
|
||||||
|
expect(mediaEngineSetProjectContext).toHaveBeenCalledWith('internal-project', expectedDataDir, expectedDataDir);
|
||||||
|
expect(postMediaEngineSetProjectContext).toHaveBeenCalledWith('internal-project');
|
||||||
|
expect(tagEngineSetProjectContext).toHaveBeenCalledWith('internal-project', expectedDataDir);
|
||||||
|
expect(scriptEngineSetProjectContext).toHaveBeenCalledWith('internal-project', expectedDataDir);
|
||||||
|
expect(templateEngineSetProjectContext).toHaveBeenCalledWith('internal-project', expectedDataDir);
|
||||||
|
expect(metaEngineSetProjectContext).toHaveBeenCalledWith('internal-project', expectedDataDir);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
tests/cli/platform.test.ts
Normal file
55
tests/cli/platform.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { platformConfigPath } from '../../src/cli/platform';
|
||||||
|
|
||||||
|
const APP = 'Blogging Desktop Server';
|
||||||
|
|
||||||
|
describe('platformConfigPath', () => {
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns ~/Library/Application Support/<app> on macOS', () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||||
|
const home = os.homedir();
|
||||||
|
expect(platformConfigPath()).toBe(path.join(home, 'Library', 'Application Support', APP));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns %APPDATA%/<app> on Windows when APPDATA is set', () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||||
|
process.env['APPDATA'] = '/mock/AppData/Roaming';
|
||||||
|
expect(platformConfigPath()).toBe(path.join('/mock/AppData/Roaming', APP));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to ~/AppData/Roaming/<app> on Windows when APPDATA is unset', () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||||
|
delete process.env['APPDATA'];
|
||||||
|
const home = os.homedir();
|
||||||
|
expect(platformConfigPath()).toBe(path.join(home, 'AppData', 'Roaming', APP));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns ~/.config/<app> on Linux', () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||||
|
delete process.env['XDG_CONFIG_HOME'];
|
||||||
|
const home = os.homedir();
|
||||||
|
expect(platformConfigPath()).toBe(path.join(home, '.config', APP));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours XDG_CONFIG_HOME on Linux', () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||||
|
process.env['XDG_CONFIG_HOME'] = '/custom/config';
|
||||||
|
expect(platformConfigPath()).toBe(path.join('/custom/config', APP));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats unknown platforms the same as Linux', () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'freebsd' });
|
||||||
|
delete process.env['XDG_CONFIG_HOME'];
|
||||||
|
const home = os.homedir();
|
||||||
|
expect(platformConfigPath()).toBe(path.join(home, '.config', APP));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
71
tests/engine/CliNotifier.test.ts
Normal file
71
tests/engine/CliNotifier.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { NoopNotifier, DbNotifier, type NotifyEntity, type NotifyAction } from '../../src/main/engine/CliNotifier';
|
||||||
|
import { dbNotifications } from '../../src/main/database/schema';
|
||||||
|
|
||||||
|
describe('NoopNotifier', () => {
|
||||||
|
it('resolves without side effects', async () => {
|
||||||
|
const notifier = new NoopNotifier();
|
||||||
|
await expect(notifier.notify('post', 'p-1', 'created')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts every valid entity and action combination', async () => {
|
||||||
|
const notifier = new NoopNotifier();
|
||||||
|
const entities: NotifyEntity[] = ['post', 'media', 'script', 'template'];
|
||||||
|
const actions: NotifyAction[] = ['created', 'updated', 'deleted'];
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
for (const action of actions) {
|
||||||
|
await expect(notifier.notify(entity, 'id-1', action)).resolves.toBeUndefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DbNotifier', () => {
|
||||||
|
let valuesFn: ReturnType<typeof vi.fn>;
|
||||||
|
let insertFn: ReturnType<typeof vi.fn>;
|
||||||
|
let mockDb: { insert: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
valuesFn = vi.fn().mockResolvedValue(undefined);
|
||||||
|
insertFn = vi.fn().mockReturnValue({ values: valuesFn });
|
||||||
|
mockDb = { insert: insertFn };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts a notification row into db_notifications', async () => {
|
||||||
|
const notifier = new DbNotifier(mockDb);
|
||||||
|
const beforeMs = Date.now();
|
||||||
|
|
||||||
|
await notifier.notify('post', 'post-42', 'created');
|
||||||
|
|
||||||
|
expect(insertFn).toHaveBeenCalledWith(dbNotifications);
|
||||||
|
expect(valuesFn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const row = valuesFn.mock.calls[0][0];
|
||||||
|
expect(row.entity).toBe('post');
|
||||||
|
expect(row.entityId).toBe('post-42');
|
||||||
|
expect(row.action).toBe('created');
|
||||||
|
expect(row.fromCli).toBe(1);
|
||||||
|
expect(row.seenAt).toBeNull();
|
||||||
|
expect(row.createdAt).toBeGreaterThanOrEqual(beforeMs);
|
||||||
|
expect(row.createdAt).toBeLessThanOrEqual(Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts distinct rows for consecutive calls', async () => {
|
||||||
|
const notifier = new DbNotifier(mockDb);
|
||||||
|
|
||||||
|
await notifier.notify('media', 'm-1', 'updated');
|
||||||
|
await notifier.notify('script', 's-1', 'deleted');
|
||||||
|
|
||||||
|
expect(valuesFn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(valuesFn.mock.calls[0][0].entity).toBe('media');
|
||||||
|
expect(valuesFn.mock.calls[1][0].entity).toBe('script');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates database insertion errors', async () => {
|
||||||
|
valuesFn.mockRejectedValueOnce(new Error('SQLITE_BUSY'));
|
||||||
|
const notifier = new DbNotifier(mockDb);
|
||||||
|
|
||||||
|
await expect(notifier.notify('template', 't-1', 'created')).rejects.toThrow('SQLITE_BUSY');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,28 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { MCPServer, type MCPServerDependencies, DEFAULT_PAGE_SIZE, encodeCursor, decodeCursor } from '../../src/main/engine/MCPServer';
|
import { MCPServer, type MCPServerDependencies, DEFAULT_PAGE_SIZE, encodeCursor, decodeCursor } from '../../src/main/engine/MCPServer';
|
||||||
|
|
||||||
// Mock all engine singletons
|
|
||||||
vi.mock('../../src/main/engine/PostEngine', () => ({
|
|
||||||
getPostEngine: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock('../../src/main/engine/MediaEngine', () => ({
|
|
||||||
getMediaEngine: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock('../../src/main/engine/ScriptEngine', () => ({
|
|
||||||
getScriptEngine: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock('../../src/main/engine/TemplateEngine', () => ({
|
|
||||||
getTemplateEngine: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock('../../src/main/engine/MetaEngine', () => ({
|
|
||||||
getMetaEngine: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock('../../src/main/engine/PostMediaEngine', () => ({
|
|
||||||
getPostMediaEngine: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock('../../src/main/engine/TagEngine', () => ({
|
|
||||||
getTagEngine: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function createMockPostEngine() {
|
function createMockPostEngine() {
|
||||||
return {
|
return {
|
||||||
@@ -60,22 +39,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 +101,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 +349,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 +402,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 +818,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(
|
||||||
|
|||||||
@@ -445,4 +445,142 @@ describe('ScriptEngine', () => {
|
|||||||
expect(endFn).toHaveBeenCalled();
|
expect(endFn).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('draft lifecycle', () => {
|
||||||
|
it('createDraftScript inserts a row with status draft and content in DB', async () => {
|
||||||
|
const created = await scriptEngine.createDraftScript({
|
||||||
|
title: 'Draft Macro',
|
||||||
|
kind: 'macro',
|
||||||
|
content: 'def render(ctx): return {"html": "<p>draft</p>"}',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created.status).toBe('draft');
|
||||||
|
expect(created.content).toBe('def render(ctx): return {"html": "<p>draft</p>"}');
|
||||||
|
expect(created.slug).toBe('draft_macro');
|
||||||
|
expect(mockScripts.has(created.id)).toBe(true);
|
||||||
|
|
||||||
|
const row = mockScripts.get(created.id);
|
||||||
|
expect(row.status).toBe('draft');
|
||||||
|
expect(row.content).toBe('def render(ctx): return {"html": "<p>draft</p>"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createDraftScript does not write a file to disk', async () => {
|
||||||
|
const fsModule = await import('fs/promises');
|
||||||
|
vi.mocked(fsModule.writeFile).mockClear();
|
||||||
|
|
||||||
|
await scriptEngine.createDraftScript({
|
||||||
|
title: 'No File Draft',
|
||||||
|
kind: 'utility',
|
||||||
|
content: 'print("no file")',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fsModule.writeFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createDraftScript generates a unique slug', async () => {
|
||||||
|
await scriptEngine.createDraftScript({
|
||||||
|
title: 'Unique Slug',
|
||||||
|
kind: 'macro',
|
||||||
|
content: 'pass',
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked((await import('uuid')).v4).mockReturnValueOnce('mock-script-id-2');
|
||||||
|
|
||||||
|
const second = await scriptEngine.createDraftScript({
|
||||||
|
title: 'Unique Slug',
|
||||||
|
kind: 'macro',
|
||||||
|
content: 'pass',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second.slug).toBe('unique_slug_2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishScript writes file and sets status to published', async () => {
|
||||||
|
const draft = await scriptEngine.createDraftScript({
|
||||||
|
title: 'Publish Me',
|
||||||
|
kind: 'macro',
|
||||||
|
content: 'def render(ctx): return {"html": "<p>publish</p>"}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const published = await scriptEngine.publishScript(draft.id);
|
||||||
|
|
||||||
|
expect(published).not.toBeNull();
|
||||||
|
expect(published!.status).toBe('published');
|
||||||
|
|
||||||
|
const row = mockScripts.get(draft.id);
|
||||||
|
expect(row.status).toBe('published');
|
||||||
|
expect(row.content).toBeNull();
|
||||||
|
|
||||||
|
const fileContent = mockFiles.get(draft.filePath);
|
||||||
|
expect(fileContent).toBeDefined();
|
||||||
|
expect(fileContent).toContain('title: "Publish Me"');
|
||||||
|
expect(fileContent).toContain('def render');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishScript returns null for non-existent script', async () => {
|
||||||
|
const result = await scriptEngine.publishScript('nonexistent-id');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishScript calls notifier with updated action', async () => {
|
||||||
|
const mockNotifier = { notify: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const notifiedEngine = new ScriptEngine(mockNotifier);
|
||||||
|
notifiedEngine.setProjectContext('default', '/mock/userData/projects/default');
|
||||||
|
|
||||||
|
const draft = await notifiedEngine.createDraftScript({
|
||||||
|
title: 'Notified Publish',
|
||||||
|
kind: 'macro',
|
||||||
|
content: 'def render(ctx): pass',
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifiedEngine.publishScript(draft.id);
|
||||||
|
|
||||||
|
expect(mockNotifier.notify).toHaveBeenCalledWith('script', draft.id, 'updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteDraftScript removes a draft row from the database', async () => {
|
||||||
|
const draft = await scriptEngine.createDraftScript({
|
||||||
|
title: 'Delete Draft',
|
||||||
|
kind: 'utility',
|
||||||
|
content: 'pass',
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = await scriptEngine.deleteDraftScript(draft.id);
|
||||||
|
|
||||||
|
expect(deleted).toBe(true);
|
||||||
|
expect(mockScripts.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteDraftScript returns false for non-existent script', async () => {
|
||||||
|
const result = await scriptEngine.deleteDraftScript('no-such-id');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteDraftScript returns false for published scripts', async () => {
|
||||||
|
const created = await scriptEngine.createScript({
|
||||||
|
title: 'Published Script',
|
||||||
|
kind: 'macro',
|
||||||
|
content: 'def render(ctx): pass',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scriptEngine.deleteDraftScript(created.id);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteDraftScript calls notifier with deleted action', async () => {
|
||||||
|
const mockNotifier = { notify: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const notifiedEngine = new ScriptEngine(mockNotifier);
|
||||||
|
notifiedEngine.setProjectContext('default', '/mock/userData/projects/default');
|
||||||
|
|
||||||
|
const draft = await notifiedEngine.createDraftScript({
|
||||||
|
title: 'Notified Delete',
|
||||||
|
kind: 'utility',
|
||||||
|
content: 'pass',
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifiedEngine.deleteDraftScript(draft.id);
|
||||||
|
|
||||||
|
expect(mockNotifier.notify).toHaveBeenCalledWith('script', draft.id, 'deleted');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -517,4 +517,142 @@ describe('TemplateEngine', () => {
|
|||||||
expect(row.title).toBe(originalTitle);
|
expect(row.title).toBe(originalTitle);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('draft lifecycle', () => {
|
||||||
|
it('createDraftTemplate inserts a row with status draft and content in DB', async () => {
|
||||||
|
const created = await templateEngine.createDraftTemplate({
|
||||||
|
title: 'Draft Post Layout',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>{{ post.title }}</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created.status).toBe('draft');
|
||||||
|
expect(created.content).toBe('<main>{{ post.title }}</main>');
|
||||||
|
expect(created.slug).toBe('draft_post_layout');
|
||||||
|
expect(mockTemplates.has(created.id)).toBe(true);
|
||||||
|
|
||||||
|
const row = mockTemplates.get(created.id);
|
||||||
|
expect(row.status).toBe('draft');
|
||||||
|
expect(row.content).toBe('<main>{{ post.title }}</main>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createDraftTemplate does not write a file to disk', async () => {
|
||||||
|
const fsModule = await import('fs/promises');
|
||||||
|
vi.mocked(fsModule.writeFile).mockClear();
|
||||||
|
|
||||||
|
await templateEngine.createDraftTemplate({
|
||||||
|
title: 'No File Draft',
|
||||||
|
kind: 'list',
|
||||||
|
content: '<main>{{ day_blocks }}</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fsModule.writeFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createDraftTemplate generates a unique slug', async () => {
|
||||||
|
await templateEngine.createDraftTemplate({
|
||||||
|
title: 'Unique Slug',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>first</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked((await import('uuid')).v4).mockReturnValueOnce('mock-template-id-2');
|
||||||
|
|
||||||
|
const second = await templateEngine.createDraftTemplate({
|
||||||
|
title: 'Unique Slug',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>second</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second.slug).toBe('unique_slug_2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishTemplate writes file and sets status to published', async () => {
|
||||||
|
const draft = await templateEngine.createDraftTemplate({
|
||||||
|
title: 'Publish Me',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>{{ post.content }}</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
const published = await templateEngine.publishTemplate(draft.id);
|
||||||
|
|
||||||
|
expect(published).not.toBeNull();
|
||||||
|
expect(published!.status).toBe('published');
|
||||||
|
|
||||||
|
const row = mockTemplates.get(draft.id);
|
||||||
|
expect(row.status).toBe('published');
|
||||||
|
expect(row.content).toBeNull();
|
||||||
|
|
||||||
|
const fileContent = mockFiles.get(draft.filePath);
|
||||||
|
expect(fileContent).toBeDefined();
|
||||||
|
expect(fileContent).toContain('title: "Publish Me"');
|
||||||
|
expect(fileContent).toContain('{{ post.content }}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishTemplate returns null for non-existent template', async () => {
|
||||||
|
const result = await templateEngine.publishTemplate('nonexistent-id');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishTemplate calls notifier with updated action', async () => {
|
||||||
|
const mockNotifier = { notify: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const notifiedEngine = new TemplateEngine(mockNotifier);
|
||||||
|
notifiedEngine.setProjectContext('default', '/mock/userData/projects/default');
|
||||||
|
|
||||||
|
const draft = await notifiedEngine.createDraftTemplate({
|
||||||
|
title: 'Notified Publish',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>notify</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifiedEngine.publishTemplate(draft.id);
|
||||||
|
|
||||||
|
expect(mockNotifier.notify).toHaveBeenCalledWith('template', draft.id, 'updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteDraftTemplate removes a draft row from the database', async () => {
|
||||||
|
const draft = await templateEngine.createDraftTemplate({
|
||||||
|
title: 'Delete Draft',
|
||||||
|
kind: 'partial',
|
||||||
|
content: '<footer>draft</footer>',
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = await templateEngine.deleteDraftTemplate(draft.id);
|
||||||
|
|
||||||
|
expect(deleted).toBe(true);
|
||||||
|
expect(mockTemplates.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteDraftTemplate returns false for non-existent template', async () => {
|
||||||
|
const result = await templateEngine.deleteDraftTemplate('no-such-id');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteDraftTemplate returns false for published templates', async () => {
|
||||||
|
const created = await templateEngine.createTemplate({
|
||||||
|
title: 'Published Template',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>published</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await templateEngine.deleteDraftTemplate(created.id);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteDraftTemplate calls notifier with deleted action', async () => {
|
||||||
|
const mockNotifier = { notify: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const notifiedEngine = new TemplateEngine(mockNotifier);
|
||||||
|
notifiedEngine.setProjectContext('default', '/mock/userData/projects/default');
|
||||||
|
|
||||||
|
const draft = await notifiedEngine.createDraftTemplate({
|
||||||
|
title: 'Notified Delete',
|
||||||
|
kind: 'partial',
|
||||||
|
content: '<footer>notify</footer>',
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifiedEngine.deleteDraftTemplate(draft.id);
|
||||||
|
|
||||||
|
expect(mockNotifier.notify).toHaveBeenCalledWith('template', draft.id, 'deleted');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the database connection module to prevent the native libsql binary from loading.
|
||||||
|
// Some modules (e.g. generatedFileHashStore) import directly from connection.ts
|
||||||
|
// instead of the barrel, so mocking the barrel alone isn't sufficient.
|
||||||
|
vi.mock('../../src/main/database/connection', () => ({
|
||||||
|
DatabaseConnection: vi.fn(),
|
||||||
|
getDatabase: vi.fn(() => ({
|
||||||
|
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getLocal: vi.fn(() => null),
|
||||||
|
getLocalClient: vi.fn(() => null),
|
||||||
|
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
||||||
|
})),
|
||||||
|
initDatabase: vi.fn(() => ({
|
||||||
|
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getLocal: vi.fn(() => null),
|
||||||
|
getLocalClient: vi.fn(() => null),
|
||||||
|
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('main bootstrap preview behavior', () => {
|
describe('main bootstrap preview behavior', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -15,6 +36,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
whenReady: vi.fn(() => Promise.resolve()),
|
whenReady: vi.fn(() => Promise.resolve()),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
quit: vi.fn(),
|
quit: vi.fn(),
|
||||||
|
getPath: vi.fn(() => '/tmp/mock-userdata'),
|
||||||
|
isPackaged: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const browserWindowCalls: any[] = [];
|
const browserWindowCalls: any[] = [];
|
||||||
@@ -60,6 +83,12 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
openExternal: vi.fn(),
|
openExternal: vi.fn(),
|
||||||
openPath: vi.fn(),
|
openPath: vi.fn(),
|
||||||
},
|
},
|
||||||
|
screen: {
|
||||||
|
getPrimaryDisplay: vi.fn(() => ({
|
||||||
|
workArea: { x: 0, y: 0, width: 1920, height: 1080 },
|
||||||
|
workAreaSize: { width: 1920, height: 1080 },
|
||||||
|
})),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
class MockPreviewServer {
|
class MockPreviewServer {
|
||||||
@@ -72,8 +101,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
PreviewServer: MockPreviewServer,
|
PreviewServer: MockPreviewServer,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/database', () => ({
|
vi.doMock('../../src/main/database', () => {
|
||||||
getDatabase: vi.fn(() => ({
|
const mockDb = {
|
||||||
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
||||||
close: vi.fn().mockResolvedValue(undefined),
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
getLocal: vi.fn(() => ({
|
getLocal: vi.fn(() => ({
|
||||||
@@ -86,8 +115,13 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
||||||
})),
|
getDbPath: vi.fn(() => '/tmp/mock.db'),
|
||||||
}));
|
};
|
||||||
|
return {
|
||||||
|
initDatabase: vi.fn(() => mockDb),
|
||||||
|
getDatabase: vi.fn(() => mockDb),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.doMock('../../src/main/ipc', () => ({
|
vi.doMock('../../src/main/ipc', () => ({
|
||||||
registerIpcHandlers: vi.fn(),
|
registerIpcHandlers: vi.fn(),
|
||||||
@@ -99,6 +133,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
|
|
||||||
vi.doMock('../../src/main/database/schema', () => ({
|
vi.doMock('../../src/main/database/schema', () => ({
|
||||||
media: {},
|
media: {},
|
||||||
|
projects: {},
|
||||||
|
dbNotifications: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('drizzle-orm', () => ({
|
vi.doMock('drizzle-orm', () => ({
|
||||||
@@ -106,9 +142,9 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/engine/MediaEngine', () => ({
|
vi.doMock('../../src/main/engine/MediaEngine', () => ({
|
||||||
getMediaEngine: vi.fn(() => ({
|
MediaEngine: vi.fn().mockImplementation(function() { return {
|
||||||
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
|
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await import('../../src/main/main');
|
await import('../../src/main/main');
|
||||||
@@ -133,6 +169,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
whenReady: vi.fn(() => Promise.resolve()),
|
whenReady: vi.fn(() => Promise.resolve()),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
quit: vi.fn(),
|
quit: vi.fn(),
|
||||||
|
getPath: vi.fn(() => '/tmp/mock-userdata'),
|
||||||
|
isPackaged: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockBrowserWindowGetAllWindows = vi.fn(() => [{ id: 1 }]);
|
const mockBrowserWindowGetAllWindows = vi.fn(() => [{ id: 1 }]);
|
||||||
@@ -175,6 +213,12 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
openExternal: vi.fn(),
|
openExternal: vi.fn(),
|
||||||
openPath: vi.fn(),
|
openPath: vi.fn(),
|
||||||
},
|
},
|
||||||
|
screen: {
|
||||||
|
getPrimaryDisplay: vi.fn(() => ({
|
||||||
|
workArea: { x: 0, y: 0, width: 1920, height: 1080 },
|
||||||
|
workAreaSize: { width: 1920, height: 1080 },
|
||||||
|
})),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockPreviewStart = vi.fn().mockResolvedValue(4123);
|
const mockPreviewStart = vi.fn().mockResolvedValue(4123);
|
||||||
@@ -191,8 +235,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
PreviewServer: MockPreviewServer,
|
PreviewServer: MockPreviewServer,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/database', () => ({
|
vi.doMock('../../src/main/database', () => {
|
||||||
getDatabase: vi.fn(() => ({
|
const mockDb = {
|
||||||
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
||||||
close: vi.fn().mockResolvedValue(undefined),
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
getLocal: vi.fn(() => ({
|
getLocal: vi.fn(() => ({
|
||||||
@@ -205,8 +249,13 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
||||||
})),
|
getDbPath: vi.fn(() => '/tmp/mock.db'),
|
||||||
}));
|
};
|
||||||
|
return {
|
||||||
|
initDatabase: vi.fn(() => mockDb),
|
||||||
|
getDatabase: vi.fn(() => mockDb),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.doMock('../../src/main/ipc', () => ({
|
vi.doMock('../../src/main/ipc', () => ({
|
||||||
registerIpcHandlers: vi.fn(),
|
registerIpcHandlers: vi.fn(),
|
||||||
@@ -218,6 +267,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
|
|
||||||
vi.doMock('../../src/main/database/schema', () => ({
|
vi.doMock('../../src/main/database/schema', () => ({
|
||||||
media: {},
|
media: {},
|
||||||
|
projects: {},
|
||||||
|
dbNotifications: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('drizzle-orm', () => ({
|
vi.doMock('drizzle-orm', () => ({
|
||||||
@@ -225,9 +276,9 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/engine/MediaEngine', () => ({
|
vi.doMock('../../src/main/engine/MediaEngine', () => ({
|
||||||
getMediaEngine: vi.fn(() => ({
|
MediaEngine: vi.fn().mockImplementation(function() { return {
|
||||||
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
|
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await import('../../src/main/main');
|
await import('../../src/main/main');
|
||||||
@@ -243,6 +294,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
whenReady: vi.fn(() => Promise.resolve()),
|
whenReady: vi.fn(() => Promise.resolve()),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
quit: vi.fn(),
|
quit: vi.fn(),
|
||||||
|
getPath: vi.fn(() => '/tmp/mock-userdata'),
|
||||||
|
isPackaged: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockBrowserWindowGetAllWindows = vi.fn(() => [{ id: 1 }]);
|
const mockBrowserWindowGetAllWindows = vi.fn(() => [{ id: 1 }]);
|
||||||
@@ -334,13 +387,13 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.doMock('../../src/main/engine/PostEngine', () => ({
|
vi.doMock('../../src/main/engine/PostEngine', () => ({
|
||||||
getPostEngine: vi.fn(() => ({
|
PostEngine: vi.fn().mockImplementation(function() { return {
|
||||||
getPost,
|
getPost,
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/database', () => ({
|
vi.doMock('../../src/main/database', () => {
|
||||||
getDatabase: vi.fn(() => ({
|
const mockDb = {
|
||||||
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
||||||
close: vi.fn().mockResolvedValue(undefined),
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
getLocal: vi.fn(() => ({
|
getLocal: vi.fn(() => ({
|
||||||
@@ -353,8 +406,13 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
||||||
})),
|
getDbPath: vi.fn(() => '/tmp/mock.db'),
|
||||||
}));
|
};
|
||||||
|
return {
|
||||||
|
initDatabase: vi.fn(() => mockDb),
|
||||||
|
getDatabase: vi.fn(() => mockDb),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.doMock('../../src/main/ipc', () => ({
|
vi.doMock('../../src/main/ipc', () => ({
|
||||||
registerIpcHandlers: vi.fn(),
|
registerIpcHandlers: vi.fn(),
|
||||||
@@ -366,6 +424,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
|
|
||||||
vi.doMock('../../src/main/database/schema', () => ({
|
vi.doMock('../../src/main/database/schema', () => ({
|
||||||
media: {},
|
media: {},
|
||||||
|
projects: {},
|
||||||
|
dbNotifications: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('drizzle-orm', () => ({
|
vi.doMock('drizzle-orm', () => ({
|
||||||
@@ -373,9 +433,9 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/engine/MediaEngine', () => ({
|
vi.doMock('../../src/main/engine/MediaEngine', () => ({
|
||||||
getMediaEngine: vi.fn(() => ({
|
MediaEngine: vi.fn().mockImplementation(function() { return {
|
||||||
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
|
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await import('../../src/main/main');
|
await import('../../src/main/main');
|
||||||
@@ -408,6 +468,7 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
quit: vi.fn(),
|
quit: vi.fn(),
|
||||||
getPath: vi.fn((name: string) => (name === 'userData' ? '/tmp/bds-user-data' : '/tmp')),
|
getPath: vi.fn((name: string) => (name === 'userData' ? '/tmp/bds-user-data' : '/tmp')),
|
||||||
|
isPackaged: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const browserWindowCalls: any[] = [];
|
const browserWindowCalls: any[] = [];
|
||||||
@@ -482,8 +543,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
return { ...mocked, default: mocked };
|
return { ...mocked, default: mocked };
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.doMock('../../src/main/database', () => ({
|
vi.doMock('../../src/main/database', () => {
|
||||||
getDatabase: vi.fn(() => ({
|
const mockDb = {
|
||||||
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
||||||
close: vi.fn().mockResolvedValue(undefined),
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
getLocal: vi.fn(() => ({
|
getLocal: vi.fn(() => ({
|
||||||
@@ -496,8 +557,13 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
||||||
})),
|
getDbPath: vi.fn(() => '/tmp/mock.db'),
|
||||||
}));
|
};
|
||||||
|
return {
|
||||||
|
initDatabase: vi.fn(() => mockDb),
|
||||||
|
getDatabase: vi.fn(() => mockDb),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.doMock('../../src/main/ipc', () => ({
|
vi.doMock('../../src/main/ipc', () => ({
|
||||||
registerIpcHandlers: vi.fn(),
|
registerIpcHandlers: vi.fn(),
|
||||||
@@ -509,6 +575,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
|
|
||||||
vi.doMock('../../src/main/database/schema', () => ({
|
vi.doMock('../../src/main/database/schema', () => ({
|
||||||
media: {},
|
media: {},
|
||||||
|
projects: {},
|
||||||
|
dbNotifications: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('drizzle-orm', () => ({
|
vi.doMock('drizzle-orm', () => ({
|
||||||
@@ -516,9 +584,9 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/engine/MediaEngine', () => ({
|
vi.doMock('../../src/main/engine/MediaEngine', () => ({
|
||||||
getMediaEngine: vi.fn(() => ({
|
MediaEngine: vi.fn().mockImplementation(function() { return {
|
||||||
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
|
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await import('../../src/main/main');
|
await import('../../src/main/main');
|
||||||
@@ -539,6 +607,7 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
quit: vi.fn(),
|
quit: vi.fn(),
|
||||||
getPath: vi.fn((name: string) => (name === 'userData' ? '/tmp/bds-user-data' : '/tmp')),
|
getPath: vi.fn((name: string) => (name === 'userData' ? '/tmp/bds-user-data' : '/tmp')),
|
||||||
|
isPackaged: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const browserWindowCalls: any[] = [];
|
const browserWindowCalls: any[] = [];
|
||||||
@@ -613,8 +682,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
return { ...mocked, default: mocked };
|
return { ...mocked, default: mocked };
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.doMock('../../src/main/database', () => ({
|
vi.doMock('../../src/main/database', () => {
|
||||||
getDatabase: vi.fn(() => ({
|
const mockDb = {
|
||||||
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
||||||
close: vi.fn().mockResolvedValue(undefined),
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
getLocal: vi.fn(() => ({
|
getLocal: vi.fn(() => ({
|
||||||
@@ -627,8 +696,13 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
||||||
})),
|
getDbPath: vi.fn(() => '/tmp/mock.db'),
|
||||||
}));
|
};
|
||||||
|
return {
|
||||||
|
initDatabase: vi.fn(() => mockDb),
|
||||||
|
getDatabase: vi.fn(() => mockDb),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.doMock('../../src/main/ipc', () => ({
|
vi.doMock('../../src/main/ipc', () => ({
|
||||||
registerIpcHandlers: vi.fn(),
|
registerIpcHandlers: vi.fn(),
|
||||||
@@ -640,6 +714,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
|
|
||||||
vi.doMock('../../src/main/database/schema', () => ({
|
vi.doMock('../../src/main/database/schema', () => ({
|
||||||
media: {},
|
media: {},
|
||||||
|
projects: {},
|
||||||
|
dbNotifications: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('drizzle-orm', () => ({
|
vi.doMock('drizzle-orm', () => ({
|
||||||
@@ -647,9 +723,9 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/engine/MediaEngine', () => ({
|
vi.doMock('../../src/main/engine/MediaEngine', () => ({
|
||||||
getMediaEngine: vi.fn(() => ({
|
MediaEngine: vi.fn().mockImplementation(function() { return {
|
||||||
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
|
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await import('../../src/main/main');
|
await import('../../src/main/main');
|
||||||
@@ -675,6 +751,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
quit: vi.fn(),
|
quit: vi.fn(),
|
||||||
requestSingleInstanceLock: vi.fn(() => true),
|
requestSingleInstanceLock: vi.fn(() => true),
|
||||||
setAsDefaultProtocolClient: vi.fn(() => true),
|
setAsDefaultProtocolClient: vi.fn(() => true),
|
||||||
|
getPath: vi.fn(() => '/tmp/mock-userdata'),
|
||||||
|
isPackaged: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const windows: Array<{ webContents: { send: ReturnType<typeof vi.fn> } }> = [];
|
const windows: Array<{ webContents: { send: ReturnType<typeof vi.fn> } }> = [];
|
||||||
@@ -723,6 +801,12 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
openExternal: vi.fn(),
|
openExternal: vi.fn(),
|
||||||
openPath: vi.fn(),
|
openPath: vi.fn(),
|
||||||
},
|
},
|
||||||
|
screen: {
|
||||||
|
getPrimaryDisplay: vi.fn(() => ({
|
||||||
|
workArea: { x: 0, y: 0, width: 1920, height: 1080 },
|
||||||
|
workAreaSize: { width: 1920, height: 1080 },
|
||||||
|
})),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
class MockPreviewServer {
|
class MockPreviewServer {
|
||||||
@@ -743,31 +827,31 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/engine/PostEngine', () => ({
|
vi.doMock('../../src/main/engine/PostEngine', () => ({
|
||||||
getPostEngine: vi.fn(() => ({
|
PostEngine: vi.fn().mockImplementation(function() { return {
|
||||||
getPost: vi.fn().mockResolvedValue(null),
|
getPost: vi.fn().mockResolvedValue(null),
|
||||||
createPost,
|
createPost,
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/engine/MetaEngine', () => ({
|
vi.doMock('../../src/main/engine/MetaEngine', () => ({
|
||||||
getMetaEngine: vi.fn(() => ({
|
MetaEngine: vi.fn().mockImplementation(function() { return {
|
||||||
getProjectMetadata: vi.fn().mockResolvedValue({ blogmarkCategory: 'article' }),
|
getProjectMetadata: vi.fn().mockResolvedValue({ blogmarkCategory: 'article' }),
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/engine/BlogmarkTransformService', () => ({
|
vi.doMock('../../src/main/engine/BlogmarkTransformService', () => ({
|
||||||
getBlogmarkTransformService: vi.fn(() => ({
|
BlogmarkTransformService: vi.fn().mockImplementation(function() { return {
|
||||||
applyTransforms: vi.fn(async (input: { post: { title: string; content: string; categories: string[] } }) => ({
|
applyTransforms: vi.fn(async (input: { post: { title: string; content: string; categories: string[] } }) => ({
|
||||||
post: input.post,
|
post: input.post,
|
||||||
appliedScriptIds: [],
|
appliedScriptIds: [],
|
||||||
errors: [],
|
errors: [],
|
||||||
toasts: [],
|
toasts: [],
|
||||||
})),
|
})),
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/database', () => ({
|
vi.doMock('../../src/main/database', () => {
|
||||||
getDatabase: vi.fn(() => ({
|
const mockDb = {
|
||||||
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
||||||
close: vi.fn().mockResolvedValue(undefined),
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
getLocal: vi.fn(() => ({
|
getLocal: vi.fn(() => ({
|
||||||
@@ -780,8 +864,13 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
||||||
})),
|
getDbPath: vi.fn(() => '/tmp/mock.db'),
|
||||||
}));
|
};
|
||||||
|
return {
|
||||||
|
initDatabase: vi.fn(() => mockDb),
|
||||||
|
getDatabase: vi.fn(() => mockDb),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.doMock('../../src/main/ipc', () => ({
|
vi.doMock('../../src/main/ipc', () => ({
|
||||||
registerIpcHandlers: vi.fn(),
|
registerIpcHandlers: vi.fn(),
|
||||||
@@ -793,6 +882,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
|
|
||||||
vi.doMock('../../src/main/database/schema', () => ({
|
vi.doMock('../../src/main/database/schema', () => ({
|
||||||
media: {},
|
media: {},
|
||||||
|
projects: {},
|
||||||
|
dbNotifications: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('drizzle-orm', () => ({
|
vi.doMock('drizzle-orm', () => ({
|
||||||
@@ -800,9 +891,9 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/engine/MediaEngine', () => ({
|
vi.doMock('../../src/main/engine/MediaEngine', () => ({
|
||||||
getMediaEngine: vi.fn(() => ({
|
MediaEngine: vi.fn().mockImplementation(function() { return {
|
||||||
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
|
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await import('../../src/main/main');
|
await import('../../src/main/main');
|
||||||
@@ -853,6 +944,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
quit: vi.fn(),
|
quit: vi.fn(),
|
||||||
requestSingleInstanceLock: vi.fn(() => true),
|
requestSingleInstanceLock: vi.fn(() => true),
|
||||||
setAsDefaultProtocolClient: vi.fn(() => true),
|
setAsDefaultProtocolClient: vi.fn(() => true),
|
||||||
|
getPath: vi.fn(() => '/tmp/mock-userdata'),
|
||||||
|
isPackaged: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const windows: Array<{ webContents: { send: ReturnType<typeof vi.fn> } }> = [];
|
const windows: Array<{ webContents: { send: ReturnType<typeof vi.fn> } }> = [];
|
||||||
@@ -905,6 +998,12 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
openExternal: vi.fn(),
|
openExternal: vi.fn(),
|
||||||
openPath: vi.fn(),
|
openPath: vi.fn(),
|
||||||
},
|
},
|
||||||
|
screen: {
|
||||||
|
getPrimaryDisplay: vi.fn(() => ({
|
||||||
|
workArea: { x: 0, y: 0, width: 1920, height: 1080 },
|
||||||
|
workAreaSize: { width: 1920, height: 1080 },
|
||||||
|
})),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
class MockPreviewServer {
|
class MockPreviewServer {
|
||||||
@@ -925,31 +1024,31 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/engine/PostEngine', () => ({
|
vi.doMock('../../src/main/engine/PostEngine', () => ({
|
||||||
getPostEngine: vi.fn(() => ({
|
PostEngine: vi.fn().mockImplementation(function() { return {
|
||||||
getPost: vi.fn().mockResolvedValue(null),
|
getPost: vi.fn().mockResolvedValue(null),
|
||||||
createPost,
|
createPost,
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/engine/MetaEngine', () => ({
|
vi.doMock('../../src/main/engine/MetaEngine', () => ({
|
||||||
getMetaEngine: vi.fn(() => ({
|
MetaEngine: vi.fn().mockImplementation(function() { return {
|
||||||
getProjectMetadata: vi.fn().mockResolvedValue({ blogmarkCategory: 'article' }),
|
getProjectMetadata: vi.fn().mockResolvedValue({ blogmarkCategory: 'article' }),
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/engine/BlogmarkTransformService', () => ({
|
vi.doMock('../../src/main/engine/BlogmarkTransformService', () => ({
|
||||||
getBlogmarkTransformService: vi.fn(() => ({
|
BlogmarkTransformService: vi.fn().mockImplementation(function() { return {
|
||||||
applyTransforms: vi.fn(async (input: { post: { title: string; content: string; categories: string[] } }) => ({
|
applyTransforms: vi.fn(async (input: { post: { title: string; content: string; categories: string[] } }) => ({
|
||||||
post: input.post,
|
post: input.post,
|
||||||
appliedScriptIds: [],
|
appliedScriptIds: [],
|
||||||
errors: [],
|
errors: [],
|
||||||
toasts: [],
|
toasts: [],
|
||||||
})),
|
})),
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/database', () => ({
|
vi.doMock('../../src/main/database', () => {
|
||||||
getDatabase: vi.fn(() => ({
|
const mockDb = {
|
||||||
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
||||||
close: vi.fn().mockResolvedValue(undefined),
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
getLocal: vi.fn(() => ({
|
getLocal: vi.fn(() => ({
|
||||||
@@ -962,8 +1061,13 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
||||||
})),
|
getDbPath: vi.fn(() => '/tmp/mock.db'),
|
||||||
}));
|
};
|
||||||
|
return {
|
||||||
|
initDatabase: vi.fn(() => mockDb),
|
||||||
|
getDatabase: vi.fn(() => mockDb),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.doMock('../../src/main/ipc', () => ({
|
vi.doMock('../../src/main/ipc', () => ({
|
||||||
registerIpcHandlers: vi.fn(),
|
registerIpcHandlers: vi.fn(),
|
||||||
@@ -975,6 +1079,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
|
|
||||||
vi.doMock('../../src/main/database/schema', () => ({
|
vi.doMock('../../src/main/database/schema', () => ({
|
||||||
media: {},
|
media: {},
|
||||||
|
projects: {},
|
||||||
|
dbNotifications: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('drizzle-orm', () => ({
|
vi.doMock('drizzle-orm', () => ({
|
||||||
@@ -982,9 +1088,9 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/engine/MediaEngine', () => ({
|
vi.doMock('../../src/main/engine/MediaEngine', () => ({
|
||||||
getMediaEngine: vi.fn(() => ({
|
MediaEngine: vi.fn().mockImplementation(function() { return {
|
||||||
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
|
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await import('../../src/main/main');
|
await import('../../src/main/main');
|
||||||
@@ -1042,6 +1148,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
quit: vi.fn(),
|
quit: vi.fn(),
|
||||||
requestSingleInstanceLock: vi.fn(() => true),
|
requestSingleInstanceLock: vi.fn(() => true),
|
||||||
setAsDefaultProtocolClient: vi.fn(() => true),
|
setAsDefaultProtocolClient: vi.fn(() => true),
|
||||||
|
getPath: vi.fn(() => '/tmp/mock-userdata'),
|
||||||
|
isPackaged: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
class MockBrowserWindow {
|
class MockBrowserWindow {
|
||||||
@@ -1082,6 +1190,12 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
openExternal: vi.fn(),
|
openExternal: vi.fn(),
|
||||||
openPath: vi.fn(),
|
openPath: vi.fn(),
|
||||||
},
|
},
|
||||||
|
screen: {
|
||||||
|
getPrimaryDisplay: vi.fn(() => ({
|
||||||
|
workArea: { x: 0, y: 0, width: 1920, height: 1080 },
|
||||||
|
workAreaSize: { width: 1920, height: 1080 },
|
||||||
|
})),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
class MockPreviewServer {
|
class MockPreviewServer {
|
||||||
@@ -1102,17 +1216,17 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/engine/PostEngine', () => ({
|
vi.doMock('../../src/main/engine/PostEngine', () => ({
|
||||||
getPostEngine: vi.fn(() => ({
|
PostEngine: vi.fn().mockImplementation(function() { return {
|
||||||
getPost: vi.fn().mockResolvedValue(null),
|
getPost: vi.fn().mockResolvedValue(null),
|
||||||
createPost,
|
createPost,
|
||||||
setProjectContext: vi.fn(),
|
setProjectContext: vi.fn(),
|
||||||
setSearchLanguage: vi.fn(),
|
setSearchLanguage: vi.fn(),
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let currentProjectId = 'default';
|
let currentProjectId = 'default';
|
||||||
vi.doMock('../../src/main/engine/MetaEngine', () => ({
|
vi.doMock('../../src/main/engine/MetaEngine', () => ({
|
||||||
getMetaEngine: vi.fn(() => ({
|
MetaEngine: vi.fn().mockImplementation(function() { return {
|
||||||
setProjectContext: vi.fn((projectId: string) => {
|
setProjectContext: vi.fn((projectId: string) => {
|
||||||
currentProjectId = projectId;
|
currentProjectId = projectId;
|
||||||
}),
|
}),
|
||||||
@@ -1120,21 +1234,21 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
getProjectMetadata: vi.fn(async () => ({
|
getProjectMetadata: vi.fn(async () => ({
|
||||||
blogmarkCategory: currentProjectId === 'project-2' ? 'aside' : 'article',
|
blogmarkCategory: currentProjectId === 'project-2' ? 'aside' : 'article',
|
||||||
})),
|
})),
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/engine/ProjectEngine', () => ({
|
vi.doMock('../../src/main/engine/ProjectEngine', () => ({
|
||||||
getProjectEngine: vi.fn(() => ({
|
ProjectEngine: vi.fn().mockImplementation(function() { return {
|
||||||
getActiveProject: vi.fn().mockResolvedValue({
|
getActiveProject: vi.fn().mockResolvedValue({
|
||||||
id: 'project-2',
|
id: 'project-2',
|
||||||
dataPath: '/tmp/project-2',
|
dataPath: '/tmp/project-2',
|
||||||
}),
|
}),
|
||||||
getDataDir: vi.fn(() => '/tmp/project-2'),
|
getDataDir: vi.fn(() => '/tmp/project-2'),
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/database', () => ({
|
vi.doMock('../../src/main/database', () => {
|
||||||
getDatabase: vi.fn(() => ({
|
const mockDb = {
|
||||||
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
||||||
close: vi.fn().mockResolvedValue(undefined),
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
getLocal: vi.fn(() => ({
|
getLocal: vi.fn(() => ({
|
||||||
@@ -1147,8 +1261,13 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
||||||
})),
|
getDbPath: vi.fn(() => '/tmp/mock.db'),
|
||||||
}));
|
};
|
||||||
|
return {
|
||||||
|
initDatabase: vi.fn(() => mockDb),
|
||||||
|
getDatabase: vi.fn(() => mockDb),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.doMock('../../src/main/ipc', () => ({
|
vi.doMock('../../src/main/ipc', () => ({
|
||||||
registerIpcHandlers: vi.fn(),
|
registerIpcHandlers: vi.fn(),
|
||||||
@@ -1160,6 +1279,8 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
|
|
||||||
vi.doMock('../../src/main/database/schema', () => ({
|
vi.doMock('../../src/main/database/schema', () => ({
|
||||||
media: {},
|
media: {},
|
||||||
|
projects: {},
|
||||||
|
dbNotifications: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('drizzle-orm', () => ({
|
vi.doMock('drizzle-orm', () => ({
|
||||||
@@ -1167,11 +1288,11 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('../../src/main/engine/MediaEngine', () => ({
|
vi.doMock('../../src/main/engine/MediaEngine', () => ({
|
||||||
getMediaEngine: vi.fn(() => ({
|
MediaEngine: vi.fn().mockImplementation(function() { return {
|
||||||
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
|
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
|
||||||
setProjectContext: vi.fn(),
|
setProjectContext: vi.fn(),
|
||||||
setSearchLanguage: vi.fn(),
|
setSearchLanguage: vi.fn(),
|
||||||
})),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await import('../../src/main/main');
|
await import('../../src/main/main');
|
||||||
|
|||||||
@@ -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,6 +352,31 @@ 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();
|
||||||
@@ -357,9 +384,17 @@ describe('IPC Handlers', () => {
|
|||||||
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(() => {
|
||||||
|
|||||||
@@ -1,9 +1,76 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||||
import { SettingsView } from '../../../src/renderer/components/SettingsView/SettingsView';
|
import { SettingsView } from '../../../src/renderer/components/SettingsView/SettingsView';
|
||||||
import { useAppStore } from '../../../src/renderer/store';
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
|
describe('MCPAgentButton uninstall', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useAppStore.setState({
|
||||||
|
activeProject: {
|
||||||
|
id: 'p1',
|
||||||
|
name: 'Test',
|
||||||
|
slug: 'test',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
gitDiffPreferences: { wordWrap: true, viewStyle: 'inline', hideUnchangedRegions: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
(window as any).electronAPI = {
|
||||||
|
...(window as any).electronAPI,
|
||||||
|
app: { getDefaultProjectPath: vi.fn().mockResolvedValue('/repo') },
|
||||||
|
meta: {
|
||||||
|
getCategories: vi.fn().mockResolvedValue(['article']),
|
||||||
|
getProjectMetadata: vi.fn().mockResolvedValue({
|
||||||
|
maxPostsPerPage: 75,
|
||||||
|
publicUrl: 'https://example.com',
|
||||||
|
categorySettings: { article: { renderInLists: true, showTitle: true } },
|
||||||
|
}),
|
||||||
|
updateProjectMetadata: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }),
|
||||||
|
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||||
|
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
|
||||||
|
},
|
||||||
|
templates: { getEnabledByKind: vi.fn().mockResolvedValue([]) },
|
||||||
|
projects: { update: vi.fn().mockResolvedValue({}) },
|
||||||
|
mcp: {
|
||||||
|
getAgents: vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'claude-code', label: 'Claude Code' },
|
||||||
|
]),
|
||||||
|
addToAgentConfig: vi.fn().mockResolvedValue({ success: true, configPath: '/p' }),
|
||||||
|
removeFromAgentConfig: vi.fn().mockResolvedValue({ success: true, configPath: '/p' }),
|
||||||
|
isConfigured: vi.fn().mockResolvedValue(true),
|
||||||
|
getPort: vi.fn().mockResolvedValue(4124),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an uninstall button when agent is already configured', async () => {
|
||||||
|
render(<SettingsView />);
|
||||||
|
const btn = await screen.findByRole('button', { name: /remove from claude code/i });
|
||||||
|
expect(btn).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls removeFromAgentConfig and shows add button after removal', async () => {
|
||||||
|
render(<SettingsView />);
|
||||||
|
const btn = await screen.findByRole('button', { name: /remove from claude code/i });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((window as any).electronAPI.mcp.removeFromAgentConfig).toHaveBeenCalledWith('claude-code');
|
||||||
|
|
||||||
|
const addBtn = await screen.findByRole('button', { name: /add to claude code/i });
|
||||||
|
expect(addBtn).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('SettingsView Diff Preferences', () => {
|
describe('SettingsView Diff Preferences', () => {
|
||||||
let updateProjectMock: ReturnType<typeof vi.fn>;
|
let updateProjectMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
|||||||
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