From 3ec8819d6dad008b6ae3255b9ab2adf0a46649e5 Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 22 Feb 2026 22:12:30 +0100 Subject: [PATCH] feat: phase 1 of python scripting --- PYTHON_SCRIPTING.md | 17 +- drizzle/0005_short_sally_floyd.sql | 15 + drizzle/meta/0005_snapshot.json | 913 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/main/database/schema.ts | 20 + src/main/engine/ScriptEngine.ts | 264 +++++ src/main/engine/index.ts | 8 + src/main/ipc/handlers.ts | 32 + src/main/preload.ts | 9 + src/main/shared/electronApi.ts | 38 + .../components/ActivityBar/ActivityBar.tsx | 13 + src/renderer/components/Editor/Editor.tsx | 2 + src/renderer/components/Panel/Panel.css | 16 + src/renderer/components/Panel/Panel.tsx | 13 +- .../components/ScriptsView/ScriptsView.css | 32 + .../components/ScriptsView/ScriptsView.tsx | 114 +++ src/renderer/components/Sidebar/Sidebar.tsx | 122 ++- src/renderer/components/TabBar/TabBar.tsx | 10 + src/renderer/components/index.ts | 1 + src/renderer/i18n/locales/de.json | 10 + src/renderer/i18n/locales/en.json | 10 + src/renderer/i18n/locales/es.json | 10 + src/renderer/i18n/locales/fr.json | 10 + src/renderer/i18n/locales/it.json | 10 + src/renderer/navigation/activityBehavior.ts | 9 +- src/renderer/navigation/editorRouting.ts | 4 +- .../navigation/sidebarViewRegistry.ts | 1 + src/renderer/navigation/tabPolicy.ts | 19 + src/renderer/python/runtimeManagerInstance.ts | 7 + src/renderer/store/appStore.ts | 17 +- src/renderer/store/index.ts | 1 + tests/engine/ScriptEngine.test.ts | 137 +++ tests/ipc/handlers.test.ts | 121 +++ tests/renderer/components/Panel.test.tsx | 18 + .../components/ScriptsView.styles.test.ts | 23 + .../renderer/components/ScriptsView.test.tsx | 80 ++ .../components/SidebarScripts.test.tsx | 147 +++ .../navigation/activityBehavior.test.ts | 17 +- .../renderer/navigation/editorRouting.test.ts | 1 + .../navigation/sidebarViewRegistry.test.ts | 1 + tests/renderer/navigation/tabPolicy.test.ts | 32 + tests/renderer/viteConfig.test.ts | 6 + vite.config.ts | 6 + 43 files changed, 2329 insertions(+), 14 deletions(-) create mode 100644 drizzle/0005_short_sally_floyd.sql create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 src/main/engine/ScriptEngine.ts create mode 100644 src/renderer/components/ScriptsView/ScriptsView.css create mode 100644 src/renderer/components/ScriptsView/ScriptsView.tsx create mode 100644 src/renderer/python/runtimeManagerInstance.ts create mode 100644 tests/engine/ScriptEngine.test.ts create mode 100644 tests/renderer/components/ScriptsView.styles.test.ts create mode 100644 tests/renderer/components/ScriptsView.test.tsx create mode 100644 tests/renderer/components/SidebarScripts.test.tsx diff --git a/PYTHON_SCRIPTING.md b/PYTHON_SCRIPTING.md index 8d79890..0784ef4 100644 --- a/PYTHON_SCRIPTING.md +++ b/PYTHON_SCRIPTING.md @@ -108,10 +108,10 @@ Baseline benchmark (22 Feb 2026, local macOS run): 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. +- [x] Script storage model (DB index + filesystem source in `scripts/*.py`). +- [x] CRUD APIs in `main/engine` + `ipc` handlers. +- [x] Renderer scripts list + editor + run button. +- [x] Console/output capture in existing bottom output area. - [ ] Project rebuild picks up `scripts/` changes. Out of scope for MVP: @@ -454,15 +454,18 @@ PR-14+: Optional advanced capabilities ## 10. Current Status -Status: Phase 0 in progress (MVP-first, full-scope preserved). +Status: Phase 1 in progress (MVP-first, full-scope preserved). Progress update (22 Feb 2026): - [x] PR-00 complete: Pyodide dependency + renderer worker bootstrap + ready signal. - [x] PR-01 complete: worker run/stdout/error protocol + timeout watchdog + runtime recovery. - [x] PR-02 complete: ABI v1 shared types/schemas + caller/worker validation. - [x] Phase 0 benchmark harness + baseline capture complete. +- [x] PR-03 complete: scripts table + `ScriptEngine` filesystem/DB round-trip persistence. +- [x] PR-04 complete: script CRUD IPC handlers + preload/shared API typing + IPC tests. +- [x] PR-05 complete: renderer scripts list/editor/run flow + output panel integration. Recommended next action: -1. Start Phase 1 PR-03: script persistence model (`scripts/*.py` + index metadata). -2. Add round-trip tests for create/update/delete between filesystem and DB. +1. Start Phase 1 PR-06: include `scripts/` in rebuild/meta-diff synchronization. +2. Keep scripts API access in renderer views/store paths only (no deep component IPC sprawl). 3. Keep benchmark command in CI/manual perf checks for regressions. diff --git a/drizzle/0005_short_sally_floyd.sql b/drizzle/0005_short_sally_floyd.sql new file mode 100644 index 0000000..c1c1a45 --- /dev/null +++ b/drizzle/0005_short_sally_floyd.sql @@ -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`); \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..5370a9e --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index bbc9190..5c1762b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 5453e2e..be695c1 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -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; diff --git a/src/main/engine/ScriptEngine.ts b/src/main/engine/ScriptEngine.ts new file mode 100644 index 0000000..63d3b65 --- /dev/null +++ b/src/main/engine/ScriptEngine.ts @@ -0,0 +1,264 @@ +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 { + 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); + + await fs.mkdir(this.getScriptsDir(), { recursive: true }); + await fs.writeFile(filePath, input.content, 'utf-8'); + + 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 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 { + const existing = await this.getScriptRow(id); + if (!existing) { + return null; + } + + const allScripts = await this.getAllScriptRows(); + const desiredSlug = this.normalizeSlug(updates.slug || 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); + } + + if (typeof updates.content === 'string') { + await fs.writeFile(nextFilePath, updates.content, 'utf-8'); + } + + await getDatabase().getLocal() + .update(scripts) + .set({ + title: updates.title ?? existing.title, + slug: nextSlug, + kind: updates.kind ?? existing.kind, + entrypoint: updates.entrypoint ?? existing.entrypoint, + enabled: updates.enabled ?? existing.enabled, + filePath: nextFilePath, + version: existing.version + 1, + 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 { + 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 { + const row = await this.getScriptRow(id); + if (!row) { + return null; + } + return this.toScriptData(row); + } + + async getAllScripts(): Promise { + const rows = await this.getAllScriptRows(); + return Promise.all(rows.map((item) => this.toScriptData(item))); + } + + private async getScriptRow(id: string): Promise