Merge pull request #17 from rfc1437/feature/python-scripting-pr00-bootstrap
Feature/python scripting pr00 bootstrap
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -2,6 +2,7 @@
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"npx vitest": true,
|
||||
"npx tsc": true,
|
||||
"git remote": true
|
||||
"git remote": true,
|
||||
"npx asar": true
|
||||
}
|
||||
}
|
||||
119
DOCUMENTATION.md
119
DOCUMENTATION.md
@@ -10,6 +10,7 @@
|
||||
- [Working with pages](#working-with-pages)
|
||||
- [Working with media](#working-with-media)
|
||||
- [Using macros](#using-macros)
|
||||
- [Using scripting (early access)](#using-scripting-early-access)
|
||||
- [Organizing with tags](#organizing-with-tags)
|
||||
- [Importing from WordPress (WXR)](#importing-from-wordpress-wxr)
|
||||
- [Using Git (Source Control)](#using-git-source-control)
|
||||
@@ -150,41 +151,37 @@ Macros let you insert dynamic content blocks directly inside post/page Markdown
|
||||
|
||||
Use macros when you need reusable rich blocks (for example embedded videos, media galleries, archive grids, or computed tag clouds) without writing raw HTML.
|
||||
|
||||
### Supported macros
|
||||
### YouTube macro
|
||||
|
||||
- `[[youtube id="VIDEO_ID" title="Optional title"]]`
|
||||
- Embeds a YouTube video.
|
||||
- `id` is required.
|
||||
- `title` is optional (used for accessibility label).
|
||||
Use `[[youtube id="VIDEO_ID" title="Optional title"]]` when you want to embed a YouTube clip directly in a post or page. This macro is best for video references, walkthroughs, and embedded talks that should stay in the editorial flow instead of linking out to another tab.
|
||||
|
||||
- `[[vimeo id="VIDEO_ID" title="Optional title"]]`
|
||||
- Embeds a Vimeo video.
|
||||
- `id` is required.
|
||||
- `title` is optional.
|
||||
The `id` parameter is required and should contain the YouTube video ID. The `title` parameter is optional, but recommended for accessibility because it becomes the reader-facing label for the embedded frame.
|
||||
|
||||
- `[[gallery columns="3" caption="Optional caption"]]`
|
||||
- Renders a lightbox-enabled gallery using media linked to the current post.
|
||||
- `columns` is optional (`1` to `6`, default `3`).
|
||||
- `caption` is optional.
|
||||
### Vimeo macro
|
||||
|
||||
- `[[photo_archive year="2025" month="2"]]`
|
||||
- Renders a photo archive grid from media dates.
|
||||
- `year` is optional (when omitted, recent months are shown).
|
||||
- `month` is optional (used with `year` for a single month view).
|
||||
- Legacy alias `[[photo_album ...]]` is also supported.
|
||||
Use `[[vimeo id="VIDEO_ID" title="Optional title"]]` for Vimeo-hosted video content. It behaves similarly to the YouTube macro, but targets Vimeo as the video source.
|
||||
|
||||
- `[[tag_cloud orientation="mixed_diagonal" width="900" height="420"]]`
|
||||
- Builds a word cloud from published tag usage counts.
|
||||
- Word size scales by usage quantity.
|
||||
- Word color is theme-aware and uses Pico CSS semantic colors.
|
||||
- Colors are distributed by quantity quantiles with easing, so dense datasets still show visible variation.
|
||||
- Visual order remains least-to-most: blue → green → yellow → orange → red.
|
||||
- Clicking a word opens that tag archive route.
|
||||
- `orientation` is optional and supports:
|
||||
- `horizontal` (all words horizontal)
|
||||
- `mixed_hv` (mix of horizontal and vertical)
|
||||
- `mixed_diagonal` (mix of horizontal/vertical/diagonal angles)
|
||||
- `width` and `height` are optional (defaults `900` and `420`).
|
||||
As with YouTube, `id` is required and `title` is optional. Use `title` whenever possible so screen-reader and assistive-technology users receive useful context.
|
||||
|
||||
### Gallery macro
|
||||
|
||||
Use `[[gallery columns="3" caption="Optional caption"]]` to render a lightbox-enabled media gallery from assets linked to the current post. This macro is appropriate when several related images belong together and should be browsed as one visual group.
|
||||
|
||||
The `columns` parameter controls layout density and accepts values from `1` to `6` (default is `3`). The optional `caption` parameter adds context above or below the gallery depending on theme presentation.
|
||||
|
||||
### Photo archive macro
|
||||
|
||||
Use `[[photo_archive year="2025" month="2"]]` when you want an archive-style grid based on media dates. This is useful for timeline-oriented projects where readers should navigate image collections by month or year.
|
||||
|
||||
Both `year` and `month` are optional. If `year` is omitted, bDS shows recent months. If `year` is provided without `month`, bDS presents the year scope. The legacy alias `[[photo_album ...]]` is still supported for compatibility.
|
||||
|
||||
### Tag cloud macro
|
||||
|
||||
Use `[[tag_cloud orientation="mixed_diagonal" width="900" height="420"]]` to visualize published tag usage as a weighted cloud. This macro is best for discovery pages, thematic overviews, and archive entry points where content density matters.
|
||||
|
||||
Word size scales with usage counts. Colors are theme-aware and distributed by quantity quantiles using eased interpolation so high-volume datasets stay readable. The color progression remains least-to-most (blue → green → yellow → orange → red), and clicking a word opens that tag archive route.
|
||||
|
||||
The optional `orientation` parameter supports `horizontal`, `mixed_hv`, and `mixed_diagonal`. The optional `width` and `height` parameters control canvas size and default to `900` and `420`.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
@@ -196,6 +193,68 @@ Use macros when you need reusable rich blocks (for example embedded videos, medi
|
||||
|
||||
---
|
||||
|
||||
## Using scripting (early access)
|
||||
|
||||
The scripting feature is an incremental capability and should currently be treated as early access. Scripts are stored as Python files in the project filesystem, while script metadata is tracked in the project database and embedded in the file metadata docstring block. This keeps scripts portable and inspectable while still allowing reliable indexing in the app.
|
||||
|
||||
Each script exposes an **Entrypoint** selector. bDS always provides a synthetic `main` entrypoint. Selecting `main` runs the full script body as before. In addition, bDS inspects your script to list top-level Python function names, which can be selected as entrypoints for upcoming execution modes and integrations.
|
||||
|
||||
At this stage, scripting is intended for controlled project workflows where scripts interact with application-provided tools. Keep scripts versioned through your normal Git workflow, review changes carefully, and prefer small, explicit scripts over monolithic utility files.
|
||||
|
||||
For transform scripts, bDS provides a built-in Python helper named `toast(message)`. It accepts a single string and emits a UI intent that the app handles on the renderer side. This keeps script ergonomics simple while preserving a controlled bridge between script runtime and user interface.
|
||||
|
||||
When transform scripts fail during a pipeline run, bDS automatically surfaces an error toast so users are notified immediately. Detailed transform diagnostics (applied scripts and per-script errors) are also written to the Output panel.
|
||||
|
||||
### Example transform script
|
||||
|
||||
Use a transform function to modify incoming bookmark/blogmark content before bDS creates the post. The function receives a mutable `post` dictionary and should return that dictionary.
|
||||
|
||||
```python
|
||||
def normalize_blogmark(post):
|
||||
# 1) Manipulate title
|
||||
title = (post.get("title") or "").strip()
|
||||
if title and not title.startswith("[Clipped]"):
|
||||
post["title"] = f"[Clipped] {title}"
|
||||
|
||||
# 2) Manipulate text/content
|
||||
content = (post.get("content") or "").strip()
|
||||
prefix = "Imported from blogmark\n\n"
|
||||
if content and not content.startswith(prefix):
|
||||
post["content"] = prefix + content
|
||||
|
||||
# 3) Set or replace categories
|
||||
post["categories"] = ["Inbox", "Research"]
|
||||
|
||||
# 4) Add and normalize tags
|
||||
tags = post.get("tags") or []
|
||||
tags.append("blogmark")
|
||||
tags.append("clipped")
|
||||
post["tags"] = sorted({str(tag).strip().lower() for tag in tags if str(tag).strip()})
|
||||
|
||||
# 5) Optional user notification
|
||||
toast(f"Transform applied: {post.get('title')}")
|
||||
return post
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `title` and `content` are strings.
|
||||
- `categories` and `tags` are string lists (e.g., `['News', 'AI']`).
|
||||
- Return the mutated `post` dict from your transform function.
|
||||
- Keep transforms small and deterministic, especially when multiple active transforms run in sequence.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Scripting is available and intentionally evolving in small steps.
|
||||
- `main` is always available and preserves whole-script execution behavior.
|
||||
- Script files and metadata remain filesystem-friendly and Git-reviewable.
|
||||
- Transform scripts can call `toast("...")` to send user-facing UI notifications.
|
||||
- Transform scripts can directly manipulate `title`, `content`, `categories`, and `tags`.
|
||||
- Transform pipeline failures always trigger automatic error toasts.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Organizing with tags
|
||||
|
||||
Tags are your precision taxonomy tool. Over time, even well-managed projects accumulate near-duplicate tags, naming inconsistencies, and labels that no longer serve users. The Tags section exists to keep taxonomy useful and prevent search and filtering quality from degrading.
|
||||
|
||||
@@ -1,455 +1,94 @@
|
||||
# Python Scripting Integration Plan (Electron + Pyodide)
|
||||
# Python Scripting — Remaining Work (Implementation-First)
|
||||
|
||||
## 1. Goal and Scope
|
||||
Last verified: 23 Feb 2026
|
||||
|
||||
Primary goal: all render-time macros run in Python, with predictable performance and safe sandboxing.
|
||||
This document is intentionally reduced to **what is still left to implement**.
|
||||
When plan and code differ, code is the source of truth.
|
||||
|
||||
Secondary goals:
|
||||
- User-editable Python scripts with project persistence (`scripts/` folder + DB index).
|
||||
- Python scripting reuse in bookmarklet/post-processing pipelines.
|
||||
- Keep architecture consistent with existing `main/engine` + `ipc` + `renderer` boundaries.
|
||||
## Implemented (checked)
|
||||
|
||||
This document defines a staged path from MVP to full scope.
|
||||
- [x] Pyodide dependency integrated.
|
||||
- [x] Renderer worker runtime exists (`pythonRuntime.worker.ts`) with ready/error/stdout/run protocol.
|
||||
- [x] Runtime timeout watchdog + reset/recovery implemented in `PythonRuntimeManager`.
|
||||
- [x] ABI v1 schemas and validation for macro context/result implemented (`abiV1.ts`).
|
||||
- [x] Benchmark harness implemented (`npm run bench:python-runtime -- <iterations>`).
|
||||
- [x] Script persistence model implemented (`scripts` DB table + `scripts/*.py` files).
|
||||
- [x] Script metadata model implemented (`kind`, `entrypoint`, `enabled`, `version`, etc.).
|
||||
- [x] Main process script CRUD engine + IPC handlers implemented.
|
||||
- [x] Preload + shared API typings for scripts implemented.
|
||||
- [x] Renderer scripts UX implemented (sidebar list, editor, save, run, delete).
|
||||
- [x] Script syntax check + entrypoint discovery integrated in editor UX.
|
||||
- [x] Blogmark transform pipeline executes Python transform scripts (`kind='transform'`).
|
||||
|
||||
---
|
||||
## Confirmed Deviations from Original Plan
|
||||
|
||||
## 2. Viability Summary (Realistic Expectations)
|
||||
These are current realities and should be treated as authoritative unless we explicitly decide to change them.
|
||||
|
||||
### Is this realistic?
|
||||
Yes, if we optimize for **low bridge overhead** and **stable execution contracts**.
|
||||
1. **Transform script runtime location differs**
|
||||
- Original plan: untrusted Python runs in renderer worker only.
|
||||
- Actual implementation: Blogmark transform scripts run in **main process** Pyodide (`BlogmarkTransformService`).
|
||||
|
||||
Key reality checks:
|
||||
- Pyodide in a worker is viable for user scripting and sandboxing.
|
||||
- Macro execution in render loops can be fast enough if we avoid frequent JS↔Python conversions.
|
||||
- `< 1ms` per macro call is possible only for simple macros with precompiled code and minimal marshaling; it must be treated as a benchmark target, not a guarantee.
|
||||
- For heavy loops, the work should stay inside Python once called (coarse-grained calls), not bounce per item between JS and Python.
|
||||
2. **Render-time macro migration has not happened yet**
|
||||
- Original plan: all render-time macros become Python-backed.
|
||||
- Actual implementation: render-time macros are still JS-based (`PageRenderer.renderMacro` and renderer macro registry/definitions).
|
||||
|
||||
Decision:
|
||||
- Keep Pyodide as the default engine.
|
||||
- Design a strict, minimal ABI (Application Binary Interface-like contract) for macro inputs/outputs.
|
||||
- Use preloading, precompilation, and caching before adding advanced optimizations.
|
||||
3. **Macro ABI exists but is not the production render path**
|
||||
- ABI v1 + runtime manager support exist.
|
||||
- Main page generation path still uses existing JS macro rendering.
|
||||
|
||||
---
|
||||
4. **Scripts rebuild/meta-diff sync is still missing**
|
||||
- Script CRUD works via app APIs.
|
||||
- No implemented project-wide “rebuild from files” parity for `scripts/` equivalent to posts/media rebuild flows.
|
||||
|
||||
## 3. Architecture Fit for bDS
|
||||
## Remaining Work Only
|
||||
|
||||
### 3.1 Layering
|
||||
## 1) Decide and enforce Python runtime boundary (P0)
|
||||
|
||||
Keep existing project boundaries:
|
||||
- `src/main/engine`: script metadata, script storage/indexing, render orchestration, benchmark/logging.
|
||||
- `src/main/ipc`: typed handlers for script CRUD, run, and diagnostics.
|
||||
- `src/renderer`: script editor UI, run controls, output panel integration.
|
||||
- Python runtime (Pyodide) stays in renderer-side worker context; main process never executes untrusted script code.
|
||||
- [ ] Decide if `transform` scripts should stay in main process or move to renderer worker.
|
||||
- [ ] If staying in main process: add explicit timeout/kill/recovery safeguards equivalent to worker watchdog behavior.
|
||||
- [ ] If moving to worker: route transform execution through typed IPC/worker bridge and remove main-process execution path.
|
||||
- [ ] Document final security model in this file after decision.
|
||||
|
||||
### 3.2 Runtime Placement
|
||||
## 2) Add scripts file-system rebuild/sync (P1)
|
||||
|
||||
- Host a long-lived `PythonRuntimeWorker` in renderer.
|
||||
- Initialize Pyodide once per app session (or lazily on first script run).
|
||||
- Maintain an in-memory registry of loaded scripts and compiled callables.
|
||||
- [ ] Implement rebuild/meta-diff style synchronization for `scripts/` so external file edits are detected.
|
||||
- [ ] Define conflict handling policy between DB metadata and script file frontmatter/body.
|
||||
- [ ] Add tests for create/edit/delete performed outside app while app is closed/open.
|
||||
|
||||
### 3.3 Macro Execution Contract (Performance-Critical)
|
||||
## 3) Wire Python macros into render pipeline (P1)
|
||||
|
||||
Use one narrow contract for all macros:
|
||||
- [ ] Add macro-to-script resolution (token/hook -> script id/slug).
|
||||
- [ ] Execute Python macro scripts from the active render path.
|
||||
- [ ] Reuse runtime cache keys across repeated macro invocations in generation loops.
|
||||
- [ ] Add guardrails for timeout/error fallback during render.
|
||||
|
||||
Python side:
|
||||
```python
|
||||
def render(context: dict) -> dict:
|
||||
...
|
||||
```
|
||||
## 4) Macro parity migration + cleanup (P2)
|
||||
|
||||
Contract rules:
|
||||
- Input `context` is plain JSON-compatible data only.
|
||||
- Output is plain JSON-compatible data only.
|
||||
- No Node/Electron direct access from Python.
|
||||
- No per-token/per-node callbacks into JS while rendering.
|
||||
- [ ] Port built-in macros to Python scripts with parity fixtures.
|
||||
- [ ] Keep fixtures/golden tests for parity verification.
|
||||
- [ ] Remove legacy JS macro path only after parity is proven.
|
||||
|
||||
### 3.4 Bridge Strategy (Keep Conversions Simple)
|
||||
## 5) Default script seeding/versioning (P2)
|
||||
|
||||
- Preferred: pass compact JSON payloads (single call in, single result out).
|
||||
- Avoid dynamic proxy-style JS objects in hot paths.
|
||||
- Avoid `toPy()/toJs()` inside tight loops.
|
||||
- Use `pyodide.globals` only for stable utility bindings set once during worker startup.
|
||||
- [ ] Bundle default scripts in repo/app resources.
|
||||
- [ ] Seed missing defaults on project init/startup deterministically.
|
||||
- [ ] Add versioned update strategy for default scripts.
|
||||
|
||||
### 3.5 Security Model
|
||||
## 6) Diagnostics and performance visibility (P3)
|
||||
|
||||
- Scripts execute only in worker.
|
||||
- Hard timeout + termination + runtime restart on runaway scripts.
|
||||
- Allowlist API surface exposed to Python (pure functions where possible).
|
||||
- Validate and sanitize all script outputs in JS before applying to render pipeline.
|
||||
- [ ] Add macro execution counters (count, timeout/error counts, p50/p95) for real render path.
|
||||
- [ ] Define regression thresholds based on benchmark trends.
|
||||
|
||||
---
|
||||
## Out of Scope Until Core Gaps Close
|
||||
|
||||
## 4. Staged Delivery Plan
|
||||
- [ ] AI assistant tooling exposure from Python scripts.
|
||||
- [ ] General Python package/dependency policy expansion.
|
||||
- [ ] Advanced bridge optimizations (only if metrics prove need).
|
||||
|
||||
## Phase 0 — Technical Spike (timeboxed)
|
||||
## Acceptance Gate Before Marking Python Scripting “Complete”
|
||||
|
||||
Objective: prove runtime viability before product surface growth.
|
||||
|
||||
Deliverables:
|
||||
- [ ] Add `pyodide` dependency and worker boot sequence.
|
||||
- [ ] Run a sample script end-to-end (`run_script`, timeout, captured stdout).
|
||||
- [ ] Benchmark baseline cold start + warm run + repeated macro calls.
|
||||
- [ ] Define initial macro ABI (`render(context) -> result`) and schema docs.
|
||||
|
||||
Exit criteria:
|
||||
- Warm script execution is stable.
|
||||
- Timeout recovery works.
|
||||
- Measured baseline captured in repo docs.
|
||||
|
||||
## Phase 1 — MVP (Minimal but Usable)
|
||||
|
||||
Objective: user can create/run scripts and see output.
|
||||
|
||||
Deliverables:
|
||||
- [ ] Script storage model (DB index + filesystem source in `scripts/*.py`).
|
||||
- [ ] CRUD APIs in `main/engine` + `ipc` handlers.
|
||||
- [ ] Renderer scripts list + editor + run button.
|
||||
- [ ] Console/output capture in existing bottom output area.
|
||||
- [ ] Project rebuild picks up `scripts/` changes.
|
||||
|
||||
Out of scope for MVP:
|
||||
- Macro replacement.
|
||||
- Bookmarklet integration.
|
||||
- AI assistant tool access from Python.
|
||||
|
||||
Exit criteria:
|
||||
- Scripts can be created, persisted, run, and debugged.
|
||||
- Script files round-trip correctly with filesystem.
|
||||
|
||||
## Phase 2 — Macro Runtime Foundation
|
||||
|
||||
Objective: integrate Python macros into renderer loop with low overhead.
|
||||
|
||||
Deliverables:
|
||||
- [ ] Add script type/metadata (`kind: macro | utility | transform`).
|
||||
- [ ] Resolve macro references from content to script IDs.
|
||||
- [ ] Implement macro runtime cache: module load once, callable reuse.
|
||||
- [ ] Convert existing macro parameter parsing into typed context object once per macro invocation.
|
||||
- [ ] Add perf counters (call count, p50/p95 runtime, timeout count).
|
||||
|
||||
Exit criteria:
|
||||
- Python macro path is feature-equivalent for at least 1–2 existing macros.
|
||||
- Measured overhead acceptable against baseline.
|
||||
|
||||
## Phase 3 — Macro Migration (Full Goal)
|
||||
|
||||
Objective: all current built-in macros are Python-backed.
|
||||
|
||||
Deliverables:
|
||||
- [ ] Port each existing macro implementation to Python scripts.
|
||||
- [ ] Keep default macro scripts versioned in repo and bundled with app.
|
||||
- [ ] On startup/project init, seed missing default macro scripts into filesystem + DB.
|
||||
- [ ] Add script-as-macro assignment in metadata and editor UX.
|
||||
- [ ] Keep parameter typing rules explicit (`"123"` quoted string stays string; unquoted numerics map to int/float).
|
||||
|
||||
Exit criteria:
|
||||
- All built-in macros execute via Python runtime.
|
||||
- Legacy JS macro path is removed after parity confirmation.
|
||||
|
||||
## Phase 4 — Performance Hardening
|
||||
|
||||
Objective: reach production-grade speed and stability for render loops.
|
||||
|
||||
Deliverables:
|
||||
- [ ] Precompile/load scripts once per worker lifecycle.
|
||||
- [ ] Batch render APIs where beneficial (`render_many(contexts)`).
|
||||
- [ ] Reduce marshaling size (compact context shape, no redundant fields).
|
||||
- [ ] Optional SharedArrayBuffer experiments only if measured need justifies added complexity.
|
||||
- [ ] Failure isolation and automatic runtime reset strategy.
|
||||
|
||||
Exit criteria:
|
||||
- Stable long-run benchmarks in CI/manual perf suite.
|
||||
- No UI thread stalls during heavy generation.
|
||||
|
||||
## Phase 5 — Bookmarklet/Post Transform Integration
|
||||
|
||||
Objective: reuse Python runtime for post-ingest transformations.
|
||||
|
||||
Deliverables:
|
||||
- [ ] Hook script transforms into bookmarklet pipeline after data sanitization.
|
||||
- [ ] Input: validated post object; output: transformed validated post object.
|
||||
- [ ] Add transform-specific script type and error handling/reporting.
|
||||
|
||||
Exit criteria:
|
||||
- Transform scripts can safely modify incoming post content.
|
||||
- Fallback behavior exists when transform fails.
|
||||
|
||||
## Phase 6 — Advanced Capabilities (Optional)
|
||||
|
||||
Objective: add power-user features only after core stability.
|
||||
|
||||
Candidates:
|
||||
- [ ] Python-accessible app tools (strict allowlist).
|
||||
- [ ] AI assistant tooling from Python scripts.
|
||||
- [ ] Script package/dependency policy for curated modules.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data and Storage Design
|
||||
|
||||
- Source of truth for scripts follows existing pattern: filesystem + DB index.
|
||||
- Files: `scripts/<slug>.py`.
|
||||
- Metadata can be stored in:
|
||||
- DB columns (preferred for indexing/query), and/or
|
||||
- leading Python block comment for file portability.
|
||||
- Rebuild/meta-diff must include `scripts/` exactly like posts/media flow.
|
||||
|
||||
Recommended script metadata:
|
||||
- `id`, `slug`, `title`, `kind`, `entrypoint`, `enabled`, `version`, `updatedAt`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Performance Plan (Macro-Critical)
|
||||
|
||||
Principles:
|
||||
- Coarse-grained calls: one macro invocation should do meaningful work in Python.
|
||||
- Stable ABI: small, predictable context payload.
|
||||
- Warm runtime reuse: no repeated Pyodide boot.
|
||||
- Compile/load once, execute many.
|
||||
|
||||
Initial target envelope (to validate in Phase 0/2):
|
||||
- Warm invocation overhead target: low single-digit milliseconds for typical macros.
|
||||
- p95 render stability target under large generation batches.
|
||||
- Timeout and memory guardrails for pathological scripts.
|
||||
|
||||
Note: The previous strict `<1ms` universal target is replaced by benchmark tiers by macro class (simple/medium/heavy), which is more realistic.
|
||||
|
||||
---
|
||||
|
||||
## 7. Security and Reliability
|
||||
|
||||
- No direct filesystem/network/process APIs in Python runtime.
|
||||
- Worker watchdog timeout and hard-kill policy.
|
||||
- Structured errors returned to UI and logs.
|
||||
- Script output validation before use in rendering.
|
||||
- Versioned default scripts to ensure deterministic behavior across app updates.
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing and Rollout Strategy
|
||||
|
||||
- Unit tests for engine-level script registry, metadata, and macro resolution.
|
||||
- Integration tests for worker protocol and timeout recovery.
|
||||
- Golden tests to compare macro output parity before/after migration.
|
||||
- Performance regression checks for macro hot paths.
|
||||
- Feature flag for staged rollout before removing legacy macro path.
|
||||
|
||||
---
|
||||
|
||||
## 9. Coding Agent Execution Pack
|
||||
|
||||
This section makes the plan directly executable by coding agents.
|
||||
|
||||
### 9.1 Working Rules for Agents
|
||||
|
||||
- Work one phase at a time; do not start the next phase before exit criteria pass.
|
||||
- Keep changes layered by architecture boundary (`main/engine`, `main/ipc`, `renderer`).
|
||||
- For each task: write/adjust tests first where feasible, then implement minimal code.
|
||||
- Keep runtime contract stable once introduced; changes require updating ABI docs and tests.
|
||||
- Do not add broad API exposure from JS/Electron into Python; only allowlisted calls.
|
||||
|
||||
### 9.2 Definition of Done (Per Phase)
|
||||
|
||||
Each phase is done only if all are true:
|
||||
- [ ] Deliverables implemented.
|
||||
- [ ] Exit criteria verified.
|
||||
- [ ] Relevant tests pass.
|
||||
- [ ] Full test suite passes (`npm test`).
|
||||
- [ ] Full build passes (`npm run build`).
|
||||
- [ ] Plan document updated with decisions/benchmarks where applicable.
|
||||
|
||||
### 9.3 Task Card Template (Use for Every Agent Task)
|
||||
|
||||
```md
|
||||
Task:
|
||||
Scope:
|
||||
Files expected to change:
|
||||
Out of scope:
|
||||
Acceptance checks:
|
||||
Commands to run:
|
||||
Notes/Risks:
|
||||
```
|
||||
|
||||
### 9.4 Phase-by-Phase Agent Backlog (Suggested)
|
||||
|
||||
#### Phase 0 backlog
|
||||
|
||||
1. Runtime bootstrap spike
|
||||
- Scope: add Pyodide dependency and worker startup path only.
|
||||
- Files likely: `package.json`, new worker file under `src/renderer/`.
|
||||
- Acceptance: worker initializes once, reports ready state.
|
||||
|
||||
2. Safe execute protocol
|
||||
- Scope: request/response protocol (`run`, `stdout`, `error`, `timeout`).
|
||||
- Files likely: renderer runtime manager + worker + related types.
|
||||
- Acceptance: sample script run succeeds; timeout kills and recovers runtime.
|
||||
|
||||
3. Baseline benchmark harness
|
||||
- Scope: cold start, warm run, repeated macro invoke metrics.
|
||||
- Files likely: engine/diagnostic service or dedicated benchmark utility + docs.
|
||||
- Acceptance: numbers recorded in this document or linked benchmark doc.
|
||||
|
||||
4. ABI v1 spec
|
||||
- Scope: formal JSON schema for macro `context` and `result`.
|
||||
- Files likely: shared type definitions + docs.
|
||||
- Acceptance: schema used by both caller and worker-side validator.
|
||||
|
||||
#### Phase 1 backlog
|
||||
|
||||
1. Script persistence model
|
||||
- Scope: DB + filesystem mapping for `scripts/*.py`.
|
||||
- Acceptance: create/update/delete round-trips both stores.
|
||||
|
||||
2. Main engine + IPC CRUD
|
||||
- Scope: add script engine methods and typed IPC handlers.
|
||||
- Acceptance: renderer can list/read/write scripts through IPC only.
|
||||
|
||||
3. Renderer MVP UI
|
||||
- Scope: scripts list, editor panel, run button, output panel integration.
|
||||
- Acceptance: user edits script, runs it, sees stdout/errors.
|
||||
|
||||
4. Rebuild/meta-diff integration
|
||||
- Scope: include scripts in existing rebuild and metadata diff flow.
|
||||
- Acceptance: external file changes in `scripts/` are detected and synchronized.
|
||||
|
||||
#### Phase 2 backlog
|
||||
|
||||
1. Macro script typing + mapping
|
||||
- Scope: `kind` metadata and mapping from macro token to script id.
|
||||
- Acceptance: at least one macro resolved to Python script.
|
||||
|
||||
2. Runtime cache path
|
||||
- Scope: load/compile once; callable reuse.
|
||||
- Acceptance: repeated macro invocations avoid re-init/re-import.
|
||||
|
||||
3. Context adapter
|
||||
- Scope: convert existing macro params into ABI v1 `context` once per invocation.
|
||||
- Acceptance: typed values obey conversion rules.
|
||||
|
||||
4. Perf counters
|
||||
- Scope: call count, p50/p95, timeout/error counts.
|
||||
- Acceptance: counters visible in logs/diagnostics.
|
||||
|
||||
#### Phase 3 backlog
|
||||
|
||||
1. Built-in macro parity migration
|
||||
- Scope: port each macro to Python scripts and add parity tests.
|
||||
- Acceptance: output parity with legacy macros for baseline fixtures.
|
||||
|
||||
2. Default script seeding/versioning
|
||||
- Scope: bundle defaults, seed missing scripts on init.
|
||||
- Acceptance: clean project bootstraps required macro scripts automatically.
|
||||
|
||||
3. Legacy path removal
|
||||
- Scope: remove JS macro implementations after parity gate.
|
||||
- Acceptance: tests pass with Python-only macro path.
|
||||
|
||||
#### Phase 4–6 backlog
|
||||
|
||||
- Keep as optimization/integration tracks only after parity and stability gates pass.
|
||||
|
||||
### 9.5 Anti-Patterns for Agents (Do Not Do)
|
||||
|
||||
- Do not call JS functions per token/item from Python in hot paths.
|
||||
- Do not pass large proxy objects through the bridge in render loops.
|
||||
- Do not introduce direct filesystem/network access in Python runtime.
|
||||
- Do not couple UI/editor work with macro migration in one PR-sized change.
|
||||
- Do not remove legacy macro code before golden parity tests pass.
|
||||
|
||||
### 9.6 Handoff Checklist (Agent to Agent)
|
||||
|
||||
Every handoff should include:
|
||||
- Completed task cards and remaining task cards.
|
||||
- Files changed and rationale.
|
||||
- Test/build command outputs summary.
|
||||
- Known risks and benchmark deltas.
|
||||
- Any ABI changes (must be explicit).
|
||||
|
||||
### 9.7 Suggested PR Boundaries (One Task, One PR)
|
||||
|
||||
Use small PRs with one primary purpose each.
|
||||
|
||||
PR-00: Pyodide bootstrap spike
|
||||
- Includes: dependency, worker init, ready signal.
|
||||
- Excludes: script persistence, UI/editor.
|
||||
- Merge gate: runtime initializes and tests/build pass.
|
||||
|
||||
PR-01: Worker run protocol + timeout recovery
|
||||
- Includes: run/stdout/error/timeout messaging, watchdog + restart behavior.
|
||||
- Excludes: macro integration.
|
||||
- Merge gate: timeout test and recovery test pass.
|
||||
|
||||
PR-02: ABI v1 types + schema validation
|
||||
- Includes: shared types and validation for `context/result`.
|
||||
- Excludes: macro migration.
|
||||
- Merge gate: caller and worker both use ABI validators.
|
||||
|
||||
PR-03: Script persistence model
|
||||
- Includes: DB + filesystem model for `scripts/*.py`.
|
||||
- Excludes: renderer UI.
|
||||
- Merge gate: round-trip persistence tests pass.
|
||||
|
||||
PR-04: Script engine + IPC CRUD
|
||||
- Includes: `main/engine` methods and typed `ipc` handlers.
|
||||
- Excludes: macro runtime.
|
||||
- Merge gate: IPC integration tests pass.
|
||||
|
||||
PR-05: Renderer MVP scripts UI
|
||||
- Includes: scripts list/editor/run/output integration.
|
||||
- Excludes: macro substitution.
|
||||
- Merge gate: end-to-end manual run path works + tests/build pass.
|
||||
|
||||
PR-06: Rebuild/meta-diff integration
|
||||
- Includes: include `scripts/` in rebuild and metadata diff paths.
|
||||
- Excludes: macro migration.
|
||||
- Merge gate: external script file changes are detected and synchronized.
|
||||
|
||||
PR-07: Macro mapping + runtime cache foundation
|
||||
- Includes: macro-to-script mapping, callable cache, first Python-backed macro.
|
||||
- Excludes: full macro parity.
|
||||
- Merge gate: at least one macro parity fixture passes.
|
||||
|
||||
PR-08: Macro parity migration batch A
|
||||
- Includes: port a small set of built-in macros (e.g., 2–3) + golden tests.
|
||||
- Excludes: removal of legacy path.
|
||||
- Merge gate: parity fixtures pass for migrated macros.
|
||||
|
||||
PR-09: Macro parity migration batch B (repeat as needed)
|
||||
- Includes: additional macro ports + fixtures.
|
||||
- Excludes: removal of legacy path.
|
||||
- Merge gate: all targeted macro parity tests pass.
|
||||
|
||||
PR-10: Default script seeding/versioning
|
||||
- Includes: bundled default scripts + startup seeding behavior.
|
||||
- Excludes: advanced scripting APIs.
|
||||
- Merge gate: clean project gets default scripts deterministically.
|
||||
|
||||
PR-11: Legacy JS macro path removal
|
||||
- Includes: delete legacy macro implementations after full parity.
|
||||
- Excludes: bookmarklet transforms.
|
||||
- Merge gate: full test suite and render parity suite pass.
|
||||
|
||||
PR-12: Performance hardening
|
||||
- Includes: benchmark harness refinements, caching improvements, optional batch APIs.
|
||||
- Excludes: unrelated UI changes.
|
||||
- Merge gate: regression thresholds (p50/p95) stay within agreed envelope.
|
||||
|
||||
PR-13: Bookmarklet transform integration
|
||||
- Includes: transform script type, pipeline hook, validation/fallback.
|
||||
- Excludes: optional advanced tool APIs.
|
||||
- Merge gate: sanitized input/output transform tests pass.
|
||||
|
||||
PR-14+: Optional advanced capabilities
|
||||
- Includes: allowlisted app tools, AI-assistant script tools, curated package policy.
|
||||
- Merge gate: explicit security review and feature-flag rollout.
|
||||
|
||||
---
|
||||
|
||||
## 10. Current Status
|
||||
|
||||
Status: Revised staged plan (MVP-first, full-scope preserved).
|
||||
|
||||
Recommended next action:
|
||||
1. Approve Phase 0 scope and benchmarks.
|
||||
2. Implement spike and record numbers.
|
||||
3. Lock ABI before building full UI and migration layers.
|
||||
- [ ] Render-time macros run through Python script path in production generation flow.
|
||||
- [ ] Scripts directory external changes are synchronized reliably.
|
||||
- [ ] Runtime boundary decision implemented and protected by tests.
|
||||
- [ ] Legacy JS macro path removed (or explicitly retained with documented rationale).
|
||||
- [ ] `npm test` and `npm run build` pass.
|
||||
|
||||
15
drizzle/0005_short_sally_floyd.sql
Normal file
15
drizzle/0005_short_sally_floyd.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE `scripts` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`slug` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`kind` text DEFAULT 'utility' NOT NULL,
|
||||
`entrypoint` text DEFAULT 'render' NOT NULL,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`version` integer DEFAULT 1 NOT NULL,
|
||||
`file_path` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `scripts_project_slug_idx` ON `scripts` (`project_id`,`slug`);
|
||||
913
drizzle/meta/0005_snapshot.json
Normal file
913
drizzle/meta/0005_snapshot.json
Normal file
@@ -0,0 +1,913 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "b157a762-0743-4499-a635-16ac3fb5ee18",
|
||||
"prevId": "46702982-9f8a-4c7e-8fb6-3270c3fbe120",
|
||||
"tables": {
|
||||
"chat_conversations": {
|
||||
"name": "chat_conversations",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"copilot_session_id": {
|
||||
"name": "copilot_session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"chat_messages": {
|
||||
"name": "chat_messages",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"conversation_id": {
|
||||
"name": "conversation_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tool_call_id": {
|
||||
"name": "tool_call_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tool_calls": {
|
||||
"name": "tool_calls",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"generated_file_hashes": {
|
||||
"name": "generated_file_hashes",
|
||||
"columns": {
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"relative_path": {
|
||||
"name": "relative_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content_hash": {
|
||||
"name": "content_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"generated_file_hashes_project_path_idx": {
|
||||
"name": "generated_file_hashes_project_path_idx",
|
||||
"columns": [
|
||||
"project_id",
|
||||
"relative_path"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"import_definitions": {
|
||||
"name": "import_definitions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"wxr_file_path": {
|
||||
"name": "wxr_file_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"uploads_folder_path": {
|
||||
"name": "uploads_folder_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_analysis_result": {
|
||||
"name": "last_analysis_result",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"media": {
|
||||
"name": "media",
|
||||
"columns": {
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"original_name": {
|
||||
"name": "original_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"alt": {
|
||||
"name": "alt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"caption": {
|
||||
"name": "caption",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"file_path": {
|
||||
"name": "file_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sidecar_path": {
|
||||
"name": "sidecar_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"checksum": {
|
||||
"name": "checksum",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"post_links": {
|
||||
"name": "post_links",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_post_id": {
|
||||
"name": "source_post_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"target_post_id": {
|
||||
"name": "target_post_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"link_text": {
|
||||
"name": "link_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"post_media": {
|
||||
"name": "post_media",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"post_id": {
|
||||
"name": "post_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"media_id": {
|
||||
"name": "media_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"post_media_post_media_idx": {
|
||||
"name": "post_media_post_media_idx",
|
||||
"columns": [
|
||||
"post_id",
|
||||
"media_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"posts": {
|
||||
"name": "posts",
|
||||
"columns": {
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"excerpt": {
|
||||
"name": "excerpt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'draft'"
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"published_at": {
|
||||
"name": "published_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"file_path": {
|
||||
"name": "file_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"checksum": {
|
||||
"name": "checksum",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"categories": {
|
||||
"name": "categories",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"published_title": {
|
||||
"name": "published_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"published_content": {
|
||||
"name": "published_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"published_tags": {
|
||||
"name": "published_tags",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"published_categories": {
|
||||
"name": "published_categories",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"published_excerpt": {
|
||||
"name": "published_excerpt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"posts_project_slug_idx": {
|
||||
"name": "posts_project_slug_idx",
|
||||
"columns": [
|
||||
"project_id",
|
||||
"slug"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"data_path": {
|
||||
"name": "data_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"projects_slug_unique": {
|
||||
"name": "projects_slug_unique",
|
||||
"columns": [
|
||||
"slug"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"scripts": {
|
||||
"name": "scripts",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'utility'"
|
||||
},
|
||||
"entrypoint": {
|
||||
"name": "entrypoint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'render'"
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"file_path": {
|
||||
"name": "file_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"scripts_project_slug_idx": {
|
||||
"name": "scripts_project_slug_idx",
|
||||
"columns": [
|
||||
"project_id",
|
||||
"slug"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"tags_project_name_idx": {
|
||||
"name": "tags_project_name_idx",
|
||||
"columns": [
|
||||
"project_id",
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,13 @@
|
||||
"when": 1771605253203,
|
||||
"tag": "0004_overjoyed_paper_doll",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1771792324840,
|
||||
"tag": "0005_short_sally_floyd",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -37,6 +37,7 @@
|
||||
"liquidjs": "^10.24.0",
|
||||
"marked-react": "^3.0.2",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"pyodide": "^0.29.3",
|
||||
"react": "^19.2.4",
|
||||
"react-arborist": "^3.4.3",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -5263,6 +5264,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/emscripten": {
|
||||
"version": "1.41.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
|
||||
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -12754,6 +12761,19 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pyodide": {
|
||||
"version": "0.29.3",
|
||||
"resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.29.3.tgz",
|
||||
"integrity": "sha512-22UBuhOJawj7vKUnS7/F3xK+515LJdjiMAHoCfuS6/PbHiOrSQVnYwDe+2sbVwiOZ3sMMexdXICew6NqOMQGgA==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"@types/emscripten": "^1.41.4",
|
||||
"ws": "^8.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-lru": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:ui": "vitest --ui",
|
||||
"bench:python-runtime": "node ./node_modules/tsx/dist/cli.mjs scripts/python-runtime-benchmark.ts",
|
||||
"lint": "eslint \"src/renderer/**/*.{ts,tsx}\" --max-warnings 0",
|
||||
"lint:i18n": "eslint \"src/renderer/**/*.{ts,tsx}\" --max-warnings 0",
|
||||
"db:generate": "node ./node_modules/drizzle-kit/bin.cjs generate",
|
||||
@@ -95,6 +96,7 @@
|
||||
"liquidjs": "^10.24.0",
|
||||
"marked-react": "^3.0.2",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"pyodide": "^0.29.3",
|
||||
"react": "^19.2.4",
|
||||
"react-arborist": "^3.4.3",
|
||||
"react-dom": "^19.2.4",
|
||||
|
||||
28
scripts/python-runtime-benchmark.ts
Normal file
28
scripts/python-runtime-benchmark.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { runPythonRuntimeBenchmark } from '../src/renderer/python/pythonRuntimeBenchmark';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const iterationsArg = process.argv[2];
|
||||
const iterations = iterationsArg ? Number(iterationsArg) : 200;
|
||||
|
||||
if (!Number.isInteger(iterations) || iterations <= 0) {
|
||||
throw new Error('Iterations must be a positive integer');
|
||||
}
|
||||
|
||||
const result = await runPythonRuntimeBenchmark({ iterations });
|
||||
|
||||
const output = {
|
||||
measuredAt: new Date().toISOString(),
|
||||
iterations,
|
||||
coldStartMs: result.coldStartMs,
|
||||
warmRunMs: result.warmRunMs,
|
||||
repeatedMacro: result.repeatedMacro.stats,
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[python-runtime-benchmark] ${message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -151,6 +151,24 @@ export const importDefinitions = sqliteTable('import_definitions', {
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
// Scripts table - stores metadata for Python scripts persisted in scripts/*.py
|
||||
export const scripts = sqliteTable('scripts', {
|
||||
id: text('id').primaryKey(),
|
||||
projectId: text('project_id').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
title: text('title').notNull(),
|
||||
kind: text('kind', { enum: ['macro', 'utility', 'transform'] }).notNull().default('utility'),
|
||||
entrypoint: text('entrypoint').notNull().default('render'),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
version: integer('version').notNull().default(1),
|
||||
filePath: text('file_path').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
}, (table) => ({
|
||||
// Composite unique index: slug must be unique within each project
|
||||
projectSlugIdx: uniqueIndex('scripts_project_slug_idx').on(table.projectId, table.slug),
|
||||
}));
|
||||
|
||||
// Types for TypeScript
|
||||
export type Project = typeof projects.$inferSelect;
|
||||
export type NewProject = typeof projects.$inferInsert;
|
||||
@@ -174,3 +192,5 @@ export type ChatMessage = typeof chatMessages.$inferSelect;
|
||||
export type NewChatMessage = typeof chatMessages.$inferInsert;
|
||||
export type ImportDefinition = typeof importDefinitions.$inferSelect;
|
||||
export type NewImportDefinition = typeof importDefinitions.$inferInsert;
|
||||
export type Script = typeof scripts.$inferSelect;
|
||||
export type NewScript = typeof scripts.$inferInsert;
|
||||
|
||||
316
src/main/engine/BlogmarkTransformService.ts
Normal file
316
src/main/engine/BlogmarkTransformService.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { z } from 'zod';
|
||||
import { getScriptEngine } from './ScriptEngine';
|
||||
|
||||
const transformPostSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
content: z.string().trim().min(1),
|
||||
tags: z.array(z.string().trim().min(1)),
|
||||
categories: z.array(z.string().trim().min(1)),
|
||||
});
|
||||
|
||||
export type BlogmarkTransformedPost = z.infer<typeof transformPostSchema>;
|
||||
|
||||
export interface BlogmarkTransformInput {
|
||||
post: BlogmarkTransformedPost;
|
||||
context: {
|
||||
source: 'blogmark';
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BlogmarkTransformScriptRecord {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
kind: 'macro' | 'utility' | 'transform';
|
||||
entrypoint: string;
|
||||
enabled: boolean;
|
||||
content: string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
export interface BlogmarkTransformExecutor {
|
||||
runTransform(script: BlogmarkTransformScriptRecord, input: BlogmarkTransformInput): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface BlogmarkTransformScriptProvider {
|
||||
getScripts(): Promise<BlogmarkTransformScriptRecord[]>;
|
||||
}
|
||||
|
||||
export interface BlogmarkTransformError {
|
||||
scriptId: string;
|
||||
scriptSlug: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BlogmarkTransformExecutionData {
|
||||
output: unknown;
|
||||
toasts: string[];
|
||||
}
|
||||
|
||||
export interface BlogmarkTransformResult {
|
||||
post: BlogmarkTransformedPost;
|
||||
appliedScriptIds: string[];
|
||||
errors: BlogmarkTransformError[];
|
||||
toasts: string[];
|
||||
}
|
||||
|
||||
const MAX_TOASTS_PER_SCRIPT = 5;
|
||||
const MAX_TOASTS_TOTAL = 20;
|
||||
const MAX_TOAST_LENGTH = 300;
|
||||
|
||||
const scriptEngineBackedProvider: BlogmarkTransformScriptProvider = {
|
||||
async getScripts() {
|
||||
return getScriptEngine().getAllScripts();
|
||||
},
|
||||
};
|
||||
|
||||
function toTimestamp(value: Date | string): number {
|
||||
if (value instanceof Date) {
|
||||
return value.getTime();
|
||||
}
|
||||
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function normalizePost(value: unknown): BlogmarkTransformedPost | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueRecord = value as Record<string, unknown>;
|
||||
const maybePost = valueRecord.post;
|
||||
|
||||
const candidate = maybePost && typeof maybePost === 'object' && !Array.isArray(maybePost)
|
||||
? maybePost
|
||||
: value;
|
||||
|
||||
const parsed = transformPostSchema.safeParse(candidate);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function normalizeToastMessage(value: unknown): string | null {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = String(value).trim();
|
||||
if (normalized.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.slice(0, MAX_TOAST_LENGTH);
|
||||
}
|
||||
|
||||
function toExecutionData(value: unknown): BlogmarkTransformExecutionData {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const valueRecord = value as Record<string, unknown>;
|
||||
const toasts = Array.isArray(valueRecord.toasts)
|
||||
? valueRecord.toasts
|
||||
.map((item) => normalizeToastMessage(item))
|
||||
.filter((item): item is string => item !== null)
|
||||
: [];
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(valueRecord, 'output')) {
|
||||
return {
|
||||
output: valueRecord.output,
|
||||
toasts,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
output: value,
|
||||
toasts,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
output: value,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && typeof error.message === 'string' && error.message.trim().length > 0) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
class PythonBlogmarkTransformExecutor implements BlogmarkTransformExecutor {
|
||||
private runtimePromise: Promise<any> | null = null;
|
||||
|
||||
async runTransform(script: BlogmarkTransformScriptRecord, input: BlogmarkTransformInput): Promise<unknown> {
|
||||
const runtime = await this.getRuntime();
|
||||
const toastMessages: string[] = [];
|
||||
const pushToast = (message: unknown): void => {
|
||||
if (toastMessages.length >= MAX_TOASTS_PER_SCRIPT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedMessage = normalizeToastMessage(message);
|
||||
if (!normalizedMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
toastMessages.push(normalizedMessage);
|
||||
};
|
||||
|
||||
runtime.globals.set('__bds_push_toast', pushToast);
|
||||
await runtime.runPythonAsync(`
|
||||
def toast(message):
|
||||
__bds_push_toast(str(message))
|
||||
`);
|
||||
|
||||
await runtime.runPythonAsync(script.content);
|
||||
|
||||
const requestedEntrypoint = this.resolveEntrypoint(script.entrypoint);
|
||||
const payload = JSON.stringify(input);
|
||||
runtime.globals.set('__bds_transform_payload_json', payload);
|
||||
runtime.globals.set('__bds_transform_entrypoint', requestedEntrypoint);
|
||||
|
||||
const rawResult = await runtime.runPythonAsync(`
|
||||
import json
|
||||
_payload = json.loads(__bds_transform_payload_json)
|
||||
_entrypoint = __bds_transform_entrypoint
|
||||
_transform_fn = globals().get(_entrypoint)
|
||||
if _transform_fn is None or not callable(_transform_fn):
|
||||
raise RuntimeError(f"Transform entrypoint '{_entrypoint}' is not callable")
|
||||
_post = _payload.get("post")
|
||||
if not isinstance(_post, dict):
|
||||
raise RuntimeError("Transform payload is missing a valid 'post' object")
|
||||
_context = _payload.get("context")
|
||||
try:
|
||||
_result = _transform_fn(_post, _context)
|
||||
except TypeError:
|
||||
_result = _transform_fn(_post)
|
||||
if _result is None:
|
||||
_result = _post
|
||||
json.dumps(_result)
|
||||
`);
|
||||
|
||||
return {
|
||||
output: JSON.parse(String(rawResult)),
|
||||
toasts: toastMessages,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveEntrypoint(value: string): string {
|
||||
const nextEntrypoint = typeof value === 'string' ? value.trim() : '';
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(nextEntrypoint) && nextEntrypoint !== 'main') {
|
||||
return nextEntrypoint;
|
||||
}
|
||||
|
||||
return 'transform';
|
||||
}
|
||||
|
||||
private async getRuntime(): Promise<any> {
|
||||
if (!this.runtimePromise) {
|
||||
this.runtimePromise = (async () => {
|
||||
const pyodideModule = await import('pyodide');
|
||||
return pyodideModule.loadPyodide();
|
||||
})();
|
||||
}
|
||||
|
||||
return this.runtimePromise;
|
||||
}
|
||||
}
|
||||
|
||||
export class BlogmarkTransformService {
|
||||
constructor(
|
||||
private readonly dependencies: {
|
||||
provider?: BlogmarkTransformScriptProvider;
|
||||
executor?: BlogmarkTransformExecutor;
|
||||
} = {},
|
||||
) {}
|
||||
|
||||
async applyTransforms(input: BlogmarkTransformInput): Promise<BlogmarkTransformResult> {
|
||||
const parsedInput = transformPostSchema.parse(input.post);
|
||||
const transformInput: BlogmarkTransformInput = {
|
||||
...input,
|
||||
post: parsedInput,
|
||||
};
|
||||
|
||||
const provider = this.dependencies.provider ?? scriptEngineBackedProvider;
|
||||
const executor = this.dependencies.executor ?? new PythonBlogmarkTransformExecutor();
|
||||
|
||||
const scripts = await provider.getScripts();
|
||||
const activeTransforms = scripts
|
||||
.filter((script) => script.enabled && script.kind === 'transform')
|
||||
.sort((left, right) => {
|
||||
const byUpdatedAt = toTimestamp(left.updatedAt) - toTimestamp(right.updatedAt);
|
||||
if (byUpdatedAt !== 0) {
|
||||
return byUpdatedAt;
|
||||
}
|
||||
|
||||
const bySlug = left.slug.localeCompare(right.slug);
|
||||
if (bySlug !== 0) {
|
||||
return bySlug;
|
||||
}
|
||||
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
|
||||
let currentPost = transformInput.post;
|
||||
const appliedScriptIds: string[] = [];
|
||||
const errors: BlogmarkTransformError[] = [];
|
||||
const toasts: string[] = [];
|
||||
|
||||
for (const script of activeTransforms) {
|
||||
try {
|
||||
const execution = await executor.runTransform(script, {
|
||||
...transformInput,
|
||||
post: currentPost,
|
||||
});
|
||||
|
||||
const executionData = toExecutionData(execution);
|
||||
const nextToasts = executionData.toasts
|
||||
.map((message) => normalizeToastMessage(message))
|
||||
.filter((message): message is string => message !== null);
|
||||
|
||||
if (nextToasts.length > 0 && toasts.length < MAX_TOASTS_TOTAL) {
|
||||
const remaining = MAX_TOASTS_TOTAL - toasts.length;
|
||||
toasts.push(...nextToasts.slice(0, remaining));
|
||||
}
|
||||
|
||||
const normalizedPost = normalizePost(executionData.output);
|
||||
if (!normalizedPost) {
|
||||
throw new Error('Transform output validation failed');
|
||||
}
|
||||
|
||||
currentPost = normalizedPost;
|
||||
appliedScriptIds.push(script.id);
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
errors.push({
|
||||
scriptId: script.id,
|
||||
scriptSlug: script.slug,
|
||||
message,
|
||||
});
|
||||
console.error(`[blogmark-transform] ${script.slug}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
post: currentPost,
|
||||
appliedScriptIds,
|
||||
errors,
|
||||
toasts,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let blogmarkTransformServiceInstance: BlogmarkTransformService | null = null;
|
||||
|
||||
export function getBlogmarkTransformService(): BlogmarkTransformService {
|
||||
if (!blogmarkTransformServiceInstance) {
|
||||
blogmarkTransformServiceInstance = new BlogmarkTransformService();
|
||||
}
|
||||
|
||||
return blogmarkTransformServiceInstance;
|
||||
}
|
||||
330
src/main/engine/ScriptEngine.ts
Normal file
330
src/main/engine/ScriptEngine.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { app } from 'electron';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { getDatabase } from '../database';
|
||||
import { scripts, type NewScript, type Script } from '../database/schema';
|
||||
|
||||
export type ScriptKind = 'macro' | 'utility' | 'transform';
|
||||
|
||||
export interface ScriptData {
|
||||
id: string;
|
||||
projectId: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
kind: ScriptKind;
|
||||
entrypoint: string;
|
||||
enabled: boolean;
|
||||
version: number;
|
||||
filePath: string;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateScriptInput {
|
||||
title: string;
|
||||
kind: ScriptKind;
|
||||
content: string;
|
||||
slug?: string;
|
||||
entrypoint?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateScriptInput {
|
||||
title?: string;
|
||||
kind?: ScriptKind;
|
||||
content?: string;
|
||||
slug?: string;
|
||||
entrypoint?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export class ScriptEngine extends EventEmitter {
|
||||
private currentProjectId = 'default';
|
||||
private dataDir: string | null = null;
|
||||
|
||||
setProjectContext(projectId: string, dataDir?: string): void {
|
||||
this.currentProjectId = projectId;
|
||||
this.dataDir = dataDir || null;
|
||||
}
|
||||
|
||||
getProjectContext(): string {
|
||||
return this.currentProjectId;
|
||||
}
|
||||
|
||||
async createScript(input: CreateScriptInput): Promise<ScriptData> {
|
||||
const now = new Date();
|
||||
const allScripts = await this.getAllScriptRows();
|
||||
const desiredSlug = this.normalizeSlug(input.slug || input.title || 'script');
|
||||
const uniqueSlug = this.ensureUniqueSlug(desiredSlug, allScripts);
|
||||
const scriptId = uuidv4();
|
||||
const filePath = this.getScriptFilePath(uniqueSlug);
|
||||
|
||||
const row: NewScript = {
|
||||
id: scriptId,
|
||||
projectId: this.currentProjectId,
|
||||
slug: uniqueSlug,
|
||||
title: input.title,
|
||||
kind: input.kind,
|
||||
entrypoint: input.entrypoint || 'render',
|
||||
enabled: input.enabled ?? true,
|
||||
version: 1,
|
||||
filePath,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await fs.mkdir(this.getScriptsDir(), { recursive: true });
|
||||
await fs.writeFile(filePath, this.serializeScriptFile(row as Script, input.content), 'utf-8');
|
||||
|
||||
await getDatabase().getLocal().insert(scripts).values(row);
|
||||
|
||||
const created = await this.toScriptData(row as Script);
|
||||
this.emit('scriptCreated', created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateScript(id: string, updates: UpdateScriptInput): Promise<ScriptData | null> {
|
||||
const existing = await this.getScriptRow(id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allScripts = await this.getAllScriptRows();
|
||||
const desiredSlug = typeof updates.slug === 'string'
|
||||
? this.normalizeSlug(updates.slug)
|
||||
: typeof updates.title === 'string'
|
||||
? this.normalizeSlug(updates.title)
|
||||
: existing.slug;
|
||||
const nextSlug = this.ensureUniqueSlug(desiredSlug, allScripts, existing.id);
|
||||
const nextFilePath = this.getScriptFilePath(nextSlug);
|
||||
const now = new Date();
|
||||
|
||||
if (existing.filePath !== nextFilePath) {
|
||||
await fs.mkdir(this.getScriptsDir(), { recursive: true });
|
||||
await fs.rename(existing.filePath, nextFilePath);
|
||||
}
|
||||
|
||||
const nextTitle = updates.title ?? existing.title;
|
||||
const nextKind = updates.kind ?? existing.kind;
|
||||
const nextEntrypoint = updates.entrypoint ?? existing.entrypoint;
|
||||
const nextEnabled = updates.enabled ?? existing.enabled;
|
||||
const nextVersion = existing.version + 1;
|
||||
const nextContent = typeof updates.content === 'string'
|
||||
? updates.content
|
||||
: await this.readScriptBody(nextFilePath);
|
||||
|
||||
const nextRow = {
|
||||
...existing,
|
||||
title: nextTitle,
|
||||
slug: nextSlug,
|
||||
kind: nextKind,
|
||||
entrypoint: nextEntrypoint,
|
||||
enabled: nextEnabled,
|
||||
filePath: nextFilePath,
|
||||
version: nextVersion,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await fs.writeFile(nextFilePath, this.serializeScriptFile(nextRow, nextContent), 'utf-8');
|
||||
|
||||
await getDatabase().getLocal()
|
||||
.update(scripts)
|
||||
.set({
|
||||
title: nextTitle,
|
||||
slug: nextSlug,
|
||||
kind: nextKind,
|
||||
entrypoint: nextEntrypoint,
|
||||
enabled: nextEnabled,
|
||||
filePath: nextFilePath,
|
||||
version: nextVersion,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(scripts.id, existing.id), eq(scripts.projectId, this.currentProjectId)));
|
||||
|
||||
const updatedRow = await this.getScriptRow(existing.id);
|
||||
if (!updatedRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated = await this.toScriptData(updatedRow);
|
||||
this.emit('scriptUpdated', updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteScript(id: string): Promise<boolean> {
|
||||
const existing = await this.getScriptRow(id);
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await getDatabase().getLocal()
|
||||
.delete(scripts)
|
||||
.where(and(eq(scripts.id, existing.id), eq(scripts.projectId, this.currentProjectId)));
|
||||
|
||||
try {
|
||||
await fs.unlink(existing.filePath);
|
||||
} catch (error) {
|
||||
const fsError = error as NodeJS.ErrnoException;
|
||||
if (fsError.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('scriptDeleted', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
async getScript(id: string): Promise<ScriptData | null> {
|
||||
const row = await this.getScriptRow(id);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return this.toScriptData(row);
|
||||
}
|
||||
|
||||
async getAllScripts(): Promise<ScriptData[]> {
|
||||
const rows = await this.getAllScriptRows();
|
||||
return Promise.all(rows.map((item) => this.toScriptData(item)));
|
||||
}
|
||||
|
||||
private async getScriptRow(id: string): Promise<Script | null> {
|
||||
const rows = await this.getAllScriptRows();
|
||||
return rows.find((item) => item.id === id) || null;
|
||||
}
|
||||
|
||||
private async getAllScriptRows(): Promise<Script[]> {
|
||||
return getDatabase().getLocal()
|
||||
.select()
|
||||
.from(scripts)
|
||||
.where(eq(scripts.projectId, this.currentProjectId))
|
||||
.orderBy(desc(scripts.updatedAt))
|
||||
.all();
|
||||
}
|
||||
|
||||
private async toScriptData(row: Script): Promise<ScriptData> {
|
||||
const content = await this.readScriptBody(row.filePath);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
projectId: row.projectId,
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
kind: row.kind,
|
||||
entrypoint: row.entrypoint,
|
||||
enabled: row.enabled,
|
||||
version: row.version,
|
||||
filePath: row.filePath,
|
||||
content,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private getDataDir(): string {
|
||||
if (this.dataDir) {
|
||||
return this.dataDir;
|
||||
}
|
||||
|
||||
return path.join(app.getPath('userData'), 'projects', this.currentProjectId);
|
||||
}
|
||||
|
||||
private getScriptsDir(): string {
|
||||
return path.join(this.getDataDir(), 'scripts');
|
||||
}
|
||||
|
||||
private getScriptFilePath(slug: string): string {
|
||||
return path.join(this.getScriptsDir(), `${slug}.py`);
|
||||
}
|
||||
|
||||
private normalizeSlug(value: string): string {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
return normalized || 'script';
|
||||
}
|
||||
|
||||
private ensureUniqueSlug(slug: string, rows: Script[], excludeId?: string): string {
|
||||
const baseSlug = slug;
|
||||
const taken = new Set(
|
||||
rows
|
||||
.filter((item) => item.id !== excludeId)
|
||||
.map((item) => item.slug)
|
||||
);
|
||||
|
||||
if (!taken.has(baseSlug)) {
|
||||
return baseSlug;
|
||||
}
|
||||
|
||||
let suffix = 2;
|
||||
while (taken.has(`${baseSlug}_${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
return `${baseSlug}_${suffix}`;
|
||||
}
|
||||
|
||||
private serializeScriptFile(row: Pick<Script, 'id' | 'projectId' | 'slug' | 'title' | 'kind' | 'entrypoint' | 'enabled' | 'version' | 'createdAt' | 'updatedAt'>, content: string): string {
|
||||
const lines = [
|
||||
'"""',
|
||||
'---',
|
||||
`id: ${this.toYamlString(row.id)}`,
|
||||
`projectId: ${this.toYamlString(row.projectId)}`,
|
||||
`slug: ${this.toYamlString(row.slug)}`,
|
||||
`title: ${this.toYamlString(row.title)}`,
|
||||
`kind: ${this.toYamlString(row.kind)}`,
|
||||
`entrypoint: ${this.toYamlString(row.entrypoint)}`,
|
||||
`enabled: ${row.enabled ? 'true' : 'false'}`,
|
||||
`version: ${row.version}`,
|
||||
`createdAt: ${this.toYamlString(row.createdAt.toISOString())}`,
|
||||
`updatedAt: ${this.toYamlString(row.updatedAt.toISOString())}`,
|
||||
'---',
|
||||
'"""',
|
||||
content,
|
||||
];
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private toYamlString(value: string): string {
|
||||
const escaped = value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"');
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
private parseScriptBody(rawContent: string): string {
|
||||
const frontmatterDocstringPattern = /^(?:"""|''')\r?\n---\r?\n[\s\S]*?\r?\n---\r?\n(?:"""|''')\r?\n?/;
|
||||
if (!frontmatterDocstringPattern.test(rawContent)) {
|
||||
return rawContent;
|
||||
}
|
||||
|
||||
return rawContent.replace(frontmatterDocstringPattern, '');
|
||||
}
|
||||
|
||||
private async readScriptBody(filePath: string): Promise<string> {
|
||||
try {
|
||||
const rawContent = await fs.readFile(filePath, 'utf-8');
|
||||
return this.parseScriptBody(rawContent);
|
||||
} catch (error) {
|
||||
const fsError = error as NodeJS.ErrnoException;
|
||||
if (fsError.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let scriptEngineInstance: ScriptEngine | null = null;
|
||||
|
||||
export function getScriptEngine(): ScriptEngine {
|
||||
if (!scriptEngineInstance) {
|
||||
scriptEngineInstance = new ScriptEngine();
|
||||
}
|
||||
return scriptEngineInstance;
|
||||
}
|
||||
@@ -100,3 +100,11 @@ export {
|
||||
type MenuDocument,
|
||||
type MenuItemKind,
|
||||
} from './MenuEngine';
|
||||
export {
|
||||
ScriptEngine,
|
||||
getScriptEngine,
|
||||
type ScriptData,
|
||||
type ScriptKind,
|
||||
type CreateScriptInput,
|
||||
type UpdateScriptInput,
|
||||
} from './ScriptEngine';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getMetaEngine } from '../engine/MetaEngine';
|
||||
import { getMenuEngine, type MenuDocument } from '../engine/MenuEngine';
|
||||
import { getTagEngine } from '../engine/TagEngine';
|
||||
import { getPostMediaEngine } from '../engine/PostMediaEngine';
|
||||
import { getScriptEngine, type CreateScriptInput, type UpdateScriptInput } from '../engine/ScriptEngine';
|
||||
import { getGitEngine } from '../engine/GitEngine';
|
||||
import { taskManager, TaskProgress } from '../engine/TaskManager';
|
||||
import { getDatabase } from '../database';
|
||||
@@ -284,11 +285,13 @@ export function registerIpcHandlers(): void {
|
||||
const metaEngine = getMetaEngine();
|
||||
const menuEngine = getMenuEngine();
|
||||
const tagEngine = getTagEngine();
|
||||
const scriptEngine = getScriptEngine();
|
||||
postEngine.setProjectContext(project.id, dataDir);
|
||||
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
||||
metaEngine.setProjectContext(project.id, dataDir);
|
||||
menuEngine.setProjectContext(project.id, dataDir);
|
||||
tagEngine.setProjectContext(project.id, dataDir);
|
||||
scriptEngine.setProjectContext(project.id, dataDir);
|
||||
const postMediaEngine = getPostMediaEngine();
|
||||
postMediaEngine.setProjectContext(project.id);
|
||||
|
||||
@@ -322,11 +325,13 @@ export function registerIpcHandlers(): void {
|
||||
const metaEngine = getMetaEngine();
|
||||
const menuEngine = getMenuEngine();
|
||||
const tagEngine = getTagEngine();
|
||||
const scriptEngine = getScriptEngine();
|
||||
postEngine.setProjectContext(project.id, dataDir);
|
||||
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
||||
metaEngine.setProjectContext(project.id, dataDir);
|
||||
menuEngine.setProjectContext(project.id, dataDir);
|
||||
tagEngine.setProjectContext(project.id, dataDir);
|
||||
scriptEngine.setProjectContext(project.id, dataDir);
|
||||
const postMediaEngine = getPostMediaEngine();
|
||||
postMediaEngine.setProjectContext(project.id);
|
||||
|
||||
@@ -723,6 +728,33 @@ export function registerIpcHandlers(): void {
|
||||
return engine.regenerateMissingThumbnails();
|
||||
});
|
||||
|
||||
// ============ Script Handlers ============
|
||||
|
||||
safeHandle('scripts:create', async (_, data: CreateScriptInput) => {
|
||||
const engine = getScriptEngine();
|
||||
return engine.createScript(data);
|
||||
});
|
||||
|
||||
safeHandle('scripts:update', async (_, id: string, data: UpdateScriptInput) => {
|
||||
const engine = getScriptEngine();
|
||||
return engine.updateScript(id, data);
|
||||
});
|
||||
|
||||
safeHandle('scripts:delete', async (_, id: string) => {
|
||||
const engine = getScriptEngine();
|
||||
return engine.deleteScript(id);
|
||||
});
|
||||
|
||||
safeHandle('scripts:get', async (_, id: string) => {
|
||||
const engine = getScriptEngine();
|
||||
return engine.getScript(id);
|
||||
});
|
||||
|
||||
safeHandle('scripts:getAll', async () => {
|
||||
const engine = getScriptEngine();
|
||||
return engine.getAllScripts();
|
||||
});
|
||||
|
||||
// ============ Task Handlers ============
|
||||
|
||||
safeHandle('tasks:getAll', async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { eq } from 'drizzle-orm';
|
||||
import { getMediaEngine } from './engine/MediaEngine';
|
||||
import { getPostEngine } from './engine/PostEngine';
|
||||
import { getMetaEngine } from './engine/MetaEngine';
|
||||
import { getBlogmarkTransformService } from './engine/BlogmarkTransformService';
|
||||
import { PreviewServer } from './engine/PreviewServer';
|
||||
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';
|
||||
@@ -373,16 +374,40 @@ async function processBlogmarkDeepLink(rawDeepLink: string): Promise<void> {
|
||||
const metadata = await getMetaEngine().getProjectMetadata();
|
||||
const preferredCategory = normalizeBlogmarkCategory((metadata as { blogmarkCategory?: unknown } | null)?.blogmarkCategory);
|
||||
|
||||
const createdPost = await getPostEngine().createPost({
|
||||
title: payload.title,
|
||||
content: buildBlogmarkMarkdownLink(payload.title, payload.url),
|
||||
categories: preferredCategory ? [preferredCategory] : [],
|
||||
const transformService = getBlogmarkTransformService();
|
||||
const transformResult = await transformService.applyTransforms({
|
||||
post: {
|
||||
title: payload.title,
|
||||
content: buildBlogmarkMarkdownLink(payload.title, payload.url),
|
||||
tags: [],
|
||||
categories: preferredCategory ? [preferredCategory] : [],
|
||||
},
|
||||
context: {
|
||||
source: 'blogmark',
|
||||
url: payload.url,
|
||||
},
|
||||
});
|
||||
|
||||
const createdPost = await getPostEngine().createPost({
|
||||
title: transformResult.post.title,
|
||||
content: transformResult.post.content,
|
||||
tags: transformResult.post.tags,
|
||||
categories: transformResult.post.categories,
|
||||
});
|
||||
|
||||
const blogmarkCreatedPayload = {
|
||||
post: createdPost,
|
||||
transform: {
|
||||
appliedScriptIds: transformResult.appliedScriptIds,
|
||||
errors: transformResult.errors,
|
||||
toasts: transformResult.toasts,
|
||||
},
|
||||
};
|
||||
|
||||
if (mainWindow && !mainWindow.isDestroyed() && rendererReady) {
|
||||
mainWindow.webContents.send('blogmark:created', createdPost);
|
||||
mainWindow.webContents.send('blogmark:created', blogmarkCreatedPayload);
|
||||
} else {
|
||||
pendingBlogmarkCreatedEvents.push(createdPost);
|
||||
pendingBlogmarkCreatedEvents.push(blogmarkCreatedPayload);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,15 @@ export const electronAPI: ElectronAPI = {
|
||||
regenerateMissingThumbnails: () => ipcRenderer.invoke('media:regenerateMissingThumbnails'),
|
||||
},
|
||||
|
||||
// Scripts
|
||||
scripts: {
|
||||
create: (data: { title: string; kind: import('./shared/electronApi').ScriptKind; content: string; slug?: string; entrypoint?: string; enabled?: boolean }) => ipcRenderer.invoke('scripts:create', data),
|
||||
update: (id: string, data: { title?: string; kind?: import('./shared/electronApi').ScriptKind; content?: string; slug?: string; entrypoint?: string; enabled?: boolean }) => ipcRenderer.invoke('scripts:update', id, data),
|
||||
delete: (id: string) => ipcRenderer.invoke('scripts:delete', id),
|
||||
get: (id: string) => ipcRenderer.invoke('scripts:get', id),
|
||||
getAll: () => ipcRenderer.invoke('scripts:getAll'),
|
||||
},
|
||||
|
||||
// Post-Media Links
|
||||
postMedia: {
|
||||
link: (postId: string, mediaId: string) => ipcRenderer.invoke('postMedia:link', postId, mediaId),
|
||||
|
||||
@@ -133,6 +133,23 @@ export interface MediaSearchResult {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type ScriptKind = 'macro' | 'utility' | 'transform';
|
||||
|
||||
export interface ScriptData {
|
||||
id: string;
|
||||
projectId: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
kind: ScriptKind;
|
||||
entrypoint: string;
|
||||
enabled: boolean;
|
||||
version: number;
|
||||
filePath: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TaskProgress {
|
||||
taskId: string;
|
||||
name: string;
|
||||
@@ -528,6 +545,27 @@ export interface ElectronAPI {
|
||||
getTags: () => Promise<string[]>;
|
||||
getTagsWithCounts: () => Promise<TagCount[]>;
|
||||
};
|
||||
scripts: {
|
||||
create: (data: {
|
||||
title: string;
|
||||
kind: ScriptKind;
|
||||
content: string;
|
||||
slug?: string;
|
||||
entrypoint?: string;
|
||||
enabled?: boolean;
|
||||
}) => Promise<ScriptData>;
|
||||
update: (id: string, data: {
|
||||
title?: string;
|
||||
kind?: ScriptKind;
|
||||
content?: string;
|
||||
slug?: string;
|
||||
entrypoint?: string;
|
||||
enabled?: boolean;
|
||||
}) => Promise<ScriptData | null>;
|
||||
delete: (id: string) => Promise<boolean>;
|
||||
get: (id: string) => Promise<ScriptData | null>;
|
||||
getAll: () => Promise<ScriptData[]>;
|
||||
};
|
||||
postMedia: {
|
||||
link: (postId: string, mediaId: string) => Promise<MediaLinkData>;
|
||||
unlink: (postId: string, mediaId: string) => Promise<void>;
|
||||
|
||||
@@ -6,6 +6,11 @@ import { openSingletonToolTab } from './navigation/tabPolicy';
|
||||
import { persistSiteValidationReport } from './navigation/siteValidationPersistence';
|
||||
import { executeActivityClick } from './navigation/activityExecution';
|
||||
import { handleBlogmarkCreatedEvent } from './navigation/blogmarkHandling';
|
||||
import {
|
||||
buildBlogmarkTransformOutputEntries,
|
||||
buildBlogmarkTransformToastNotifications,
|
||||
parseBlogmarkCreatedEventPayload,
|
||||
} from './navigation/blogmarkTransformOutput';
|
||||
import { createDeferredEventGate } from './navigation/deferredEventGate';
|
||||
import { createAndFocusPost } from './navigation/postCreation';
|
||||
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme';
|
||||
@@ -34,6 +39,7 @@ const App: React.FC = () => {
|
||||
setPicoTheme,
|
||||
openTab,
|
||||
restoreTabState,
|
||||
appendPanelOutputEntry,
|
||||
} = useAppStore();
|
||||
const blogmarkEventGateRef = useRef(createDeferredEventGate<PostData>());
|
||||
|
||||
@@ -239,12 +245,46 @@ const App: React.FC = () => {
|
||||
);
|
||||
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('blogmark:created', (post: unknown) => {
|
||||
const created = post as PostData;
|
||||
window.electronAPI?.on('blogmark:created', (payload: unknown) => {
|
||||
const parsedPayload = parseBlogmarkCreatedEventPayload(payload);
|
||||
if (!parsedPayload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const created = parsedPayload.post as PostData;
|
||||
if (!created?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const outputEntries = buildBlogmarkTransformOutputEntries(parsedPayload.transform, tr);
|
||||
const toastNotifications = buildBlogmarkTransformToastNotifications(parsedPayload.transform, tr);
|
||||
|
||||
toastNotifications.forEach((notification) => {
|
||||
if (notification.kind === 'error') {
|
||||
showToast.error(notification.message);
|
||||
return;
|
||||
}
|
||||
|
||||
showToast.success(notification.message);
|
||||
});
|
||||
|
||||
if (outputEntries.length > 0) {
|
||||
const createdAt = new Date().toISOString();
|
||||
outputEntries.forEach((entry, index) => {
|
||||
appendPanelOutputEntry({
|
||||
id: `blogmark-transform-${Date.now()}-${index}`,
|
||||
createdAt,
|
||||
message: entry.message,
|
||||
kind: entry.kind,
|
||||
});
|
||||
});
|
||||
|
||||
useAppStore.setState({
|
||||
panelVisible: true,
|
||||
panelActiveTab: 'output',
|
||||
});
|
||||
}
|
||||
|
||||
blogmarkEventGateRef.current.push(created, processBlogmarkCreated);
|
||||
}) || (() => {})
|
||||
);
|
||||
|
||||
@@ -30,6 +30,12 @@ const MediaIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ScriptsIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 3H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7v2H8v2h8v-2h-3v-2h7a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zM5 14V5h14v9H5zm2-7.5L9.5 9 7 11.5l1.4 1.4L12.3 9 8.4 5.1 7 6.5zm6.5 5.5h4v-2h-4v2z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SettingsIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>
|
||||
@@ -170,6 +176,13 @@ export const ActivityBar: React.FC = () => {
|
||||
>
|
||||
<MediaIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`activity-bar-item ${isActivityActive(snapshot, 'scripts') ? 'active' : ''}`}
|
||||
onClick={() => executeActivityClick('scripts')}
|
||||
title={getTitle('scripts')}
|
||||
>
|
||||
<ScriptsIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`activity-bar-item ${isActivityActive(snapshot, 'tags') ? 'active' : ''}`}
|
||||
onClick={() => executeActivityClick('tags')}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
@import '@highlightjs/cdn-assets/styles/github.min.css' screen and (prefers-color-scheme: light);
|
||||
@import '@highlightjs/cdn-assets/styles/github-dark.min.css' screen and (prefers-color-scheme: dark);
|
||||
|
||||
.documentation-view {
|
||||
--doc-bg: var(--pico-background-color);
|
||||
--doc-surface: var(--pico-card-background-color);
|
||||
@@ -94,6 +97,54 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.documentation-code-block {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.documentation-code-copy-button {
|
||||
position: absolute;
|
||||
top: .4rem;
|
||||
right: .4rem;
|
||||
border: 1px solid var(--doc-border);
|
||||
border-radius: .25rem;
|
||||
background: var(--doc-surface);
|
||||
color: var(--doc-muted);
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
opacity: .88;
|
||||
}
|
||||
|
||||
.documentation-code-copy-button:hover,
|
||||
.documentation-code-copy-button:focus-visible {
|
||||
opacity: 1;
|
||||
color: var(--doc-text);
|
||||
}
|
||||
|
||||
.documentation-code-block pre {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.documentation-code-block .code-copy-icon {
|
||||
font-size: .95rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.documentation-code-block.code-copy-success .documentation-code-copy-button {
|
||||
color: var(--pico-ins-color, rgb(53, 117, 56));
|
||||
border-color: var(--pico-ins-color, rgb(53, 117, 56));
|
||||
}
|
||||
|
||||
.documentation-code-block.code-copy-failed .documentation-code-copy-button {
|
||||
color: var(--pico-del-color, rgb(183, 72, 72));
|
||||
border-color: var(--pico-del-color, rgb(183, 72, 72));
|
||||
}
|
||||
|
||||
.documentation-content.markdown-body blockquote {
|
||||
margin: 10px 0;
|
||||
padding: 0 0 0 12px;
|
||||
|
||||
@@ -1,15 +1,253 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Markdown from 'marked-react';
|
||||
import hljs from '@highlightjs/cdn-assets/es/highlight.min.js';
|
||||
import type { ReactNode } from 'react';
|
||||
import documentationContent from '../../../../DOCUMENTATION.md?raw';
|
||||
import { useAppStore } from '../../store';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from '../../utils/picoTheme';
|
||||
import './DocumentationView.css';
|
||||
|
||||
const HEADING_LEVELS = new Set([1, 2, 3, 4, 5, 6]);
|
||||
|
||||
function extractText(content: ReactNode): string {
|
||||
if (typeof content === 'string' || typeof content === 'number') {
|
||||
return String(content);
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return content.map(extractText).join('');
|
||||
}
|
||||
|
||||
if (React.isValidElement(content)) {
|
||||
return extractText((content.props as { children?: ReactNode }).children);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function slugifyHeading(value: string): string {
|
||||
return value
|
||||
.normalize('NFKD')
|
||||
.toLowerCase()
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
function isScrollable(element: HTMLElement): boolean {
|
||||
const style = window.getComputedStyle(element);
|
||||
const overflowY = style.overflowY;
|
||||
const canScroll = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay';
|
||||
return canScroll && element.scrollHeight > element.clientHeight;
|
||||
}
|
||||
|
||||
function resolveScrollContainer(target: HTMLElement, preferred: HTMLElement | null): HTMLElement | null {
|
||||
if (preferred && preferred.contains(target)) {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
let current: HTMLElement | null = target.parentElement;
|
||||
while (current) {
|
||||
if (isScrollable(current)) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
if (preferred) {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
const scrollingElement = document.scrollingElement;
|
||||
return scrollingElement instanceof HTMLElement ? scrollingElement : null;
|
||||
}
|
||||
|
||||
function resolveTargetHeadingInArticle(articleElement: HTMLElement, targetId: string): HTMLElement | null {
|
||||
for (const candidate of articleElement.querySelectorAll<HTMLElement>('[id]')) {
|
||||
if (candidate.id === targetId) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const headingCandidates = articleElement.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6');
|
||||
for (const heading of headingCandidates) {
|
||||
const headingSlug = slugifyHeading(heading.textContent ?? '');
|
||||
if (headingSlug === targetId) {
|
||||
heading.id = targetId;
|
||||
return heading;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const DocumentationView: React.FC = () => {
|
||||
const { t: tr } = useI18n();
|
||||
const { picoTheme } = useAppStore();
|
||||
const resolvedTheme = getRendererPicoTheme(picoTheme);
|
||||
const CODE_COPY_DEFAULT_ICON = '\u29c9';
|
||||
const CODE_COPY_SUCCESS_ICON = '\u2713';
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
const articleRef = useRef<HTMLElement | null>(null);
|
||||
const headingSlugCounts = new Map<string, number>();
|
||||
let rendererKeyIndex = 0;
|
||||
|
||||
const jumpToDocumentationHash = (href: string): boolean => {
|
||||
if (!href.startsWith('#') || href.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetId = decodeURIComponent(href.slice(1));
|
||||
const articleElement = articleRef.current;
|
||||
const preferredScrollContainer = scrollContainerRef.current;
|
||||
|
||||
if (!articleElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetHeading = resolveTargetHeadingInArticle(articleElement, targetId);
|
||||
|
||||
if (!targetHeading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const scrollContainer = resolveScrollContainer(targetHeading, preferredScrollContainer);
|
||||
if (!scrollContainer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const containerRect = scrollContainer.getBoundingClientRect();
|
||||
const headingRect = targetHeading.getBoundingClientRect();
|
||||
const targetTop = Math.max(0, scrollContainer.scrollTop + (headingRect.top - containerRect.top) - 12);
|
||||
|
||||
scrollContainer.scrollTop = 0;
|
||||
scrollContainer.scrollTop = targetTop;
|
||||
window.location.hash = href;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const getRendererKey = (prefix: string): string => {
|
||||
rendererKeyIndex += 1;
|
||||
return `${prefix}-${rendererKeyIndex}`;
|
||||
};
|
||||
|
||||
const markdownRenderer = {
|
||||
heading(children: ReactNode, level: number) {
|
||||
const levelNumber = HEADING_LEVELS.has(level as 1 | 2 | 3 | 4 | 5 | 6) ? level : 2;
|
||||
const headingText = extractText(children);
|
||||
const baseId = slugifyHeading(headingText);
|
||||
const existingCount = headingSlugCounts.get(baseId) ?? 0;
|
||||
const nextCount = existingCount + 1;
|
||||
headingSlugCounts.set(baseId, nextCount);
|
||||
const headingId = existingCount === 0 ? baseId : `${baseId}-${nextCount}`;
|
||||
|
||||
return React.createElement(`h${levelNumber}` as keyof JSX.IntrinsicElements, { id: headingId, key: getRendererKey('heading') }, children);
|
||||
},
|
||||
link(href: string, text: ReactNode) {
|
||||
if (!href.startsWith('#')) {
|
||||
return <a href={href} key={getRendererKey('link')}>{text}</a>;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
key={getRendererKey('hash-link')}
|
||||
onClick={(event) => {
|
||||
if (jumpToDocumentationHash(href)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
code(code: ReactNode, lang: string | undefined) {
|
||||
const normalizedLanguage = typeof lang === 'string' ? lang.trim().toLowerCase() : '';
|
||||
const sourceCode = extractText(code);
|
||||
const codeBlockKey = getRendererKey('code-block');
|
||||
|
||||
let highlightedHtml = '';
|
||||
let languageClass = '';
|
||||
|
||||
if (normalizedLanguage.length > 0 && hljs.getLanguage(normalizedLanguage)) {
|
||||
highlightedHtml = hljs.highlight(sourceCode, { language: normalizedLanguage }).value;
|
||||
languageClass = `language-${normalizedLanguage}`;
|
||||
} else {
|
||||
const autoDetected = hljs.highlightAuto(sourceCode);
|
||||
highlightedHtml = autoDetected.value;
|
||||
languageClass = autoDetected.language ? `language-${autoDetected.language}` : 'language-plaintext';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="documentation-code-block" key={codeBlockKey}>
|
||||
<button
|
||||
type="button"
|
||||
className="code-copy-button documentation-code-copy-button"
|
||||
aria-label={tr('docs.copyCode')}
|
||||
title={tr('docs.copyCode')}
|
||||
onClick={(event) => {
|
||||
const button = event.currentTarget;
|
||||
const wrapper = button.closest('.documentation-code-block');
|
||||
const icon = button.querySelector('.code-copy-icon');
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(sourceCode);
|
||||
return;
|
||||
}
|
||||
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = sourceCode;
|
||||
textArea.setAttribute('readonly', '');
|
||||
textArea.style.position = 'absolute';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
};
|
||||
|
||||
copyToClipboard()
|
||||
.then(() => {
|
||||
wrapper?.classList.remove('code-copy-failed');
|
||||
wrapper?.classList.remove('code-copy-success');
|
||||
wrapper?.classList.add('code-copy-success');
|
||||
|
||||
if (icon) {
|
||||
icon.textContent = CODE_COPY_SUCCESS_ICON;
|
||||
setTimeout(() => {
|
||||
icon.textContent = CODE_COPY_DEFAULT_ICON;
|
||||
wrapper?.classList.remove('code-copy-success');
|
||||
}, 1200);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
wrapper?.classList.remove('code-copy-success');
|
||||
wrapper?.classList.add('code-copy-failed');
|
||||
setTimeout(() => {
|
||||
wrapper?.classList.remove('code-copy-failed');
|
||||
}, 1200);
|
||||
console.error('Failed to copy documentation code block:', error);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="code-copy-icon">{CODE_COPY_DEFAULT_ICON}</span>
|
||||
</button>
|
||||
<pre>
|
||||
<code
|
||||
className={`hljs ${languageClass}`}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
ensureRendererPicoThemeStylesheet(resolvedTheme).catch((error) => {
|
||||
@@ -23,10 +261,13 @@ export const DocumentationView: React.FC = () => {
|
||||
<h1>{tr('docs.title')}</h1>
|
||||
<p>{tr('docs.subtitle')}</p>
|
||||
</div>
|
||||
<main className="documentation-scroll">
|
||||
<main
|
||||
className="documentation-scroll"
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
<div className="documentation-content markdown-body pico" data-theme="auto" data-pico-theme={resolvedTheme}>
|
||||
<article className="documentation-article">
|
||||
<Markdown>{documentationContent}</Markdown>
|
||||
<article className="documentation-article" ref={articleRef}>
|
||||
<Markdown renderer={markdownRenderer}>{documentationContent}</Markdown>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -19,7 +19,8 @@ import { MetadataDiffPanel } from '../MetadataDiffPanel';
|
||||
import { GitDiffView } from '../GitDiffView/GitDiffView';
|
||||
import { DocumentationView } from '../DocumentationView/DocumentationView';
|
||||
import { SiteValidationView } from '../SiteValidationView';
|
||||
import { AutoSaveManager, getContrastColor } from '../../utils';
|
||||
import { ScriptsView } from '../ScriptsView/ScriptsView';
|
||||
import { AutoSaveManager, getContrastColor, loadTagColorMap } from '../../utils';
|
||||
import { InsertModal } from '../InsertModal';
|
||||
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
|
||||
import { openEntityTab } from '../../navigation/tabPolicy';
|
||||
@@ -1475,12 +1476,6 @@ interface CategoryCount {
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface TagDataWithColor {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const { t: tr, language } = useI18n();
|
||||
const { posts, media } = useAppStore();
|
||||
@@ -1499,26 +1494,18 @@ const Dashboard: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const [ds, ym, tc, cc, allTagsData] = await Promise.all([
|
||||
const [ds, ym, tc, cc, colorMap] = await Promise.all([
|
||||
window.electronAPI?.posts.getDashboardStats(),
|
||||
window.electronAPI?.posts.getByYearMonth(),
|
||||
window.electronAPI?.posts.getTagsWithCounts(),
|
||||
window.electronAPI?.posts.getCategoriesWithCounts(),
|
||||
window.electronAPI?.tags.getAll(),
|
||||
loadTagColorMap(),
|
||||
]);
|
||||
if (ds) setStats(ds);
|
||||
if (ym) setYearMonthData(ym);
|
||||
if (tc) setTagCounts(tc);
|
||||
if (cc) setCategoryCounts(cc);
|
||||
if (allTagsData) {
|
||||
const colorMap = new Map<string, string>();
|
||||
for (const tag of allTagsData as TagDataWithColor[]) {
|
||||
if (tag.color) {
|
||||
colorMap.set(tag.name, tag.color);
|
||||
}
|
||||
}
|
||||
setTagColors(colorMap);
|
||||
}
|
||||
setTagColors(colorMap);
|
||||
} catch (e) {
|
||||
console.error('Failed to load dashboard stats:', e);
|
||||
}
|
||||
@@ -1796,6 +1783,7 @@ export const Editor: React.FC = () => {
|
||||
: <Dashboard />,
|
||||
documentation: () => <DocumentationView />,
|
||||
'site-validation': () => <SiteValidationView />,
|
||||
scripts: () => <ScriptsView scriptId={editorRoute.tabId} />,
|
||||
post: () => (editorRoute.tabId ? <PostEditor key={editorRoute.tabId} postId={editorRoute.tabId} /> : <Dashboard />),
|
||||
media: () => (editorRoute.tabId ? <MediaEditor key={editorRoute.tabId} mediaId={editorRoute.tabId} /> : <Dashboard />),
|
||||
dashboard: () => <Dashboard />,
|
||||
|
||||
@@ -86,6 +86,47 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.output-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.output-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.output-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.output-copy-button {
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
background-color: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.output-copy-button:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.output-item {
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-editor-foreground);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.task-group-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -43,6 +43,7 @@ export const Panel: React.FC = () => {
|
||||
panelVisible,
|
||||
panelActiveTab,
|
||||
setPanelActiveTab,
|
||||
panelOutputEntries,
|
||||
tasks,
|
||||
tabs,
|
||||
activeTabId,
|
||||
@@ -249,6 +250,35 @@ export const Panel: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyOutput = async () => {
|
||||
if (panelOutputEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const outputText = panelOutputEntries.map((entry) => entry.message).join('\n\n');
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(outputText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined' || typeof document.createElement !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = outputText;
|
||||
textArea.setAttribute('readonly', '');
|
||||
textArea.style.position = 'absolute';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
if (typeof document.execCommand === 'function') {
|
||||
document.execCommand('copy');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
};
|
||||
|
||||
const renderTaskRow = (task: TaskProgress, isChild = false) => (
|
||||
<div key={task.taskId} className={`task-item status-${task.status} ${isChild ? 'task-child-row' : ''}`.trim()}>
|
||||
<div className="task-status">
|
||||
@@ -383,7 +413,30 @@ export const Panel: React.FC = () => {
|
||||
)}
|
||||
|
||||
{effectiveActivePanelTab === 'output' && (
|
||||
<div className="panel-empty">{t('panel.noOutput')}</div>
|
||||
panelOutputEntries.length === 0 ? (
|
||||
<div className="panel-empty">{t('panel.noOutput')}</div>
|
||||
) : (
|
||||
<div className="output-content">
|
||||
<div className="output-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="output-copy-button"
|
||||
onClick={() => {
|
||||
void handleCopyOutput();
|
||||
}}
|
||||
>
|
||||
{t('panel.copyOutput')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="output-list">
|
||||
{panelOutputEntries.map((entry) => (
|
||||
<div key={entry.id} className={`output-item output-${entry.kind}`}>
|
||||
{entry.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{effectiveActivePanelTab === 'post-links' && (
|
||||
|
||||
60
src/renderer/components/ScriptsView/ScriptsView.css
Normal file
60
src/renderer/components/ScriptsView/ScriptsView.css
Normal file
@@ -0,0 +1,60 @@
|
||||
.scripts-view-shell {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.scripts-view {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.scripts-meta-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.scripts-enabled-field {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.scripts-enabled-field label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scripts-editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.scripts-toolbar {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.scripts-monaco {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background-color: var(--vscode-input-background);
|
||||
}
|
||||
|
||||
.scripts-run-button {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scripts-save-button {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scripts-check-button {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
562
src/renderer/components/ScriptsView/ScriptsView.tsx
Normal file
562
src/renderer/components/ScriptsView/ScriptsView.tsx
Normal file
@@ -0,0 +1,562 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import MonacoEditor, { type Monaco } from '@monaco-editor/react';
|
||||
import type { ScriptData } from '../../../main/shared/electronApi';
|
||||
import { useAppStore } from '../../store';
|
||||
import { BDS_EVENT_SCRIPTS_CHANGED, dispatchWindowEvent } from '../../utils';
|
||||
import { getPythonRuntimeManager } from '../../python/runtimeManagerInstance';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { showToast } from '../Toast';
|
||||
import './ScriptsView.css';
|
||||
|
||||
type ScriptMonacoEditor = {
|
||||
getModel: () => unknown;
|
||||
};
|
||||
|
||||
type ScriptMonacoRuntime = {
|
||||
editor: {
|
||||
setModelMarkers: (model: unknown, owner: string, markers: Array<{
|
||||
startLineNumber: number;
|
||||
startColumn: number;
|
||||
endLineNumber: number;
|
||||
endColumn: number;
|
||||
message: string;
|
||||
severity: number;
|
||||
}>) => void;
|
||||
};
|
||||
MarkerSeverity: {
|
||||
Error: number;
|
||||
};
|
||||
};
|
||||
|
||||
const SCRIPT_SYNTAX_MARKER_OWNER = 'scripts-python-syntax';
|
||||
|
||||
const UI_DATE_LOCALE: Record<string, string> = {
|
||||
en: 'en-US',
|
||||
de: 'de-DE',
|
||||
fr: 'fr-FR',
|
||||
it: 'it-IT',
|
||||
es: 'es-ES',
|
||||
};
|
||||
|
||||
interface ScriptsViewProps {
|
||||
scriptId: string | null;
|
||||
}
|
||||
|
||||
export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
||||
const { t, language } = useI18n();
|
||||
const appendPanelOutputEntry = useAppStore((state) => state.appendPanelOutputEntry);
|
||||
const closeTab = useAppStore((state) => state.closeTab);
|
||||
const [script, setScript] = useState<ScriptData | null>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [kind, setKind] = useState<ScriptData['kind']>('utility');
|
||||
const [entrypoint, setEntrypoint] = useState('render');
|
||||
const [availableEntrypoints, setAvailableEntrypoints] = useState<string[]>([]);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [scriptContent, setScriptContent] = useState('');
|
||||
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isCheckingSyntax, setIsCheckingSyntax] = useState(false);
|
||||
const editorRef = useRef<ScriptMonacoEditor | null>(null);
|
||||
const monacoRef = useRef<ScriptMonacoRuntime | null>(null);
|
||||
|
||||
const buildCacheKey = (scriptMeta: Pick<ScriptData, 'id' | 'version'>, content: string): string => {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
hash = ((hash << 5) - hash + content.charCodeAt(index)) | 0;
|
||||
}
|
||||
return `${scriptMeta.id}:${scriptMeta.version}:${Math.abs(hash).toString(36)}`;
|
||||
};
|
||||
|
||||
const withMainEntrypoint = (entrypoints: string[]): string[] => {
|
||||
const filtered = entrypoints.filter((name) => name !== 'main');
|
||||
return ['main', ...filtered];
|
||||
};
|
||||
|
||||
const toFunctionSlug = (value: string) => {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
return normalized || 'script';
|
||||
};
|
||||
|
||||
const applySyntaxMarkers = useCallback((errors: Array<{
|
||||
line: number;
|
||||
column: number;
|
||||
endLine: number;
|
||||
endColumn: number;
|
||||
message: string;
|
||||
}>) => {
|
||||
const model = editorRef.current?.getModel?.();
|
||||
const monacoRuntime = monacoRef.current;
|
||||
|
||||
if (!model || !monacoRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markers = errors.map((error) => {
|
||||
const startLineNumber = Math.max(1, Math.floor(error.line || 1));
|
||||
const startColumn = Math.max(1, Math.floor(error.column || 1));
|
||||
const endLineNumber = Math.max(startLineNumber, Math.floor(error.endLine || startLineNumber));
|
||||
const fallbackEndColumn = startColumn + 1;
|
||||
const endColumn = Math.max(startColumn, Math.floor(error.endColumn || fallbackEndColumn));
|
||||
|
||||
return {
|
||||
startLineNumber,
|
||||
startColumn,
|
||||
endLineNumber,
|
||||
endColumn,
|
||||
message: error.message,
|
||||
severity: monacoRuntime.MarkerSeverity.Error,
|
||||
};
|
||||
});
|
||||
|
||||
monacoRuntime.editor.setModelMarkers(model, SCRIPT_SYNTAX_MARKER_OWNER, markers);
|
||||
}, []);
|
||||
|
||||
const handleEditorDidMount = useCallback((editor: unknown, monaco: Monaco) => {
|
||||
editorRef.current = editor as ScriptMonacoEditor;
|
||||
monacoRef.current = monaco as unknown as ScriptMonacoRuntime;
|
||||
}, []);
|
||||
|
||||
const handleCheckSyntax = useCallback(async (options: { notify: boolean } = { notify: true }): Promise<boolean> => {
|
||||
if (!script || isCheckingSyntax) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsCheckingSyntax(true);
|
||||
try {
|
||||
const runtimeManager = getPythonRuntimeManager();
|
||||
const syntax = await runtimeManager.syntaxCheck(scriptContent, {
|
||||
cacheKey: buildCacheKey(script, scriptContent),
|
||||
});
|
||||
|
||||
applySyntaxMarkers(syntax.errors);
|
||||
|
||||
if (syntax.errors.length > 0) {
|
||||
if (options.notify) {
|
||||
showToast.error(t('scripts.syntax.invalid', { count: syntax.errors.length }));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.notify) {
|
||||
showToast.success(t('scripts.syntax.valid'));
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
appendPanelOutputEntry({
|
||||
id: `output-${Date.now()}-syntax-error`,
|
||||
message,
|
||||
createdAt: new Date().toISOString(),
|
||||
kind: 'error',
|
||||
});
|
||||
if (options.notify) {
|
||||
showToast.error(t('scripts.syntax.checkFailed'));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setIsCheckingSyntax(false);
|
||||
}
|
||||
}, [appendPanelOutputEntry, applySyntaxMarkers, isCheckingSyntax, script, scriptContent, t]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const refreshEntrypoints = async (content: string, scriptMeta: ScriptData) => {
|
||||
try {
|
||||
const runtimeManager = getPythonRuntimeManager();
|
||||
const discoveredEntrypoints = await runtimeManager.inspectEntrypoints(content, {
|
||||
cacheKey: buildCacheKey(scriptMeta, content),
|
||||
});
|
||||
const available = withMainEntrypoint(discoveredEntrypoints);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAvailableEntrypoints(available);
|
||||
|
||||
const preferredEntrypoint = available.includes(scriptMeta.entrypoint)
|
||||
? scriptMeta.entrypoint
|
||||
: 'main';
|
||||
setEntrypoint(preferredEntrypoint);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setAvailableEntrypoints(['main']);
|
||||
setEntrypoint('main');
|
||||
}
|
||||
};
|
||||
|
||||
const loadScript = async () => {
|
||||
if (!scriptId) {
|
||||
setScript(null);
|
||||
setTitle('');
|
||||
setSlug('');
|
||||
setKind('utility');
|
||||
setEntrypoint('render');
|
||||
setAvailableEntrypoints(['main']);
|
||||
setEnabled(true);
|
||||
setScriptContent('');
|
||||
setIsSlugManuallyEdited(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const item = await window.electronAPI?.scripts.get(scriptId);
|
||||
if (cancelled || !item) {
|
||||
setScript(null);
|
||||
setTitle('');
|
||||
setSlug('');
|
||||
setKind('utility');
|
||||
setEntrypoint('render');
|
||||
setAvailableEntrypoints(['main']);
|
||||
setEnabled(true);
|
||||
setScriptContent('');
|
||||
setIsSlugManuallyEdited(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setScript(item);
|
||||
setTitle(item.title || '');
|
||||
setSlug(toFunctionSlug(item.slug || item.title || ''));
|
||||
setKind(item.kind || 'utility');
|
||||
setEntrypoint(item.entrypoint || 'render');
|
||||
setEnabled(item.enabled ?? true);
|
||||
setScriptContent(item.content || '');
|
||||
const normalizedExisting = toFunctionSlug(item.slug || item.title || '');
|
||||
setIsSlugManuallyEdited(normalizedExisting !== toFunctionSlug(item.title || ''));
|
||||
await refreshEntrypoints(item.content || '', item);
|
||||
};
|
||||
|
||||
void loadScript();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [scriptId]);
|
||||
|
||||
const hasChanges = !!script && (
|
||||
title !== script.title ||
|
||||
slug !== script.slug ||
|
||||
kind !== script.kind ||
|
||||
entrypoint !== script.entrypoint ||
|
||||
enabled !== script.enabled ||
|
||||
scriptContent !== script.content
|
||||
);
|
||||
|
||||
const handleTitleChange = (nextTitle: string) => {
|
||||
setTitle(nextTitle);
|
||||
if (!isSlugManuallyEdited) {
|
||||
setSlug(toFunctionSlug(nextTitle));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSlugChange = (nextSlug: string) => {
|
||||
setIsSlugManuallyEdited(true);
|
||||
setSlug(toFunctionSlug(nextSlug));
|
||||
};
|
||||
|
||||
const handleSaveScript = async () => {
|
||||
if (!script || isSaving || !hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const isSyntaxValid = await handleCheckSyntax({ notify: true });
|
||||
if (!isSyntaxValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeManager = getPythonRuntimeManager();
|
||||
const discoveredEntrypoints = await runtimeManager.inspectEntrypoints(scriptContent, {
|
||||
cacheKey: buildCacheKey(script, scriptContent),
|
||||
});
|
||||
const available = withMainEntrypoint(discoveredEntrypoints);
|
||||
|
||||
const normalizedEntrypoint = available.includes(entrypoint)
|
||||
? entrypoint
|
||||
: 'main';
|
||||
|
||||
setAvailableEntrypoints(available);
|
||||
setEntrypoint(normalizedEntrypoint);
|
||||
|
||||
const updated = await window.electronAPI?.scripts.update(script.id, {
|
||||
title,
|
||||
slug,
|
||||
kind,
|
||||
entrypoint: normalizedEntrypoint,
|
||||
enabled,
|
||||
content: scriptContent,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return;
|
||||
}
|
||||
|
||||
setScript(updated);
|
||||
setTitle(updated.title || '');
|
||||
setSlug(toFunctionSlug(updated.slug || updated.title || ''));
|
||||
setKind(updated.kind || 'utility');
|
||||
setEntrypoint(updated.entrypoint || 'render');
|
||||
setAvailableEntrypoints(available);
|
||||
setEnabled(updated.enabled ?? true);
|
||||
setScriptContent(updated.content || '');
|
||||
const normalizedExisting = toFunctionSlug(updated.slug || updated.title || '');
|
||||
setIsSlugManuallyEdited(normalizedExisting !== toFunctionSlug(updated.title || ''));
|
||||
dispatchWindowEvent(BDS_EVENT_SCRIPTS_CHANGED);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteScript = async () => {
|
||||
if (!script) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deleted = await window.electronAPI?.scripts.delete(script.id);
|
||||
if (!deleted) {
|
||||
showToast.error(t('sidebar.scripts.deleteFailed'));
|
||||
return;
|
||||
}
|
||||
closeTab(script.id);
|
||||
dispatchWindowEvent(BDS_EVENT_SCRIPTS_CHANGED);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete script:', error);
|
||||
showToast.error(t('sidebar.scripts.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault();
|
||||
void handleSaveScript();
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window.addEventListener !== 'function' || typeof window.removeEventListener !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleSaveScript]);
|
||||
|
||||
const handleRunScript = async () => {
|
||||
if (!script || isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRunning(true);
|
||||
|
||||
try {
|
||||
const runtimeManager = getPythonRuntimeManager();
|
||||
const result = await runtimeManager.execute(scriptContent, {
|
||||
cacheKey: buildCacheKey(script, scriptContent),
|
||||
entrypoint,
|
||||
});
|
||||
|
||||
const now = new Date().toISOString();
|
||||
if (result.result.trim().length > 0) {
|
||||
appendPanelOutputEntry({
|
||||
id: `output-${Date.now()}-result`,
|
||||
message: result.result,
|
||||
createdAt: now,
|
||||
kind: 'result',
|
||||
});
|
||||
}
|
||||
|
||||
if (result.stdout.trim().length > 0) {
|
||||
appendPanelOutputEntry({
|
||||
id: `output-${Date.now()}-stdout`,
|
||||
message: result.stdout,
|
||||
createdAt: now,
|
||||
kind: 'stdout',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
appendPanelOutputEntry({
|
||||
id: `output-${Date.now()}-error`,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
createdAt: new Date().toISOString(),
|
||||
kind: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="scripts-view-shell">
|
||||
<div className="editor-header scripts-header">
|
||||
<div className="editor-tabs">
|
||||
<div className="editor-tab active">
|
||||
<span className="editor-tab-title">{title || t('editor.untitled')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="scripts-save-button"
|
||||
onClick={handleSaveScript}
|
||||
disabled={!script || !hasChanges || isSaving}
|
||||
>
|
||||
{isSaving ? t('editor.saving') : t('scripts.save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="scripts-run-button"
|
||||
onClick={handleRunScript}
|
||||
disabled={!script || isRunning}
|
||||
>
|
||||
{t('scripts.run')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="scripts-check-button"
|
||||
onClick={() => {
|
||||
void handleCheckSyntax({ notify: true });
|
||||
}}
|
||||
disabled={!script || isCheckingSyntax || isSaving}
|
||||
>
|
||||
{isCheckingSyntax ? t('scripts.syntax.checking') : t('scripts.syntax.check')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary danger"
|
||||
onClick={handleDeleteScript}
|
||||
disabled={!script}
|
||||
title={t('scripts.delete')}
|
||||
>
|
||||
{t('scripts.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-content scripts-view">
|
||||
<div className="editor-header-row scripts-meta-row">
|
||||
<div className="editor-meta">
|
||||
<div className="editor-field-row">
|
||||
<div className="editor-field">
|
||||
<label htmlFor="script-title">{t('editor.field.title')}</label>
|
||||
<input
|
||||
id="script-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(event) => handleTitleChange(event.target.value)}
|
||||
disabled={!script}
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label htmlFor="script-slug">{t('editor.field.slug')}</label>
|
||||
<input
|
||||
id="script-slug"
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(event) => handleSlugChange(event.target.value)}
|
||||
disabled={!script}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-field-row">
|
||||
<div className="editor-field">
|
||||
<label htmlFor="script-kind">{t('scripts.field.kind')}</label>
|
||||
<select
|
||||
id="script-kind"
|
||||
value={kind}
|
||||
onChange={(event) => setKind(event.target.value as ScriptData['kind'])}
|
||||
disabled={!script}
|
||||
>
|
||||
<option value="utility">{t('scripts.kind.utility')}</option>
|
||||
<option value="macro">{t('scripts.kind.macro')}</option>
|
||||
<option value="transform">{t('scripts.kind.transform')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label htmlFor="script-entrypoint">{t('scripts.field.entrypoint')}</label>
|
||||
<select
|
||||
id="script-entrypoint"
|
||||
value={entrypoint}
|
||||
onChange={(event) => setEntrypoint(event.target.value)}
|
||||
disabled={!script}
|
||||
>
|
||||
{availableEntrypoints.map((name) => (
|
||||
<option key={name} value={name}>{name === 'main' ? t('scripts.entrypoint.main') : name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="editor-field scripts-enabled-field">
|
||||
<label htmlFor="script-enabled">
|
||||
<input
|
||||
id="script-enabled"
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(event) => setEnabled(event.target.checked)}
|
||||
disabled={!script}
|
||||
/>
|
||||
{t('scripts.field.enabled')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-body scripts-editor">
|
||||
<div className="editor-toolbar scripts-toolbar">
|
||||
<div className="editor-toolbar-left">
|
||||
<label>{t('scripts.content')}</label>
|
||||
</div>
|
||||
<div className="editor-toolbar-center" />
|
||||
<div className="editor-toolbar-right" />
|
||||
</div>
|
||||
|
||||
<div className="scripts-monaco">
|
||||
<MonacoEditor
|
||||
height="100%"
|
||||
language="python"
|
||||
theme="vs-dark"
|
||||
value={scriptContent}
|
||||
onChange={(value) => setScriptContent(value || '')}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'on',
|
||||
fontSize: 14,
|
||||
fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace",
|
||||
padding: { top: 12, bottom: 12 },
|
||||
automaticLayout: true,
|
||||
scrollBeyondLastLine: false,
|
||||
renderLineHighlight: 'line',
|
||||
formatOnPaste: true,
|
||||
cursorStyle: 'line',
|
||||
cursorBlinking: 'smooth',
|
||||
readOnly: !script,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{script && (
|
||||
<div className="editor-footer">
|
||||
<span className="text-muted text-small">
|
||||
{t('editor.footer.created')}: {new Date(script.createdAt).toLocaleString(UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en)}
|
||||
</span>
|
||||
<span className="text-muted text-small">
|
||||
{t('editor.footer.updated')}: {new Date(script.updatedAt).toLocaleString(UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +1,20 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useAppStore, PostData, MediaData } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import { getContrastColor, groupPostsByStatus } from '../../utils';
|
||||
import { BDS_EVENT_SCRIPTS_CHANGED, dispatchWindowEvent, getContrastColor, groupPostsByStatus, loadTagColorMap } from '../../utils';
|
||||
import type { ChatConversation, ImportDefinitionData } from '../../types/electron';
|
||||
import { GitSidebar } from '../GitSidebar/GitSidebar';
|
||||
import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/SettingsView';
|
||||
import { scrollToTagsSection, TagsCategory } from '../TagsView';
|
||||
import { activateSidebarSection } from '../../navigation/sectionActivation';
|
||||
import { getPersistedSidebarSection, setPersistedSidebarSection } from '../../navigation/sidebarUiPersistence';
|
||||
import { openChatTab, openEntityTab, openImportTab, openSingletonToolTab } from '../../navigation/tabPolicy';
|
||||
import { openChatTab, openEntityTab, openImportTab, openScriptTab, openSingletonToolTab } from '../../navigation/tabPolicy';
|
||||
import { createAndFocusPost } from '../../navigation/postCreation';
|
||||
import type { SidebarView } from '../../navigation/sidebarViewRegistry';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { useProjectScopedSidebarData } from './useProjectScopedSidebarData';
|
||||
import { SidebarEntityList } from './SidebarEntityList';
|
||||
import { formatSidebarRelativeDate } from './sidebarDateFormatting';
|
||||
import './Sidebar.css';
|
||||
|
||||
/** Get display name for media: title (truncated to 60 chars) or fallback to filename */
|
||||
@@ -24,13 +27,6 @@ function getMediaDisplayName(media: MediaData): string {
|
||||
return media.originalName;
|
||||
}
|
||||
|
||||
// Tag data with color information
|
||||
interface TagData {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const UI_DATE_LOCALE: Record<string, string> = {
|
||||
en: 'en-US',
|
||||
de: 'de-DE',
|
||||
@@ -534,10 +530,10 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
// Load available tags with colors and categories
|
||||
useEffect(() => {
|
||||
const loadFilters = async () => {
|
||||
const [tags, categories, allTagsData] = await Promise.all([
|
||||
const [tags, categories, colorMap] = await Promise.all([
|
||||
window.electronAPI?.posts.getTags(),
|
||||
window.electronAPI?.posts.getCategories(),
|
||||
window.electronAPI?.tags?.getAll?.(),
|
||||
loadTagColorMap(),
|
||||
]);
|
||||
if (tags) setAvailableTags(tags as string[]);
|
||||
if (categories) {
|
||||
@@ -548,15 +544,7 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
: allCategories
|
||||
);
|
||||
}
|
||||
if (allTagsData) {
|
||||
const colorMap = new Map<string, string>();
|
||||
for (const tag of allTagsData as TagData[]) {
|
||||
if (tag.color) {
|
||||
colorMap.set(tag.name, tag.color);
|
||||
}
|
||||
}
|
||||
setTagColors(colorMap);
|
||||
}
|
||||
setTagColors(colorMap);
|
||||
};
|
||||
loadFilters();
|
||||
}, [posts]);
|
||||
@@ -1002,20 +990,12 @@ const MediaList: React.FC = () => {
|
||||
// Load available tags with colors
|
||||
useEffect(() => {
|
||||
const loadTags = async () => {
|
||||
const [tags, allTagsData] = await Promise.all([
|
||||
const [tags, colorMap] = await Promise.all([
|
||||
window.electronAPI?.media.getTags(),
|
||||
window.electronAPI?.tags?.getAll?.(),
|
||||
loadTagColorMap(),
|
||||
]);
|
||||
if (tags) setAvailableTags(tags as string[]);
|
||||
if (allTagsData) {
|
||||
const colorMap = new Map<string, string>();
|
||||
for (const tag of allTagsData as TagData[]) {
|
||||
if (tag.color) {
|
||||
colorMap.set(tag.name, tag.color);
|
||||
}
|
||||
}
|
||||
setTagColors(colorMap);
|
||||
}
|
||||
setTagColors(colorMap);
|
||||
};
|
||||
loadTags();
|
||||
}, [media]);
|
||||
@@ -1371,23 +1351,29 @@ const SettingsNav: React.FC = () => {
|
||||
// Chat conversations list
|
||||
const ChatList: React.FC = () => {
|
||||
const { t, language } = useI18n();
|
||||
const { openTab, closeTab } = useAppStore();
|
||||
const [conversations, setConversations] = useState<ChatConversation[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { openTab, closeTab, activeProject } = useAppStore();
|
||||
const activeProjectId = activeProject?.id;
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
// Load conversations
|
||||
const loadConversations = useCallback(async () => {
|
||||
const loadConversations = useCallback(async (): Promise<ChatConversation[]> => {
|
||||
try {
|
||||
const convs = await window.electronAPI?.chat.getConversations();
|
||||
if (convs) {
|
||||
setConversations(convs);
|
||||
}
|
||||
return convs ?? [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load conversations:', error);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const {
|
||||
items: conversations,
|
||||
setItems: setConversations,
|
||||
isLoading,
|
||||
} = useProjectScopedSidebarData<ChatConversation>({
|
||||
load: loadConversations,
|
||||
activeProjectId,
|
||||
});
|
||||
|
||||
// Check if service is ready
|
||||
const checkReady = useCallback(async () => {
|
||||
try {
|
||||
@@ -1399,13 +1385,7 @@ const ChatList: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
setIsLoading(true);
|
||||
await checkReady();
|
||||
await loadConversations();
|
||||
setIsLoading(false);
|
||||
};
|
||||
init();
|
||||
void checkReady();
|
||||
|
||||
// Subscribe to title updates
|
||||
const unsubTitle = window.electronAPI?.chat.onTitleUpdated((data) => {
|
||||
@@ -1417,7 +1397,7 @@ const ChatList: React.FC = () => {
|
||||
return () => {
|
||||
unsubTitle?.();
|
||||
};
|
||||
}, [loadConversations, checkReady]);
|
||||
}, [checkReady, setConversations]);
|
||||
|
||||
const handleNewChat = async () => {
|
||||
try {
|
||||
@@ -1448,109 +1428,75 @@ const ChatList: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatChatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en;
|
||||
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString(uiDateLocale, { hour: 'numeric', minute: '2-digit' });
|
||||
} else if (diffDays === 1) {
|
||||
return t('sidebar.chat.yesterday');
|
||||
} else if (diffDays < 7) {
|
||||
return date.toLocaleDateString(uiDateLocale, { weekday: 'short' });
|
||||
}
|
||||
return date.toLocaleDateString(uiDateLocale, { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="chat-list">
|
||||
<div className="chat-list-header">
|
||||
<span>{t('sidebar.chat.header')}</span>
|
||||
</div>
|
||||
<div className="chat-loading">{t('sidebar.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-list">
|
||||
<div className="chat-list-header">
|
||||
<span>{t('sidebar.chat.header')}</span>
|
||||
<button className="chat-new-button" onClick={handleNewChat} title={t('sidebar.chat.newChat')}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{!isReady && (
|
||||
<div className="chat-auth-prompt">
|
||||
<p>{t('sidebar.chat.apiKeyNeeded')}</p>
|
||||
<SidebarEntityList
|
||||
header={t('sidebar.chat.header')}
|
||||
createTitle={t('sidebar.chat.newChat')}
|
||||
onCreate={handleNewChat}
|
||||
isLoading={isLoading}
|
||||
loadingLabel={t('sidebar.loading')}
|
||||
emptyMessage={t('sidebar.chat.noConversations')}
|
||||
emptyActionLabel={t('sidebar.chat.startNew')}
|
||||
onEmptyAction={handleNewChat}
|
||||
items={conversations}
|
||||
getItemKey={(conversation) => conversation.id}
|
||||
topContent={
|
||||
!isReady ? (
|
||||
<div className="chat-auth-prompt">
|
||||
<p>{t('sidebar.chat.apiKeyNeeded')}</p>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
renderItem={(conversation) => (
|
||||
<div
|
||||
className="chat-list-item"
|
||||
onClick={() => handleOpenChat(conversation.id)}
|
||||
>
|
||||
<div className="chat-item-content">
|
||||
<div className="chat-item-title">{conversation.title}</div>
|
||||
<div className="chat-item-date">
|
||||
{formatSidebarRelativeDate({ dateString: conversation.updatedAt, language, t })}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="chat-item-delete"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleDeleteChat(conversation.id);
|
||||
}}
|
||||
title={t('sidebar.chat.deleteConversation')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-list-items">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="chat-empty">
|
||||
<p>{t('sidebar.chat.noConversations')}</p>
|
||||
<button className="chat-start-button" onClick={handleNewChat}>
|
||||
{t('sidebar.chat.startNew')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
conversations.map(conv => (
|
||||
<div
|
||||
key={conv.id}
|
||||
className="chat-list-item"
|
||||
onClick={() => handleOpenChat(conv.id)}
|
||||
>
|
||||
<div className="chat-item-content">
|
||||
<div className="chat-item-title">{conv.title}</div>
|
||||
<div className="chat-item-date">{formatChatDate(conv.updatedAt)}</div>
|
||||
</div>
|
||||
<button
|
||||
className="chat-item-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteChat(conv.id);
|
||||
}}
|
||||
title={t('sidebar.chat.deleteConversation')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ImportList: React.FC = () => {
|
||||
const { t, language } = useI18n();
|
||||
const { openTab, closeTab, activeProject } = useAppStore();
|
||||
const [definitions, setDefinitions] = useState<ImportDefinitionData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const activeProjectId = activeProject?.id;
|
||||
|
||||
const loadDefinitions = useCallback(async () => {
|
||||
const loadDefinitions = useCallback(async (): Promise<ImportDefinitionData[]> => {
|
||||
try {
|
||||
const defs = await window.electronAPI?.importDefinitions.getAll();
|
||||
if (defs) {
|
||||
setDefinitions(defs);
|
||||
}
|
||||
return defs ?? [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load import definitions:', error);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Reload definitions when project changes
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
setIsLoading(true);
|
||||
await loadDefinitions();
|
||||
setIsLoading(false);
|
||||
};
|
||||
init();
|
||||
}, [loadDefinitions, activeProject?.id]);
|
||||
const {
|
||||
items: definitions,
|
||||
setItems: setDefinitions,
|
||||
isLoading,
|
||||
} = useProjectScopedSidebarData<ImportDefinitionData>({
|
||||
load: loadDefinitions,
|
||||
activeProjectId,
|
||||
});
|
||||
|
||||
// Listen for import definition name updates
|
||||
useEffect(() => {
|
||||
@@ -1598,71 +1544,154 @@ const ImportList: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en;
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString(uiDateLocale, { hour: 'numeric', minute: '2-digit' });
|
||||
} else if (diffDays === 1) {
|
||||
return t('sidebar.chat.yesterday');
|
||||
} else if (diffDays < 7) {
|
||||
return date.toLocaleDateString(uiDateLocale, { weekday: 'short' });
|
||||
return (
|
||||
<SidebarEntityList
|
||||
header={t('sidebar.import.header')}
|
||||
createTitle={t('sidebar.import.newDefinition')}
|
||||
onCreate={handleNewDefinition}
|
||||
isLoading={isLoading}
|
||||
loadingLabel={t('sidebar.loading')}
|
||||
emptyMessage={t('sidebar.import.none')}
|
||||
emptyActionLabel={t('sidebar.import.createDefinition')}
|
||||
onEmptyAction={handleNewDefinition}
|
||||
items={definitions}
|
||||
getItemKey={(definition) => definition.id}
|
||||
renderItem={(definition) => (
|
||||
<div
|
||||
className="chat-list-item"
|
||||
onClick={() => handleOpenDefinition(definition.id)}
|
||||
>
|
||||
<div className="chat-item-content">
|
||||
<div className="chat-item-title">{definition.name}</div>
|
||||
<div className="chat-item-date">
|
||||
{formatSidebarRelativeDate({ dateString: definition.updatedAt, language, t })}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="chat-item-delete"
|
||||
onClick={(event) => handleDeleteDefinition(event, definition.id)}
|
||||
title={t('sidebar.import.deleteDefinition')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ScriptsList: React.FC = () => {
|
||||
const { t, language } = useI18n();
|
||||
const { openTab, activeTabId, closeTab } = useAppStore();
|
||||
const activeProjectId = useAppStore((state) => state.activeProject?.id);
|
||||
|
||||
const loadScripts = useCallback(async (): Promise<Array<{ id: string; title: string; updatedAt: string }>> => {
|
||||
const items = await window.electronAPI?.scripts.getAll();
|
||||
return (items ?? []).map((item) => ({ id: item.id, title: item.title, updatedAt: item.updatedAt }));
|
||||
}, []);
|
||||
|
||||
const {
|
||||
items: scripts,
|
||||
setItems: setScripts,
|
||||
isLoading,
|
||||
reload: reloadScripts,
|
||||
} = useProjectScopedSidebarData<Array<{ id: string; title: string; updatedAt: string }>[number]>({
|
||||
load: loadScripts,
|
||||
activeProjectId,
|
||||
refreshEventName: BDS_EVENT_SCRIPTS_CHANGED,
|
||||
});
|
||||
|
||||
const handleCreateScript = async () => {
|
||||
try {
|
||||
const created = await window.electronAPI?.scripts.create({
|
||||
title: t('sidebar.scripts.newScript'),
|
||||
kind: 'utility',
|
||||
content: 'print("new script")',
|
||||
entrypoint: 'render',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
return;
|
||||
}
|
||||
|
||||
setScripts((prev) => [
|
||||
{ id: created.id, title: created.title, updatedAt: created.updatedAt },
|
||||
...prev.filter((script) => script.id !== created.id),
|
||||
]);
|
||||
dispatchWindowEvent(BDS_EVENT_SCRIPTS_CHANGED);
|
||||
openScriptTab(openTab, created.id, 'pin');
|
||||
void reloadScripts();
|
||||
} catch (error) {
|
||||
console.error('Failed to create script:', error);
|
||||
showToast.error(t('sidebar.scripts.createFailed'));
|
||||
}
|
||||
return date.toLocaleDateString(uiDateLocale, { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="chat-list">
|
||||
<div className="chat-list-header">
|
||||
<span>{t('sidebar.import.header')}</span>
|
||||
</div>
|
||||
<div className="chat-loading">{t('sidebar.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const handleDeleteScript = async (event: React.MouseEvent, scriptId: string) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
const deleted = await window.electronAPI?.scripts.delete(scriptId);
|
||||
if (!deleted) {
|
||||
showToast.error(t('sidebar.scripts.deleteFailed'));
|
||||
return;
|
||||
}
|
||||
setScripts((prev) => prev.filter((script) => script.id !== scriptId));
|
||||
closeTab(scriptId);
|
||||
dispatchWindowEvent(BDS_EVENT_SCRIPTS_CHANGED);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete script:', error);
|
||||
showToast.error(t('sidebar.scripts.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-list">
|
||||
<div className="chat-list-header">
|
||||
<span>{t('sidebar.import.header')}</span>
|
||||
<button className="chat-new-button" onClick={handleNewDefinition} title={t('sidebar.import.newDefinition')}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="chat-list-items">
|
||||
{definitions.length === 0 ? (
|
||||
<div className="chat-empty">
|
||||
<p>{t('sidebar.import.none')}</p>
|
||||
<button className="chat-start-button" onClick={handleNewDefinition}>
|
||||
{t('sidebar.import.createDefinition')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
definitions.map(def => (
|
||||
<div
|
||||
key={def.id}
|
||||
className="chat-list-item"
|
||||
onClick={() => handleOpenDefinition(def.id)}
|
||||
>
|
||||
<div className="chat-item-content">
|
||||
<div className="chat-item-title">{def.name}</div>
|
||||
<div className="chat-item-date">{formatDate(def.updatedAt)}</div>
|
||||
</div>
|
||||
<button
|
||||
className="chat-item-delete"
|
||||
onClick={(e) => handleDeleteDefinition(e, def.id)}
|
||||
title={t('sidebar.import.deleteDefinition')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<SidebarEntityList
|
||||
header={t('sidebar.scripts.header')}
|
||||
createTitle={t('sidebar.scripts.newScript')}
|
||||
onCreate={handleCreateScript}
|
||||
isLoading={isLoading}
|
||||
loadingLabel={t('sidebar.loading')}
|
||||
emptyMessage={t('sidebar.scripts.none')}
|
||||
emptyActionLabel={t('sidebar.scripts.createScript')}
|
||||
onEmptyAction={handleCreateScript}
|
||||
items={scripts}
|
||||
getItemKey={(script) => script.id}
|
||||
renderItem={(script) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={script.title}
|
||||
className={`chat-list-item ${activeTabId === script.id ? 'active' : ''}`}
|
||||
onClick={() => openScriptTab(openTab, script.id, 'preview')}
|
||||
onDoubleClick={() => openScriptTab(openTab, script.id, 'pin')}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
openScriptTab(openTab, script.id, 'pin');
|
||||
return;
|
||||
}
|
||||
if (event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openScriptTab(openTab, script.id, 'preview');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="chat-item-content">
|
||||
<div className="chat-item-title">{script.title}</div>
|
||||
<div className="chat-item-date">
|
||||
{formatSidebarRelativeDate({ dateString: script.updatedAt, language, t })}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="chat-item-delete"
|
||||
onClick={(event) => handleDeleteScript(event, script.id)}
|
||||
title={t('sidebar.scripts.deleteScript')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1677,6 +1706,7 @@ export const Sidebar: React.FC = () => {
|
||||
posts: <PostsList mode="posts" isActive={true} />,
|
||||
pages: <PostsList mode="pages" isActive={true} />,
|
||||
media: <MediaList />,
|
||||
scripts: <ScriptsList />,
|
||||
settings: <SettingsNav />,
|
||||
tags: <TagsNav />,
|
||||
chat: <ChatList />,
|
||||
|
||||
70
src/renderer/components/Sidebar/SidebarEntityList.tsx
Normal file
70
src/renderer/components/Sidebar/SidebarEntityList.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SidebarEntityListProps<TItem> {
|
||||
header: string;
|
||||
createTitle: string;
|
||||
onCreate: () => void;
|
||||
isLoading: boolean;
|
||||
loadingLabel: string;
|
||||
emptyMessage: string;
|
||||
emptyActionLabel: string;
|
||||
onEmptyAction: () => void;
|
||||
items: TItem[];
|
||||
renderItem: (item: TItem) => React.ReactNode;
|
||||
getItemKey?: (item: TItem) => string;
|
||||
topContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SidebarEntityList<TItem>({
|
||||
header,
|
||||
createTitle,
|
||||
onCreate,
|
||||
isLoading,
|
||||
loadingLabel,
|
||||
emptyMessage,
|
||||
emptyActionLabel,
|
||||
onEmptyAction,
|
||||
items,
|
||||
renderItem,
|
||||
getItemKey,
|
||||
topContent,
|
||||
}: SidebarEntityListProps<TItem>): JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="chat-list">
|
||||
<div className="chat-list-header">
|
||||
<span>{header}</span>
|
||||
</div>
|
||||
<div className="chat-loading">{loadingLabel}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-list">
|
||||
<div className="chat-list-header">
|
||||
<span>{header}</span>
|
||||
<button className="chat-new-button" onClick={onCreate} title={createTitle} aria-label={createTitle}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{topContent}
|
||||
<div className="chat-list-items">
|
||||
{items.length === 0 ? (
|
||||
<div className="chat-empty">
|
||||
<p>{emptyMessage}</p>
|
||||
<button className="chat-start-button" onClick={onEmptyAction}>
|
||||
{emptyActionLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<React.Fragment key={getItemKey ? getItemKey(item) : String((item as { id?: string }).id)}>
|
||||
{renderItem(item)}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/renderer/components/Sidebar/sidebarDateFormatting.ts
Normal file
34
src/renderer/components/Sidebar/sidebarDateFormatting.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
const UI_DATE_LOCALE: Record<string, string> = {
|
||||
en: 'en-US',
|
||||
de: 'de-DE',
|
||||
fr: 'fr-FR',
|
||||
it: 'it-IT',
|
||||
es: 'es-ES',
|
||||
};
|
||||
|
||||
interface FormatSidebarRelativeDateArgs {
|
||||
dateString: string;
|
||||
language: string;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export function formatSidebarRelativeDate({ dateString, language, t }: FormatSidebarRelativeDateArgs): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en;
|
||||
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString(uiDateLocale, { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
if (diffDays === 1) {
|
||||
return t('sidebar.chat.yesterday');
|
||||
}
|
||||
|
||||
if (diffDays < 7) {
|
||||
return date.toLocaleDateString(uiDateLocale, { weekday: 'short' });
|
||||
}
|
||||
|
||||
return date.toLocaleDateString(uiDateLocale, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { addWindowEventListener, type BdsWindowEventName } from '../../utils';
|
||||
|
||||
interface ProjectScopedSidebarDataOptions<TItem> {
|
||||
load: () => Promise<TItem[] | null | undefined>;
|
||||
activeProjectId?: string;
|
||||
refreshEventName?: BdsWindowEventName;
|
||||
}
|
||||
|
||||
interface ProjectScopedSidebarDataResult<TItem> {
|
||||
items: TItem[];
|
||||
setItems: Dispatch<SetStateAction<TItem[]>>;
|
||||
isLoading: boolean;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useProjectScopedSidebarData<TItem>(options: ProjectScopedSidebarDataOptions<TItem>): ProjectScopedSidebarDataResult<TItem> {
|
||||
const { load, activeProjectId, refreshEventName } = options;
|
||||
const [items, setItems] = useState<TItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const nextItems = await load();
|
||||
setItems(nextItems ?? []);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadInitial = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const nextItems = await load();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setItems(nextItems ?? []);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadInitial();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [load, activeProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!refreshEventName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = addWindowEventListener(refreshEventName, () => {
|
||||
void reload();
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [refreshEventName, reload]);
|
||||
|
||||
return {
|
||||
items,
|
||||
setItems,
|
||||
isLoading,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const getTabTitle = (
|
||||
tab: Tab,
|
||||
postTitles: Map<string, string>,
|
||||
media: { id: string; originalName: string }[],
|
||||
scriptTitles: Map<string, string>,
|
||||
chatTitles: Map<string, string>,
|
||||
importDefTitles: Map<string, string>,
|
||||
commitTitles: Map<string, string>,
|
||||
@@ -80,6 +81,10 @@ const getTabTitle = (
|
||||
return tr('siteValidation.tabTitle');
|
||||
}
|
||||
|
||||
if (tab.type === 'scripts') {
|
||||
return scriptTitles.get(tab.id) || tr('tabBar.scripts');
|
||||
}
|
||||
|
||||
return tr('tabBar.unknown');
|
||||
};
|
||||
|
||||
@@ -158,6 +163,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
|
||||
<path d="M8 1.5a6.5 6.5 0 1 0 6.5 6.5A6.5 6.5 0 0 0 8 1.5zm0 1a5.5 5.5 0 0 1 4.39 8.82l-.88-.88a.5.5 0 0 0-.7.7l.8.8A5.5 5.5 0 1 1 8 2.5zm2.35 3.15L7 9 5.65 7.65a.5.5 0 1 0-.7.7l1.7 1.7a.5.5 0 0 0 .7 0l3.7-3.7a.5.5 0 1 0-.7-.7z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'scripts':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 3H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7v2H8v2h8v-2h-3v-2h7a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zM5 14V5h14v9H5zm2-7.5L9.5 9 7 11.5l1.4 1.4L12.3 9 8.4 5.1 7 6.5zm6.5 5.5h4v-2h-4v2z"/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
@@ -204,6 +215,7 @@ export const TabBar: React.FC = () => {
|
||||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||
const [showRightArrow, setShowRightArrow] = useState(false);
|
||||
const [postTitles, setPostTitles] = useState<Map<string, string>>(new Map());
|
||||
const [scriptTitles, setScriptTitles] = useState<Map<string, string>>(new Map());
|
||||
const [chatTitles, setChatTitles] = useState<Map<string, string>>(new Map());
|
||||
const [importDefTitles, setImportDefTitles] = useState<Map<string, string>>(new Map());
|
||||
const [commitTitles, setCommitTitles] = useState<Map<string, string>>(new Map());
|
||||
@@ -287,6 +299,102 @@ export const TabBar: React.FC = () => {
|
||||
};
|
||||
}, [tr]);
|
||||
|
||||
// Fetch script titles for script tabs
|
||||
useEffect(() => {
|
||||
const scriptTabs = tabs.filter((t) => t.type === 'scripts');
|
||||
const scriptTabIds = new Set(scriptTabs.map((t) => t.id));
|
||||
|
||||
setScriptTitles((previous) => {
|
||||
const next = new Map(previous);
|
||||
let changed = false;
|
||||
|
||||
for (const id of Array.from(next.keys())) {
|
||||
if (!scriptTabIds.has(id)) {
|
||||
next.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : previous;
|
||||
});
|
||||
|
||||
if (scriptTabs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchScriptTitles = async () => {
|
||||
const newTitles = new Map(scriptTitles);
|
||||
let changed = false;
|
||||
|
||||
for (const tab of scriptTabs) {
|
||||
if (scriptTitles.has(tab.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const script = await window.electronAPI?.scripts.get(tab.id);
|
||||
if (script) {
|
||||
const title = script.title || tr('editor.untitled');
|
||||
if (newTitles.get(tab.id) !== title) {
|
||||
newTitles.set(tab.id, title);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(tr('tabBar.error.fetchScriptTitle'), error);
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
setScriptTitles(newTitles);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchScriptTitles();
|
||||
}, [tabs, tr]); // Note: intentionally not including scriptTitles to avoid infinite loops
|
||||
|
||||
// Listen for script updates to refresh titles
|
||||
useEffect(() => {
|
||||
const handleScriptsChanged = async () => {
|
||||
const scriptTabs = tabs.filter((t) => t.type === 'scripts');
|
||||
if (scriptTabs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = new Map(scriptTitles);
|
||||
let changed = false;
|
||||
|
||||
for (const tab of scriptTabs) {
|
||||
try {
|
||||
const script = await window.electronAPI?.scripts.get(tab.id);
|
||||
if (script) {
|
||||
const title = script.title || tr('editor.untitled');
|
||||
if (updated.get(tab.id) !== title) {
|
||||
updated.set(tab.id, title);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(tr('tabBar.error.fetchScriptTitle'), error);
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
setScriptTitles(updated);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window.addEventListener === 'function') {
|
||||
window.addEventListener('bds:scripts-changed', handleScriptsChanged);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typeof window.removeEventListener === 'function') {
|
||||
window.removeEventListener('bds:scripts-changed', handleScriptsChanged);
|
||||
}
|
||||
};
|
||||
}, [tabs, scriptTitles, tr]);
|
||||
|
||||
// Fetch chat titles for chat tabs
|
||||
useEffect(() => {
|
||||
const chatTabs = tabs.filter(t => t.type === 'chat');
|
||||
@@ -555,7 +663,7 @@ export const TabBar: React.FC = () => {
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const isDirty = tab.type === 'post' && dirtyPosts.has(tab.id);
|
||||
const title = getTabTitle(tab, postTitles, media, chatTitles, importDefTitles, commitTitles, tr);
|
||||
const title = getTabTitle(tab, postTitles, media, scriptTitles, chatTitles, importDefTitles, commitTitles, tr);
|
||||
const icon = getTabIcon(tab);
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,3 +26,4 @@ export { InsertModal } from './InsertModal';
|
||||
export { WindowTitleBar } from './WindowTitleBar';
|
||||
export { DocumentationView } from './DocumentationView/DocumentationView';
|
||||
export { SiteValidationView } from './SiteValidationView';
|
||||
export { ScriptsView } from './ScriptsView/ScriptsView';
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"activity.posts": "Beiträge",
|
||||
"activity.pages": "Seiten",
|
||||
"activity.media": "Medien",
|
||||
"activity.scripts": "Skripte",
|
||||
"activity.tags": "Schlagwörter",
|
||||
"activity.aiAssistant": "KI-Assistent",
|
||||
"activity.import": "Importieren",
|
||||
@@ -22,6 +23,11 @@
|
||||
"tasks.triggerTitle": "{running} laufend, {pending} ausstehend",
|
||||
"app.taskCompleted": "Aufgabe abgeschlossen: {message}",
|
||||
"app.taskFailed": "Aufgabe fehlgeschlagen: {message}",
|
||||
"app.blogmark.transforms.summary": "Blogmark-Transformationen: {applied} angewendet, {failed} fehlgeschlagen",
|
||||
"app.blogmark.transforms.appliedList": "Angewendete Skripte: {scripts}",
|
||||
"app.blogmark.transforms.failed": "Transformation fehlgeschlagen ({script}): {message}",
|
||||
"app.blogmark.transforms.toast": "Skript-Toast: {message}",
|
||||
"app.blogmark.transforms.errorToast": "Blogmark-Transformationsfehler: {count}",
|
||||
"app.databaseRebuildFailed": "Datenbank-Neuaufbau fehlgeschlagen",
|
||||
"app.textReindexFailed": "Text-Neuindizierung fehlgeschlagen",
|
||||
"app.sitemapGenerationFailed": "Sitemap-Erstellung fehlgeschlagen",
|
||||
@@ -238,6 +244,7 @@
|
||||
"postLinks.openTitle": "Öffnen: {title}",
|
||||
"docs.title": "Dokumentation",
|
||||
"docs.subtitle": "Benutzerhandbuch für diese installierte bDS-Version.",
|
||||
"docs.copyCode": "Code kopieren",
|
||||
"gitDiff.header": "Unterschied: {target}",
|
||||
"gitDiff.noProject": "Kein aktives Projekt ausgewählt.",
|
||||
"gitDiff.noProjectPath": "Projektpfad konnte nicht ermittelt werden.",
|
||||
@@ -339,6 +346,7 @@
|
||||
"gitSidebar.placeholder.commitMessage": "Commit-Nachricht",
|
||||
"editor.untitled": "Unbenannt",
|
||||
"tabBar.style": "Stil",
|
||||
"tabBar.scripts": "Skripte",
|
||||
"tabBar.loading": "Laden...",
|
||||
"tabBar.unknown": "Unbekannt",
|
||||
"tabBar.preview": "Vorschau",
|
||||
@@ -350,6 +358,7 @@
|
||||
"tabBar.error.fetchPostTitle": "Beitragstitel konnte nicht geladen werden:",
|
||||
"tabBar.error.fetchChatTitle": "Chat-Titel konnte nicht geladen werden:",
|
||||
"tabBar.error.fetchImportTitle": "Titel der Importdefinition konnte nicht geladen werden:",
|
||||
"tabBar.error.fetchScriptTitle": "Skript-Titel konnte nicht geladen werden:",
|
||||
"tabBar.error.fetchCommitTitle": "Commit-Titel konnten nicht geladen werden:",
|
||||
"metadataDiff.title": "Metadaten-Diff-Werkzeug",
|
||||
"metadataDiff.description": "Vergleicht Beitragsmetadaten zwischen Datenbank und Markdown-Dateien. Behebt Abweichungen durch Bugs oder manuelle Änderungen.",
|
||||
@@ -415,6 +424,24 @@
|
||||
"sidebar.nav.publishing": "Veröffentlichung",
|
||||
"sidebar.nav.data": "Daten",
|
||||
"sidebar.nav.style": "Stil",
|
||||
"sidebar.nav.scripts": "Skripte",
|
||||
"scripts.run": "Skript ausführen",
|
||||
"scripts.save": "Skript speichern",
|
||||
"scripts.delete": "Skript löschen",
|
||||
"scripts.content": "Skriptinhalt",
|
||||
"scripts.field.kind": "Typ",
|
||||
"scripts.field.entrypoint": "Einstiegspunkt",
|
||||
"scripts.entrypoint.main": "main",
|
||||
"scripts.entrypoint.none": "Keine Funktionen gefunden",
|
||||
"scripts.field.enabled": "Aktiviert",
|
||||
"scripts.syntax.check": "Syntax prüfen",
|
||||
"scripts.syntax.checking": "Prüfe...",
|
||||
"scripts.syntax.valid": "Python-Syntax ist gültig",
|
||||
"scripts.syntax.invalid": "Python-Syntaxfehler: {count}",
|
||||
"scripts.syntax.checkFailed": "Python-Syntaxprüfung fehlgeschlagen",
|
||||
"scripts.kind.utility": "utility",
|
||||
"scripts.kind.macro": "macro",
|
||||
"scripts.kind.transform": "transform",
|
||||
"sidebar.tagCloud": "Tag-Wolke",
|
||||
"sidebar.createEdit": "Erstellen & Bearbeiten",
|
||||
"sidebar.mergeTags": "Tags zusammenführen",
|
||||
@@ -613,6 +640,7 @@
|
||||
"panel.closeTitle": "Panel schließen",
|
||||
"panel.noRecentTasks": "Keine aktuellen Aufgaben",
|
||||
"panel.noOutput": "Keine Ausgabe",
|
||||
"panel.copyOutput": "Ausgabe kopieren",
|
||||
"panel.openPostEditor": "Öffne einen Beitragseditor, um Beitragslinks zu sehen",
|
||||
"panel.loadingPostLinks": "Beitragslinks werden geladen...",
|
||||
"panel.noPostLinks": "Keine Beitragslinks für diesen Beitrag",
|
||||
@@ -695,6 +723,13 @@
|
||||
"sidebar.chat.yesterday": "Gestern",
|
||||
"sidebar.import.header": "IMPORTE",
|
||||
"sidebar.import.newDefinition": "Neue Importdefinition",
|
||||
"sidebar.scripts.header": "SKRIPTE",
|
||||
"sidebar.scripts.newScript": "Neues Skript",
|
||||
"sidebar.scripts.none": "Noch keine Skripte",
|
||||
"sidebar.scripts.createScript": "Ein Skript erstellen",
|
||||
"sidebar.scripts.createFailed": "Skript konnte nicht erstellt werden",
|
||||
"sidebar.scripts.deleteScript": "Skript löschen",
|
||||
"sidebar.scripts.deleteFailed": "Skript konnte nicht gelöscht werden",
|
||||
"sidebar.import.none": "Noch keine Importdefinitionen",
|
||||
"sidebar.import.createDefinition": "Eine Importdefinition erstellen",
|
||||
"sidebar.import.deleteDefinition": "Importdefinition löschen",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"activity.posts": "Posts",
|
||||
"activity.pages": "Pages",
|
||||
"activity.media": "Media",
|
||||
"activity.scripts": "Scripts",
|
||||
"activity.tags": "Tags",
|
||||
"activity.aiAssistant": "AI Assistant",
|
||||
"activity.import": "Import",
|
||||
@@ -22,6 +23,11 @@
|
||||
"tasks.triggerTitle": "{running} running, {pending} pending",
|
||||
"app.taskCompleted": "Task completed: {message}",
|
||||
"app.taskFailed": "Task failed: {message}",
|
||||
"app.blogmark.transforms.summary": "Blogmark transforms: {applied} applied, {failed} failed",
|
||||
"app.blogmark.transforms.appliedList": "Applied scripts: {scripts}",
|
||||
"app.blogmark.transforms.failed": "Transform failed ({script}): {message}",
|
||||
"app.blogmark.transforms.toast": "Script toast: {message}",
|
||||
"app.blogmark.transforms.errorToast": "Blogmark transform errors: {count}",
|
||||
"app.databaseRebuildFailed": "Database rebuild failed",
|
||||
"app.textReindexFailed": "Text reindex failed",
|
||||
"app.sitemapGenerationFailed": "Sitemap generation failed",
|
||||
@@ -238,6 +244,7 @@
|
||||
"postLinks.openTitle": "Open: {title}",
|
||||
"docs.title": "Documentation",
|
||||
"docs.subtitle": "User guide for this installed bDS version.",
|
||||
"docs.copyCode": "Copy code",
|
||||
"gitDiff.header": "Diff: {target}",
|
||||
"gitDiff.noProject": "No active project selected.",
|
||||
"gitDiff.noProjectPath": "Unable to resolve project path.",
|
||||
@@ -339,6 +346,7 @@
|
||||
"gitSidebar.placeholder.commitMessage": "Commit message",
|
||||
"editor.untitled": "Untitled",
|
||||
"tabBar.style": "Style",
|
||||
"tabBar.scripts": "Scripts",
|
||||
"tabBar.loading": "Loading...",
|
||||
"tabBar.unknown": "Unknown",
|
||||
"tabBar.preview": "Preview",
|
||||
@@ -350,6 +358,7 @@
|
||||
"tabBar.error.fetchPostTitle": "Failed to fetch post title:",
|
||||
"tabBar.error.fetchChatTitle": "Failed to fetch chat title:",
|
||||
"tabBar.error.fetchImportTitle": "Failed to fetch import definition title:",
|
||||
"tabBar.error.fetchScriptTitle": "Failed to fetch script title:",
|
||||
"tabBar.error.fetchCommitTitle": "Failed to fetch commit titles:",
|
||||
"metadataDiff.title": "Metadata Diff Tool",
|
||||
"metadataDiff.description": "Compare post metadata between database and markdown files. Fix inconsistencies caused by bugs or manual edits.",
|
||||
@@ -415,6 +424,24 @@
|
||||
"sidebar.nav.publishing": "Publishing",
|
||||
"sidebar.nav.data": "Data",
|
||||
"sidebar.nav.style": "Style",
|
||||
"sidebar.nav.scripts": "Scripts",
|
||||
"scripts.run": "Run Script",
|
||||
"scripts.save": "Save Script",
|
||||
"scripts.delete": "Delete Script",
|
||||
"scripts.content": "Script Content",
|
||||
"scripts.field.kind": "Kind",
|
||||
"scripts.field.entrypoint": "Entrypoint",
|
||||
"scripts.entrypoint.main": "main",
|
||||
"scripts.entrypoint.none": "No functions found",
|
||||
"scripts.field.enabled": "Enabled",
|
||||
"scripts.syntax.check": "Check Syntax",
|
||||
"scripts.syntax.checking": "Checking...",
|
||||
"scripts.syntax.valid": "Python syntax is valid",
|
||||
"scripts.syntax.invalid": "Python syntax errors: {count}",
|
||||
"scripts.syntax.checkFailed": "Python syntax check failed",
|
||||
"scripts.kind.utility": "utility",
|
||||
"scripts.kind.macro": "macro",
|
||||
"scripts.kind.transform": "transform",
|
||||
"sidebar.tagCloud": "Tag Cloud",
|
||||
"sidebar.createEdit": "Create & Edit",
|
||||
"sidebar.mergeTags": "Merge Tags",
|
||||
@@ -613,6 +640,7 @@
|
||||
"panel.closeTitle": "Close panel",
|
||||
"panel.noRecentTasks": "No recent tasks",
|
||||
"panel.noOutput": "No output",
|
||||
"panel.copyOutput": "Copy Output",
|
||||
"panel.openPostEditor": "Open a post editor to view post links",
|
||||
"panel.loadingPostLinks": "Loading post links...",
|
||||
"panel.noPostLinks": "No post links for this post",
|
||||
@@ -695,6 +723,13 @@
|
||||
"sidebar.chat.yesterday": "Yesterday",
|
||||
"sidebar.import.header": "IMPORTS",
|
||||
"sidebar.import.newDefinition": "New Import Definition",
|
||||
"sidebar.scripts.header": "SCRIPTS",
|
||||
"sidebar.scripts.newScript": "New Script",
|
||||
"sidebar.scripts.none": "No scripts yet",
|
||||
"sidebar.scripts.createScript": "Create a script",
|
||||
"sidebar.scripts.createFailed": "Failed to create script",
|
||||
"sidebar.scripts.deleteScript": "Delete script",
|
||||
"sidebar.scripts.deleteFailed": "Failed to delete script",
|
||||
"sidebar.import.none": "No import definitions yet",
|
||||
"sidebar.import.createDefinition": "Create an import definition",
|
||||
"sidebar.import.deleteDefinition": "Delete import definition",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"activity.posts": "Entradas",
|
||||
"activity.pages": "Páginas",
|
||||
"activity.media": "Medios",
|
||||
"activity.scripts": "Scripts",
|
||||
"activity.tags": "Etiquetas",
|
||||
"activity.aiAssistant": "Asistente IA",
|
||||
"activity.import": "Importar",
|
||||
@@ -22,6 +23,11 @@
|
||||
"tasks.triggerTitle": "{running} en ejecución, {pending} pendiente",
|
||||
"app.taskCompleted": "Tarea completada: {message}",
|
||||
"app.taskFailed": "Tarea fallida: {message}",
|
||||
"app.blogmark.transforms.summary": "Transformaciones de blogmark: {applied} aplicadas, {failed} fallidas",
|
||||
"app.blogmark.transforms.appliedList": "Scripts aplicados: {scripts}",
|
||||
"app.blogmark.transforms.failed": "Transformación fallida ({script}): {message}",
|
||||
"app.blogmark.transforms.toast": "Toast del script: {message}",
|
||||
"app.blogmark.transforms.errorToast": "Errores de transformación de blogmark: {count}",
|
||||
"app.databaseRebuildFailed": "La reconstrucción de la base de datos falló",
|
||||
"app.textReindexFailed": "La reindexación de texto falló",
|
||||
"app.sitemapGenerationFailed": "La generación del sitemap falló",
|
||||
@@ -238,6 +244,7 @@
|
||||
"postLinks.openTitle": "Abrir: {title}",
|
||||
"docs.title": "Documentación",
|
||||
"docs.subtitle": "Guía de usuario para esta versión instalada de bDS.",
|
||||
"docs.copyCode": "Copiar código",
|
||||
"gitDiff.header": "Diferencia: {target}",
|
||||
"gitDiff.noProject": "No hay un proyecto activo seleccionado.",
|
||||
"gitDiff.noProjectPath": "No se pudo resolver la ruta del proyecto.",
|
||||
@@ -339,6 +346,7 @@
|
||||
"gitSidebar.placeholder.commitMessage": "Mensaje de commit",
|
||||
"editor.untitled": "Sin título",
|
||||
"tabBar.style": "Estilo",
|
||||
"tabBar.scripts": "Scripts",
|
||||
"tabBar.loading": "Cargando...",
|
||||
"tabBar.unknown": "Desconocido",
|
||||
"tabBar.preview": "Vista previa",
|
||||
@@ -350,6 +358,7 @@
|
||||
"tabBar.error.fetchPostTitle": "No se pudo cargar el título de la entrada:",
|
||||
"tabBar.error.fetchChatTitle": "No se pudo cargar el título del chat:",
|
||||
"tabBar.error.fetchImportTitle": "No se pudo cargar el título de la definición de importación:",
|
||||
"tabBar.error.fetchScriptTitle": "No se pudo cargar el título del script:",
|
||||
"tabBar.error.fetchCommitTitle": "No se pudieron cargar los títulos de los commits:",
|
||||
"metadataDiff.title": "Herramienta diff de metadatos",
|
||||
"metadataDiff.description": "Compara los metadatos de las entradas entre la base de datos y los archivos Markdown. Corrige inconsistencias causadas por errores o ediciones manuales.",
|
||||
@@ -415,6 +424,24 @@
|
||||
"sidebar.nav.publishing": "Publicación",
|
||||
"sidebar.nav.data": "Datos",
|
||||
"sidebar.nav.style": "Estilo",
|
||||
"sidebar.nav.scripts": "Scripts",
|
||||
"scripts.run": "Ejecutar script",
|
||||
"scripts.save": "Guardar script",
|
||||
"scripts.delete": "Eliminar script",
|
||||
"scripts.content": "Contenido del script",
|
||||
"scripts.field.kind": "Tipo",
|
||||
"scripts.field.entrypoint": "Punto de entrada",
|
||||
"scripts.entrypoint.main": "main",
|
||||
"scripts.entrypoint.none": "No se encontraron funciones",
|
||||
"scripts.field.enabled": "Habilitado",
|
||||
"scripts.syntax.check": "Comprobar sintaxis",
|
||||
"scripts.syntax.checking": "Comprobando...",
|
||||
"scripts.syntax.valid": "La sintaxis de Python es válida",
|
||||
"scripts.syntax.invalid": "Errores de sintaxis de Python: {count}",
|
||||
"scripts.syntax.checkFailed": "La comprobación de sintaxis de Python falló",
|
||||
"scripts.kind.utility": "utility",
|
||||
"scripts.kind.macro": "macro",
|
||||
"scripts.kind.transform": "transform",
|
||||
"sidebar.tagCloud": "Nube de etiquetas",
|
||||
"sidebar.createEdit": "Crear y editar",
|
||||
"sidebar.mergeTags": "Combinar etiquetas",
|
||||
@@ -613,6 +640,7 @@
|
||||
"panel.closeTitle": "Cerrar panel",
|
||||
"panel.noRecentTasks": "No hay tareas recientes",
|
||||
"panel.noOutput": "Sin salida",
|
||||
"panel.copyOutput": "Copiar salida",
|
||||
"panel.openPostEditor": "Abre un editor de entradas para ver los enlaces",
|
||||
"panel.loadingPostLinks": "Cargando enlaces de entradas...",
|
||||
"panel.noPostLinks": "No hay enlaces para esta entrada",
|
||||
@@ -695,6 +723,13 @@
|
||||
"sidebar.chat.yesterday": "Ayer",
|
||||
"sidebar.import.header": "Importación",
|
||||
"sidebar.import.newDefinition": "Nueva definición",
|
||||
"sidebar.scripts.header": "SCRIPTS",
|
||||
"sidebar.scripts.newScript": "Nuevo script",
|
||||
"sidebar.scripts.none": "Aún no hay scripts",
|
||||
"sidebar.scripts.createScript": "Crear un script",
|
||||
"sidebar.scripts.createFailed": "No se pudo crear el script",
|
||||
"sidebar.scripts.deleteScript": "Eliminar script",
|
||||
"sidebar.scripts.deleteFailed": "No se pudo eliminar el script",
|
||||
"sidebar.import.none": "Sin definiciones de importación",
|
||||
"sidebar.import.createDefinition": "Crear definición",
|
||||
"sidebar.import.deleteDefinition": "Eliminar definición",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"activity.posts": "Articles",
|
||||
"activity.pages": "Pages du site",
|
||||
"activity.media": "Médias",
|
||||
"activity.scripts": "Scripts",
|
||||
"activity.tags": "Étiquettes",
|
||||
"activity.aiAssistant": "Assistant IA",
|
||||
"activity.import": "Importation",
|
||||
@@ -22,6 +23,11 @@
|
||||
"tasks.triggerTitle": "{running} en cours, {pending} en attente",
|
||||
"app.taskCompleted": "Tâche terminée : {message}",
|
||||
"app.taskFailed": "Échec de la tâche : {message}",
|
||||
"app.blogmark.transforms.summary": "Transformations blogmark : {applied} appliquées, {failed} en échec",
|
||||
"app.blogmark.transforms.appliedList": "Scripts appliqués : {scripts}",
|
||||
"app.blogmark.transforms.failed": "Échec de transformation ({script}) : {message}",
|
||||
"app.blogmark.transforms.toast": "Toast du script : {message}",
|
||||
"app.blogmark.transforms.errorToast": "Erreurs de transformation blogmark : {count}",
|
||||
"app.databaseRebuildFailed": "Échec de la reconstruction de la base de données",
|
||||
"app.textReindexFailed": "Échec de la réindexation du texte",
|
||||
"app.sitemapGenerationFailed": "Échec de la génération du sitemap",
|
||||
@@ -238,6 +244,7 @@
|
||||
"postLinks.openTitle": "Ouvrir: {title}",
|
||||
"docs.title": "Guide utilisateur",
|
||||
"docs.subtitle": "Guide utilisateur pour cette version installée de bDS.",
|
||||
"docs.copyCode": "Copier le code",
|
||||
"gitDiff.header": "Diff : {target}",
|
||||
"gitDiff.noProject": "Aucun projet actif sélectionné.",
|
||||
"gitDiff.noProjectPath": "Impossible de résoudre le chemin du projet.",
|
||||
@@ -339,6 +346,7 @@
|
||||
"gitSidebar.placeholder.commitMessage": "Message de commit",
|
||||
"editor.untitled": "Sans titre",
|
||||
"tabBar.style": "Apparence",
|
||||
"tabBar.scripts": "Scripts",
|
||||
"tabBar.loading": "Chargement...",
|
||||
"tabBar.unknown": "Inconnu",
|
||||
"tabBar.preview": "Aperçu",
|
||||
@@ -350,6 +358,7 @@
|
||||
"tabBar.error.fetchPostTitle": "Impossible de charger le titre de l’article :",
|
||||
"tabBar.error.fetchChatTitle": "Impossible de charger le titre du chat :",
|
||||
"tabBar.error.fetchImportTitle": "Impossible de charger le titre de la définition d’import :",
|
||||
"tabBar.error.fetchScriptTitle": "Impossible de charger le titre du script :",
|
||||
"tabBar.error.fetchCommitTitle": "Impossible de charger les titres des commits :",
|
||||
"metadataDiff.title": "Outil de diff des métadonnées",
|
||||
"metadataDiff.description": "Compare les métadonnées des articles entre la base de données et les fichiers Markdown. Corrige les incohérences causées par des bugs ou des modifications manuelles.",
|
||||
@@ -415,6 +424,24 @@
|
||||
"sidebar.nav.publishing": "Publication",
|
||||
"sidebar.nav.data": "Données",
|
||||
"sidebar.nav.style": "Style",
|
||||
"sidebar.nav.scripts": "Scripts",
|
||||
"scripts.run": "Exécuter le script",
|
||||
"scripts.save": "Enregistrer le script",
|
||||
"scripts.delete": "Supprimer le script",
|
||||
"scripts.content": "Contenu du script",
|
||||
"scripts.field.kind": "Type",
|
||||
"scripts.field.entrypoint": "Point d’entrée",
|
||||
"scripts.entrypoint.main": "main",
|
||||
"scripts.entrypoint.none": "Aucune fonction trouvée",
|
||||
"scripts.field.enabled": "Activé",
|
||||
"scripts.syntax.check": "Vérifier la syntaxe",
|
||||
"scripts.syntax.checking": "Vérification...",
|
||||
"scripts.syntax.valid": "La syntaxe Python est valide",
|
||||
"scripts.syntax.invalid": "Erreurs de syntaxe Python : {count}",
|
||||
"scripts.syntax.checkFailed": "Échec de la vérification de la syntaxe Python",
|
||||
"scripts.kind.utility": "utility",
|
||||
"scripts.kind.macro": "macro",
|
||||
"scripts.kind.transform": "transform",
|
||||
"sidebar.tagCloud": "Nuage d’étiquettes",
|
||||
"sidebar.createEdit": "Créer & modifier",
|
||||
"sidebar.mergeTags": "Fusionner les étiquettes",
|
||||
@@ -613,6 +640,7 @@
|
||||
"panel.closeTitle": "Fermer le panneau",
|
||||
"panel.noRecentTasks": "Aucune tâche récente",
|
||||
"panel.noOutput": "Aucune sortie",
|
||||
"panel.copyOutput": "Copier la sortie",
|
||||
"panel.openPostEditor": "Ouvrez un éditeur d'article pour voir les liens",
|
||||
"panel.loadingPostLinks": "Chargement des liens d'articles...",
|
||||
"panel.noPostLinks": "Aucun lien pour cet article",
|
||||
@@ -695,6 +723,13 @@
|
||||
"sidebar.chat.yesterday": "Hier",
|
||||
"sidebar.import.header": "Import",
|
||||
"sidebar.import.newDefinition": "Nouvelle définition",
|
||||
"sidebar.scripts.header": "SCRIPTS",
|
||||
"sidebar.scripts.newScript": "Nouveau script",
|
||||
"sidebar.scripts.none": "Aucun script",
|
||||
"sidebar.scripts.createScript": "Créer un script",
|
||||
"sidebar.scripts.createFailed": "Impossible de créer le script",
|
||||
"sidebar.scripts.deleteScript": "Supprimer le script",
|
||||
"sidebar.scripts.deleteFailed": "Impossible de supprimer le script",
|
||||
"sidebar.import.none": "Aucune définition d’import",
|
||||
"sidebar.import.createDefinition": "Créer une définition",
|
||||
"sidebar.import.deleteDefinition": "Supprimer la définition",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"activity.posts": "Post",
|
||||
"activity.pages": "Pagine",
|
||||
"activity.media": "Contenuti media",
|
||||
"activity.scripts": "Script",
|
||||
"activity.tags": "Tag",
|
||||
"activity.aiAssistant": "Assistente IA",
|
||||
"activity.import": "Importa",
|
||||
@@ -22,6 +23,11 @@
|
||||
"tasks.triggerTitle": "{running} in esecuzione, {pending} in attesa",
|
||||
"app.taskCompleted": "Attività completata: {message}",
|
||||
"app.taskFailed": "Attività non riuscita: {message}",
|
||||
"app.blogmark.transforms.summary": "Trasformazioni blogmark: {applied} applicate, {failed} non riuscite",
|
||||
"app.blogmark.transforms.appliedList": "Script applicati: {scripts}",
|
||||
"app.blogmark.transforms.failed": "Trasformazione non riuscita ({script}): {message}",
|
||||
"app.blogmark.transforms.toast": "Toast script: {message}",
|
||||
"app.blogmark.transforms.errorToast": "Errori di trasformazione blogmark: {count}",
|
||||
"app.databaseRebuildFailed": "Ricostruzione database non riuscita",
|
||||
"app.textReindexFailed": "Reindicizzazione testo non riuscita",
|
||||
"app.sitemapGenerationFailed": "Generazione sitemap non riuscita",
|
||||
@@ -238,6 +244,7 @@
|
||||
"postLinks.openTitle": "Apri: {title}",
|
||||
"docs.title": "Documentazione",
|
||||
"docs.subtitle": "Guida utente per questa versione installata di bDS.",
|
||||
"docs.copyCode": "Copia codice",
|
||||
"gitDiff.header": "Differenza: {target}",
|
||||
"gitDiff.noProject": "Nessun progetto attivo selezionato.",
|
||||
"gitDiff.noProjectPath": "Impossibile risolvere il percorso del progetto.",
|
||||
@@ -339,6 +346,7 @@
|
||||
"gitSidebar.placeholder.commitMessage": "Messaggio di commit",
|
||||
"editor.untitled": "Senza titolo",
|
||||
"tabBar.style": "Stile",
|
||||
"tabBar.scripts": "Script",
|
||||
"tabBar.loading": "Caricamento...",
|
||||
"tabBar.unknown": "Sconosciuto",
|
||||
"tabBar.preview": "Anteprima",
|
||||
@@ -350,6 +358,7 @@
|
||||
"tabBar.error.fetchPostTitle": "Impossibile caricare il titolo del post:",
|
||||
"tabBar.error.fetchChatTitle": "Impossibile caricare il titolo della chat:",
|
||||
"tabBar.error.fetchImportTitle": "Impossibile caricare il titolo della definizione di importazione:",
|
||||
"tabBar.error.fetchScriptTitle": "Impossibile caricare il titolo dello script:",
|
||||
"tabBar.error.fetchCommitTitle": "Impossibile caricare i titoli dei commit:",
|
||||
"metadataDiff.title": "Strumento diff metadati",
|
||||
"metadataDiff.description": "Confronta i metadati dei post tra database e file markdown. Correggi incongruenze causate da bug o modifiche manuali.",
|
||||
@@ -415,6 +424,24 @@
|
||||
"sidebar.nav.publishing": "Pubblicazione",
|
||||
"sidebar.nav.data": "Dati",
|
||||
"sidebar.nav.style": "Stile",
|
||||
"sidebar.nav.scripts": "Script",
|
||||
"scripts.run": "Esegui script",
|
||||
"scripts.save": "Salva script",
|
||||
"scripts.delete": "Elimina script",
|
||||
"scripts.content": "Contenuto script",
|
||||
"scripts.field.kind": "Tipo",
|
||||
"scripts.field.entrypoint": "Punto di ingresso",
|
||||
"scripts.entrypoint.main": "main",
|
||||
"scripts.entrypoint.none": "Nessuna funzione trovata",
|
||||
"scripts.field.enabled": "Abilitato",
|
||||
"scripts.syntax.check": "Controlla sintassi",
|
||||
"scripts.syntax.checking": "Controllo...",
|
||||
"scripts.syntax.valid": "La sintassi Python è valida",
|
||||
"scripts.syntax.invalid": "Errori di sintassi Python: {count}",
|
||||
"scripts.syntax.checkFailed": "Controllo della sintassi Python non riuscito",
|
||||
"scripts.kind.utility": "utility",
|
||||
"scripts.kind.macro": "macro",
|
||||
"scripts.kind.transform": "transform",
|
||||
"sidebar.tagCloud": "Nuvola tag",
|
||||
"sidebar.createEdit": "Crea e modifica",
|
||||
"sidebar.mergeTags": "Unisci tag",
|
||||
@@ -613,6 +640,7 @@
|
||||
"panel.closeTitle": "Chiudi pannello",
|
||||
"panel.noRecentTasks": "Nessuna attività recente",
|
||||
"panel.noOutput": "Nessun output",
|
||||
"panel.copyOutput": "Copia output",
|
||||
"panel.openPostEditor": "Apri un editor post per visualizzare i collegamenti",
|
||||
"panel.loadingPostLinks": "Caricamento collegamenti post...",
|
||||
"panel.noPostLinks": "Nessun collegamento per questo post",
|
||||
@@ -695,6 +723,13 @@
|
||||
"sidebar.chat.yesterday": "Ieri",
|
||||
"sidebar.import.header": "Importazione",
|
||||
"sidebar.import.newDefinition": "Nuova definizione",
|
||||
"sidebar.scripts.header": "SCRIPTS",
|
||||
"sidebar.scripts.newScript": "Nuovo script",
|
||||
"sidebar.scripts.none": "Nessuno script",
|
||||
"sidebar.scripts.createScript": "Crea uno script",
|
||||
"sidebar.scripts.createFailed": "Impossibile creare lo script",
|
||||
"sidebar.scripts.deleteScript": "Elimina script",
|
||||
"sidebar.scripts.deleteFailed": "Impossibile eliminare lo script",
|
||||
"sidebar.import.none": "Nessuna definizione di importazione",
|
||||
"sidebar.import.createDefinition": "Crea definizione",
|
||||
"sidebar.import.deleteDefinition": "Elimina definizione",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Tab } from '../store/appStore';
|
||||
import type { SidebarView } from './sidebarViewRegistry';
|
||||
|
||||
export type ActivityId = 'posts' | 'pages' | 'media' | 'tags' | 'chat' | 'import' | 'git' | 'settings';
|
||||
export type ActivityId = 'posts' | 'pages' | 'media' | 'scripts' | 'tags' | 'chat' | 'import' | 'git' | 'settings';
|
||||
|
||||
export interface ActivitySnapshot {
|
||||
activeView: SidebarView;
|
||||
@@ -43,6 +43,13 @@ const ACTIVITY_CONFIG: Record<ActivityId, ActivityConfig> = {
|
||||
activeStrategy: 'sidebar-owner',
|
||||
clickStrategy: 'sidebar-toggle',
|
||||
},
|
||||
scripts: {
|
||||
id: 'scripts',
|
||||
view: 'scripts',
|
||||
labelKey: 'activity.scripts',
|
||||
activeStrategy: 'sidebar-owner',
|
||||
clickStrategy: 'sidebar-toggle',
|
||||
},
|
||||
tags: {
|
||||
id: 'tags',
|
||||
view: 'tags',
|
||||
|
||||
168
src/renderer/navigation/blogmarkTransformOutput.ts
Normal file
168
src/renderer/navigation/blogmarkTransformOutput.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { PanelOutputEntry, PostData } from '../store';
|
||||
|
||||
export interface BlogmarkTransformDebugError {
|
||||
scriptId: string;
|
||||
scriptSlug: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BlogmarkTransformDebugInfo {
|
||||
appliedScriptIds: string[];
|
||||
errors: BlogmarkTransformDebugError[];
|
||||
toasts: string[];
|
||||
}
|
||||
|
||||
export interface BlogmarkTransformToastNotification {
|
||||
kind: 'success' | 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BlogmarkCreatedEventPayload {
|
||||
post: PostData;
|
||||
transform?: BlogmarkTransformDebugInfo;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseTransformDebugInfo(value: unknown): BlogmarkTransformDebugInfo | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const appliedScriptIds = Array.isArray(value.appliedScriptIds)
|
||||
? value.appliedScriptIds.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
|
||||
const errors = Array.isArray(value.errors)
|
||||
? value.errors
|
||||
.map((entry) => {
|
||||
if (!isRecord(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scriptId = typeof entry.scriptId === 'string' ? entry.scriptId : '';
|
||||
const scriptSlug = typeof entry.scriptSlug === 'string' ? entry.scriptSlug : '';
|
||||
const message = typeof entry.message === 'string' ? entry.message : '';
|
||||
|
||||
if (!scriptId || !scriptSlug || !message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scriptId,
|
||||
scriptSlug,
|
||||
message,
|
||||
};
|
||||
})
|
||||
.filter((item): item is BlogmarkTransformDebugError => item !== null)
|
||||
: [];
|
||||
|
||||
const toasts = Array.isArray(value.toasts)
|
||||
? value.toasts
|
||||
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
|
||||
.filter((entry) => entry.length > 0)
|
||||
: [];
|
||||
|
||||
if (appliedScriptIds.length === 0 && errors.length === 0 && toasts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
appliedScriptIds,
|
||||
errors,
|
||||
toasts,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBlogmarkCreatedEventPayload(payload: unknown): BlogmarkCreatedEventPayload | null {
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isRecord(payload.post)) {
|
||||
return {
|
||||
post: payload.post as PostData,
|
||||
transform: parseTransformDebugInfo(payload.transform),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
post: payload as PostData,
|
||||
transform: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBlogmarkTransformOutputEntries(
|
||||
transform: BlogmarkTransformDebugInfo | undefined,
|
||||
t: (key: string, values?: Record<string, string | number>) => string,
|
||||
): Array<Omit<PanelOutputEntry, 'id' | 'createdAt'>> {
|
||||
if (!transform) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries: Array<Omit<PanelOutputEntry, 'id' | 'createdAt'>> = [];
|
||||
entries.push({
|
||||
kind: 'result',
|
||||
message: t('app.blogmark.transforms.summary', {
|
||||
applied: transform.appliedScriptIds.length,
|
||||
failed: transform.errors.length,
|
||||
}),
|
||||
});
|
||||
|
||||
if (transform.appliedScriptIds.length > 0) {
|
||||
entries.push({
|
||||
kind: 'result',
|
||||
message: t('app.blogmark.transforms.appliedList', {
|
||||
scripts: transform.appliedScriptIds.join(', '),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
for (const toastMessage of transform.toasts) {
|
||||
entries.push({
|
||||
kind: 'result',
|
||||
message: t('app.blogmark.transforms.toast', {
|
||||
message: toastMessage,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
for (const error of transform.errors) {
|
||||
entries.push({
|
||||
kind: 'error',
|
||||
message: t('app.blogmark.transforms.failed', {
|
||||
script: error.scriptSlug,
|
||||
message: error.message,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function buildBlogmarkTransformToastNotifications(
|
||||
transform: BlogmarkTransformDebugInfo | undefined,
|
||||
t: (key: string, values?: Record<string, string | number>) => string,
|
||||
): BlogmarkTransformToastNotification[] {
|
||||
if (!transform) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const notifications: BlogmarkTransformToastNotification[] = transform.toasts.map((message) => ({
|
||||
kind: 'success',
|
||||
message,
|
||||
}));
|
||||
|
||||
if (transform.errors.length > 0) {
|
||||
notifications.push({
|
||||
kind: 'error',
|
||||
message: t('app.blogmark.transforms.errorToast', {
|
||||
count: transform.errors.length,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return notifications;
|
||||
}
|
||||
@@ -14,7 +14,8 @@ export type EditorRoute =
|
||||
| 'metadata-diff'
|
||||
| 'git-diff'
|
||||
| 'documentation'
|
||||
| 'site-validation';
|
||||
| 'site-validation'
|
||||
| 'scripts';
|
||||
|
||||
export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'dashboard'>> = {
|
||||
post: 'post',
|
||||
@@ -29,6 +30,7 @@ export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'da
|
||||
'git-diff': 'git-diff',
|
||||
documentation: 'documentation',
|
||||
'site-validation': 'site-validation',
|
||||
scripts: 'scripts',
|
||||
};
|
||||
|
||||
export interface EditorRouteResolution {
|
||||
|
||||
@@ -2,6 +2,7 @@ export const SIDEBAR_VIEW_REGISTRY = [
|
||||
'posts',
|
||||
'pages',
|
||||
'media',
|
||||
'scripts',
|
||||
'settings',
|
||||
'tags',
|
||||
'chat',
|
||||
|
||||
@@ -4,6 +4,7 @@ export type SingletonToolTabKey =
|
||||
| 'settings'
|
||||
| 'tags'
|
||||
| 'style'
|
||||
| 'scripts'
|
||||
| 'menu-editor'
|
||||
| 'documentation'
|
||||
| 'metadata-diff'
|
||||
@@ -17,12 +18,14 @@ export interface CanonicalTabSpec {
|
||||
|
||||
export type EntityTabType = 'post' | 'media';
|
||||
export type EntityTabOpenIntent = 'preview' | 'pin';
|
||||
export type ScriptTabOpenIntent = 'preview' | 'pin';
|
||||
export type GitDiffResourceOpenIntent = 'preview' | 'pin';
|
||||
|
||||
const SINGLETON_TOOL_TAB_REGISTRY: Record<SingletonToolTabKey, CanonicalTabSpec> = {
|
||||
settings: { type: 'settings', id: 'settings', isTransient: false },
|
||||
tags: { type: 'tags', id: 'tags', isTransient: false },
|
||||
style: { type: 'style', id: 'style', isTransient: false },
|
||||
scripts: { type: 'scripts', id: 'scripts', isTransient: false },
|
||||
'menu-editor': { type: 'menu-editor', id: 'menu-editor', isTransient: false },
|
||||
documentation: { type: 'documentation', id: 'documentation', isTransient: false },
|
||||
'metadata-diff': { type: 'metadata-diff', id: 'metadata-diff', isTransient: false },
|
||||
@@ -91,6 +94,22 @@ export function openImportTab(
|
||||
openTab(getImportTabSpec(definitionId));
|
||||
}
|
||||
|
||||
export function getScriptTabSpec(scriptId: string, intent: ScriptTabOpenIntent): CanonicalTabSpec {
|
||||
return {
|
||||
type: 'scripts',
|
||||
id: scriptId,
|
||||
isTransient: intent === 'preview',
|
||||
};
|
||||
}
|
||||
|
||||
export function openScriptTab(
|
||||
openTab: (tab: CanonicalTabSpec) => void,
|
||||
scriptId: string,
|
||||
intent: ScriptTabOpenIntent,
|
||||
): void {
|
||||
openTab(getScriptTabSpec(scriptId, intent));
|
||||
}
|
||||
|
||||
export function getGitDiffFileTabId(filePath: string): string {
|
||||
return `git-diff:${filePath}`;
|
||||
}
|
||||
|
||||
401
src/renderer/python/PythonRuntimeManager.ts
Normal file
401
src/renderer/python/PythonRuntimeManager.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import { createPythonRuntimeWorker } from './createPythonRuntimeWorker';
|
||||
import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol';
|
||||
import type { PythonSyntaxError } from './runtimeProtocol';
|
||||
import { parseMacroContextV1, parseMacroResultV1, type MacroContextV1, type MacroResultV1 } from './abiV1';
|
||||
|
||||
type WorkerFactory = () => Worker;
|
||||
|
||||
interface InitializeDeferred {
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface PendingRun {
|
||||
kind: 'run' | 'macro-v1' | 'inspect-entrypoints' | 'syntax-check';
|
||||
stdout: string;
|
||||
resolve: (value: PythonRunResult | PythonMacroV1Result | string[] | PythonSyntaxCheckResult) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutId: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
export interface PythonRunResult {
|
||||
result: string;
|
||||
stdout: string;
|
||||
}
|
||||
|
||||
export interface PythonExecuteOptions {
|
||||
timeoutMs?: number;
|
||||
cacheKey?: string;
|
||||
entrypoint?: string;
|
||||
}
|
||||
|
||||
export interface PythonMacroSourceOptions {
|
||||
kind: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface PythonMacroRenderOptions extends PythonExecuteOptions {
|
||||
macroHook?: string;
|
||||
macroSource?: PythonMacroSourceOptions;
|
||||
}
|
||||
|
||||
export interface PythonMacroV1Result {
|
||||
result: MacroResultV1;
|
||||
stdout: string;
|
||||
}
|
||||
|
||||
export interface PythonSyntaxCheckResult {
|
||||
errors: PythonSyntaxError[];
|
||||
}
|
||||
|
||||
export class PythonRuntimeManager {
|
||||
private worker: Worker | null = null;
|
||||
private initializingPromise: Promise<void> | null = null;
|
||||
private initializeDeferred: InitializeDeferred | null = null;
|
||||
private ready = false;
|
||||
private pendingRuns = new Map<string, PendingRun>();
|
||||
private requestCounter = 0;
|
||||
|
||||
constructor(private readonly workerFactory: WorkerFactory = createPythonRuntimeWorker) {}
|
||||
|
||||
initialize(): Promise<void> {
|
||||
if (this.ready) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.initializingPromise) {
|
||||
return this.initializingPromise;
|
||||
}
|
||||
|
||||
this.worker = this.workerFactory();
|
||||
this.ready = false;
|
||||
|
||||
this.initializingPromise = new Promise<void>((resolve, reject) => {
|
||||
this.initializeDeferred = { resolve, reject };
|
||||
|
||||
if (!this.worker) {
|
||||
this.initializeDeferred = null;
|
||||
reject(new Error('Python runtime worker factory returned no worker'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.worker.onmessage = (event: MessageEvent<PythonWorkerMessage>) => {
|
||||
this.handleWorkerMessage(event.data);
|
||||
};
|
||||
this.worker.onerror = (event: ErrorEvent) => {
|
||||
this.handleWorkerError(event.error instanceof Error ? event.error : new Error(event.message || 'Python runtime worker failed to initialize'));
|
||||
};
|
||||
});
|
||||
|
||||
return this.initializingPromise;
|
||||
}
|
||||
|
||||
async execute(code: string, options?: PythonExecuteOptions): Promise<PythonRunResult> {
|
||||
await this.initialize();
|
||||
|
||||
if (!this.worker || !this.ready) {
|
||||
throw new Error('Python runtime is not ready');
|
||||
}
|
||||
|
||||
const requestId = this.nextRequestId();
|
||||
const timeoutMs = options?.timeoutMs ?? 5000;
|
||||
|
||||
return new Promise<PythonRunResult>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.pendingRuns.delete(requestId);
|
||||
this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
|
||||
reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRuns.set(requestId, {
|
||||
kind: 'run',
|
||||
stdout: '',
|
||||
resolve: (value) => resolve(value as PythonRunResult),
|
||||
reject,
|
||||
timeoutId,
|
||||
});
|
||||
|
||||
const message: PythonWorkerRequest = {
|
||||
type: 'run',
|
||||
requestId,
|
||||
code,
|
||||
cacheKey: options?.cacheKey,
|
||||
entrypoint: options?.entrypoint,
|
||||
};
|
||||
|
||||
this.worker!.postMessage(message);
|
||||
});
|
||||
}
|
||||
|
||||
async renderMacroV1(code: string, context: unknown, options?: PythonMacroRenderOptions): Promise<PythonMacroV1Result> {
|
||||
const contextWithMetadata = this.withMacroEnvMetadata(context, options);
|
||||
const validatedContext = parseMacroContextV1(contextWithMetadata);
|
||||
await this.initialize();
|
||||
|
||||
if (!this.worker || !this.ready) {
|
||||
throw new Error('Python runtime is not ready');
|
||||
}
|
||||
|
||||
const requestId = this.nextRequestId();
|
||||
const timeoutMs = options?.timeoutMs ?? 5000;
|
||||
|
||||
return new Promise<PythonMacroV1Result>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.pendingRuns.delete(requestId);
|
||||
this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
|
||||
reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRuns.set(requestId, {
|
||||
kind: 'macro-v1',
|
||||
stdout: '',
|
||||
resolve: (value) => resolve(value as PythonMacroV1Result),
|
||||
reject,
|
||||
timeoutId,
|
||||
});
|
||||
|
||||
const message: PythonWorkerRequest = {
|
||||
type: 'renderMacroV1',
|
||||
requestId,
|
||||
code,
|
||||
context: validatedContext,
|
||||
cacheKey: options?.cacheKey,
|
||||
};
|
||||
|
||||
this.worker!.postMessage(message);
|
||||
});
|
||||
}
|
||||
|
||||
async inspectEntrypoints(code: string, options?: PythonExecuteOptions): Promise<string[]> {
|
||||
await this.initialize();
|
||||
|
||||
if (!this.worker || !this.ready) {
|
||||
throw new Error('Python runtime is not ready');
|
||||
}
|
||||
|
||||
const requestId = this.nextRequestId();
|
||||
const timeoutMs = options?.timeoutMs ?? 5000;
|
||||
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.pendingRuns.delete(requestId);
|
||||
this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
|
||||
reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRuns.set(requestId, {
|
||||
kind: 'inspect-entrypoints',
|
||||
stdout: '',
|
||||
resolve: (value) => resolve(value as string[]),
|
||||
reject,
|
||||
timeoutId,
|
||||
});
|
||||
|
||||
const message: PythonWorkerRequest = {
|
||||
type: 'inspectEntrypoints',
|
||||
requestId,
|
||||
code,
|
||||
cacheKey: options?.cacheKey,
|
||||
};
|
||||
|
||||
this.worker!.postMessage(message);
|
||||
});
|
||||
}
|
||||
|
||||
async syntaxCheck(code: string, options?: PythonExecuteOptions): Promise<PythonSyntaxCheckResult> {
|
||||
await this.initialize();
|
||||
|
||||
if (!this.worker || !this.ready) {
|
||||
throw new Error('Python runtime is not ready');
|
||||
}
|
||||
|
||||
const requestId = this.nextRequestId();
|
||||
const timeoutMs = options?.timeoutMs ?? 5000;
|
||||
|
||||
return new Promise<PythonSyntaxCheckResult>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.pendingRuns.delete(requestId);
|
||||
this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
|
||||
reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRuns.set(requestId, {
|
||||
kind: 'syntax-check',
|
||||
stdout: '',
|
||||
resolve: (value) => resolve(value as PythonSyntaxCheckResult),
|
||||
reject,
|
||||
timeoutId,
|
||||
});
|
||||
|
||||
const message: PythonWorkerRequest = {
|
||||
type: 'syntaxCheck',
|
||||
requestId,
|
||||
code,
|
||||
cacheKey: options?.cacheKey,
|
||||
};
|
||||
|
||||
this.worker!.postMessage(message);
|
||||
});
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.resetRuntime();
|
||||
}
|
||||
|
||||
private handleWorkerMessage(payload: PythonWorkerMessage): void {
|
||||
if (payload.type === 'ready') {
|
||||
this.ready = true;
|
||||
this.initializingPromise = null;
|
||||
this.initializeDeferred?.resolve();
|
||||
this.initializeDeferred = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === 'error') {
|
||||
this.handleWorkerError(new Error(payload.error));
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingRun = this.pendingRuns.get(payload.requestId);
|
||||
if (!pendingRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === 'stdout') {
|
||||
pendingRun.stdout += payload.chunk;
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingRuns.delete(payload.requestId);
|
||||
if (pendingRun.timeoutId) {
|
||||
clearTimeout(pendingRun.timeoutId);
|
||||
}
|
||||
|
||||
if (payload.type === 'runResult') {
|
||||
if (pendingRun.kind !== 'run') {
|
||||
pendingRun.reject(new Error('Invalid response type for pending macro request'));
|
||||
return;
|
||||
}
|
||||
pendingRun.resolve({ result: payload.result, stdout: pendingRun.stdout });
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === 'entrypoints') {
|
||||
if (pendingRun.kind !== 'inspect-entrypoints') {
|
||||
pendingRun.reject(new Error('Invalid response type for pending run request'));
|
||||
return;
|
||||
}
|
||||
pendingRun.resolve(payload.entrypoints);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === 'syntaxResult') {
|
||||
if (pendingRun.kind !== 'syntax-check') {
|
||||
pendingRun.reject(new Error('Invalid response type for pending syntax check request'));
|
||||
return;
|
||||
}
|
||||
pendingRun.resolve({ errors: payload.errors });
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === 'macroResult') {
|
||||
if (pendingRun.kind !== 'macro-v1') {
|
||||
pendingRun.reject(new Error('Invalid response type for pending run request'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const validatedResult = parseMacroResultV1(payload.result);
|
||||
pendingRun.resolve({ result: validatedResult, stdout: pendingRun.stdout });
|
||||
} catch (error) {
|
||||
pendingRun.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRun.reject(new Error(payload.error));
|
||||
}
|
||||
|
||||
private handleWorkerError(error: Error): void {
|
||||
if (this.initializeDeferred) {
|
||||
this.initializeDeferred.reject(error);
|
||||
this.initializeDeferred = null;
|
||||
}
|
||||
|
||||
for (const run of this.pendingRuns.values()) {
|
||||
if (run.timeoutId) {
|
||||
clearTimeout(run.timeoutId);
|
||||
}
|
||||
run.reject(error);
|
||||
}
|
||||
|
||||
this.pendingRuns.clear();
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
this.initializingPromise = null;
|
||||
this.ready = false;
|
||||
}
|
||||
|
||||
private resetRuntime(timeoutErrorMessage?: string): void {
|
||||
if (this.initializeDeferred) {
|
||||
this.initializeDeferred.reject(new Error(timeoutErrorMessage ?? 'Python runtime reset'));
|
||||
this.initializeDeferred = null;
|
||||
}
|
||||
|
||||
for (const run of this.pendingRuns.values()) {
|
||||
if (run.timeoutId) {
|
||||
clearTimeout(run.timeoutId);
|
||||
}
|
||||
if (timeoutErrorMessage) {
|
||||
run.reject(new Error(timeoutErrorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
this.pendingRuns.clear();
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
this.initializingPromise = null;
|
||||
this.ready = false;
|
||||
}
|
||||
|
||||
private nextRequestId(): string {
|
||||
this.requestCounter += 1;
|
||||
return `req-${this.requestCounter}`;
|
||||
}
|
||||
|
||||
private withMacroEnvMetadata(context: unknown, options?: PythonMacroRenderOptions): unknown {
|
||||
if (!options?.macroHook && !options?.macroSource) {
|
||||
return context;
|
||||
}
|
||||
|
||||
if (!context || typeof context !== 'object' || Array.isArray(context)) {
|
||||
return context;
|
||||
}
|
||||
|
||||
const contextRecord = context as Record<string, unknown>;
|
||||
const envValue = contextRecord.env;
|
||||
if (!envValue || typeof envValue !== 'object' || Array.isArray(envValue)) {
|
||||
return context;
|
||||
}
|
||||
|
||||
const envRecord = envValue as Record<string, unknown>;
|
||||
const nextEnv: Record<string, unknown> = { ...envRecord };
|
||||
|
||||
if (nextEnv.hook === undefined && options.macroHook !== undefined) {
|
||||
nextEnv.hook = options.macroHook;
|
||||
}
|
||||
|
||||
if (nextEnv.source === undefined && options.macroSource !== undefined) {
|
||||
nextEnv.source = options.macroSource;
|
||||
}
|
||||
|
||||
return {
|
||||
...contextRecord,
|
||||
env: nextEnv,
|
||||
};
|
||||
}
|
||||
}
|
||||
60
src/renderer/python/abiV1.ts
Normal file
60
src/renderer/python/abiV1.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const jsonValueSchema: z.ZodType<unknown> = z.lazy(() =>
|
||||
z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
z.array(jsonValueSchema),
|
||||
z.record(z.string(), jsonValueSchema),
|
||||
])
|
||||
);
|
||||
|
||||
export const macroContextV1Schema = z
|
||||
.object({
|
||||
env: z
|
||||
.object({
|
||||
isPreview: z.boolean(),
|
||||
mainLanguage: z.string().min(1).optional(),
|
||||
hook: z.string().min(1).optional(),
|
||||
source: z
|
||||
.object({
|
||||
kind: z.string().min(1),
|
||||
id: z.string().min(1).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict(),
|
||||
params: z.record(z.string(), jsonValueSchema).optional(),
|
||||
data: z.record(z.string(), jsonValueSchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const macroResultV1Schema = z
|
||||
.object({
|
||||
html: z.string(),
|
||||
data: z.record(z.string(), jsonValueSchema).optional(),
|
||||
warnings: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type MacroContextV1 = z.infer<typeof macroContextV1Schema>;
|
||||
export type MacroResultV1 = z.infer<typeof macroResultV1Schema>;
|
||||
|
||||
export function parseMacroContextV1(value: unknown): MacroContextV1 {
|
||||
const parsed = macroContextV1Schema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid macro context: ${parsed.error.issues[0]?.message ?? 'schema validation failed'}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export function parseMacroResultV1(value: unknown): MacroResultV1 {
|
||||
const parsed = macroResultV1Schema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid macro result: ${parsed.error.issues[0]?.message ?? 'schema validation failed'}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
5
src/renderer/python/createPythonRuntimeWorker.ts
Normal file
5
src/renderer/python/createPythonRuntimeWorker.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import PythonRuntimeWorker from './pythonRuntime.worker?worker';
|
||||
|
||||
export function createPythonRuntimeWorker(): Worker {
|
||||
return new PythonRuntimeWorker();
|
||||
}
|
||||
34
src/renderer/python/macroRenderOptions.ts
Normal file
34
src/renderer/python/macroRenderOptions.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { PythonMacroRenderOptions, PythonMacroSourceOptions } from './PythonRuntimeManager';
|
||||
|
||||
export interface MacroRenderOptionInput {
|
||||
timeoutMs?: number;
|
||||
cacheKey?: string;
|
||||
hook?: string;
|
||||
source?: PythonMacroSourceOptions;
|
||||
}
|
||||
|
||||
export function createMacroRenderOptions(input?: MacroRenderOptionInput): PythonMacroRenderOptions {
|
||||
if (!input) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const options: PythonMacroRenderOptions = {};
|
||||
|
||||
if (input.timeoutMs !== undefined) {
|
||||
options.timeoutMs = input.timeoutMs;
|
||||
}
|
||||
|
||||
if (input.cacheKey !== undefined) {
|
||||
options.cacheKey = input.cacheKey;
|
||||
}
|
||||
|
||||
if (input.hook !== undefined) {
|
||||
options.macroHook = input.hook;
|
||||
}
|
||||
|
||||
if (input.source !== undefined) {
|
||||
options.macroSource = input.source;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
9
src/renderer/python/pyodideAssetUrl.ts
Normal file
9
src/renderer/python/pyodideAssetUrl.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function resolvePyodideIndexURL(workerModuleUrl: string): string | undefined {
|
||||
const parsed = new URL(workerModuleUrl);
|
||||
|
||||
if (parsed.protocol !== 'file:') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new URL('../../../node_modules/pyodide/', workerModuleUrl).toString();
|
||||
}
|
||||
262
src/renderer/python/pythonRuntime.worker.ts
Normal file
262
src/renderer/python/pythonRuntime.worker.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { loadPyodide, type PyodideInterface } from 'pyodide';
|
||||
import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol';
|
||||
import { parseMacroContextV1, parseMacroResultV1 } from './abiV1';
|
||||
import { resolvePyodideIndexURL } from './pyodideAssetUrl';
|
||||
import { runPythonSyntaxCheck } from './pythonSyntaxCheck';
|
||||
|
||||
let runtime: PyodideInterface | null = null;
|
||||
let activeRequestId: string | null = null;
|
||||
|
||||
function postRuntimeMessage(message: PythonWorkerMessage): void {
|
||||
self.postMessage(message);
|
||||
}
|
||||
|
||||
function toResultString(result: unknown): string {
|
||||
if (result === undefined || result === null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof result === 'string') {
|
||||
return result;
|
||||
}
|
||||
return String(result);
|
||||
}
|
||||
|
||||
async function runPythonCode(code: string, cacheKey?: string): Promise<unknown> {
|
||||
if (!runtime) {
|
||||
throw new Error('Python runtime is not ready');
|
||||
}
|
||||
|
||||
if (!cacheKey) {
|
||||
return runtime.runPythonAsync(code);
|
||||
}
|
||||
|
||||
runtime.globals.set('__bds_source_code', code);
|
||||
runtime.globals.set('__bds_cache_key', cacheKey);
|
||||
|
||||
return runtime.runPythonAsync(`
|
||||
__bds_compiled_cache = globals().setdefault("__bds_compiled_cache", {})
|
||||
__bds_compiled_code = __bds_compiled_cache.get(__bds_cache_key)
|
||||
if __bds_compiled_code is None:
|
||||
__bds_compiled_code = compile(__bds_source_code, f"<bds:{__bds_cache_key}>", "exec")
|
||||
__bds_compiled_cache[__bds_cache_key] = __bds_compiled_code
|
||||
exec(__bds_compiled_code, globals(), globals())
|
||||
`);
|
||||
}
|
||||
|
||||
async function runScript(request: PythonWorkerRequest): Promise<void> {
|
||||
if (request.type !== 'run') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runtime) {
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: 'Python runtime is not ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeRequestId) {
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: 'Python runtime is busy' });
|
||||
return;
|
||||
}
|
||||
|
||||
activeRequestId = request.requestId;
|
||||
|
||||
try {
|
||||
let result: unknown;
|
||||
if (request.entrypoint && request.entrypoint !== 'main') {
|
||||
await runPythonCode(request.code, request.cacheKey);
|
||||
runtime.globals.set('__bds_selected_entrypoint', request.entrypoint);
|
||||
result = await runtime.runPythonAsync(`
|
||||
__bds_target = globals().get(__bds_selected_entrypoint)
|
||||
if __bds_target is None:
|
||||
raise NameError(f"Entrypoint '{__bds_selected_entrypoint}' not found")
|
||||
if not callable(__bds_target):
|
||||
raise TypeError(f"Entrypoint '{__bds_selected_entrypoint}' is not callable")
|
||||
__bds_target()
|
||||
`);
|
||||
} else {
|
||||
result = await runPythonCode(request.code, request.cacheKey);
|
||||
}
|
||||
|
||||
postRuntimeMessage({
|
||||
type: 'runResult',
|
||||
requestId: request.requestId,
|
||||
result: toResultString(result),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message });
|
||||
} finally {
|
||||
activeRequestId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function runMacroV1(request: PythonWorkerRequest): Promise<void> {
|
||||
if (request.type !== 'renderMacroV1') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runtime) {
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: 'Python runtime is not ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeRequestId) {
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: 'Python runtime is busy' });
|
||||
return;
|
||||
}
|
||||
|
||||
activeRequestId = request.requestId;
|
||||
|
||||
try {
|
||||
const validatedContext = parseMacroContextV1(request.context);
|
||||
runtime.globals.set('__bds_context_v1', validatedContext);
|
||||
|
||||
await runPythonCode(request.code, request.cacheKey);
|
||||
|
||||
const rawJsonResult = await runtime.runPythonAsync(`
|
||||
import json
|
||||
json.dumps(render(__bds_context_v1))
|
||||
`);
|
||||
|
||||
const parsedResult = parseMacroResultV1(JSON.parse(toResultString(rawJsonResult)));
|
||||
postRuntimeMessage({
|
||||
type: 'macroResult',
|
||||
requestId: request.requestId,
|
||||
result: parsedResult,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message });
|
||||
} finally {
|
||||
activeRequestId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectEntrypoints(request: PythonWorkerRequest): Promise<void> {
|
||||
if (request.type !== 'inspectEntrypoints') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runtime) {
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: 'Python runtime is not ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeRequestId) {
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: 'Python runtime is busy' });
|
||||
return;
|
||||
}
|
||||
|
||||
activeRequestId = request.requestId;
|
||||
|
||||
try {
|
||||
runtime.globals.set('__bds_entrypoints_source', request.code);
|
||||
const rawJsonResult = await runtime.runPythonAsync(`
|
||||
import ast
|
||||
import json
|
||||
|
||||
__bds_entrypoints_tree = ast.parse(__bds_entrypoints_source)
|
||||
__bds_entrypoints = []
|
||||
for __bds_node in __bds_entrypoints_tree.body:
|
||||
if isinstance(__bds_node, (ast.FunctionDef, ast.AsyncFunctionDef)) and not __bds_node.name.startswith('_'):
|
||||
__bds_entrypoints.append(__bds_node.name)
|
||||
|
||||
json.dumps(__bds_entrypoints)
|
||||
`);
|
||||
|
||||
const parsed = JSON.parse(toResultString(rawJsonResult));
|
||||
const entrypoints = Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
|
||||
postRuntimeMessage({
|
||||
type: 'entrypoints',
|
||||
requestId: request.requestId,
|
||||
entrypoints,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message });
|
||||
} finally {
|
||||
activeRequestId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function syntaxCheck(request: PythonWorkerRequest): Promise<void> {
|
||||
if (request.type !== 'syntaxCheck') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runtime) {
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: 'Python runtime is not ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeRequestId) {
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: 'Python runtime is busy' });
|
||||
return;
|
||||
}
|
||||
|
||||
activeRequestId = request.requestId;
|
||||
|
||||
try {
|
||||
const errors = await runPythonSyntaxCheck(runtime, request.code);
|
||||
|
||||
postRuntimeMessage({
|
||||
type: 'syntaxResult',
|
||||
requestId: request.requestId,
|
||||
errors,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message });
|
||||
} finally {
|
||||
activeRequestId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapRuntime(): Promise<void> {
|
||||
try {
|
||||
const indexURL = resolvePyodideIndexURL(import.meta.url);
|
||||
runtime = await loadPyodide({
|
||||
...(indexURL ? { indexURL } : {}),
|
||||
stdout: (chunk) => {
|
||||
if (!activeRequestId) {
|
||||
return;
|
||||
}
|
||||
postRuntimeMessage({ type: 'stdout', requestId: activeRequestId, chunk });
|
||||
},
|
||||
});
|
||||
if (!runtime) {
|
||||
throw new Error('Pyodide initialization returned no runtime');
|
||||
}
|
||||
postRuntimeMessage({ type: 'ready' });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
postRuntimeMessage({ type: 'error', error: message });
|
||||
}
|
||||
}
|
||||
|
||||
self.onmessage = (event: MessageEvent<PythonWorkerRequest>) => {
|
||||
const request = event.data;
|
||||
if (request.type === 'run') {
|
||||
void runScript(request);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.type === 'renderMacroV1') {
|
||||
void runMacroV1(request);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.type === 'inspectEntrypoints') {
|
||||
void inspectEntrypoints(request);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.type === 'syntaxCheck') {
|
||||
void syntaxCheck(request);
|
||||
}
|
||||
};
|
||||
|
||||
void bootstrapRuntime();
|
||||
117
src/renderer/python/pythonRuntimeBenchmark.ts
Normal file
117
src/renderer/python/pythonRuntimeBenchmark.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { loadPyodide } from 'pyodide';
|
||||
|
||||
export interface BenchmarkStats {
|
||||
count: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
meanMs: number;
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkResult {
|
||||
coldStartMs: number;
|
||||
warmRunMs: number;
|
||||
repeatedMacro: {
|
||||
samplesMs: number[];
|
||||
stats: BenchmarkStats;
|
||||
};
|
||||
}
|
||||
|
||||
interface PythonRuntimeLike {
|
||||
runPythonAsync(code: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface BenchmarkOptions {
|
||||
iterations?: number;
|
||||
loadRuntime?: () => Promise<PythonRuntimeLike>;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function percentile(values: number[], p: number): number {
|
||||
if (values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (values.length === 1) {
|
||||
return values[0];
|
||||
}
|
||||
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const index = (sorted.length - 1) * p;
|
||||
const lower = Math.floor(index);
|
||||
const upper = Math.ceil(index);
|
||||
|
||||
if (lower === upper) {
|
||||
return sorted[lower];
|
||||
}
|
||||
|
||||
const weight = index - lower;
|
||||
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
export function summarizeDurations(durationsMs: number[]): BenchmarkStats {
|
||||
if (durationsMs.length === 0) {
|
||||
return {
|
||||
count: 0,
|
||||
minMs: 0,
|
||||
maxMs: 0,
|
||||
meanMs: 0,
|
||||
p50Ms: 0,
|
||||
p95Ms: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const minMs = Math.min(...durationsMs);
|
||||
const maxMs = Math.max(...durationsMs);
|
||||
const meanMs = durationsMs.reduce((sum, value) => sum + value, 0) / durationsMs.length;
|
||||
|
||||
return {
|
||||
count: durationsMs.length,
|
||||
minMs: round2(minMs),
|
||||
maxMs: round2(maxMs),
|
||||
meanMs: round2(meanMs),
|
||||
p50Ms: round2(percentile(durationsMs, 0.5)),
|
||||
p95Ms: round2(percentile(durationsMs, 0.95)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runPythonRuntimeBenchmark(options: BenchmarkOptions = {}): Promise<BenchmarkResult> {
|
||||
const iterations = options.iterations ?? 200;
|
||||
const loadRuntime = options.loadRuntime ?? (async () => loadPyodide());
|
||||
const now = options.now ?? (() => performance.now());
|
||||
|
||||
const coldStartStart = now();
|
||||
const runtime = await loadRuntime();
|
||||
const coldStartMs = now() - coldStartStart;
|
||||
|
||||
const warmStart = now();
|
||||
await runtime.runPythonAsync('1 + 1');
|
||||
const warmRunMs = now() - warmStart;
|
||||
|
||||
await runtime.runPythonAsync(`
|
||||
def render(context):
|
||||
title = context.get("title", "")
|
||||
return {"html": f"<p>{title}</p>"}
|
||||
`);
|
||||
|
||||
const samplesMs: number[] = [];
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
const started = now();
|
||||
await runtime.runPythonAsync('render({"title": "Benchmark"})["html"]');
|
||||
samplesMs.push(now() - started);
|
||||
}
|
||||
|
||||
return {
|
||||
coldStartMs: round2(coldStartMs),
|
||||
warmRunMs: round2(warmRunMs),
|
||||
repeatedMacro: {
|
||||
samplesMs: samplesMs.map(round2),
|
||||
stats: summarizeDurations(samplesMs),
|
||||
},
|
||||
};
|
||||
}
|
||||
73
src/renderer/python/pythonSyntaxCheck.ts
Normal file
73
src/renderer/python/pythonSyntaxCheck.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { PyodideInterface } from 'pyodide';
|
||||
import type { PythonSyntaxError } from './runtimeProtocol';
|
||||
|
||||
const SYNTAX_CHECK_SCRIPT = [
|
||||
'import ast',
|
||||
'import json',
|
||||
'',
|
||||
'__bds_syntax_errors = []',
|
||||
'try:',
|
||||
' ast.parse(__bds_syntax_source)',
|
||||
'except SyntaxError as exc:',
|
||||
' line = exc.lineno or 1',
|
||||
' column = exc.offset or 1',
|
||||
' end_line = getattr(exc, "end_lineno", None) or line',
|
||||
' end_column = getattr(exc, "end_offset", None) or (column + 1)',
|
||||
' __bds_syntax_errors.append({',
|
||||
' "line": line,',
|
||||
' "column": column,',
|
||||
' "endLine": end_line,',
|
||||
' "endColumn": end_column,',
|
||||
' "message": exc.msg or "invalid syntax",',
|
||||
' })',
|
||||
'__bds_syntax_result_json = json.dumps({"errors": __bds_syntax_errors})',
|
||||
'__bds_syntax_result_json',
|
||||
].join('\n');
|
||||
|
||||
function toResultString(result: unknown): string {
|
||||
if (typeof result === 'string') {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result === null || result === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(result);
|
||||
}
|
||||
|
||||
function parseSyntaxErrors(rawJsonResult: unknown): PythonSyntaxError[] {
|
||||
const rawText = toResultString(rawJsonResult).trim();
|
||||
if (rawText.length === 0) {
|
||||
throw new Error('Python syntax check returned no JSON result');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(rawText) as { errors?: unknown };
|
||||
if (!Array.isArray(parsed.errors)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.errors
|
||||
.filter((item): item is PythonSyntaxError => {
|
||||
return item
|
||||
&& typeof item === 'object'
|
||||
&& Number.isFinite((item as { line?: number }).line)
|
||||
&& Number.isFinite((item as { column?: number }).column)
|
||||
&& Number.isFinite((item as { endLine?: number }).endLine)
|
||||
&& Number.isFinite((item as { endColumn?: number }).endColumn)
|
||||
&& typeof (item as { message?: unknown }).message === 'string';
|
||||
})
|
||||
.map((item) => ({
|
||||
line: Math.max(1, Math.floor(item.line)),
|
||||
column: Math.max(1, Math.floor(item.column)),
|
||||
endLine: Math.max(1, Math.floor(item.endLine)),
|
||||
endColumn: Math.max(1, Math.floor(item.endColumn)),
|
||||
message: item.message,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function runPythonSyntaxCheck(runtime: PyodideInterface, sourceCode: string): Promise<PythonSyntaxError[]> {
|
||||
runtime.globals.set('__bds_syntax_source', sourceCode);
|
||||
const rawJsonResult = await runtime.runPythonAsync(SYNTAX_CHECK_SCRIPT);
|
||||
return parseSyntaxErrors(rawJsonResult);
|
||||
}
|
||||
7
src/renderer/python/runtimeManagerInstance.ts
Normal file
7
src/renderer/python/runtimeManagerInstance.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { PythonRuntimeManager } from './PythonRuntimeManager';
|
||||
|
||||
const runtimeManager = new PythonRuntimeManager();
|
||||
|
||||
export function getPythonRuntimeManager(): PythonRuntimeManager {
|
||||
return runtimeManager;
|
||||
}
|
||||
47
src/renderer/python/runtimeProtocol.ts
Normal file
47
src/renderer/python/runtimeProtocol.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { MacroContextV1, MacroResultV1 } from './abiV1';
|
||||
|
||||
export type PythonWorkerRequest =
|
||||
| {
|
||||
type: 'run';
|
||||
requestId: string;
|
||||
code: string;
|
||||
cacheKey?: string;
|
||||
entrypoint?: string;
|
||||
}
|
||||
| {
|
||||
type: 'renderMacroV1';
|
||||
requestId: string;
|
||||
code: string;
|
||||
context: MacroContextV1;
|
||||
cacheKey?: string;
|
||||
}
|
||||
| {
|
||||
type: 'inspectEntrypoints';
|
||||
requestId: string;
|
||||
code: string;
|
||||
cacheKey?: string;
|
||||
}
|
||||
| {
|
||||
type: 'syntaxCheck';
|
||||
requestId: string;
|
||||
code: string;
|
||||
cacheKey?: string;
|
||||
};
|
||||
|
||||
export interface PythonSyntaxError {
|
||||
line: number;
|
||||
column: number;
|
||||
endLine: number;
|
||||
endColumn: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type PythonWorkerMessage =
|
||||
| { type: 'ready' }
|
||||
| { type: 'error'; error: string }
|
||||
| { type: 'stdout'; requestId: string; chunk: string }
|
||||
| { type: 'runResult'; requestId: string; result: string }
|
||||
| { type: 'entrypoints'; requestId: string; entrypoints: string[] }
|
||||
| { type: 'syntaxResult'; requestId: string; errors: PythonSyntaxError[] }
|
||||
| { type: 'macroResult'; requestId: string; result: MacroResultV1 }
|
||||
| { type: 'runError'; requestId: string; error: string };
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
const STORAGE_KEY = 'bds-app-state';
|
||||
|
||||
// Tab types
|
||||
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'site-validation';
|
||||
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'site-validation' | 'scripts';
|
||||
|
||||
export interface Tab {
|
||||
type: TabType;
|
||||
@@ -42,6 +42,13 @@ export type EditorMode = 'wysiwyg' | 'markdown' | 'preview';
|
||||
export type GitDiffViewStyle = 'inline' | 'side-by-side';
|
||||
export type PanelTab = 'tasks' | 'output' | 'post-links' | 'git-log';
|
||||
|
||||
export interface PanelOutputEntry {
|
||||
id: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
kind: 'stdout' | 'result' | 'error';
|
||||
}
|
||||
|
||||
export interface GitDiffPreferences {
|
||||
wordWrap: boolean;
|
||||
viewStyle: GitDiffViewStyle;
|
||||
@@ -63,6 +70,7 @@ interface AppState {
|
||||
sidebarVisible: boolean;
|
||||
panelVisible: boolean;
|
||||
panelActiveTab: PanelTab;
|
||||
panelOutputEntries: PanelOutputEntry[];
|
||||
selectedPostId: string | null;
|
||||
selectedMediaId: string | null;
|
||||
preferredEditorMode: EditorMode;
|
||||
@@ -112,6 +120,8 @@ interface AppState {
|
||||
toggleSidebar: () => void;
|
||||
togglePanel: () => void;
|
||||
setPanelActiveTab: (tab: PanelTab) => void;
|
||||
appendPanelOutputEntry: (entry: PanelOutputEntry) => void;
|
||||
clearPanelOutputEntries: () => void;
|
||||
setSelectedPost: (id: string | null) => void;
|
||||
setSelectedMedia: (id: string | null) => void;
|
||||
setPreferredEditorMode: (mode: EditorMode) => void;
|
||||
@@ -166,6 +176,7 @@ export const useAppStore = create<AppState>()(
|
||||
sidebarVisible: true,
|
||||
panelVisible: false,
|
||||
panelActiveTab: 'tasks',
|
||||
panelOutputEntries: [],
|
||||
selectedPostId: null,
|
||||
selectedMediaId: null,
|
||||
preferredEditorMode: 'wysiwyg',
|
||||
@@ -290,6 +301,10 @@ export const useAppStore = create<AppState>()(
|
||||
toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })),
|
||||
togglePanel: () => set((state) => ({ panelVisible: !state.panelVisible })),
|
||||
setPanelActiveTab: (panelActiveTab) => set({ panelActiveTab }),
|
||||
appendPanelOutputEntry: (entry) => set((state) => ({
|
||||
panelOutputEntries: [...state.panelOutputEntries, entry],
|
||||
})),
|
||||
clearPanelOutputEntries: () => set({ panelOutputEntries: [] }),
|
||||
setSelectedPost: (id) => set({ selectedPostId: id }),
|
||||
setSelectedMedia: (id) => set({ selectedMediaId: id }),
|
||||
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
type TaskProgress,
|
||||
type EditorMode,
|
||||
type ErrorDetails,
|
||||
type PanelOutputEntry,
|
||||
type Tab,
|
||||
type TabType,
|
||||
type TabState
|
||||
|
||||
@@ -3,3 +3,5 @@ export { getContrastColor } from './color';
|
||||
export { unescapeMacroSyntax } from './markdownEscape';
|
||||
export { groupPostsByStatus, type GroupedPosts, type PostStatus } from './postGrouping';
|
||||
export { loadTabsForProject, saveTabsForProject } from './tabPersistence';
|
||||
export { buildTagColorMap, loadTagColorMap } from './tagColors';
|
||||
export { BDS_EVENT_SCRIPTS_CHANGED, addWindowEventListener, dispatchWindowEvent, type BdsWindowEventName } from './windowEvents';
|
||||
|
||||
21
src/renderer/utils/tagColors.ts
Normal file
21
src/renderer/utils/tagColors.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
interface TagColorSource {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export function buildTagColorMap(tags: TagColorSource[] | null | undefined): Map<string, string> {
|
||||
const colorMap = new Map<string, string>();
|
||||
|
||||
for (const tag of tags ?? []) {
|
||||
if (tag.color) {
|
||||
colorMap.set(tag.name, tag.color);
|
||||
}
|
||||
}
|
||||
|
||||
return colorMap;
|
||||
}
|
||||
|
||||
export async function loadTagColorMap(): Promise<Map<string, string>> {
|
||||
const allTagsData = await window.electronAPI?.tags?.getAll?.();
|
||||
return buildTagColorMap(allTagsData as TagColorSource[] | null | undefined);
|
||||
}
|
||||
34
src/renderer/utils/windowEvents.ts
Normal file
34
src/renderer/utils/windowEvents.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const BDS_EVENT_SCRIPTS_CHANGED = 'bds:scripts-changed' as const;
|
||||
|
||||
export type BdsWindowEventName =
|
||||
| typeof BDS_EVENT_SCRIPTS_CHANGED
|
||||
| 'bds:site-validation-updated';
|
||||
|
||||
export function addWindowEventListener<TDetail = unknown>(
|
||||
eventName: BdsWindowEventName,
|
||||
handler: (event: CustomEvent<TDetail>) => void,
|
||||
): () => void {
|
||||
if (typeof window.addEventListener !== 'function' || typeof window.removeEventListener !== 'function') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const listener = (event: Event) => {
|
||||
handler(event as CustomEvent<TDetail>);
|
||||
};
|
||||
|
||||
window.addEventListener(eventName, listener as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener(eventName, listener as EventListener);
|
||||
};
|
||||
}
|
||||
|
||||
export function dispatchWindowEvent<TDetail = unknown>(
|
||||
eventName: BdsWindowEventName,
|
||||
detail?: TDetail,
|
||||
): boolean {
|
||||
if (typeof window.dispatchEvent !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.dispatchEvent(new CustomEvent<TDetail>(eventName, { detail }));
|
||||
}
|
||||
80
tests/engine/BlogmarkTransformService.integration.test.ts
Normal file
80
tests/engine/BlogmarkTransformService.integration.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ScriptData } from '../../src/main/shared/electronApi';
|
||||
import {
|
||||
BlogmarkTransformService,
|
||||
type BlogmarkTransformInput,
|
||||
type BlogmarkTransformScriptProvider,
|
||||
} from '../../src/main/engine/BlogmarkTransformService';
|
||||
|
||||
function createInput(overrides: Partial<BlogmarkTransformInput> = {}): BlogmarkTransformInput {
|
||||
return {
|
||||
post: {
|
||||
title: 'BoardGameGeek | Great Game',
|
||||
content: 'Read this: BoardGameGeek | Great Game',
|
||||
tags: ['inbox'],
|
||||
categories: ['blogmark'],
|
||||
},
|
||||
context: {
|
||||
source: 'blogmark',
|
||||
url: 'https://boardgamegeek.com/boardgame/12345',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTransformScript(overrides: Partial<ScriptData> = {}): ScriptData {
|
||||
return {
|
||||
id: 'bgg-link-transform',
|
||||
projectId: 'default',
|
||||
slug: 'bgg_link',
|
||||
title: 'BGG Link Transform',
|
||||
kind: 'transform',
|
||||
entrypoint: 'normalize_blogmark',
|
||||
enabled: true,
|
||||
version: 1,
|
||||
filePath: '/tmp/bgg_link.py',
|
||||
content: [
|
||||
'def normalize_blogmark(post, context=None):',
|
||||
' title = (post.get("title") or "").strip()',
|
||||
' if title and "BoardGameGeek" in title:',
|
||||
' clean_title = title.split(" | ")[0]',
|
||||
' post["title"] = clean_title',
|
||||
' post["content"] = (post.get("content") or "").replace(title, clean_title)',
|
||||
' post["categories"] = ["spielelog", "asides"]',
|
||||
' tags = post.get("tags") or []',
|
||||
' tags.append("spielen")',
|
||||
' post["tags"] = sorted({str(tag).strip().lower() for tag in tags if str(tag).strip()})',
|
||||
' if context and context.get("url"):',
|
||||
' toast(f"BGG transform applied: {post.get(' + "'title'" + ')} @ {context.get(' + "'url'" + ')}")',
|
||||
' else:',
|
||||
' toast(f"BGG transform applied: {post.get(' + "'title'" + ')}")',
|
||||
' return post',
|
||||
'',
|
||||
].join('\n'),
|
||||
createdAt: '2026-02-23T00:00:00.000Z',
|
||||
updatedAt: '2026-02-23T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BlogmarkTransformService (Pyodide integration)', () => {
|
||||
it('executes transform scripts with real pyodide runtime and applies post mutations', async () => {
|
||||
const provider: BlogmarkTransformScriptProvider = {
|
||||
getScripts: async () => [createTransformScript()],
|
||||
};
|
||||
|
||||
const service = new BlogmarkTransformService({ provider });
|
||||
|
||||
const result = await service.applyTransforms(createInput());
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.appliedScriptIds).toEqual(['bgg-link-transform']);
|
||||
expect(result.post.title).toBe('BoardGameGeek');
|
||||
expect(result.post.content).toBe('Read this: BoardGameGeek');
|
||||
expect(result.post.categories).toEqual(['spielelog', 'asides']);
|
||||
expect(result.post.tags).toEqual(['inbox', 'spielen']);
|
||||
expect(result.toasts).toHaveLength(1);
|
||||
expect(result.toasts[0]).toContain('BGG transform applied: BoardGameGeek');
|
||||
expect(result.toasts[0]).toContain('boardgamegeek.com/boardgame/12345');
|
||||
}, 60000);
|
||||
});
|
||||
313
tests/engine/BlogmarkTransformService.test.ts
Normal file
313
tests/engine/BlogmarkTransformService.test.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ScriptData } from '../../src/main/shared/electronApi';
|
||||
import {
|
||||
BlogmarkTransformService,
|
||||
type BlogmarkTransformExecutor,
|
||||
type BlogmarkTransformInput,
|
||||
type BlogmarkTransformScriptProvider,
|
||||
} from '../../src/main/engine/BlogmarkTransformService';
|
||||
|
||||
function createScript(overrides: Partial<ScriptData>): ScriptData {
|
||||
const baseDate = '2026-02-23T00:00:00.000Z';
|
||||
return {
|
||||
id: 'script-default',
|
||||
projectId: 'default',
|
||||
slug: 'script_default',
|
||||
title: 'Default Script',
|
||||
kind: 'transform',
|
||||
entrypoint: 'transform',
|
||||
enabled: true,
|
||||
version: 1,
|
||||
filePath: '/tmp/default.py',
|
||||
content: 'def transform(payload):\n return payload',
|
||||
createdAt: baseDate,
|
||||
updatedAt: baseDate,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createInput(overrides: Partial<BlogmarkTransformInput> = {}): BlogmarkTransformInput {
|
||||
return {
|
||||
post: {
|
||||
title: 'Hello',
|
||||
content: '[Hello](https://example.com)',
|
||||
tags: ['inbox'],
|
||||
categories: ['blogmark'],
|
||||
},
|
||||
context: {
|
||||
source: 'blogmark',
|
||||
url: 'https://example.com',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BlogmarkTransformService', () => {
|
||||
it('applies enabled transform scripts sequentially in deterministic order', async () => {
|
||||
const scripts: ScriptData[] = [
|
||||
createScript({ id: 'b', slug: 'b', updatedAt: '2026-02-23T00:00:01.000Z' }),
|
||||
createScript({ id: 'a', slug: 'a', updatedAt: '2026-02-23T00:00:01.000Z' }),
|
||||
createScript({ id: 'c', slug: 'c', updatedAt: '2026-02-23T00:00:00.000Z' }),
|
||||
];
|
||||
|
||||
const executionOrder: string[] = [];
|
||||
const executor: BlogmarkTransformExecutor = {
|
||||
runTransform: vi.fn(async (script, input) => {
|
||||
executionOrder.push(script.id);
|
||||
return {
|
||||
post: {
|
||||
...input.post,
|
||||
title: `${input.post.title}:${script.id}`,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const provider: BlogmarkTransformScriptProvider = {
|
||||
getScripts: vi.fn(async () => scripts),
|
||||
};
|
||||
|
||||
const service = new BlogmarkTransformService({ executor, provider });
|
||||
|
||||
const result = await service.applyTransforms(createInput());
|
||||
|
||||
expect(executionOrder).toEqual(['c', 'a', 'b']);
|
||||
expect(result.post.title).toBe('Hello:c:a:b');
|
||||
expect(result.appliedScriptIds).toEqual(['c', 'a', 'b']);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.toasts).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips disabled and non-transform scripts', async () => {
|
||||
const scripts: ScriptData[] = [
|
||||
createScript({ id: 'transform-enabled', kind: 'transform', enabled: true }),
|
||||
createScript({ id: 'transform-disabled', kind: 'transform', enabled: false }),
|
||||
createScript({ id: 'macro-enabled', kind: 'macro', enabled: true }),
|
||||
createScript({ id: 'utility-enabled', kind: 'utility', enabled: true }),
|
||||
];
|
||||
|
||||
const executor: BlogmarkTransformExecutor = {
|
||||
runTransform: vi.fn(async (script, input) => ({
|
||||
post: {
|
||||
...input.post,
|
||||
title: `${input.post.title}:${script.id}`,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
const service = new BlogmarkTransformService({
|
||||
executor,
|
||||
provider: { getScripts: async () => scripts },
|
||||
});
|
||||
|
||||
const result = await service.applyTransforms(createInput());
|
||||
|
||||
expect(result.post.title).toBe('Hello:transform-enabled');
|
||||
expect(result.appliedScriptIds).toEqual(['transform-enabled']);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.toasts).toEqual([]);
|
||||
});
|
||||
|
||||
it('continues with next scripts when one transform fails', async () => {
|
||||
const scripts: ScriptData[] = [
|
||||
createScript({ id: 'first', slug: 'first' }),
|
||||
createScript({ id: 'broken', slug: 'broken' }),
|
||||
createScript({ id: 'last', slug: 'last' }),
|
||||
];
|
||||
|
||||
const executor: BlogmarkTransformExecutor = {
|
||||
runTransform: vi.fn(async (script, input) => {
|
||||
if (script.id === 'broken') {
|
||||
throw new Error('boom');
|
||||
}
|
||||
|
||||
return {
|
||||
post: {
|
||||
...input.post,
|
||||
title: `${input.post.title}:${script.id}`,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new BlogmarkTransformService({
|
||||
executor,
|
||||
provider: { getScripts: async () => scripts },
|
||||
});
|
||||
|
||||
const result = await service.applyTransforms(createInput());
|
||||
|
||||
expect(result.post.title).toBe('Hello:first:last');
|
||||
expect(result.appliedScriptIds).toEqual(['first', 'last']);
|
||||
expect(result.errors).toEqual([
|
||||
{
|
||||
scriptId: 'broken',
|
||||
scriptSlug: 'broken',
|
||||
message: 'boom',
|
||||
},
|
||||
]);
|
||||
expect(result.toasts).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects invalid transform result and keeps latest valid post', async () => {
|
||||
const scripts: ScriptData[] = [
|
||||
createScript({ id: 'valid-1', slug: 'valid-1' }),
|
||||
createScript({ id: 'invalid', slug: 'invalid' }),
|
||||
createScript({ id: 'valid-2', slug: 'valid-2' }),
|
||||
];
|
||||
|
||||
const executor: BlogmarkTransformExecutor = {
|
||||
runTransform: vi.fn(async (script, input) => {
|
||||
if (script.id === 'invalid') {
|
||||
return {
|
||||
post: {
|
||||
title: '',
|
||||
content: '',
|
||||
tags: [],
|
||||
categories: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${input.post.title}:${script.id}`,
|
||||
content: input.post.content,
|
||||
tags: input.post.tags,
|
||||
categories: input.post.categories,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new BlogmarkTransformService({
|
||||
executor,
|
||||
provider: { getScripts: async () => scripts },
|
||||
});
|
||||
|
||||
const result = await service.applyTransforms(createInput());
|
||||
|
||||
expect(result.post.title).toBe('Hello:valid-1:valid-2');
|
||||
expect(result.appliedScriptIds).toEqual(['valid-1', 'valid-2']);
|
||||
expect(result.errors).toEqual([
|
||||
{
|
||||
scriptId: 'invalid',
|
||||
scriptSlug: 'invalid',
|
||||
message: 'Transform output validation failed',
|
||||
},
|
||||
]);
|
||||
expect(result.toasts).toEqual([]);
|
||||
});
|
||||
|
||||
it('allows transforms to set multiple categories and add tags', async () => {
|
||||
const scripts: ScriptData[] = [
|
||||
createScript({ id: 'taxonomy', slug: 'taxonomy' }),
|
||||
];
|
||||
|
||||
const executor: BlogmarkTransformExecutor = {
|
||||
runTransform: vi.fn(async (_script, input) => ({
|
||||
output: {
|
||||
...input.post,
|
||||
tags: [...input.post.tags, 'reading-list', 'python'],
|
||||
categories: ['link', 'reference'],
|
||||
},
|
||||
toasts: [],
|
||||
})),
|
||||
};
|
||||
|
||||
const service = new BlogmarkTransformService({
|
||||
executor,
|
||||
provider: { getScripts: async () => scripts },
|
||||
});
|
||||
|
||||
const result = await service.applyTransforms(createInput());
|
||||
|
||||
expect(result.post.tags).toEqual(['inbox', 'reading-list', 'python']);
|
||||
expect(result.post.categories).toEqual(['link', 'reference']);
|
||||
});
|
||||
|
||||
it('collects toast intents emitted by transform scripts', async () => {
|
||||
const scripts: ScriptData[] = [
|
||||
createScript({ id: 'alpha', slug: 'alpha' }),
|
||||
createScript({ id: 'beta', slug: 'beta' }),
|
||||
];
|
||||
|
||||
const executor: BlogmarkTransformExecutor = {
|
||||
runTransform: vi.fn(async (_script, input) => ({
|
||||
post: input.post,
|
||||
toasts: ['Step finished'],
|
||||
})),
|
||||
};
|
||||
|
||||
const service = new BlogmarkTransformService({
|
||||
executor,
|
||||
provider: { getScripts: async () => scripts },
|
||||
});
|
||||
|
||||
const result = await service.applyTransforms(createInput());
|
||||
|
||||
expect(result.toasts).toEqual(['Step finished', 'Step finished']);
|
||||
});
|
||||
|
||||
it('invokes python transform entrypoint with post payload shape', async () => {
|
||||
const globalsStore = new Map<string, unknown>();
|
||||
const runPythonAsync = vi.fn(async (code: string) => {
|
||||
if (code.includes('json.dumps(_result)')) {
|
||||
const payload = JSON.parse(String(globalsStore.get('__bds_transform_payload_json')));
|
||||
|
||||
if (code.includes('_transform_fn(_payload)')) {
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
...payload.post,
|
||||
title: 'Normalized',
|
||||
categories: ['spielelog', 'asides'],
|
||||
tags: ['inbox', 'spielen'],
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
vi.doMock('pyodide', () => ({
|
||||
loadPyodide: vi.fn(async () => ({
|
||||
globals: {
|
||||
set: (key: string, value: unknown) => {
|
||||
globalsStore.set(key, value);
|
||||
},
|
||||
},
|
||||
runPythonAsync,
|
||||
})),
|
||||
}));
|
||||
|
||||
const provider: BlogmarkTransformScriptProvider = {
|
||||
getScripts: vi.fn(async () => [
|
||||
createScript({
|
||||
id: 'pyodide-transform',
|
||||
slug: 'pyodide-transform',
|
||||
title: 'Pyodide Transform',
|
||||
kind: 'transform',
|
||||
entrypoint: 'normalize_blogmark',
|
||||
content: 'def normalize_blogmark(post):\n return post',
|
||||
}),
|
||||
]),
|
||||
};
|
||||
|
||||
const service = new BlogmarkTransformService({ provider });
|
||||
|
||||
const result = await service.applyTransforms(createInput());
|
||||
|
||||
const transformInvocationCode = runPythonAsync.mock.calls
|
||||
.map((call) => call[0])
|
||||
.find((code) => typeof code === 'string' && String(code).includes('json.dumps(_result)'));
|
||||
|
||||
expect(result.post.title).toBe('Normalized');
|
||||
expect(result.post.categories).toEqual(['spielelog', 'asides']);
|
||||
expect(result.post.tags).toEqual(['inbox', 'spielen']);
|
||||
expect(transformInvocationCode).toBeDefined();
|
||||
expect(String(transformInvocationCode)).not.toContain('import inspect');
|
||||
expect(String(transformInvocationCode)).toContain('\ntry:\n');
|
||||
expect(String(transformInvocationCode)).toContain('\nexcept TypeError:\n');
|
||||
expect(String(transformInvocationCode)).not.toContain('\n try:\n');
|
||||
expect(String(transformInvocationCode)).not.toContain('\n except TypeError:\n');
|
||||
});
|
||||
});
|
||||
178
tests/engine/ScriptEngine.test.ts
Normal file
178
tests/engine/ScriptEngine.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import * as fs from 'fs/promises';
|
||||
import { ScriptEngine } from '../../src/main/engine/ScriptEngine';
|
||||
|
||||
const mockScripts = new Map<string, any>();
|
||||
const mockFiles = new Map<string, string>();
|
||||
|
||||
function createSelectChain() {
|
||||
return {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
all: vi.fn().mockImplementation(() => Promise.resolve(Array.from(mockScripts.values()))),
|
||||
get: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
|
||||
};
|
||||
}
|
||||
|
||||
function createDrizzleMock() {
|
||||
return {
|
||||
select: vi.fn(() => createSelectChain()),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn((data: any) => {
|
||||
mockScripts.set(data.id, data);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
})),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn((updates: any) => ({
|
||||
where: vi.fn(async () => {
|
||||
for (const [scriptId, existing] of mockScripts.entries()) {
|
||||
mockScripts.set(scriptId, { ...existing, ...updates });
|
||||
}
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
delete: vi.fn(() => ({
|
||||
where: vi.fn(async () => {
|
||||
mockScripts.clear();
|
||||
return Promise.resolve();
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const mockLocalDb = createDrizzleMock();
|
||||
|
||||
vi.mock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({
|
||||
getLocal: vi.fn(() => mockLocalDb),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
v4: vi.fn(() => 'mock-script-id'),
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(async (filePath: string) => {
|
||||
const value = (globalThis as any).__mockScriptFiles.get(filePath);
|
||||
if (typeof value !== 'string') {
|
||||
const error = new Error('ENOENT');
|
||||
(error as any).code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
writeFile: vi.fn(async (filePath: string, content: string) => {
|
||||
(globalThis as any).__mockScriptFiles.set(filePath, content);
|
||||
}),
|
||||
unlink: vi.fn(async (filePath: string) => {
|
||||
(globalThis as any).__mockScriptFiles.delete(filePath);
|
||||
}),
|
||||
rename: vi.fn(async (fromPath: string, toPath: string) => {
|
||||
const files = (globalThis as any).__mockScriptFiles;
|
||||
const content = files.get(fromPath);
|
||||
files.delete(fromPath);
|
||||
files.set(toPath, content);
|
||||
}),
|
||||
mkdir: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
describe('ScriptEngine', () => {
|
||||
let scriptEngine: ScriptEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockScripts.clear();
|
||||
mockFiles.clear();
|
||||
(globalThis as any).__mockScriptFiles = mockFiles;
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain());
|
||||
|
||||
scriptEngine = new ScriptEngine();
|
||||
scriptEngine.setProjectContext('default', '/mock/userData/projects/default');
|
||||
});
|
||||
|
||||
it('creates script metadata and source file', async () => {
|
||||
const created = await scriptEngine.createScript({
|
||||
title: 'Render Hero',
|
||||
kind: 'macro',
|
||||
content: 'def render(context):\n return {"html": "<h1>Hi</h1>"}',
|
||||
});
|
||||
|
||||
expect(created.slug).toBe('render_hero');
|
||||
expect(mockScripts.has(created.id)).toBe(true);
|
||||
const persistedFile = mockFiles.get('/mock/userData/projects/default/scripts/render_hero.py') || '';
|
||||
expect(persistedFile).toContain('---');
|
||||
expect(persistedFile).toContain('title: "Render Hero"');
|
||||
expect(persistedFile).toContain('kind: "macro"');
|
||||
expect(persistedFile).toContain('entrypoint: "render"');
|
||||
expect(persistedFile).toContain('def render');
|
||||
expect(created.content).toBe('def render(context):\n return {"html": "<h1>Hi</h1>"}');
|
||||
});
|
||||
|
||||
it('updates script metadata and file content', async () => {
|
||||
const created = await scriptEngine.createScript({
|
||||
title: 'Render Hero',
|
||||
kind: 'macro',
|
||||
content: 'def render(context):\n return {"html": "<h1>Hi</h1>"}',
|
||||
});
|
||||
|
||||
const updated = await scriptEngine.updateScript(created.id, {
|
||||
title: 'Render Hero Banner',
|
||||
content: 'def render(context):\n return {"html": "<h1>Banner</h1>"}',
|
||||
});
|
||||
|
||||
expect(updated?.slug).toBe('render_hero_banner');
|
||||
expect(mockFiles.get('/mock/userData/projects/default/scripts/render_hero_banner.py')).toContain('Banner');
|
||||
});
|
||||
|
||||
it('appends underscore numeric suffix for duplicate slugs', async () => {
|
||||
const first = await scriptEngine.createScript({
|
||||
title: 'Render Hero',
|
||||
kind: 'macro',
|
||||
content: 'def render(context):\n return {"html": "<h1>Hi</h1>"}',
|
||||
});
|
||||
|
||||
vi.mocked((await import('uuid')).v4)
|
||||
.mockReturnValueOnce('mock-script-id-2');
|
||||
|
||||
const second = await scriptEngine.createScript({
|
||||
title: 'Render Hero',
|
||||
kind: 'macro',
|
||||
content: 'def render(context):\n return {"html": "<h1>Again</h1>"}',
|
||||
});
|
||||
|
||||
expect(first.slug).toBe('render_hero');
|
||||
expect(second.slug).toBe('render_hero_2');
|
||||
expect(mockFiles.get('/mock/userData/projects/default/scripts/render_hero_2.py')).toContain('Again');
|
||||
});
|
||||
|
||||
it('deletes script metadata and source file', async () => {
|
||||
const created = await scriptEngine.createScript({
|
||||
title: 'Delete Me',
|
||||
kind: 'utility',
|
||||
content: 'print("bye")',
|
||||
});
|
||||
|
||||
const deleted = await scriptEngine.deleteScript(created.id);
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
expect(mockScripts.has(created.id)).toBe(false);
|
||||
expect(mockFiles.has('/mock/userData/projects/default/scripts/delete_me.py')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps script content clean when file contains metadata docstring frontmatter', async () => {
|
||||
const created = await scriptEngine.createScript({
|
||||
title: 'Metadata Test',
|
||||
kind: 'utility',
|
||||
content: 'print("hello")',
|
||||
});
|
||||
|
||||
const loaded = await scriptEngine.getScript(created.id);
|
||||
|
||||
expect(loaded?.content).toBe('print("hello")');
|
||||
expect(loaded?.title).toBe('Metadata Test');
|
||||
expect(loaded?.entrypoint).toBe('render');
|
||||
});
|
||||
});
|
||||
@@ -740,6 +740,17 @@ describe('main bootstrap preview behavior', () => {
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.doMock('../../src/main/engine/BlogmarkTransformService', () => ({
|
||||
getBlogmarkTransformService: vi.fn(() => ({
|
||||
applyTransforms: vi.fn(async (input: { post: { title: string; content: string; categories: string[] } }) => ({
|
||||
post: input.post,
|
||||
appliedScriptIds: [],
|
||||
errors: [],
|
||||
toasts: [],
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.doMock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({
|
||||
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -802,7 +813,14 @@ describe('main bootstrap preview behavior', () => {
|
||||
|
||||
expect(windows[0]?.webContents.send).toHaveBeenCalledWith(
|
||||
'blogmark:created',
|
||||
expect.objectContaining({ id: 'new-post-id' }),
|
||||
expect.objectContaining({
|
||||
post: expect.objectContaining({ id: 'new-post-id' }),
|
||||
transform: expect.objectContaining({
|
||||
appliedScriptIds: [],
|
||||
errors: [],
|
||||
toasts: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -903,6 +921,17 @@ describe('main bootstrap preview behavior', () => {
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.doMock('../../src/main/engine/BlogmarkTransformService', () => ({
|
||||
getBlogmarkTransformService: vi.fn(() => ({
|
||||
applyTransforms: vi.fn(async (input: { post: { title: string; content: string; categories: string[] } }) => ({
|
||||
post: input.post,
|
||||
appliedScriptIds: [],
|
||||
errors: [],
|
||||
toasts: [],
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.doMock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({
|
||||
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -971,7 +1000,14 @@ describe('main bootstrap preview behavior', () => {
|
||||
|
||||
expect(windows[0]?.webContents.send).toHaveBeenCalledWith(
|
||||
'blogmark:created',
|
||||
expect.objectContaining({ id: 'queued-post-id' }),
|
||||
expect.objectContaining({
|
||||
post: expect.objectContaining({ id: 'queued-post-id' }),
|
||||
transform: expect.objectContaining({
|
||||
appliedScriptIds: [],
|
||||
errors: [],
|
||||
toasts: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -158,6 +158,16 @@ const mockPostMediaEngine = {
|
||||
rebuildFromSidecars: vi.fn(),
|
||||
};
|
||||
|
||||
const mockScriptEngine = {
|
||||
on: vi.fn(),
|
||||
setProjectContext: vi.fn(),
|
||||
createScript: vi.fn(),
|
||||
updateScript: vi.fn(),
|
||||
deleteScript: vi.fn(),
|
||||
getScript: vi.fn(),
|
||||
getAllScripts: vi.fn(),
|
||||
};
|
||||
|
||||
const mockGitEngine = {
|
||||
checkAvailability: vi.fn(),
|
||||
getHeadCommit: vi.fn(),
|
||||
@@ -263,6 +273,10 @@ vi.mock('../../src/main/engine/PostMediaEngine', () => ({
|
||||
getPostMediaEngine: vi.fn(() => mockPostMediaEngine),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/ScriptEngine', () => ({
|
||||
getScriptEngine: vi.fn(() => mockScriptEngine),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/GitEngine', () => ({
|
||||
getGitEngine: vi.fn(() => mockGitEngine),
|
||||
}));
|
||||
@@ -2593,6 +2607,113 @@ describe('IPC Handlers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Script Handlers ============
|
||||
describe('Script Handlers', () => {
|
||||
describe('scripts:create', () => {
|
||||
it('should call ScriptEngine.createScript with payload', async () => {
|
||||
const payload = {
|
||||
title: 'Render Hero',
|
||||
kind: 'macro',
|
||||
content: 'def render(context):\n return {"html":"<h1>Hi</h1>"}',
|
||||
};
|
||||
const expected = {
|
||||
id: 'script-1',
|
||||
projectId: 'default',
|
||||
...payload,
|
||||
slug: 'render-hero',
|
||||
entrypoint: 'render',
|
||||
enabled: true,
|
||||
version: 1,
|
||||
filePath: '/mock/userData/projects/default/scripts/render-hero.py',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockScriptEngine.createScript.mockResolvedValue(expected);
|
||||
|
||||
const result = await invokeHandler('scripts:create', payload);
|
||||
|
||||
expect(mockScriptEngine.createScript).toHaveBeenCalledWith(payload);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scripts:update', () => {
|
||||
it('should call ScriptEngine.updateScript with id and updates', async () => {
|
||||
const updates = { title: 'Updated Script', content: 'print("updated")' };
|
||||
const expected = {
|
||||
id: 'script-1',
|
||||
projectId: 'default',
|
||||
slug: 'updated-script',
|
||||
title: 'Updated Script',
|
||||
kind: 'utility',
|
||||
entrypoint: 'render',
|
||||
enabled: true,
|
||||
version: 2,
|
||||
filePath: '/mock/userData/projects/default/scripts/updated-script.py',
|
||||
content: 'print("updated")',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockScriptEngine.updateScript.mockResolvedValue(expected);
|
||||
|
||||
const result = await invokeHandler('scripts:update', 'script-1', updates);
|
||||
|
||||
expect(mockScriptEngine.updateScript).toHaveBeenCalledWith('script-1', updates);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scripts:delete', () => {
|
||||
it('should call ScriptEngine.deleteScript with id', async () => {
|
||||
mockScriptEngine.deleteScript.mockResolvedValue(true);
|
||||
|
||||
const result = await invokeHandler('scripts:delete', 'script-1');
|
||||
|
||||
expect(mockScriptEngine.deleteScript).toHaveBeenCalledWith('script-1');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scripts:get', () => {
|
||||
it('should call ScriptEngine.getScript with id', async () => {
|
||||
const expected = {
|
||||
id: 'script-1',
|
||||
projectId: 'default',
|
||||
slug: 'render-hero',
|
||||
title: 'Render Hero',
|
||||
kind: 'macro',
|
||||
entrypoint: 'render',
|
||||
enabled: true,
|
||||
version: 1,
|
||||
filePath: '/mock/userData/projects/default/scripts/render-hero.py',
|
||||
content: 'def render(context):\n return {"html":"<h1>Hi</h1>"}',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
mockScriptEngine.getScript.mockResolvedValue(expected);
|
||||
|
||||
const result = await invokeHandler('scripts:get', 'script-1');
|
||||
|
||||
expect(mockScriptEngine.getScript).toHaveBeenCalledWith('script-1');
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scripts:getAll', () => {
|
||||
it('should call ScriptEngine.getAllScripts', async () => {
|
||||
const expected = [{ id: 'script-1' }, { id: 'script-2' }];
|
||||
mockScriptEngine.getAllScripts.mockResolvedValue(expected);
|
||||
|
||||
const result = await invokeHandler('scripts:getAll');
|
||||
|
||||
expect(mockScriptEngine.getAllScripts).toHaveBeenCalled();
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Error Handling ============
|
||||
describe('Error Handling', () => {
|
||||
it('should silently handle "Database is closing" errors', async () => {
|
||||
|
||||
242
tests/renderer/components/DocumentationView.test.tsx
Normal file
242
tests/renderer/components/DocumentationView.test.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { DocumentationView } from '../../../src/renderer/components/DocumentationView/DocumentationView';
|
||||
import { I18nProvider } from '../../../src/renderer/i18n';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
const openSpy = vi.fn();
|
||||
|
||||
Object.defineProperty(window, 'open', {
|
||||
value: openSpy,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe('DocumentationView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
...window.location,
|
||||
hash: '',
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
useAppStore.setState({
|
||||
picoTheme: 'slate',
|
||||
} as never);
|
||||
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders fenced code blocks with language-specific classes for syntax highlighting', async () => {
|
||||
const { container } = render(
|
||||
<I18nProvider>
|
||||
<DocumentationView />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
await screen.findByText('bDS User Guide');
|
||||
const pythonBlock = container.querySelector('pre > code.language-python');
|
||||
expect(pythonBlock).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders heading anchors and keeps in-document toc links in-page', async () => {
|
||||
const { container } = render(
|
||||
<I18nProvider>
|
||||
<DocumentationView />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
const targetHeading = await screen.findByRole('heading', { level: 2, name: 'Who this guide is for' });
|
||||
expect(targetHeading).toHaveAttribute('id', 'who-this-guide-is-for');
|
||||
|
||||
const tocLink = container.querySelector('a[href="#who-this-guide-is-for"]');
|
||||
expect(tocLink).not.toBeNull();
|
||||
|
||||
const scrollContainer = container.querySelector('.documentation-scroll');
|
||||
expect(scrollContainer).not.toBeNull();
|
||||
|
||||
Object.defineProperty(scrollContainer as HTMLElement, 'scrollTop', {
|
||||
value: 10,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(scrollContainer as HTMLElement, 'getBoundingClientRect', {
|
||||
value: () => ({ top: 100, left: 0, width: 800, height: 600, right: 800, bottom: 700, x: 0, y: 100, toJSON: () => ({}) }),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(targetHeading, 'getBoundingClientRect', {
|
||||
value: () => ({ top: 340, left: 0, width: 100, height: 20, right: 100, bottom: 360, x: 0, y: 340, toJSON: () => ({}) }),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
fireEvent.click(tocLink as HTMLAnchorElement);
|
||||
|
||||
expect((scrollContainer as HTMLElement).scrollTop).toBe(238);
|
||||
expect(window.location.hash).toBe('#who-this-guide-is-for');
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps scroll position unchanged for unresolved in-document links', async () => {
|
||||
const { container } = render(
|
||||
<I18nProvider>
|
||||
<DocumentationView />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
await screen.findByRole('heading', { level: 2, name: 'Who this guide is for' });
|
||||
const tocLink = container.querySelector('a[href="#does-not-exist"]');
|
||||
if (!tocLink) {
|
||||
const article = container.querySelector('.documentation-article') as HTMLElement;
|
||||
const missingAnchor = document.createElement('a');
|
||||
missingAnchor.setAttribute('href', '#does-not-exist');
|
||||
missingAnchor.textContent = 'Missing';
|
||||
article.appendChild(missingAnchor);
|
||||
}
|
||||
|
||||
const unresolvedLink = container.querySelector('a[href="#does-not-exist"]');
|
||||
expect(unresolvedLink).not.toBeNull();
|
||||
|
||||
const scrollContainer = container.querySelector('.documentation-scroll') as HTMLElement;
|
||||
expect(scrollContainer).not.toBeNull();
|
||||
|
||||
Object.defineProperty(scrollContainer, 'scrollTop', {
|
||||
value: 222,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
expect(() => fireEvent.click(unresolvedLink as HTMLAnchorElement)).not.toThrow();
|
||||
expect(scrollContainer.scrollTop).toBe(222);
|
||||
});
|
||||
|
||||
it('handles toc clicks when clicking nested content inside the anchor', async () => {
|
||||
const { container } = render(
|
||||
<I18nProvider>
|
||||
<DocumentationView />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
const targetHeading = await screen.findByRole('heading', { level: 2, name: 'Who this guide is for' });
|
||||
const tocLink = container.querySelector('a[href="#who-this-guide-is-for"]') as HTMLAnchorElement;
|
||||
expect(tocLink).not.toBeNull();
|
||||
|
||||
const nested = document.createElement('span');
|
||||
nested.textContent = 'Who this guide is for';
|
||||
tocLink.appendChild(nested);
|
||||
|
||||
const scrollContainer = container.querySelector('.documentation-scroll') as HTMLElement;
|
||||
Object.defineProperty(scrollContainer, 'scrollTop', {
|
||||
value: 5,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(scrollContainer, 'getBoundingClientRect', {
|
||||
value: () => ({ top: 100, left: 0, width: 800, height: 600, right: 800, bottom: 700, x: 0, y: 100, toJSON: () => ({}) }),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(targetHeading, 'getBoundingClientRect', {
|
||||
value: () => ({ top: 300, left: 0, width: 100, height: 20, right: 100, bottom: 320, x: 0, y: 300, toJSON: () => ({}) }),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
fireEvent.click(nested);
|
||||
expect(scrollContainer.scrollTop).toBe(193);
|
||||
});
|
||||
|
||||
it('resolves hash target within the clicked documentation instance', async () => {
|
||||
const { container } = render(
|
||||
<I18nProvider>
|
||||
<DocumentationView />
|
||||
<DocumentationView />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
await screen.findAllByRole('heading', { level: 2, name: 'Using macros' });
|
||||
|
||||
const wrappers = container.querySelectorAll('.documentation-view');
|
||||
expect(wrappers.length).toBe(2);
|
||||
|
||||
const firstScroll = wrappers[0].querySelector('.documentation-scroll') as HTMLElement;
|
||||
const secondScroll = wrappers[1].querySelector('.documentation-scroll') as HTMLElement;
|
||||
const secondLink = wrappers[1].querySelector('a[href="#using-macros"]') as HTMLAnchorElement;
|
||||
const secondArticle = wrappers[1].querySelector('.documentation-article') as HTMLElement;
|
||||
const secondHeading = Array.from(secondArticle.querySelectorAll<HTMLElement>('[id]')).find((node) => node.id === 'using-macros') as HTMLElement;
|
||||
|
||||
Object.defineProperty(firstScroll, 'scrollTop', { value: 7, writable: true });
|
||||
Object.defineProperty(secondScroll, 'scrollTop', { value: 11, writable: true });
|
||||
|
||||
Object.defineProperty(secondScroll, 'getBoundingClientRect', {
|
||||
value: () => ({ top: 120, left: 0, width: 700, height: 500, right: 700, bottom: 620, x: 0, y: 120, toJSON: () => ({}) }),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(secondHeading, 'getBoundingClientRect', {
|
||||
value: () => ({ top: 420, left: 0, width: 300, height: 30, right: 300, bottom: 450, x: 0, y: 420, toJSON: () => ({}) }),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
fireEvent.click(secondLink);
|
||||
|
||||
expect(firstScroll.scrollTop).toBe(7);
|
||||
expect(secondScroll.scrollTop).toBe(299);
|
||||
});
|
||||
|
||||
it('falls back to heading text slug when heading id does not match hash', async () => {
|
||||
const { container } = render(
|
||||
<I18nProvider>
|
||||
<DocumentationView />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
const targetHeading = await screen.findByRole('heading', { level: 2, name: 'Using scripting (early access)' });
|
||||
targetHeading.id = 'unexpected-id';
|
||||
|
||||
const tocLink = container.querySelector('a[href="#using-scripting-early-access"]') as HTMLAnchorElement;
|
||||
expect(tocLink).not.toBeNull();
|
||||
|
||||
const scrollContainer = container.querySelector('.documentation-scroll') as HTMLElement;
|
||||
Object.defineProperty(scrollContainer, 'scrollTop', {
|
||||
value: 50,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(scrollContainer, 'getBoundingClientRect', {
|
||||
value: () => ({ top: 100, left: 0, width: 800, height: 600, right: 800, bottom: 700, x: 0, y: 100, toJSON: () => ({}) }),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(targetHeading, 'getBoundingClientRect', {
|
||||
value: () => ({ top: 450, left: 0, width: 200, height: 24, right: 200, bottom: 474, x: 0, y: 450, toJSON: () => ({}) }),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
fireEvent.click(tocLink);
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(388);
|
||||
expect(targetHeading.id).toBe('using-scripting-early-access');
|
||||
});
|
||||
|
||||
it('shows a copy button for fenced code blocks and copies the block content', async () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<DocumentationView />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
const copyButtons = await screen.findAllByRole('button', { name: /copy code/i });
|
||||
expect(copyButtons.length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(copyButtons[0]);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalled();
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -235,6 +235,58 @@ describe('Panel', () => {
|
||||
expect(screen.getByRole('tab', { name: 'Output' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('renders output entries when output tab is active', () => {
|
||||
useAppStore.setState({
|
||||
panelActiveTab: 'output',
|
||||
panelOutputEntries: [
|
||||
{
|
||||
id: 'output-1',
|
||||
message: 'hello from script',
|
||||
createdAt: '2026-02-22T00:00:00.000Z',
|
||||
kind: 'stdout',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<Panel />);
|
||||
|
||||
expect(screen.getByText('hello from script')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies output entries from output tab with copy button', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { clipboard: { writeText } },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
useAppStore.setState({
|
||||
panelActiveTab: 'output',
|
||||
panelOutputEntries: [
|
||||
{
|
||||
id: 'output-1',
|
||||
message: 'pyodide.asm.js missing',
|
||||
createdAt: '2026-02-22T00:00:00.000Z',
|
||||
kind: 'error',
|
||||
},
|
||||
{
|
||||
id: 'output-2',
|
||||
message: 'second line',
|
||||
createdAt: '2026-02-22T00:00:01.000Z',
|
||||
kind: 'stdout',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<Panel />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy Output' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(writeText).toHaveBeenCalledWith('pyodide.asm.js missing\n\nsecond line');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders grouped tasks as expandable parent rows with child task names in tasks tab', async () => {
|
||||
useAppStore.setState({
|
||||
tasks: [
|
||||
|
||||
23
tests/renderer/components/ScriptsView.styles.test.ts
Normal file
23
tests/renderer/components/ScriptsView.styles.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
describe('ScriptsView styles', () => {
|
||||
const cssPath = path.resolve(
|
||||
__dirname,
|
||||
'../../../src/renderer/components/ScriptsView/ScriptsView.css'
|
||||
);
|
||||
|
||||
it('uses full editor area layout for the scripts container', () => {
|
||||
const css = fs.readFileSync(cssPath, 'utf8');
|
||||
|
||||
expect(css).toMatch(/\.scripts-view\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s);
|
||||
});
|
||||
|
||||
it('keeps editor and textarea stretched to fill available space', () => {
|
||||
const css = fs.readFileSync(cssPath, 'utf8');
|
||||
|
||||
expect(css).toMatch(/\.scripts-editor\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s);
|
||||
expect(css).toMatch(/\.scripts-monaco\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s);
|
||||
});
|
||||
});
|
||||
360
tests/renderer/components/ScriptsView.test.tsx
Normal file
360
tests/renderer/components/ScriptsView.test.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ScriptsView } from '../../../src/renderer/components/ScriptsView/ScriptsView';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
const executeMock = vi.fn();
|
||||
const inspectEntrypointsMock = vi.fn();
|
||||
const syntaxCheckMock = vi.fn();
|
||||
const monacoPropsSpy = vi.fn();
|
||||
const setModelMarkersMock = vi.fn();
|
||||
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
default: (props: {
|
||||
value?: string;
|
||||
onChange?: (value?: string) => void;
|
||||
language?: string;
|
||||
onMount?: (editor: unknown, monaco: unknown) => void;
|
||||
}) => {
|
||||
monacoPropsSpy(props);
|
||||
props.onMount?.(
|
||||
{
|
||||
getModel: () => ({ uri: 'inmemory://script.py' }),
|
||||
},
|
||||
{
|
||||
editor: {
|
||||
setModelMarkers: setModelMarkersMock,
|
||||
},
|
||||
MarkerSeverity: {
|
||||
Error: 8,
|
||||
},
|
||||
},
|
||||
);
|
||||
return (
|
||||
<textarea
|
||||
aria-label="Script Content"
|
||||
value={props.value || ''}
|
||||
onChange={(event) => props.onChange?.(event.target.value)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/renderer/python/runtimeManagerInstance', () => ({
|
||||
getPythonRuntimeManager: () => ({
|
||||
execute: executeMock,
|
||||
inspectEntrypoints: inspectEntrypointsMock,
|
||||
syntaxCheck: syntaxCheckMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ScriptsView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
executeMock.mockResolvedValue({ result: '2', stdout: 'hello\n' });
|
||||
inspectEntrypointsMock.mockResolvedValue(['render', 'helper']);
|
||||
syntaxCheckMock.mockResolvedValue({ errors: [] });
|
||||
|
||||
(window as any).electronAPI = {
|
||||
...(window as any).electronAPI,
|
||||
scripts: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn().mockResolvedValue({
|
||||
id: 'script-1',
|
||||
projectId: 'default',
|
||||
slug: 'hello-script',
|
||||
title: 'Hello Script',
|
||||
kind: 'utility',
|
||||
entrypoint: 'render',
|
||||
enabled: true,
|
||||
version: 1,
|
||||
filePath: '/tmp/hello-script.py',
|
||||
content: 'print("hello")',
|
||||
createdAt: '2026-02-22T00:00:00.000Z',
|
||||
updatedAt: '2026-02-22T00:00:00.000Z',
|
||||
}),
|
||||
getAll: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
useAppStore.setState({
|
||||
panelVisible: false,
|
||||
panelActiveTab: 'tasks',
|
||||
panelOutputEntries: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('loads scripts and allows editing content', async () => {
|
||||
render(<ScriptsView scriptId="script-1" />);
|
||||
|
||||
const textarea = screen.getByLabelText('Script Content') as HTMLTextAreaElement;
|
||||
await vi.waitFor(() => {
|
||||
expect(textarea.value).toContain('print("hello")');
|
||||
});
|
||||
|
||||
fireEvent.change(textarea, { target: { value: 'print("updated")' } });
|
||||
expect(textarea.value).toContain('updated');
|
||||
|
||||
expect(monacoPropsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
language: 'python',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows metadata fields and footer timestamps', async () => {
|
||||
render(<ScriptsView scriptId="script-1" />);
|
||||
|
||||
const titleInput = await screen.findByLabelText('Title') as HTMLInputElement;
|
||||
const slugInput = screen.getByLabelText('Slug') as HTMLInputElement;
|
||||
const kindSelect = screen.getByLabelText('Kind') as HTMLSelectElement;
|
||||
const entrypointInput = screen.getByLabelText('Entrypoint') as HTMLInputElement;
|
||||
const enabledInput = screen.getByLabelText('Enabled') as HTMLInputElement;
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(titleInput.value).toBe('Hello Script');
|
||||
expect(slugInput.value).toBe('hello_script');
|
||||
});
|
||||
expect(kindSelect.value).toBe('utility');
|
||||
expect(entrypointInput.value).toBe('render');
|
||||
expect(enabledInput.checked).toBe(true);
|
||||
|
||||
expect(screen.getByText(/Created:/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Updated:/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads available entrypoints from script functions', async () => {
|
||||
render(<ScriptsView scriptId="script-1" />);
|
||||
|
||||
const entrypointSelect = await screen.findByLabelText('Entrypoint') as HTMLSelectElement;
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(inspectEntrypointsMock).toHaveBeenCalledWith('print("hello")', {
|
||||
cacheKey: expect.stringMatching(/^script-1:1:/),
|
||||
});
|
||||
});
|
||||
|
||||
expect(Array.from(entrypointSelect.options).map((option) => option.value)).toEqual(['main', 'render', 'helper']);
|
||||
expect(entrypointSelect.value).toBe('render');
|
||||
});
|
||||
|
||||
it('always exposes main entrypoint and falls back to it when no functions are discovered', async () => {
|
||||
inspectEntrypointsMock.mockResolvedValueOnce([]);
|
||||
|
||||
const updateMock = vi.fn().mockResolvedValue({
|
||||
id: 'script-1',
|
||||
projectId: 'default',
|
||||
slug: 'hello_script',
|
||||
title: 'Hello Script',
|
||||
kind: 'utility',
|
||||
entrypoint: 'main',
|
||||
enabled: true,
|
||||
version: 2,
|
||||
filePath: '/tmp/hello-script.py',
|
||||
content: 'print("hello")',
|
||||
createdAt: '2026-02-22T00:00:00.000Z',
|
||||
updatedAt: '2026-02-22T00:01:00.000Z',
|
||||
});
|
||||
(window as any).electronAPI.scripts.update = updateMock;
|
||||
|
||||
render(<ScriptsView scriptId="script-1" />);
|
||||
|
||||
const entrypointSelect = await screen.findByLabelText('Entrypoint') as HTMLSelectElement;
|
||||
await vi.waitFor(() => {
|
||||
expect(Array.from(entrypointSelect.options).map((option) => option.value)).toEqual(['main']);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Script' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(updateMock).toHaveBeenCalledWith(
|
||||
'script-1',
|
||||
expect.objectContaining({
|
||||
entrypoint: 'main',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('saves renamed script metadata and content', async () => {
|
||||
const updateMock = vi.fn().mockResolvedValue({
|
||||
id: 'script-1',
|
||||
projectId: 'default',
|
||||
slug: 'my_helper_function',
|
||||
title: 'My Helper Function',
|
||||
kind: 'utility',
|
||||
entrypoint: 'render',
|
||||
enabled: true,
|
||||
version: 2,
|
||||
filePath: '/tmp/hello-script.py',
|
||||
content: 'print("renamed")',
|
||||
createdAt: '2026-02-22T00:00:00.000Z',
|
||||
updatedAt: '2026-02-22T00:01:00.000Z',
|
||||
});
|
||||
|
||||
(window as any).electronAPI.scripts.update = updateMock;
|
||||
|
||||
render(<ScriptsView scriptId="script-1" />);
|
||||
|
||||
const titleInput = await screen.findByLabelText('Title');
|
||||
const kindSelect = screen.getByLabelText('Kind');
|
||||
const entrypointInput = screen.getByLabelText('Entrypoint');
|
||||
const enabledInput = screen.getByLabelText('Enabled');
|
||||
const textarea = screen.getByLabelText('Script Content');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect((titleInput as HTMLInputElement).value).toBe('Hello Script');
|
||||
});
|
||||
|
||||
fireEvent.change(kindSelect, { target: { value: 'transform' } });
|
||||
fireEvent.click(enabledInput);
|
||||
fireEvent.change(textarea, { target: { value: 'print("renamed")' } });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect((kindSelect as HTMLSelectElement).value).toBe('transform');
|
||||
expect((enabledInput as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Script' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(updateMock).toHaveBeenCalledWith(
|
||||
'script-1',
|
||||
expect.objectContaining({
|
||||
title: 'Hello Script',
|
||||
slug: 'hello_script',
|
||||
kind: 'transform',
|
||||
entrypoint: 'render',
|
||||
enabled: false,
|
||||
content: 'print("hello")',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('runs selected script and writes output into panel output log', async () => {
|
||||
render(<ScriptsView scriptId="script-1" />);
|
||||
|
||||
await screen.findByLabelText('Script Content');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run Script' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(executeMock).toHaveBeenCalledWith('print("hello")', {
|
||||
cacheKey: expect.stringMatching(/^script-1:1:/),
|
||||
entrypoint: 'render',
|
||||
});
|
||||
});
|
||||
|
||||
const state = useAppStore.getState();
|
||||
expect(state.panelVisible).toBe(false);
|
||||
expect(state.panelActiveTab).toBe('tasks');
|
||||
expect(state.panelOutputEntries.length).toBeGreaterThan(0);
|
||||
expect(state.panelOutputEntries[state.panelOutputEntries.length - 1].message).toContain('hello');
|
||||
});
|
||||
|
||||
it('checks syntax manually and writes editor markers for syntax errors', async () => {
|
||||
syntaxCheckMock.mockResolvedValueOnce({
|
||||
errors: [
|
||||
{
|
||||
line: 3,
|
||||
column: 5,
|
||||
endLine: 3,
|
||||
endColumn: 10,
|
||||
message: 'invalid syntax',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ScriptsView scriptId="script-1" />);
|
||||
|
||||
await screen.findByLabelText('Script Content');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check Syntax' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(syntaxCheckMock).toHaveBeenCalledWith('print("hello")', {
|
||||
cacheKey: expect.stringMatching(/^script-1:1:/),
|
||||
});
|
||||
expect(setModelMarkersMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'scripts-python-syntax',
|
||||
[
|
||||
expect.objectContaining({
|
||||
startLineNumber: 3,
|
||||
startColumn: 5,
|
||||
endLineNumber: 3,
|
||||
endColumn: 10,
|
||||
message: 'invalid syntax',
|
||||
}),
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('runs syntax check automatically on save before updating script', async () => {
|
||||
const updateMock = vi.fn().mockResolvedValue({
|
||||
id: 'script-1',
|
||||
projectId: 'default',
|
||||
slug: 'hello_script',
|
||||
title: 'Hello Script',
|
||||
kind: 'utility',
|
||||
entrypoint: 'render',
|
||||
enabled: true,
|
||||
version: 2,
|
||||
filePath: '/tmp/hello-script.py',
|
||||
content: 'print("hello")',
|
||||
createdAt: '2026-02-22T00:00:00.000Z',
|
||||
updatedAt: '2026-02-22T00:01:00.000Z',
|
||||
});
|
||||
(window as any).electronAPI.scripts.update = updateMock;
|
||||
|
||||
render(<ScriptsView scriptId="script-1" />);
|
||||
|
||||
const titleInput = await screen.findByLabelText('Title');
|
||||
fireEvent.change(titleInput, { target: { value: 'Hello Script Updated' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Script' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(syntaxCheckMock).toHaveBeenCalledWith('print("hello")', {
|
||||
cacheKey: expect.stringMatching(/^script-1:1:/),
|
||||
});
|
||||
expect(updateMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('runs selected non-main entrypoint function', async () => {
|
||||
render(<ScriptsView scriptId="script-1" />);
|
||||
|
||||
const entrypointSelect = await screen.findByLabelText('Entrypoint');
|
||||
fireEvent.change(entrypointSelect, { target: { value: 'helper' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run Script' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(executeMock).toHaveBeenCalledWith('print("hello")', expect.objectContaining({
|
||||
entrypoint: 'helper',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes script from editor action', async () => {
|
||||
const deleteMock = vi.fn().mockResolvedValue(true);
|
||||
(window as any).electronAPI.scripts.delete = deleteMock;
|
||||
|
||||
useAppStore.setState({
|
||||
tabs: [{ type: 'scripts', id: 'script-1', isTransient: false }],
|
||||
activeTabId: 'script-1',
|
||||
});
|
||||
|
||||
render(<ScriptsView scriptId="script-1" />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Delete Script' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(deleteMock).toHaveBeenCalledWith('script-1');
|
||||
expect(useAppStore.getState().tabs).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
67
tests/renderer/components/SidebarChat.test.tsx
Normal file
67
tests/renderer/components/SidebarChat.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { Sidebar } from '../../../src/renderer/components/Sidebar/Sidebar';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
describe('Sidebar chat list behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
(window as any).electronAPI = {
|
||||
...(window as any).electronAPI,
|
||||
chat: {
|
||||
getConversations: vi.fn(),
|
||||
checkReady: vi.fn().mockResolvedValue({ ready: true }),
|
||||
onTitleUpdated: vi.fn(() => () => {}),
|
||||
createConversation: vi.fn(),
|
||||
deleteConversation: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
useAppStore.setState({
|
||||
activeProject: null,
|
||||
activeView: 'chat',
|
||||
sidebarVisible: true,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('reloads chat conversations when active project becomes available after mount', async () => {
|
||||
const getConversationsMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'conv-1',
|
||||
title: 'Project Conversation',
|
||||
createdAt: '2026-02-22T00:00:00.000Z',
|
||||
updatedAt: '2026-02-22T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
(window as any).electronAPI.chat.getConversations = getConversationsMock;
|
||||
|
||||
render(<Sidebar />);
|
||||
|
||||
expect(await screen.findByText('No conversations yet')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
useAppStore.setState({
|
||||
activeProject: {
|
||||
id: 'project-1',
|
||||
name: 'Project 1',
|
||||
slug: 'project-1',
|
||||
dataPath: '/tmp/project-1',
|
||||
isActive: true,
|
||||
createdAt: '2026-02-22T00:00:00.000Z',
|
||||
updatedAt: '2026-02-22T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Project Conversation')).toBeInTheDocument();
|
||||
expect(getConversationsMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
54
tests/renderer/components/SidebarDateFormatting.test.ts
Normal file
54
tests/renderer/components/SidebarDateFormatting.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { formatSidebarRelativeDate } from '../../../src/renderer/components/Sidebar/sidebarDateFormatting';
|
||||
|
||||
describe('formatSidebarRelativeDate', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-02-23T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('formats same-day dates as time', () => {
|
||||
const result = formatSidebarRelativeDate({
|
||||
dateString: '2026-02-23T10:30:00.000Z',
|
||||
language: 'en',
|
||||
t: () => 'Yesterday',
|
||||
});
|
||||
|
||||
expect(result).toMatch(/\d/);
|
||||
expect(result).not.toBe('Yesterday');
|
||||
});
|
||||
|
||||
it('formats one-day-old dates as localized yesterday label', () => {
|
||||
const result = formatSidebarRelativeDate({
|
||||
dateString: '2026-02-22T10:30:00.000Z',
|
||||
language: 'en',
|
||||
t: () => 'Yesterday',
|
||||
});
|
||||
|
||||
expect(result).toBe('Yesterday');
|
||||
});
|
||||
|
||||
it('formats older dates within a week using weekday', () => {
|
||||
const result = formatSidebarRelativeDate({
|
||||
dateString: '2026-02-20T10:30:00.000Z',
|
||||
language: 'en',
|
||||
t: () => 'Yesterday',
|
||||
});
|
||||
|
||||
expect(result).toMatch(/^[A-Za-z]{3}$/);
|
||||
});
|
||||
|
||||
it('formats older dates with month/day', () => {
|
||||
const result = formatSidebarRelativeDate({
|
||||
dateString: '2026-02-10T10:30:00.000Z',
|
||||
language: 'en',
|
||||
t: () => 'Yesterday',
|
||||
});
|
||||
|
||||
expect(result).toMatch(/[A-Za-z]{3}/);
|
||||
});
|
||||
});
|
||||
72
tests/renderer/components/SidebarEntityList.test.tsx
Normal file
72
tests/renderer/components/SidebarEntityList.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { SidebarEntityList } from '../../../src/renderer/components/Sidebar/SidebarEntityList';
|
||||
|
||||
describe('SidebarEntityList', () => {
|
||||
it('renders loading state with header', () => {
|
||||
render(
|
||||
<SidebarEntityList
|
||||
header="Header"
|
||||
createTitle="Create"
|
||||
onCreate={vi.fn()}
|
||||
isLoading
|
||||
loadingLabel="Loading..."
|
||||
emptyMessage="Empty"
|
||||
emptyActionLabel="Create first"
|
||||
onEmptyAction={vi.fn()}
|
||||
items={[]}
|
||||
renderItem={() => null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Header')).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state and triggers empty action', () => {
|
||||
const onEmptyAction = vi.fn();
|
||||
|
||||
render(
|
||||
<SidebarEntityList
|
||||
header="Header"
|
||||
createTitle="Create"
|
||||
onCreate={vi.fn()}
|
||||
isLoading={false}
|
||||
loadingLabel="Loading..."
|
||||
emptyMessage="Empty"
|
||||
emptyActionLabel="Create first"
|
||||
onEmptyAction={onEmptyAction}
|
||||
items={[]}
|
||||
renderItem={() => null}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create first' }));
|
||||
expect(onEmptyAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders rows and create button action', () => {
|
||||
const onCreate = vi.fn();
|
||||
|
||||
render(
|
||||
<SidebarEntityList
|
||||
header="Header"
|
||||
createTitle="Create"
|
||||
onCreate={onCreate}
|
||||
isLoading={false}
|
||||
loadingLabel="Loading..."
|
||||
emptyMessage="Empty"
|
||||
emptyActionLabel="Create first"
|
||||
onEmptyAction={vi.fn()}
|
||||
items={[{ id: 'a' }]}
|
||||
getItemKey={(item) => item.id}
|
||||
renderItem={(item) => <div>{item.id}</div>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('a')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
283
tests/renderer/components/SidebarScripts.test.tsx
Normal file
283
tests/renderer/components/SidebarScripts.test.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { act, render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Sidebar } from '../../../src/renderer/components/Sidebar/Sidebar';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
describe('Sidebar scripts list behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const listeners = new Map<string, Set<(event: Event) => void>>();
|
||||
(window as any).addEventListener = vi.fn((type: string, listener: (event: Event) => void) => {
|
||||
if (!listeners.has(type)) {
|
||||
listeners.set(type, new Set());
|
||||
}
|
||||
listeners.get(type)?.add(listener);
|
||||
});
|
||||
(window as any).removeEventListener = vi.fn((type: string, listener: (event: Event) => void) => {
|
||||
listeners.get(type)?.delete(listener);
|
||||
});
|
||||
(window as any).dispatchEvent = vi.fn((event: Event) => {
|
||||
listeners.get(event.type)?.forEach((listener) => listener(event));
|
||||
return true;
|
||||
});
|
||||
|
||||
(window as any).electronAPI = {
|
||||
...(window as any).electronAPI,
|
||||
scripts: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getAll: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'script-1',
|
||||
projectId: 'default',
|
||||
slug: 'hello-script',
|
||||
title: 'Hello Script',
|
||||
kind: 'utility',
|
||||
entrypoint: 'render',
|
||||
enabled: true,
|
||||
version: 1,
|
||||
filePath: '/tmp/hello-script.py',
|
||||
content: 'print("hello")',
|
||||
createdAt: '2026-02-22T00:00:00.000Z',
|
||||
updatedAt: '2026-02-22T00:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
useAppStore.setState({
|
||||
activeView: 'scripts',
|
||||
sidebarVisible: true,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('opens a transient script tab on single click', async () => {
|
||||
const { container } = render(<Sidebar />);
|
||||
|
||||
const scriptRow = await screen.findByRole('button', { name: 'Hello Script' });
|
||||
expect(scriptRow).toHaveClass('chat-list-item');
|
||||
expect(container.querySelector('.chat-item-date')).not.toBeNull();
|
||||
fireEvent.click(scriptRow);
|
||||
|
||||
expect(useAppStore.getState().tabs).toEqual([
|
||||
{
|
||||
type: 'scripts',
|
||||
id: 'script-1',
|
||||
isTransient: true,
|
||||
},
|
||||
]);
|
||||
expect(useAppStore.getState().activeTabId).toBe('script-1');
|
||||
});
|
||||
|
||||
it('renders scripts section title and create button', async () => {
|
||||
render(<Sidebar />);
|
||||
|
||||
expect(screen.getByText('SCRIPTS')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'New Script' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state while scripts are being fetched', () => {
|
||||
(window as any).electronAPI.scripts.getAll = vi.fn().mockImplementation(
|
||||
() => new Promise(() => {}),
|
||||
);
|
||||
|
||||
render(<Sidebar />);
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state with create action when no scripts exist', async () => {
|
||||
(window as any).electronAPI.scripts.getAll = vi.fn().mockResolvedValue([]);
|
||||
|
||||
render(<Sidebar />);
|
||||
|
||||
expect(await screen.findByText('No scripts yet')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Create a script' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates a new script from the create button and opens it pinned', async () => {
|
||||
const createMock = vi.fn().mockResolvedValue({
|
||||
id: 'script-new',
|
||||
projectId: 'default',
|
||||
slug: 'new-script',
|
||||
title: 'New Script',
|
||||
kind: 'utility',
|
||||
entrypoint: 'render',
|
||||
enabled: true,
|
||||
version: 1,
|
||||
filePath: '/tmp/new-script.py',
|
||||
content: 'print("new script")',
|
||||
createdAt: '2026-02-22T00:00:00.000Z',
|
||||
updatedAt: '2026-02-22T00:00:00.000Z',
|
||||
});
|
||||
|
||||
(window as any).electronAPI.scripts.create = createMock;
|
||||
|
||||
render(<Sidebar />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'New Script' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(createMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'New Script',
|
||||
kind: 'utility',
|
||||
entrypoint: 'render',
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(useAppStore.getState().tabs).toEqual([
|
||||
{
|
||||
type: 'scripts',
|
||||
id: 'script-new',
|
||||
isTransient: false,
|
||||
},
|
||||
]);
|
||||
expect(useAppStore.getState().activeTabId).toBe('script-new');
|
||||
});
|
||||
});
|
||||
|
||||
it('opens a pinned script tab on double click', async () => {
|
||||
render(<Sidebar />);
|
||||
|
||||
const scriptRow = await screen.findByRole('button', { name: 'Hello Script' });
|
||||
fireEvent.doubleClick(scriptRow);
|
||||
|
||||
expect(useAppStore.getState().tabs).toEqual([
|
||||
{
|
||||
type: 'scripts',
|
||||
id: 'script-1',
|
||||
isTransient: false,
|
||||
},
|
||||
]);
|
||||
expect(useAppStore.getState().activeTabId).toBe('script-1');
|
||||
});
|
||||
|
||||
it('deletes a script from sidebar action', async () => {
|
||||
const deleteMock = vi.fn().mockResolvedValue(true);
|
||||
(window as any).electronAPI.scripts.delete = deleteMock;
|
||||
|
||||
useAppStore.setState({
|
||||
tabs: [{ type: 'scripts', id: 'script-1', isTransient: false }],
|
||||
activeTabId: 'script-1',
|
||||
});
|
||||
|
||||
render(<Sidebar />);
|
||||
|
||||
const deleteButton = await screen.findByTitle('Delete script');
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(deleteMock).toHaveBeenCalledWith('script-1');
|
||||
expect(useAppStore.getState().tabs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes scripts list when scripts-changed event is emitted', async () => {
|
||||
const getAllMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'script-1',
|
||||
projectId: 'default',
|
||||
slug: 'hello_script',
|
||||
title: 'Hello Script',
|
||||
kind: 'utility',
|
||||
entrypoint: 'render',
|
||||
enabled: true,
|
||||
version: 1,
|
||||
filePath: '/tmp/hello-script.py',
|
||||
content: 'print("hello")',
|
||||
createdAt: '2026-02-22T00:00:00.000Z',
|
||||
updatedAt: '2026-02-22T00:00:00.000Z',
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'script-1',
|
||||
projectId: 'default',
|
||||
slug: 'renamed_script',
|
||||
title: 'Renamed Script',
|
||||
kind: 'utility',
|
||||
entrypoint: 'render',
|
||||
enabled: true,
|
||||
version: 2,
|
||||
filePath: '/tmp/hello-script.py',
|
||||
content: 'print("hello")',
|
||||
createdAt: '2026-02-22T00:00:00.000Z',
|
||||
updatedAt: '2026-02-22T00:01:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
(window as any).electronAPI.scripts.getAll = getAllMock;
|
||||
|
||||
render(<Sidebar />);
|
||||
|
||||
await screen.findByRole('button', { name: 'Hello Script' });
|
||||
window.dispatchEvent(new CustomEvent('bds:scripts-changed'));
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Renamed Script' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reloads scripts when active project context becomes available after mount', async () => {
|
||||
const getAllMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'script-1',
|
||||
projectId: 'project-1',
|
||||
slug: 'hello_script',
|
||||
title: 'Hello Script',
|
||||
kind: 'utility',
|
||||
entrypoint: 'render',
|
||||
enabled: true,
|
||||
version: 1,
|
||||
filePath: '/tmp/hello-script.py',
|
||||
content: 'print("hello")',
|
||||
createdAt: '2026-02-22T00:00:00.000Z',
|
||||
updatedAt: '2026-02-22T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
(window as any).electronAPI.scripts.getAll = getAllMock;
|
||||
|
||||
useAppStore.setState({
|
||||
activeProject: null,
|
||||
activeView: 'scripts',
|
||||
sidebarVisible: true,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
});
|
||||
|
||||
render(<Sidebar />);
|
||||
|
||||
expect(await screen.findByText('No scripts yet')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
useAppStore.setState({
|
||||
activeProject: {
|
||||
id: 'project-1',
|
||||
name: 'Project 1',
|
||||
slug: 'project-1',
|
||||
dataPath: '/tmp/project-1',
|
||||
isActive: true,
|
||||
createdAt: '2026-02-22T00:00:00.000Z',
|
||||
updatedAt: '2026-02-22T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Hello Script' })).toBeInTheDocument();
|
||||
expect(getAllMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,10 @@ describe('TabBar', () => {
|
||||
get: vi.fn(),
|
||||
onNameUpdated: vi.fn(() => () => {}),
|
||||
},
|
||||
scripts: {
|
||||
...(window as any).electronAPI?.scripts,
|
||||
get: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -136,4 +140,24 @@ describe('TabBar', () => {
|
||||
|
||||
expect(await screen.findByText('Updated Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders script title for script tab', async () => {
|
||||
useAppStore.setState({
|
||||
tabs: [{ type: 'scripts', id: 'script-1', isTransient: false }],
|
||||
activeTabId: 'script-1',
|
||||
posts: [],
|
||||
media: [],
|
||||
dirtyPosts: new Set<string>(),
|
||||
});
|
||||
|
||||
(window as any).electronAPI.scripts.get = vi.fn().mockResolvedValue({
|
||||
id: 'script-1',
|
||||
title: 'Publish Macro',
|
||||
});
|
||||
|
||||
render(<TabBar />);
|
||||
|
||||
expect(await screen.findByText('Publish Macro')).toBeInTheDocument();
|
||||
expect((window as any).electronAPI.scripts.get).toHaveBeenCalledWith('script-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,11 @@ describe('documentation structure and presentation', () => {
|
||||
const markdown = readFileSync(docPath, 'utf8');
|
||||
|
||||
expect(markdown).toContain('## Using macros');
|
||||
expect(markdown).toContain('### YouTube macro');
|
||||
expect(markdown).toContain('### Vimeo macro');
|
||||
expect(markdown).toContain('### Gallery macro');
|
||||
expect(markdown).toContain('### Photo archive macro');
|
||||
expect(markdown).toContain('### Tag cloud macro');
|
||||
expect(markdown).toContain('[[youtube');
|
||||
expect(markdown).toContain('[[vimeo');
|
||||
expect(markdown).toContain('[[gallery');
|
||||
|
||||
@@ -87,7 +87,20 @@ describe('activityBehavior', () => {
|
||||
});
|
||||
|
||||
it('supports all expected activity ids', () => {
|
||||
const ids: ActivityId[] = ['posts', 'pages', 'media', 'tags', 'chat', 'import', 'git', 'settings'];
|
||||
expect(ids).toHaveLength(8);
|
||||
const ids: ActivityId[] = ['posts', 'pages', 'media', 'scripts', 'tags', 'chat', 'import', 'git', 'settings'];
|
||||
expect(ids).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('returns posts-style sidebar actions for scripts', () => {
|
||||
const hiddenSidebarSnapshot = createSnapshot({ activeView: 'posts', sidebarVisible: false });
|
||||
expect(getActivityClickActions(hiddenSidebarSnapshot, 'scripts')).toEqual([
|
||||
{ type: 'setActiveView', view: 'scripts' },
|
||||
{ type: 'toggleSidebar' },
|
||||
]);
|
||||
|
||||
const visibleSidebarSnapshot = createSnapshot({ activeView: 'posts', sidebarVisible: true });
|
||||
expect(getActivityClickActions(visibleSidebarSnapshot, 'scripts')).toEqual([
|
||||
{ type: 'setActiveView', view: 'scripts' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
125
tests/renderer/navigation/blogmarkTransformOutput.test.ts
Normal file
125
tests/renderer/navigation/blogmarkTransformOutput.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildBlogmarkTransformOutputEntries,
|
||||
buildBlogmarkTransformToastNotifications,
|
||||
parseBlogmarkCreatedEventPayload,
|
||||
} from '../../../src/renderer/navigation/blogmarkTransformOutput';
|
||||
|
||||
describe('parseBlogmarkCreatedEventPayload', () => {
|
||||
it('parses legacy payload shape where event value is the post itself', () => {
|
||||
const payload = parseBlogmarkCreatedEventPayload({ id: 'post-1', title: 'Legacy post' });
|
||||
|
||||
expect(payload).toEqual({
|
||||
post: { id: 'post-1', title: 'Legacy post' },
|
||||
transform: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses new payload shape with post and transform metadata', () => {
|
||||
const payload = parseBlogmarkCreatedEventPayload({
|
||||
post: { id: 'post-2', title: 'With transforms' },
|
||||
transform: {
|
||||
appliedScriptIds: ['a', 'b'],
|
||||
errors: [{ scriptId: 'c', scriptSlug: 'c', message: 'boom' }],
|
||||
toasts: ['done'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
post: { id: 'post-2', title: 'With transforms' },
|
||||
transform: {
|
||||
appliedScriptIds: ['a', 'b'],
|
||||
errors: [{ scriptId: 'c', scriptSlug: 'c', message: 'boom' }],
|
||||
toasts: ['done'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBlogmarkTransformOutputEntries', () => {
|
||||
const t = (key: string, values?: Record<string, string | number>) => {
|
||||
if (key === 'app.blogmark.transforms.summary') {
|
||||
return `summary:${values?.applied}:${values?.failed}`;
|
||||
}
|
||||
if (key === 'app.blogmark.transforms.appliedList') {
|
||||
return `applied:${values?.scripts}`;
|
||||
}
|
||||
if (key === 'app.blogmark.transforms.failed') {
|
||||
return `failed:${values?.script}:${values?.message}`;
|
||||
}
|
||||
if (key === 'app.blogmark.transforms.toast') {
|
||||
return `toast:${values?.message}`;
|
||||
}
|
||||
if (key === 'app.blogmark.transforms.errorToast') {
|
||||
return `error-toast:${values?.count}`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
it('returns empty list when no transform info is provided', () => {
|
||||
expect(buildBlogmarkTransformOutputEntries(undefined, t)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns summary and applied list entries for successful transforms', () => {
|
||||
const entries = buildBlogmarkTransformOutputEntries(
|
||||
{
|
||||
appliedScriptIds: ['alpha', 'beta'],
|
||||
errors: [],
|
||||
toasts: [],
|
||||
},
|
||||
t,
|
||||
);
|
||||
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]?.kind).toBe('result');
|
||||
expect(entries[0]?.message).toBe('summary:2:0');
|
||||
expect(entries[1]?.kind).toBe('result');
|
||||
expect(entries[1]?.message).toBe('applied:alpha, beta');
|
||||
});
|
||||
|
||||
it('returns one error entry per failed transform', () => {
|
||||
const entries = buildBlogmarkTransformOutputEntries(
|
||||
{
|
||||
appliedScriptIds: ['alpha'],
|
||||
errors: [
|
||||
{ scriptId: 'broken', scriptSlug: 'broken_slug', message: 'boom' },
|
||||
{ scriptId: 'bad', scriptSlug: 'bad_slug', message: 'invalid output' },
|
||||
],
|
||||
toasts: ['Step finished'],
|
||||
},
|
||||
t,
|
||||
);
|
||||
|
||||
expect(entries).toHaveLength(5);
|
||||
expect(entries[0]?.message).toBe('summary:1:2');
|
||||
expect(entries[1]?.message).toBe('applied:alpha');
|
||||
expect(entries[2]?.kind).toBe('result');
|
||||
expect(entries[2]?.message).toBe('toast:Step finished');
|
||||
expect(entries[3]?.kind).toBe('error');
|
||||
expect(entries[3]?.message).toBe('failed:broken_slug:boom');
|
||||
expect(entries[4]?.kind).toBe('error');
|
||||
expect(entries[4]?.message).toBe('failed:bad_slug:invalid output');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBlogmarkTransformToastNotifications', () => {
|
||||
const t = (key: string, values?: Record<string, string | number>) => {
|
||||
if (key === 'app.blogmark.transforms.errorToast') {
|
||||
return `error-toast:${values?.count}`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
it('returns toast notifications for script toasts and aggregated errors', () => {
|
||||
const notifications = buildBlogmarkTransformToastNotifications({
|
||||
appliedScriptIds: ['alpha'],
|
||||
toasts: ['Saved one item'],
|
||||
errors: [{ scriptId: 'broken', scriptSlug: 'broken_slug', message: 'boom' }],
|
||||
}, t);
|
||||
|
||||
expect(notifications).toEqual([
|
||||
{ kind: 'success', message: 'Saved one item' },
|
||||
{ kind: 'error', message: 'error-toast:1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ describe('editorRouting', () => {
|
||||
'git-diff': 'git-diff',
|
||||
documentation: 'documentation',
|
||||
'site-validation': 'site-validation',
|
||||
scripts: 'scripts',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ describe('sidebarViewRegistry', () => {
|
||||
'posts',
|
||||
'pages',
|
||||
'media',
|
||||
'scripts',
|
||||
'settings',
|
||||
'tags',
|
||||
'chat',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getGitDiffCommitTabSpec,
|
||||
getGitDiffFileTabSpec,
|
||||
getImportTabSpec,
|
||||
getScriptTabSpec,
|
||||
parseGitDiffTabId,
|
||||
openChatTab,
|
||||
getSingletonToolTabSpec,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
openGitDiffCommitTab,
|
||||
openGitDiffFileTab,
|
||||
openImportTab,
|
||||
openScriptTab,
|
||||
openSingletonToolTab,
|
||||
} from '../../../src/renderer/navigation/tabPolicy';
|
||||
|
||||
@@ -20,6 +22,7 @@ describe('tabPolicy', () => {
|
||||
expect(getSingletonToolTabSpec('settings')).toEqual({ type: 'settings', id: 'settings', isTransient: false });
|
||||
expect(getSingletonToolTabSpec('tags')).toEqual({ type: 'tags', id: 'tags', isTransient: false });
|
||||
expect(getSingletonToolTabSpec('style')).toEqual({ type: 'style', id: 'style', isTransient: false });
|
||||
expect(getSingletonToolTabSpec('scripts')).toEqual({ type: 'scripts', id: 'scripts', isTransient: false });
|
||||
expect(getSingletonToolTabSpec('menu-editor')).toEqual({ type: 'menu-editor', id: 'menu-editor', isTransient: false });
|
||||
expect(getSingletonToolTabSpec('documentation')).toEqual({ type: 'documentation', id: 'documentation', isTransient: false });
|
||||
expect(getSingletonToolTabSpec('metadata-diff')).toEqual({ type: 'metadata-diff', id: 'metadata-diff', isTransient: false });
|
||||
@@ -93,6 +96,35 @@ describe('tabPolicy', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('provides canonical script tab spec for preview and pin intents', () => {
|
||||
expect(getScriptTabSpec('script-1', 'preview')).toEqual({
|
||||
type: 'scripts',
|
||||
id: 'script-1',
|
||||
isTransient: true,
|
||||
});
|
||||
|
||||
expect(getScriptTabSpec('script-1', 'pin')).toEqual({
|
||||
type: 'scripts',
|
||||
id: 'script-1',
|
||||
isTransient: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('opens script tabs from shared policy', () => {
|
||||
const opened: Array<{ type: string; id: string; isTransient: boolean }> = [];
|
||||
const openTab = (tab: { type: string; id: string; isTransient: boolean }) => {
|
||||
opened.push(tab);
|
||||
};
|
||||
|
||||
openScriptTab(openTab, 'script-preview', 'preview');
|
||||
openScriptTab(openTab, 'script-pin', 'pin');
|
||||
|
||||
expect(opened).toEqual([
|
||||
{ type: 'scripts', id: 'script-preview', isTransient: true },
|
||||
{ type: 'scripts', id: 'script-pin', isTransient: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds and parses git-diff file and commit tab specs', () => {
|
||||
expect(getGitDiffFileTabSpec('posts/first.md', 'preview')).toEqual({
|
||||
type: 'git-diff',
|
||||
|
||||
398
tests/renderer/python/PythonRuntimeManager.test.ts
Normal file
398
tests/renderer/python/PythonRuntimeManager.test.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { PythonRuntimeManager } from '../../../src/renderer/python/PythonRuntimeManager';
|
||||
import { createMacroRenderOptions } from '../../../src/renderer/python/macroRenderOptions';
|
||||
|
||||
class MockWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: ((event: ErrorEvent) => void) | null = null;
|
||||
terminated = false;
|
||||
postedMessages: unknown[] = [];
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.postedMessages.push(message);
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated = true;
|
||||
}
|
||||
|
||||
emitMessage(data: unknown): void {
|
||||
this.onmessage?.({ data } as MessageEvent);
|
||||
}
|
||||
|
||||
emitError(error: Error): void {
|
||||
this.onerror?.({ error } as ErrorEvent);
|
||||
}
|
||||
}
|
||||
|
||||
describe('PythonRuntimeManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('initializes worker once and resolves on ready signal', async () => {
|
||||
const createdWorkers: MockWorker[] = [];
|
||||
const manager = new PythonRuntimeManager(() => {
|
||||
const worker = new MockWorker();
|
||||
createdWorkers.push(worker);
|
||||
return worker as unknown as Worker;
|
||||
});
|
||||
|
||||
const initA = manager.initialize();
|
||||
const initB = manager.initialize();
|
||||
|
||||
expect(initA).toBe(initB);
|
||||
expect(createdWorkers).toHaveLength(1);
|
||||
|
||||
createdWorkers[0].emitMessage({ type: 'ready' });
|
||||
await expect(initA).resolves.toBeUndefined();
|
||||
expect(manager.isReady()).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when worker emits an error before ready', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitError(new Error('bootstrap failed'));
|
||||
|
||||
await expect(initPromise).rejects.toThrow('bootstrap failed');
|
||||
expect(manager.isReady()).toBe(false);
|
||||
});
|
||||
|
||||
it('executes code and returns stdout and result', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitMessage({ type: 'ready' });
|
||||
await initPromise;
|
||||
|
||||
const runPromise = manager.execute('print("hello")\n1 + 1');
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as { type: string; requestId: string; code: string };
|
||||
|
||||
worker.emitMessage({ type: 'stdout', requestId: request.requestId, chunk: 'hello\n' });
|
||||
worker.emitMessage({ type: 'runResult', requestId: request.requestId, result: '2' });
|
||||
|
||||
await expect(runPromise).resolves.toEqual({ result: '2', stdout: 'hello\n' });
|
||||
});
|
||||
|
||||
it('forwards compile cache key in execute request options', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitMessage({ type: 'ready' });
|
||||
await initPromise;
|
||||
|
||||
const runPromise = manager.execute('value = 1', { cacheKey: 'script-1:3' });
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as { requestId: string; cacheKey?: string };
|
||||
expect(request.cacheKey).toBe('script-1:3');
|
||||
|
||||
worker.emitMessage({ type: 'runResult', requestId: request.requestId, result: '' });
|
||||
await expect(runPromise).resolves.toEqual({ result: '', stdout: '' });
|
||||
});
|
||||
|
||||
it('forwards selected entrypoint in execute request options', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitMessage({ type: 'ready' });
|
||||
await initPromise;
|
||||
|
||||
const runPromise = manager.execute('def helper():\n return 42', { entrypoint: 'helper' });
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as { requestId: string; entrypoint?: string };
|
||||
expect(request.entrypoint).toBe('helper');
|
||||
|
||||
worker.emitMessage({ type: 'runResult', requestId: request.requestId, result: '42' });
|
||||
await expect(runPromise).resolves.toEqual({ result: '42', stdout: '' });
|
||||
});
|
||||
|
||||
it('inspects script and returns available function names', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitMessage({ type: 'ready' });
|
||||
await initPromise;
|
||||
|
||||
const inspectPromise = manager.inspectEntrypoints('def render(context):\n return {}\n\ndef helper():\n return 1');
|
||||
await Promise.resolve();
|
||||
|
||||
const request = worker.postedMessages[0] as { type: string; requestId: string; code: string };
|
||||
expect(request.type).toBe('inspectEntrypoints');
|
||||
|
||||
worker.emitMessage({ type: 'entrypoints', requestId: request.requestId, entrypoints: ['render', 'helper'] });
|
||||
await expect(inspectPromise).resolves.toEqual(['render', 'helper']);
|
||||
});
|
||||
|
||||
it('checks syntax and returns structured syntax errors', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitMessage({ type: 'ready' });
|
||||
await initPromise;
|
||||
|
||||
const syntaxPromise = manager.syntaxCheck('def broken(:\n pass');
|
||||
await Promise.resolve();
|
||||
|
||||
const request = worker.postedMessages[0] as { type: string; requestId: string; code: string };
|
||||
expect(request.type).toBe('syntaxCheck');
|
||||
|
||||
worker.emitMessage({
|
||||
type: 'syntaxResult',
|
||||
requestId: request.requestId,
|
||||
errors: [
|
||||
{
|
||||
line: 1,
|
||||
column: 11,
|
||||
endLine: 1,
|
||||
endColumn: 12,
|
||||
message: 'invalid syntax',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(syntaxPromise).resolves.toEqual({
|
||||
errors: [
|
||||
{
|
||||
line: 1,
|
||||
column: 11,
|
||||
endLine: 1,
|
||||
endColumn: 12,
|
||||
message: 'invalid syntax',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when runtime returns run error', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitMessage({ type: 'ready' });
|
||||
await initPromise;
|
||||
|
||||
const runPromise = manager.execute('raise RuntimeError("boom")');
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as { requestId: string };
|
||||
worker.emitMessage({ type: 'runError', requestId: request.requestId, error: 'boom' });
|
||||
|
||||
await expect(runPromise).rejects.toThrow('boom');
|
||||
});
|
||||
|
||||
it('terminates timed out worker and recovers with a new worker', async () => {
|
||||
const workers: MockWorker[] = [];
|
||||
const manager = new PythonRuntimeManager(() => {
|
||||
const worker = new MockWorker();
|
||||
workers.push(worker);
|
||||
return worker as unknown as Worker;
|
||||
});
|
||||
|
||||
const firstInit = manager.initialize();
|
||||
workers[0].emitMessage({ type: 'ready' });
|
||||
await firstInit;
|
||||
|
||||
const timedOutRun = manager.execute('while True: pass', { timeoutMs: 100 });
|
||||
await Promise.resolve();
|
||||
vi.advanceTimersByTime(101);
|
||||
await expect(timedOutRun).rejects.toThrow('timed out');
|
||||
expect(workers[0].terminated).toBe(true);
|
||||
expect(manager.isReady()).toBe(false);
|
||||
|
||||
const secondInit = manager.initialize();
|
||||
workers[1].emitMessage({ type: 'ready' });
|
||||
await secondInit;
|
||||
|
||||
const secondRun = manager.execute('40 + 2');
|
||||
await Promise.resolve();
|
||||
const request = workers[1].postedMessages[0] as { requestId: string };
|
||||
workers[1].emitMessage({ type: 'runResult', requestId: request.requestId, result: '42' });
|
||||
|
||||
await expect(secondRun).resolves.toEqual({ result: '42', stdout: '' });
|
||||
});
|
||||
|
||||
it('rejects macro execution when ABI context is invalid on caller side', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitMessage({ type: 'ready' });
|
||||
await initPromise;
|
||||
|
||||
const runPromise = manager.renderMacroV1('def render(context):\n return {"html": "<p>ok</p>"}', {
|
||||
env: {
|
||||
isPreview: 'yes',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runPromise).rejects.toThrow('Invalid macro context');
|
||||
expect(worker.postedMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns validated macro result and stdout', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitMessage({ type: 'ready' });
|
||||
await initPromise;
|
||||
|
||||
const runPromise = manager.renderMacroV1('def render(context):\n return {"html": "<p>ok</p>"}', {
|
||||
env: {
|
||||
isPreview: true,
|
||||
},
|
||||
params: {
|
||||
title: 'Hello',
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as { requestId: string };
|
||||
worker.emitMessage({ type: 'stdout', requestId: request.requestId, chunk: 'rendering\n' });
|
||||
worker.emitMessage({ type: 'macroResult', requestId: request.requestId, result: { html: '<p>ok</p>' } });
|
||||
|
||||
await expect(runPromise).resolves.toEqual({ result: { html: '<p>ok</p>' }, stdout: 'rendering\n' });
|
||||
});
|
||||
|
||||
it('accepts optional env hook and source metadata for macro execution', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitMessage({ type: 'ready' });
|
||||
await initPromise;
|
||||
|
||||
const runPromise = manager.renderMacroV1('def render(context):\n return {"html": "<p>ok</p>"}', {
|
||||
env: {
|
||||
isPreview: true,
|
||||
hook: 'post:render',
|
||||
source: {
|
||||
kind: 'post',
|
||||
id: 'post-1',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as {
|
||||
requestId: string;
|
||||
context: { env: { hook?: string; source?: { kind: string; id?: string } } };
|
||||
};
|
||||
expect(request.context.env.hook).toBe('post:render');
|
||||
expect(request.context.env.source).toEqual({ kind: 'post', id: 'post-1' });
|
||||
|
||||
worker.emitMessage({ type: 'macroResult', requestId: request.requestId, result: { html: '<p>ok</p>' } });
|
||||
await expect(runPromise).resolves.toEqual({ result: { html: '<p>ok</p>' }, stdout: '' });
|
||||
});
|
||||
|
||||
it('injects env hook and source metadata from macro execution options', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitMessage({ type: 'ready' });
|
||||
await initPromise;
|
||||
|
||||
const runPromise = manager.renderMacroV1(
|
||||
'def render(context):\n return {"html": "<p>ok</p>"}',
|
||||
{
|
||||
env: {
|
||||
isPreview: true,
|
||||
},
|
||||
},
|
||||
createMacroRenderOptions({
|
||||
hook: 'preview:macro',
|
||||
source: {
|
||||
kind: 'post',
|
||||
id: 'post-77',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as {
|
||||
requestId: string;
|
||||
context: { env: { hook?: string; source?: { kind: string; id?: string } } };
|
||||
};
|
||||
|
||||
expect(request.context.env.hook).toBe('preview:macro');
|
||||
expect(request.context.env.source).toEqual({ kind: 'post', id: 'post-77' });
|
||||
|
||||
worker.emitMessage({ type: 'macroResult', requestId: request.requestId, result: { html: '<p>ok</p>' } });
|
||||
await expect(runPromise).resolves.toEqual({ result: { html: '<p>ok</p>' }, stdout: '' });
|
||||
});
|
||||
|
||||
it('preserves explicit env hook and source over macro execution options', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitMessage({ type: 'ready' });
|
||||
await initPromise;
|
||||
|
||||
const runPromise = manager.renderMacroV1(
|
||||
'def render(context):\n return {"html": "<p>ok</p>"}',
|
||||
{
|
||||
env: {
|
||||
isPreview: true,
|
||||
hook: 'explicit:hook',
|
||||
source: {
|
||||
kind: 'page',
|
||||
id: 'page-9',
|
||||
},
|
||||
},
|
||||
},
|
||||
createMacroRenderOptions({
|
||||
hook: 'preview:macro',
|
||||
source: {
|
||||
kind: 'post',
|
||||
id: 'post-77',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as {
|
||||
requestId: string;
|
||||
context: { env: { hook?: string; source?: { kind: string; id?: string } } };
|
||||
};
|
||||
|
||||
expect(request.context.env.hook).toBe('explicit:hook');
|
||||
expect(request.context.env.source).toEqual({ kind: 'page', id: 'page-9' });
|
||||
|
||||
worker.emitMessage({ type: 'macroResult', requestId: request.requestId, result: { html: '<p>ok</p>' } });
|
||||
await expect(runPromise).resolves.toEqual({ result: { html: '<p>ok</p>' }, stdout: '' });
|
||||
});
|
||||
|
||||
it('rejects macro execution when worker result violates ABI schema', async () => {
|
||||
const worker = new MockWorker();
|
||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||
|
||||
const initPromise = manager.initialize();
|
||||
worker.emitMessage({ type: 'ready' });
|
||||
await initPromise;
|
||||
|
||||
const runPromise = manager.renderMacroV1('def render(context):\n return {"html": "<p>ok</p>"}', {
|
||||
env: {
|
||||
isPreview: true,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
const request = worker.postedMessages[0] as { requestId: string };
|
||||
worker.emitMessage({ type: 'macroResult', requestId: request.requestId, result: { html: 42 } });
|
||||
|
||||
await expect(runPromise).rejects.toThrow('Invalid macro result');
|
||||
});
|
||||
});
|
||||
41
tests/renderer/python/abiV1.test.ts
Normal file
41
tests/renderer/python/abiV1.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseMacroContextV1 } from '../../../src/renderer/python/abiV1';
|
||||
|
||||
describe('macroContextV1Schema', () => {
|
||||
it('accepts optional env hook and source metadata', () => {
|
||||
const parsed = parseMacroContextV1({
|
||||
env: {
|
||||
isPreview: true,
|
||||
mainLanguage: 'en',
|
||||
hook: 'post:render',
|
||||
source: {
|
||||
kind: 'post',
|
||||
id: 'post-1',
|
||||
},
|
||||
},
|
||||
params: {
|
||||
title: 'Hello',
|
||||
},
|
||||
data: {
|
||||
post: {
|
||||
id: 'post-1',
|
||||
slug: 'hello',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.env.hook).toBe('post:render');
|
||||
expect(parsed.env.source).toEqual({ kind: 'post', id: 'post-1' });
|
||||
});
|
||||
|
||||
it('rejects unknown env fields', () => {
|
||||
expect(() =>
|
||||
parseMacroContextV1({
|
||||
env: {
|
||||
isPreview: true,
|
||||
unknown: 'value',
|
||||
},
|
||||
})
|
||||
).toThrow('Invalid macro context');
|
||||
});
|
||||
});
|
||||
30
tests/renderer/python/macroRenderOptions.test.ts
Normal file
30
tests/renderer/python/macroRenderOptions.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createMacroRenderOptions } from '../../../src/renderer/python/macroRenderOptions';
|
||||
|
||||
describe('createMacroRenderOptions', () => {
|
||||
it('maps hook/source metadata into runtime macro options', () => {
|
||||
const options = createMacroRenderOptions({
|
||||
hook: 'preview:macro',
|
||||
source: {
|
||||
kind: 'post',
|
||||
id: 'post-5',
|
||||
},
|
||||
cacheKey: 'script-1:1:abc',
|
||||
timeoutMs: 4000,
|
||||
});
|
||||
|
||||
expect(options).toEqual({
|
||||
macroHook: 'preview:macro',
|
||||
macroSource: {
|
||||
kind: 'post',
|
||||
id: 'post-5',
|
||||
},
|
||||
cacheKey: 'script-1:1:abc',
|
||||
timeoutMs: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty options when no values are provided', () => {
|
||||
expect(createMacroRenderOptions()).toEqual({});
|
||||
});
|
||||
});
|
||||
21
tests/renderer/python/pyodideAssetUrl.test.ts
Normal file
21
tests/renderer/python/pyodideAssetUrl.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolvePyodideIndexURL } from '../../../src/renderer/python/pyodideAssetUrl';
|
||||
|
||||
describe('resolvePyodideIndexURL', () => {
|
||||
it('resolves to packaged node_modules path for dist worker urls', () => {
|
||||
const workerUrl = 'file:///Applications/bDS.app/Contents/Resources/app.asar/dist/renderer/assets/pythonRuntime.worker-abc123.js';
|
||||
expect(resolvePyodideIndexURL(workerUrl)).toBe(
|
||||
'file:///Applications/bDS.app/Contents/Resources/app.asar/node_modules/pyodide/'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for dev worker urls to let pyodide use module-relative defaults', () => {
|
||||
const workerUrl = 'http://localhost:5173/src/renderer/python/pythonRuntime.worker.ts';
|
||||
expect(resolvePyodideIndexURL(workerUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for vite worker_file urls with query parameters', () => {
|
||||
const workerUrl = 'http://localhost:5173/python/pythonRuntime.worker.ts?worker_file&type=module';
|
||||
expect(resolvePyodideIndexURL(workerUrl)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
45
tests/renderer/python/pythonRuntimeBenchmark.test.ts
Normal file
45
tests/renderer/python/pythonRuntimeBenchmark.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { summarizeDurations, runPythonRuntimeBenchmark } from '../../../src/renderer/python/pythonRuntimeBenchmark';
|
||||
|
||||
describe('pythonRuntimeBenchmark', () => {
|
||||
it('computes p50 and p95 summary metrics', () => {
|
||||
const summary = summarizeDurations([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
|
||||
expect(summary.count).toBe(10);
|
||||
expect(summary.minMs).toBe(1);
|
||||
expect(summary.maxMs).toBe(10);
|
||||
expect(summary.meanMs).toBe(5.5);
|
||||
expect(summary.p50Ms).toBe(5.5);
|
||||
expect(summary.p95Ms).toBe(9.55);
|
||||
});
|
||||
|
||||
it('runs benchmark phases and returns measured durations', async () => {
|
||||
const calls: string[] = [];
|
||||
const runtime = {
|
||||
async runPythonAsync(code: string): Promise<string> {
|
||||
calls.push(code);
|
||||
return 'ok';
|
||||
},
|
||||
};
|
||||
|
||||
const timestamps = [0, 100, 100, 110, 110, 111, 111, 113, 113, 116, 116, 120];
|
||||
let index = 0;
|
||||
|
||||
const result = await runPythonRuntimeBenchmark({
|
||||
iterations: 4,
|
||||
loadRuntime: async () => runtime,
|
||||
now: () => {
|
||||
const value = timestamps[index];
|
||||
index += 1;
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.coldStartMs).toBe(100);
|
||||
expect(result.warmRunMs).toBe(10);
|
||||
expect(result.repeatedMacro.samplesMs).toEqual([1, 2, 3, 4]);
|
||||
expect(result.repeatedMacro.stats.p50Ms).toBe(2.5);
|
||||
expect(result.repeatedMacro.stats.p95Ms).toBe(3.85);
|
||||
expect(calls).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
43
tests/renderer/python/pythonSyntaxCheck.integration.test.ts
Normal file
43
tests/renderer/python/pythonSyntaxCheck.integration.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { loadPyodide, type PyodideInterface } from 'pyodide';
|
||||
import { runPythonSyntaxCheck } from '../../../src/renderer/python/pythonSyntaxCheck';
|
||||
|
||||
describe('pythonSyntaxCheck integration (real pyodide)', () => {
|
||||
let runtime: PyodideInterface;
|
||||
|
||||
beforeAll(async () => {
|
||||
runtime = await loadPyodide();
|
||||
}, 60000);
|
||||
|
||||
it('returns no errors for syntactically valid python', async () => {
|
||||
const source = [
|
||||
'def normalize_blogmark(post):',
|
||||
' title = (post.get("title") or "").strip()',
|
||||
' if title:',
|
||||
' post["title"] = title',
|
||||
' return post',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const errors = await runPythonSyntaxCheck(runtime, source);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns structured syntax diagnostics for invalid python', async () => {
|
||||
const source = [
|
||||
'def normalize_blogmark(post):',
|
||||
' if post.get("title")',
|
||||
' return post',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const errors = await runPythonSyntaxCheck(runtime, source);
|
||||
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0]).toEqual(expect.objectContaining({
|
||||
line: 2,
|
||||
message: expect.any(String),
|
||||
}));
|
||||
});
|
||||
});
|
||||
49
tests/renderer/utils/tagColors.test.ts
Normal file
49
tests/renderer/utils/tagColors.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { buildTagColorMap, loadTagColorMap } from '../../../src/renderer/utils/tagColors';
|
||||
|
||||
describe('tagColors utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('buildTagColorMap includes only tags with colors', () => {
|
||||
const map = buildTagColorMap([
|
||||
{ id: '1', name: 'alpha', color: '#111111' },
|
||||
{ id: '2', name: 'beta' },
|
||||
{ id: '3', name: 'gamma', color: '#333333' },
|
||||
{ id: '4', name: 'delta', color: null },
|
||||
]);
|
||||
|
||||
expect(map.get('alpha')).toBe('#111111');
|
||||
expect(map.get('gamma')).toBe('#333333');
|
||||
expect(map.has('beta')).toBe(false);
|
||||
expect(map.has('delta')).toBe(false);
|
||||
});
|
||||
|
||||
it('loadTagColorMap resolves colors from electron tags api', async () => {
|
||||
(window as any).electronAPI = {
|
||||
...(window as any).electronAPI,
|
||||
tags: {
|
||||
getAll: vi.fn().mockResolvedValue([
|
||||
{ id: '1', name: 'blue', color: '#0000ff' },
|
||||
{ id: '2', name: 'red', color: '#ff0000' },
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const map = await loadTagColorMap();
|
||||
|
||||
expect(map.get('blue')).toBe('#0000ff');
|
||||
expect(map.get('red')).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('loadTagColorMap returns empty map when api is unavailable', async () => {
|
||||
(window as any).electronAPI = {
|
||||
...(window as any).electronAPI,
|
||||
tags: undefined,
|
||||
};
|
||||
|
||||
const map = await loadTagColorMap();
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
61
tests/renderer/utils/windowEvents.test.ts
Normal file
61
tests/renderer/utils/windowEvents.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { addWindowEventListener, dispatchWindowEvent } from '../../../src/renderer/utils/windowEvents';
|
||||
|
||||
describe('windowEvents utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const listeners = new Map<string, Set<(event: Event) => void>>();
|
||||
(window as any).addEventListener = vi.fn((type: string, listener: (event: Event) => void) => {
|
||||
if (!listeners.has(type)) {
|
||||
listeners.set(type, new Set());
|
||||
}
|
||||
listeners.get(type)?.add(listener);
|
||||
});
|
||||
(window as any).removeEventListener = vi.fn((type: string, listener: (event: Event) => void) => {
|
||||
listeners.get(type)?.delete(listener);
|
||||
});
|
||||
(window as any).dispatchEvent = vi.fn((event: Event) => {
|
||||
listeners.get(event.type)?.forEach((listener) => listener(event));
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches custom events with detail payload', () => {
|
||||
const listener = vi.fn();
|
||||
addWindowEventListener('bds:scripts-changed', listener);
|
||||
|
||||
const dispatched = dispatchWindowEvent('bds:scripts-changed', { scriptId: 'script-1' });
|
||||
|
||||
expect(dispatched).toBe(true);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const event = listener.mock.calls[0][0] as CustomEvent<{ scriptId: string }>;
|
||||
expect(event.detail).toEqual({ scriptId: 'script-1' });
|
||||
});
|
||||
|
||||
it('returns no-op unsubscribe when listener APIs are unavailable', () => {
|
||||
const originalAdd = (window as any).addEventListener;
|
||||
const originalRemove = (window as any).removeEventListener;
|
||||
(window as any).addEventListener = undefined;
|
||||
(window as any).removeEventListener = undefined;
|
||||
|
||||
const unsubscribe = addWindowEventListener('bds:scripts-changed', vi.fn());
|
||||
expect(typeof unsubscribe).toBe('function');
|
||||
expect(() => unsubscribe()).not.toThrow();
|
||||
|
||||
(window as any).addEventListener = originalAdd;
|
||||
(window as any).removeEventListener = originalRemove;
|
||||
});
|
||||
|
||||
it('subscribes and unsubscribes listeners', () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = addWindowEventListener('bds:scripts-changed', listener);
|
||||
|
||||
dispatchWindowEvent('bds:scripts-changed');
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsubscribe();
|
||||
dispatchWindowEvent('bds:scripts-changed');
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -22,4 +22,10 @@ describe('vite renderer chunking', () => {
|
||||
const resolved = typeof viteConfig === 'function' ? viteConfig({ command: 'build', mode: 'production', isSsrBuild: false, isPreview: false }) : viteConfig;
|
||||
expect(resolved.build?.chunkSizeWarningLimit).toBe(8000);
|
||||
});
|
||||
|
||||
it('excludes pyodide from optimizeDeps pre-bundling', () => {
|
||||
const resolved = typeof viteConfig === 'function' ? viteConfig({ command: 'serve', mode: 'development', isSsrBuild: false, isPreview: false }) : viteConfig;
|
||||
const excluded = resolved.optimizeDeps?.exclude ?? [];
|
||||
expect(excluded).toContain('pyodide');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,4 +41,10 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['pyodide'],
|
||||
},
|
||||
worker: {
|
||||
format: 'es',
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user