diff --git a/API.md b/API.md index 127ff5c..4276dbd 100644 --- a/API.md +++ b/API.md @@ -1,6 +1,6 @@ # API Documentation -Contract version: 1.9.0 +Contract version: 1.10.0 This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide. @@ -26,6 +26,7 @@ project = await bds.meta.get_project_metadata() - [app](#app) - [meta](#meta) - [tags](#tags) +- [chat](#chat) - [sync](#sync) - [publish](#publish) - [Data Structures](#data-structures) @@ -378,6 +379,7 @@ result = await bds.posts.create(data={}) 'content': 'value', 'status': 'draft', 'author': 'value', + 'language': 'value', 'createdAt': 'value', 'updatedAt': 'value', 'publishedAt': 'value', @@ -421,6 +423,7 @@ None # or 'content': 'value', 'status': 'draft', 'author': 'value', + 'language': 'value', 'createdAt': 'value', 'updatedAt': 'value', 'publishedAt': 'value', @@ -488,6 +491,7 @@ None # or 'content': 'value', 'status': 'draft', 'author': 'value', + 'language': 'value', 'createdAt': 'value', 'updatedAt': 'value', 'publishedAt': 'value', @@ -581,6 +585,7 @@ result = await bds.posts.get_by_status(status='status') 'content': 'value', 'status': 'draft', 'author': 'value', + 'language': 'value', 'createdAt': 'value', 'updatedAt': 'value', 'publishedAt': 'value', @@ -624,6 +629,7 @@ None # or 'content': 'value', 'status': 'draft', 'author': 'value', + 'language': 'value', 'createdAt': 'value', 'updatedAt': 'value', 'publishedAt': 'value', @@ -666,6 +672,7 @@ None # or 'content': 'value', 'status': 'draft', 'author': 'value', + 'language': 'value', 'createdAt': 'value', 'updatedAt': 'value', 'publishedAt': 'value', @@ -807,6 +814,7 @@ result = await bds.posts.filter(filter={}) 'content': 'value', 'status': 'draft', 'author': 'value', + 'language': 'value', 'createdAt': 'value', 'updatedAt': 'value', 'publishedAt': 'value', @@ -999,6 +1007,7 @@ result = await bds.posts.get_links_to(id='id-1') 'content': 'value', 'status': 'draft', 'author': 'value', + 'language': 'value', 'createdAt': 'value', 'updatedAt': 'value', 'publishedAt': 'value', @@ -1041,6 +1050,7 @@ result = await bds.posts.get_linked_by(id='id-1') 'content': 'value', 'status': 'draft', 'author': 'value', + 'language': 'value', 'createdAt': 'value', 'updatedAt': 'value', 'publishedAt': 'value', @@ -3448,6 +3458,40 @@ result = await bds.tags.sync_from_posts() [↑ Back to Table of contents](#table-of-contents) +## chat + +**Module APIs** + +- [chat.detectPostLanguage](#chatdetectpostlanguage) + +### chat.detectPostLanguage + +Detect the language of a post from its title and content. + +**Parameters** + +- title (str, required) +- content (str, required) + +**Response specification** + +- Return type: `{ success: boolean; language?: string; error?: string }` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.detect_post_language(title='title', content='content') +``` + +**Example response** + +```python +{} +``` + +[↑ Back to Table of contents](#table-of-contents) + ## sync **Module APIs** @@ -3821,6 +3865,7 @@ Canonical post object used across editor and generation flows. - content (`string`, required): Markdown body content. - status (`'draft' | 'published' | 'archived'`, required): Publication lifecycle state. - author (`string`, optional): Optional author name. +- language (`string`, optional): Optional per-post language code (e.g. en, de, fr, it, es). - createdAt (`string`, required): Creation timestamp (ISO string). - updatedAt (`string`, required): Last update timestamp (ISO string). - publishedAt (`string`, optional): Publication timestamp for published posts. diff --git a/drizzle/0011_loving_alex_wilder.sql b/drizzle/0011_loving_alex_wilder.sql new file mode 100644 index 0000000..51d835e --- /dev/null +++ b/drizzle/0011_loving_alex_wilder.sql @@ -0,0 +1 @@ +ALTER TABLE `posts` ADD `language` text; \ No newline at end of file diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..d1aa865 --- /dev/null +++ b/drizzle/meta/0011_snapshot.json @@ -0,0 +1,1439 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3312ee41-2b01-4579-aa9b-7fd4f0f3c7a4", + "prevId": "b3e7e63c-d906-48af-98a0-e3d0741ff13a", + "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": {} + }, + "db_notifications": { + "name": "db_notifications", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_cli": { + "name": "from_cli", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "seen_at": { + "name": "seen_at", + "type": "integer", + "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": {} + }, + "ai_models": { + "name": "ai_models", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "family": { + "name": "family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachment": { + "name": "attachment", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "reasoning": { + "name": "reasoning", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "tool_call": { + "name": "tool_call", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "structured_output": { + "name": "structured_output", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "temperature": { + "name": "temperature", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "knowledge": { + "name": "knowledge", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "release_date": { + "name": "release_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_updated_date": { + "name": "last_updated_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_weights": { + "name": "open_weights", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "input_price": { + "name": "input_price", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_price": { + "name": "output_price", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_price": { + "name": "cache_read_price", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_price": { + "name": "cache_write_price", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_input_tokens": { + "name": "max_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interleaved": { + "name": "interleaved", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_npm": { + "name": "provider_npm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ai_models_provider_model_id_pk": { + "columns": [ + "provider", + "model_id" + ], + "name": "ai_models_provider_model_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_catalog_meta": { + "name": "ai_catalog_meta", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_model_modalities": { + "name": "ai_model_modalities", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modality": { + "name": "modality", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ai_model_modalities_provider_model_id_direction_modality_pk": { + "columns": [ + "provider", + "model_id", + "direction", + "modality" + ], + "name": "ai_model_modalities_provider_model_id_direction_modality_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_providers": { + "name": "ai_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm": { + "name": "npm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api": { + "name": "api", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "doc": { + "name": "doc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "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 + }, + "template_slug": { + "name": "template_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "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 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'published'" + }, + "content": { + "name": "content", + "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": { + "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 + }, + "post_template_slug": { + "name": "post_template_slug", + "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": {} + }, + "templates": { + "name": "templates", + "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": "'post'" + }, + "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 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'published'" + }, + "content": { + "name": "content", + "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": { + "templates_project_slug_idx": { + "name": "templates_project_slug_idx", + "columns": [ + "project_id", + "slug" + ], + "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 07a31d4..c590da5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1772380619098, "tag": "0009_model_catalog_v2", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1772462693094, + "tag": "0011_loving_alex_wilder", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index cda75aa..d48aecc 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -34,6 +34,7 @@ export const posts = sqliteTable('posts', { tags: text('tags'), // JSON array stored as text categories: text('categories'), // JSON array stored as text templateSlug: text('template_slug'), // Optional user template override for this post + language: text('language'), // Optional per-post language override (ISO code, e.g. 'en', 'de') // Legacy columns (kept for migration compatibility, no longer written) publishedTitle: text('published_title'), publishedContent: text('published_content'), diff --git a/src/main/engine/GenerationSitemapFeedService.ts b/src/main/engine/GenerationSitemapFeedService.ts index 8f669ea..1cefc24 100644 --- a/src/main/engine/GenerationSitemapFeedService.ts +++ b/src/main/engine/GenerationSitemapFeedService.ts @@ -397,6 +397,7 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema ` ${escapeXml(permalink)}`, ` ${(post.publishedAt || post.updatedAt).toUTCString()}`, post.author ? ` ${escapeXml(post.author)}` : null, + (post as { language?: string }).language ? ` ${escapeXml((post as { language?: string }).language!)}` : null, ` `, ` `, ...categories.map((entry) => ` ${entry}`), @@ -406,7 +407,7 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema const rssXml = [ '', - '', + '', ' ', ` ${escapeXml(feedTitle)}`, ` ${escapeXml(baseLink)}`, @@ -430,8 +431,10 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema ...(post.categories || []).map((category) => ``), ]; + const postLanguageAttr = (post as { language?: string }).language ? ` xml:lang="${escapeXml((post as { language?: string }).language!)}"` : ''; + return [ - ' ', + ` `, ` ${escapeXml(post.title)}`, ` ${escapeXml(permalink)}`, ` `, diff --git a/src/main/engine/MetadataDiffEngine.ts b/src/main/engine/MetadataDiffEngine.ts index 8fc498b..71e64ae 100644 --- a/src/main/engine/MetadataDiffEngine.ts +++ b/src/main/engine/MetadataDiffEngine.ts @@ -25,7 +25,7 @@ export interface FieldDifference { /** * The fields that can have differences */ -export type DiffField = 'tags' | 'categories' | 'title' | 'excerpt' | 'author'; +export type DiffField = 'tags' | 'categories' | 'title' | 'excerpt' | 'author' | 'language'; /** * Metadata differences for a single post @@ -248,6 +248,11 @@ export class MetadataDiffEngine extends EventEmitter { differences.author = { dbValue: dbPost.author || '', fileValue: fileData.author || '' }; } + // Compare language + if ((dbPost.language || '') !== (fileData.language || '')) { + differences.language = { dbValue: dbPost.language || '', fileValue: fileData.language || '' }; + } + return { postId: dbPost.id, title: dbPost.title, @@ -279,7 +284,7 @@ export class MetadataDiffEngine extends EventEmitter { // Get all published posts with file paths const result = await client.execute({ - sql: `SELECT id, title, slug, file_path, tags, categories, excerpt, author + sql: `SELECT id, title, slug, file_path, tags, categories, excerpt, author, language FROM posts WHERE project_id = ? AND status = 'published' @@ -331,6 +336,7 @@ export class MetadataDiffEngine extends EventEmitter { title: 'Title', excerpt: 'Excerpt', author: 'Author', + language: 'Language', }; for (const diff of diffs) { @@ -428,6 +434,9 @@ export class MetadataDiffEngine extends EventEmitter { if (!field || field === 'author') { updateData.author = fileData.author || null; } + if (!field || field === 'language') { + updateData.language = fileData.language || null; + } // Update database await db diff --git a/src/main/engine/PageRenderer.ts b/src/main/engine/PageRenderer.ts index 5be2d80..9fc218b 100644 --- a/src/main/engine/PageRenderer.ts +++ b/src/main/engine/PageRenderer.ts @@ -62,6 +62,7 @@ export interface TemplatePostEntry { title: string; content: string; show_title: boolean; + language?: string; } export interface CategoryRenderSettings { @@ -1485,8 +1486,12 @@ export class PageRenderer { const canonicalPostPathBySlug = mapToRecord(rewriteContext.canonicalPostPathBySlug); + // Per-post language overrides the page-level language when present + const postLanguage = (renderablePost as { language?: string }).language; + const context: SinglePostTemplateContext = { ...pageContext, + language: postLanguage || pageContext.language, menu_items: pageContext.menu_items ?? [], post: { id: renderablePost.id, @@ -1494,6 +1499,7 @@ export class PageRenderer { title: renderablePost.title, content: renderablePost.content, show_title: false, + language: postLanguage, }, post_categories: postCategories, post_tags: postTags, diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index 1ac2fdd..5e8d062 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -24,6 +24,7 @@ export interface PostData { content: string; status: 'draft' | 'published' | 'archived'; author?: string; + language?: string; createdAt: Date; updatedAt: Date; publishedAt?: Date; @@ -39,6 +40,7 @@ export interface PostMetadata { excerpt?: string; status: 'draft' | 'published' | 'archived'; author?: string; + language?: string; createdAt: string; updatedAt: string; publishedAt?: string; @@ -319,6 +321,7 @@ export class PostEngine extends EventEmitter { // Only add optional fields if they have values (gray-matter can't serialize undefined) if (post.excerpt) metadata.excerpt = post.excerpt; if (post.author) metadata.author = post.author; + if (post.language) metadata.language = post.language; if (post.publishedAt) metadata.publishedAt = post.publishedAt.toISOString(); // Use date-based directory structure (posts/YYYY/MM/) @@ -392,6 +395,7 @@ export class PostEngine extends EventEmitter { content: data.content || '', status: data.status || 'draft', author: data.author, + language: data.language, createdAt: now, updatedAt: now, publishedAt: data.publishedAt, @@ -418,6 +422,7 @@ export class PostEngine extends EventEmitter { checksum, tags: JSON.stringify(post.tags), categories: JSON.stringify(post.categories), + language: post.language || null, }; await db.insert(posts).values(dbPost); @@ -445,7 +450,9 @@ export class PostEngine extends EventEmitter { data.title !== undefined || data.tags !== undefined || data.categories !== undefined || - data.excerpt !== undefined; + data.excerpt !== undefined || + data.language !== undefined || + data.author !== undefined; let newStatus = data.status || existing.status; if (existing.status === 'published' && isContentOrMetadataChange && !data.status) { @@ -484,6 +491,7 @@ export class PostEngine extends EventEmitter { checksum, tags: JSON.stringify(updated.tags), categories: JSON.stringify(updated.categories), + language: updated.language || null, }) .where(eq(posts.id, id)); @@ -576,6 +584,7 @@ export class PostEngine extends EventEmitter { content: body, status: dbPost.status as 'draft' | 'published' | 'archived', author: dbPost.author || undefined, + language: (dbPost as { language?: string | null }).language || undefined, createdAt: dbPost.createdAt, updatedAt: dbPost.updatedAt, publishedAt: dbPost.publishedAt || undefined, @@ -1331,6 +1340,7 @@ export class PostEngine extends EventEmitter { checksum, tags: JSON.stringify(published.tags), categories: JSON.stringify(published.categories), + language: published.language || null, }) .where(eq(posts.id, id)); @@ -1550,6 +1560,7 @@ export class PostEngine extends EventEmitter { content: null, status: 'published', author: fileData.author, + language: fileData.language || null, createdAt: fileData.createdAt, updatedAt: fileData.updatedAt, publishedAt: nextPublishedAt, @@ -1579,6 +1590,7 @@ export class PostEngine extends EventEmitter { content: fileData.content, status: 'published', author: fileData.author || undefined, + language: fileData.language || undefined, createdAt: fileData.createdAt, updatedAt: fileData.updatedAt, publishedAt: nextPublishedAt || undefined, @@ -1630,6 +1642,7 @@ export class PostEngine extends EventEmitter { content: null, status: 'published', author: fileData.author, + language: fileData.language || null, createdAt: fileData.createdAt, updatedAt: fileData.updatedAt, publishedAt, @@ -1856,6 +1869,7 @@ export class PostEngine extends EventEmitter { content: null, status: 'published', author: postData.author, + language: postData.language || null, createdAt: postData.createdAt, updatedAt: postData.updatedAt, publishedAt: postData.publishedAt || postData.updatedAt, diff --git a/src/main/engine/TaskManager.ts b/src/main/engine/TaskManager.ts index db354a1..1ff40ee 100644 --- a/src/main/engine/TaskManager.ts +++ b/src/main/engine/TaskManager.ts @@ -27,6 +27,7 @@ export interface Task { export class TaskManager extends EventEmitter { private tasks: Map = new Map(); private runningTasks: Map = new Map(); + private externalTasks: Set = new Set(); private maxConcurrentTasks = 3; private taskQueue: Task[] = []; @@ -136,7 +137,80 @@ export class TaskManager extends EventEmitter { } } + // --------------------------------------------------------------------------- + // External tasks — lifecycle controlled by the caller (e.g. renderer-side + // utility script execution) rather than an execute() callback. + // --------------------------------------------------------------------------- + + startExternalTask(taskId: string, name: string): void { + const progress: TaskProgress = { + taskId, + name, + status: 'running', + progress: 0, + message: 'Running…', + startTime: new Date(), + }; + + this.tasks.set(taskId, progress); + this.externalTasks.add(taskId); + this.emit('taskCreated', progress); + this.emit('taskStarted', progress); + } + + updateExternalTaskProgress(taskId: string, progress: number, message: string): void { + const entry = this.tasks.get(taskId); + if (!entry || !this.externalTasks.has(taskId)) { + return; + } + + entry.progress = progress; + entry.message = message; + this.emit('taskProgress', { ...entry }); + } + + completeExternalTask(taskId: string): void { + const entry = this.tasks.get(taskId); + if (!entry || !this.externalTasks.has(taskId)) { + return; + } + + entry.status = 'completed'; + entry.progress = 100; + entry.message = 'Completed'; + entry.endTime = new Date(); + this.externalTasks.delete(taskId); + this.emit('taskCompleted', entry); + } + + failExternalTask(taskId: string, error: string): void { + const entry = this.tasks.get(taskId); + if (!entry || !this.externalTasks.has(taskId)) { + return; + } + + entry.status = 'failed'; + entry.error = error; + entry.message = `Failed: ${error}`; + entry.endTime = new Date(); + this.externalTasks.delete(taskId); + this.emit('taskFailed', entry); + } + cancelTask(taskId: string): boolean { + // Check external tasks first + if (this.externalTasks.has(taskId)) { + this.externalTasks.delete(taskId); + const progress = this.tasks.get(taskId); + if (progress) { + progress.status = 'cancelled'; + progress.message = 'Cancelled'; + progress.endTime = new Date(); + this.emit('taskCancelled', progress); + } + return true; + } + const controller = this.runningTasks.get(taskId); if (controller) { controller.abort(); diff --git a/src/main/engine/ai/tasks.ts b/src/main/engine/ai/tasks.ts index 2544d4e..bbaa693 100644 --- a/src/main/engine/ai/tasks.ts +++ b/src/main/engine/ai/tasks.ts @@ -30,6 +30,12 @@ export interface ImageAnalysisResult { error?: string; } +export interface LanguageDetectionResult { + success: boolean; + language?: string; + error?: string; +} + // --------------------------------------------------------------------------- // OneShotTasks // --------------------------------------------------------------------------- @@ -275,4 +281,70 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu return { success: false, error: (error as Error).message }; } } + + /** + * Detect the language of a post based on its title and content. + * Uses the configured title model (lightweight, text-only). + */ + async detectPostLanguage( + title: string, + content: string, + ): Promise { + // Use the title model — lightweight, text-only task + let modelId = await this.chatEngine.getSetting('chat_title_model'); + if (!modelId || !this.providers.isProviderKeySet(this.providers.detectModelProvider(modelId))) { + modelId = this.providers.getOpencodeKey() + ? 'claude-sonnet-4-5' + : this.providers.getMistralKey() + ? 'mistral-large-latest' + : null; + } + + // In offline mode, swap to configured offline title model + if (this.providers.isOfflineMode()) { + const offlineModel = await this.chatEngine.getSetting('offline_title_model') + || this.providers.getFirstKnownLocalModelId(); + if (offlineModel) { + modelId = offlineModel; + } else if (!modelId || (!this.providers.isOllamaModel(modelId) && !this.providers.isLmstudioModel(modelId))) { + return { success: false, error: 'No offline model configured. Set one in Settings → AI → Airplane Mode.' }; + } + } + + if (!modelId) { + return { success: false, error: 'API key not configured. Please set an API key in Settings.' }; + } + + const snippet = content.slice(0, 500); + const supportedLanguages = ['en', 'de', 'fr', 'it', 'es']; + + const systemPrompt = `You are a language detection assistant. Given a blog post title and a content snippet, determine the language of the text. Respond with ONLY a JSON object: { "language": "" } where is one of: ${supportedLanguages.join(', ')}. If the language is not in the list, pick the closest match. No other text.`; + + const userPrompt = `Title: ${title}\n\nContent:\n${snippet}`; + + try { + const model = this.providers.resolveModel(modelId); + + const { text } = await generateText({ + model, + system: systemPrompt, + prompt: userPrompt, + maxOutputTokens: 50, + maxRetries: 2, + }); + + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) return { success: false, error: 'Invalid response format from AI' }; + + const result = JSON.parse(jsonMatch[0]); + const detected = (result.language || '').toLowerCase().trim(); + if (!supportedLanguages.includes(detected)) { + return { success: false, error: `Unsupported language detected: ${detected}` }; + } + + return { success: true, language: detected }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + } } diff --git a/src/main/engine/postFileUtils.ts b/src/main/engine/postFileUtils.ts index 1b81ba3..8e57470 100644 --- a/src/main/engine/postFileUtils.ts +++ b/src/main/engine/postFileUtils.ts @@ -14,6 +14,7 @@ export interface PostFileData { content: string; status: 'draft' | 'published' | 'archived'; author?: string; + language?: string; createdAt: Date; updatedAt: Date; publishedAt?: Date; @@ -28,6 +29,7 @@ interface PostFileMetadata { excerpt?: string; status: 'draft' | 'published' | 'archived'; author?: string; + language?: string; createdAt: string; updatedAt: string; publishedAt?: string; @@ -62,6 +64,7 @@ export async function readPostFile(filePath: string): Promise { + try { + await ensureInitialized(); + return await getOneShotTasks().detectPostLanguage(title, content); + } catch (error) { + console.error('[Chat IPC] Error detecting post language:', error); + return { success: false, error: (error as Error).message }; + } + }); + // ============ A2UI Actions ============ ipcMain.handle('a2ui:dispatch', async (_, action: { surfaceId: string; componentId: string; action: string; payload?: Record }) => { diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index fe008b2..0dc0d3d 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -416,6 +416,15 @@ export function registerIpcHandlers(bundle: EngineBundle): void { data.author = metadata.defaultAuthor; } } + + // If no language provided, default from project settings + if (!data.language) { + const metaEngine = bundle.metaEngine; + const metadata = await metaEngine.getProjectMetadata(); + if (metadata?.mainLanguage) { + data.language = metadata.mainLanguage; + } + } return engine.createPost(data); }); @@ -828,6 +837,20 @@ export function registerIpcHandlers(bundle: EngineBundle): void { return true; }); + // ============ Script Task Lifecycle (external tasks for utility scripts) ==== + + safeHandle('scripts:startTask', async (_, taskId: string, name: string) => { + bundle.taskManager.startExternalTask(taskId, name); + }); + + safeHandle('scripts:completeTask', async (_, taskId: string) => { + bundle.taskManager.completeExternalTask(taskId); + }); + + safeHandle('scripts:failTask', async (_, taskId: string, error: string) => { + bundle.taskManager.failExternalTask(taskId, error); + }); + // ============ Template Handlers ============ safeHandle('templates:create', async (_, data: CreateTemplateInput) => { diff --git a/src/main/preload.ts b/src/main/preload.ts index 4b3263b..289a14e 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -110,6 +110,9 @@ export const electronAPI: ElectronAPI = { getAll: () => ipcRenderer.invoke('scripts:getAll'), getEnabledMacroSlugs: () => ipcRenderer.invoke('scripts:getEnabledMacroSlugs'), rebuildFromFiles: () => ipcRenderer.invoke('scripts:rebuildFromFiles'), + startTask: (taskId: string, name: string) => ipcRenderer.invoke('scripts:startTask', taskId, name), + completeTask: (taskId: string) => ipcRenderer.invoke('scripts:completeTask', taskId), + failTask: (taskId: string, error: string) => ipcRenderer.invoke('scripts:failTask', taskId, error), }, // Templates @@ -376,6 +379,9 @@ export const electronAPI: ElectronAPI = { // Media Analysis analyzeMediaImage: (mediaId: string, language?: string) => ipcRenderer.invoke('chat:analyzeMediaImage', mediaId, language), + // Post Language Detection + detectPostLanguage: (title: string, content: string) => ipcRenderer.invoke('chat:detectPostLanguage', title, content), + // Event listeners for streaming/progress onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => { const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; delta: string }) => callback(data); diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 581f531..2d061b8 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -94,6 +94,7 @@ export interface PostData { content: string; status: 'draft' | 'published' | 'archived'; author?: string; + language?: string; createdAt: string; updatedAt: string; publishedAt?: string; @@ -643,6 +644,12 @@ export interface ElectronAPI { /** Internal: editor macro plugin helper. Not exposed via Python API contract. */ getEnabledMacroSlugs: () => Promise; rebuildFromFiles: () => Promise; + /** Create a task entry for a running utility script. */ + startTask: (taskId: string, name: string) => Promise; + /** Mark a utility script task as completed. */ + completeTask: (taskId: string) => Promise; + /** Mark a utility script task as failed. */ + failTask: (taskId: string, error: string) => Promise; }; templates: { create: (data: { @@ -894,6 +901,9 @@ export interface ElectronAPI { // Media Analysis analyzeMediaImage: (mediaId: string, language?: string) => Promise<{ success: boolean; title?: string; alt?: string; caption?: string; error?: string }>; + // Post Language Detection + detectPostLanguage: (title: string, content: string) => Promise<{ success: boolean; language?: string; error?: string }>; + // Event listeners for streaming/progress onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void; onToolCall: (callback: (data: ChatToolCall) => void) => () => void; diff --git a/src/main/shared/pythonApiContractV1.ts b/src/main/shared/pythonApiContractV1.ts index e44f93e..bdb058f 100644 --- a/src/main/shared/pythonApiContractV1.ts +++ b/src/main/shared/pythonApiContractV1.ts @@ -188,6 +188,9 @@ const METHODS_V1: PythonApiMethodContractV1[] = [ // expensive external API calls that require user oversight and interactive streaming. // This namespace can be re-added in a future version if AI-from-Python becomes a // supported use case with proper rate limiting and cost controls. + // Exception: detectPostLanguage is exposed as a lightweight one-shot task. + + method('chat.detectPostLanguage', 'Detect the language of a post from its title and content.', [requiredString('title'), requiredString('content')], '{ success: boolean; language?: string; error?: string }'), method('sync.checkAvailability', 'Check if git is available.', [], 'GitAvailability'), method('sync.getRepoState', 'Get repository state for active project.', [], 'RepoState'), @@ -239,6 +242,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [ { name: 'content', type: 'string', required: true, description: 'Markdown body content.' }, { name: 'status', type: "'draft' | 'published' | 'archived'", required: true, description: 'Publication lifecycle state.' }, { name: 'author', type: 'string', required: false, description: 'Optional author name.' }, + { name: 'language', type: 'string', required: false, description: 'Optional per-post language code (e.g. en, de, fr, it, es).' }, { name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' }, { name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' }, { name: 'publishedAt', type: 'string', required: false, description: 'Publication timestamp for published posts.' }, @@ -404,7 +408,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [ ]; export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = { - version: '1.9.0', + version: '1.10.0', generatedAt: '2026-02-27T00:00:00.000Z', methods: METHODS_V1, dataStructures: DATA_STRUCTURES_V1, diff --git a/src/renderer/components/Editor/Editor.css b/src/renderer/components/Editor/Editor.css index b258157..2954fe3 100644 --- a/src/renderer/components/Editor/Editor.css +++ b/src/renderer/components/Editor/Editor.css @@ -192,6 +192,23 @@ gap: 12px; } +.editor-language-row { + display: flex; + gap: 6px; + align-items: center; +} + +.editor-language-row select { + flex: 1; +} + +.editor-language-row button.compact { + padding: 6px 8px; + font-size: 13px; + min-width: unset; + line-height: 1; +} + .editor-body { flex: 1; display: flex; diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 54630bd..44793d8 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -75,6 +75,9 @@ const autoSaveManager = new AutoSaveManager({ if ('templateSlug' in changes) { (update as Record).templateSlug = changes.templateSlug as string || null; } + if ('language' in changes) { + update.language = changes.language as string || undefined; + } const updated = await window.electronAPI?.posts.update(id, update); if (updated) { @@ -196,8 +199,10 @@ export const PostEditor: React.FC = ({ postId }) => { const [tags, setTags] = useState([]); const [selectedCategories, setSelectedCategories] = useState(['article']); const [templateSlug, setTemplateSlug] = useState(''); + const [postLanguage, setPostLanguage] = useState(''); const [availablePostTemplates, setAvailablePostTemplates] = useState>([]); const [isSaving, setIsSaving] = useState(false); + const [isDetectingLanguage, setIsDetectingLanguage] = useState(false); const [hasPublishedVersion, setHasPublishedVersion] = useState(false); const [editorMode, setEditorMode] = useState(preferredEditorMode); const [previewUrl, setPreviewUrl] = useState(null); @@ -326,6 +331,7 @@ export const PostEditor: React.FC = ({ postId }) => { setTags(post.tags); setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']); setTemplateSlug((post as PostData & { templateSlug?: string }).templateSlug || ''); + setPostLanguage(post.language || ''); setMetadataExpanded(post.title === ''); markClean(postId); // Mark as initialized AFTER setting local state @@ -347,7 +353,8 @@ export const PostEditor: React.FC = ({ postId }) => { const titleChanged = title !== post.title; const authorChanged = author !== (post.author || ''); const templateSlugChanged = templateSlug !== ((post as PostData & { templateSlug?: string }).templateSlug || ''); - const hasChanges = contentChanged || titleChanged || authorChanged || templateSlugChanged || + const languageChanged = postLanguage !== (post.language || ''); + const hasChanges = contentChanged || titleChanged || authorChanged || templateSlugChanged || languageChanged || JSON.stringify(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort()) || JSON.stringify(selectedCategories.slice().sort()) !== JSON.stringify(post.categories.slice().sort()); @@ -362,11 +369,12 @@ export const PostEditor: React.FC = ({ postId }) => { tags: tags.join(', '), categories: selectedCategories, templateSlug: templateSlug || undefined, + language: postLanguage || undefined, }); } else { markClean(postId); } - }, [title, content, author, tags, selectedCategories, templateSlug, post, postId, isInitialized, isDirty, markDirty, markClean]); + }, [title, content, author, tags, selectedCategories, templateSlug, postLanguage, post, postId, isInitialized, isDirty, markDirty, markClean]); // Handle editor mode change and persist preference const handleEditorModeChange = (mode: EditorMode) => { @@ -386,6 +394,7 @@ export const PostEditor: React.FC = ({ postId }) => { title, content, author: author || undefined, + language: postLanguage || undefined, tags, categories: selectedCategories.length > 0 ? selectedCategories : ['article'], templateSlug: templateSlug || null, @@ -409,6 +418,24 @@ export const PostEditor: React.FC = ({ postId }) => { } }, [postId, title, content, author, tags, selectedCategories, isDirty, isSaving, updatePost, markClean, showErrorModal]); + const handleDetectLanguage = useCallback(async () => { + if (isDetectingLanguage || (!title && !content)) return; + setIsDetectingLanguage(true); + try { + const result = await window.electronAPI?.chat.detectPostLanguage(title, content); + if (result?.success && result.language) { + setPostLanguage(result.language); + showToast.success(tr('editor.post.quickActions.languageDetected')); + } else { + showToast.error(result?.error || tr('editor.post.quickActions.detectLanguageFailed')); + } + } catch (error) { + console.error('Failed to detect post language:', error); + showToast.error(tr('editor.post.quickActions.detectLanguageFailed')); + } finally { + setIsDetectingLanguage(false); + } + }, [title, content, isDetectingLanguage, tr]); const handlePublish = async () => { await handleSave(); try { @@ -791,6 +818,30 @@ export const PostEditor: React.FC = ({ postId }) => { placeholder={tr('editor.placeholder.author')} /> +
+ +
+ + +
+
diff --git a/src/renderer/components/ScriptsView/ScriptsView.tsx b/src/renderer/components/ScriptsView/ScriptsView.tsx index 996be30..68cd6d4 100644 --- a/src/renderer/components/ScriptsView/ScriptsView.tsx +++ b/src/renderer/components/ScriptsView/ScriptsView.tsx @@ -363,11 +363,27 @@ export const ScriptsView: React.FC = ({ scriptId }) => { setIsRunning(true); + const isUtility = kind === 'utility'; + const taskId = isUtility ? `script-${script.id}-${Date.now()}` : undefined; + + if (isUtility && taskId) { + await window.electronAPI?.scripts.startTask(taskId, title || script.title); + } + try { const runtimeManager = getPythonRuntimeManager(); const result = await runtimeManager.execute(scriptContent, { cacheKey: buildCacheKey(script, scriptContent), entrypoint, + timeoutMs: 0, + onStdout: (chunk: string) => { + appendPanelOutputEntry({ + id: `output-${Date.now()}-stdout-stream`, + message: chunk, + createdAt: new Date().toISOString(), + kind: 'stdout', + }); + }, }); const now = new Date().toISOString(); @@ -380,21 +396,21 @@ export const ScriptsView: React.FC = ({ scriptId }) => { }); } - if (result.stdout.trim().length > 0) { - appendPanelOutputEntry({ - id: `output-${Date.now()}-stdout`, - message: result.stdout, - createdAt: now, - kind: 'stdout', - }); + if (isUtility && taskId) { + await window.electronAPI?.scripts.completeTask(taskId); } } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); appendPanelOutputEntry({ id: `output-${Date.now()}-error`, - message: error instanceof Error ? error.message : String(error), + message: errorMessage, createdAt: new Date().toISOString(), kind: 'error', }); + + if (isUtility && taskId) { + await window.electronAPI?.scripts.failTask(taskId, errorMessage); + } } finally { setIsRunning(false); } diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index 8d1b72e..1815cbf 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -517,6 +517,13 @@ "editor.field.content": "Inhalt", "editor.field.template": "Vorlage", "editor.field.templateDefault": "Standard", + "editor.field.language": "Sprache", + "editor.field.languageDefault": "Projektstandard", + "language.en": "Englisch", + "language.de": "Deutsch", + "language.fr": "Französisch", + "language.it": "Italienisch", + "language.es": "Spanisch", "editor.placeholder.tags": "Tags hinzufügen...", "editor.placeholder.author": "Autorenname", "editor.placeholder.categories": "Kategorien hinzufügen...", @@ -879,6 +886,10 @@ "editor.media.quickActions.button": "⚡ Schnellaktionen", "editor.media.quickActions.aiTitle": "KI: Titel, Alt-Text und Bildunterschrift erzeugen", "editor.media.quickActions.aiDescription": "Analysiert das Bild und schlägt Metadaten vor", + "editor.post.quickActions.detectLanguageDescription": "Sprache mit KI erkennen", + "editor.post.quickActions.detecting": "Erkennung…", + "editor.post.quickActions.languageDetected": "Sprache erkannt", + "editor.post.quickActions.detectLanguageFailed": "Spracherkennung fehlgeschlagen", "editor.media.replaceFile": "Datei ersetzen", "editor.media.field.fileName": "Dateiname", "editor.media.field.type": "Typ", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index db01d5a..15d1393 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -517,6 +517,13 @@ "editor.field.content": "Content", "editor.field.template": "Template", "editor.field.templateDefault": "Default", + "editor.field.language": "Language", + "editor.field.languageDefault": "Project default", + "language.en": "English", + "language.de": "German", + "language.fr": "French", + "language.it": "Italian", + "language.es": "Spanish", "editor.placeholder.tags": "Add tags...", "editor.placeholder.author": "Author name", "editor.placeholder.categories": "Add categories...", @@ -879,6 +886,10 @@ "editor.media.quickActions.button": "⚡ Quick Actions", "editor.media.quickActions.aiTitle": "AI: Generate Title, Alt & Caption", "editor.media.quickActions.aiDescription": "Analyzes the image to suggest metadata", + "editor.post.quickActions.detectLanguageDescription": "Detect language using AI", + "editor.post.quickActions.detecting": "Detecting…", + "editor.post.quickActions.languageDetected": "Language detected", + "editor.post.quickActions.detectLanguageFailed": "Language detection failed", "editor.media.replaceFile": "Replace File", "editor.media.field.fileName": "File Name", "editor.media.field.type": "Type", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index cc8e30f..35072a5 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -517,6 +517,13 @@ "editor.field.content": "Contenido", "editor.field.template": "Plantilla", "editor.field.templateDefault": "Predeterminada", + "editor.field.language": "Idioma", + "editor.field.languageDefault": "Predeterminado del proyecto", + "language.en": "Inglés", + "language.de": "Alemán", + "language.fr": "Francés", + "language.it": "Italiano", + "language.es": "Español", "editor.placeholder.tags": "Agregar etiquetas...", "editor.placeholder.author": "Nombre del autor", "editor.placeholder.categories": "Agregar categorías...", @@ -879,6 +886,10 @@ "editor.media.quickActions.button": "✨ Analizar con IA", "editor.media.quickActions.aiTitle": "Título sugerido por IA", "editor.media.quickActions.aiDescription": "Genera automáticamente título, texto alternativo y pie de foto.", + "editor.post.quickActions.detectLanguageDescription": "Detectar idioma con IA", + "editor.post.quickActions.detecting": "Detectando…", + "editor.post.quickActions.languageDetected": "Idioma detectado", + "editor.post.quickActions.detectLanguageFailed": "Error al detectar el idioma", "editor.media.replaceFile": "Reemplazar archivo", "editor.media.field.fileName": "Nombre de archivo", "editor.media.field.type": "Tipo", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index d17cf67..8493a23 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -517,6 +517,13 @@ "editor.field.content": "Contenu", "editor.field.template": "Modèle", "editor.field.templateDefault": "Par défaut", + "editor.field.language": "Langue", + "editor.field.languageDefault": "Par défaut du projet", + "language.en": "Anglais", + "language.de": "Allemand", + "language.fr": "Français", + "language.it": "Italien", + "language.es": "Espagnol", "editor.placeholder.tags": "Ajouter des étiquettes...", "editor.placeholder.author": "Nom de l’auteur", "editor.placeholder.categories": "Ajouter des catégories...", @@ -879,6 +886,10 @@ "editor.media.quickActions.button": "✨ Analyser avec l’IA", "editor.media.quickActions.aiTitle": "Titre suggéré par l’IA", "editor.media.quickActions.aiDescription": "Générez automatiquement un titre, un texte alternatif et une légende.", + "editor.post.quickActions.detectLanguageDescription": "Détecter la langue avec l'IA", + "editor.post.quickActions.detecting": "Détection…", + "editor.post.quickActions.languageDetected": "Langue détectée", + "editor.post.quickActions.detectLanguageFailed": "Échec de la détection de la langue", "editor.media.replaceFile": "Remplacer le fichier", "editor.media.field.fileName": "Nom du fichier", "editor.media.field.type": "Type", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index a8c1f1d..3d1cc1f 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -517,6 +517,13 @@ "editor.field.content": "Contenuto", "editor.field.template": "Modello", "editor.field.templateDefault": "Predefinito", + "editor.field.language": "Lingua", + "editor.field.languageDefault": "Predefinito del progetto", + "language.en": "Inglese", + "language.de": "Tedesco", + "language.fr": "Francese", + "language.it": "Italiano", + "language.es": "Spagnolo", "editor.placeholder.tags": "Aggiungi tag...", "editor.placeholder.author": "Nome autore", "editor.placeholder.categories": "Aggiungi categorie...", @@ -879,6 +886,10 @@ "editor.media.quickActions.button": "✨ Analizza con IA", "editor.media.quickActions.aiTitle": "Titolo suggerito dall’IA", "editor.media.quickActions.aiDescription": "Genera automaticamente titolo, testo alternativo e didascalia.", + "editor.post.quickActions.detectLanguageDescription": "Rileva la lingua con l'IA", + "editor.post.quickActions.detecting": "Rilevamento…", + "editor.post.quickActions.languageDetected": "Lingua rilevata", + "editor.post.quickActions.detectLanguageFailed": "Rilevamento lingua non riuscito", "editor.media.replaceFile": "Sostituisci file", "editor.media.field.fileName": "Nome file", "editor.media.field.type": "Tipo", diff --git a/src/renderer/python/PythonRuntimeManager.ts b/src/renderer/python/PythonRuntimeManager.ts index 895693f..6de0d0a 100644 --- a/src/renderer/python/PythonRuntimeManager.ts +++ b/src/renderer/python/PythonRuntimeManager.ts @@ -3,12 +3,22 @@ import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol import type { PythonSyntaxError } from './runtimeProtocol'; import { parseMacroContextV1, parseMacroResultV1, type MacroContextV1, type MacroResultV1 } from './abiV1'; import { invokePythonApiMethodV1 } from './pythonApiInvokerV1'; +import { showToast } from '../components/Toast'; type WorkerFactory = () => Worker; type PythonApiInvoker = (method: string, args: unknown) => Promise; +type ToastHandler = (message: string, toastType?: string) => void; + +const TOAST_TYPES = new Set(['success', 'error', 'info']); + +function defaultToastHandler(message: string, toastType?: string): void { + const resolvedType = (toastType && TOAST_TYPES.has(toastType) ? toastType : 'info') as 'success' | 'error' | 'info'; + showToast[resolvedType](message); +} interface PythonRuntimeManagerOptions { invokeApiCall?: PythonApiInvoker; + onToast?: ToastHandler; } interface InitializeDeferred { @@ -22,6 +32,8 @@ interface PendingRun { resolve: (value: PythonRunResult | PythonMacroV1Result | string[] | PythonSyntaxCheckResult) => void; reject: (error: Error) => void; timeoutId: ReturnType | null; + timeoutMs: number; + onStdout?: (chunk: string) => void; } export interface PythonRunResult { @@ -33,6 +45,7 @@ export interface PythonExecuteOptions { timeoutMs?: number; cacheKey?: string; entrypoint?: string; + onStdout?: (chunk: string) => void; } export interface PythonMacroSourceOptions { @@ -65,12 +78,14 @@ export class PythonRuntimeManager { private activeRequestId: string | null = null; private requestCounter = 0; private readonly invokeApiCall: PythonApiInvoker; + private readonly onToast: ToastHandler; constructor( private readonly workerFactory: WorkerFactory = createPythonRuntimeWorker, options: PythonRuntimeManagerOptions = {} ) { this.invokeApiCall = options.invokeApiCall ?? invokePythonApiMethodV1; + this.onToast = options.onToast ?? defaultToastHandler; } initialize(): Promise { @@ -116,18 +131,14 @@ export class PythonRuntimeManager { const timeoutMs = options?.timeoutMs ?? 5000; return new Promise((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, + timeoutId: null, + timeoutMs, + onStdout: options?.onStdout, }); const message: PythonWorkerRequest = { @@ -155,18 +166,13 @@ export class PythonRuntimeManager { const timeoutMs = options?.timeoutMs ?? 5000; return new Promise((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, + timeoutId: null, + timeoutMs, }); const message: PythonWorkerRequest = { @@ -194,18 +200,13 @@ export class PythonRuntimeManager { const timeoutMs = options?.timeoutMs ?? 5000; return new Promise((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, + timeoutId: null, + timeoutMs, }); const message: PythonWorkerRequest = { @@ -230,18 +231,13 @@ export class PythonRuntimeManager { const timeoutMs = options?.timeoutMs ?? 5000; return new Promise((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, + timeoutId: null, + timeoutMs, }); const message: PythonWorkerRequest = { @@ -282,6 +278,11 @@ export class PythonRuntimeManager { return; } + if (payload.type === 'toast') { + this.onToast(payload.message, payload.toastType); + return; + } + const pendingRun = this.pendingRuns.get(payload.requestId); if (!pendingRun) { if (this.activeRequestId === payload.requestId && payload.type !== 'stdout') { @@ -293,6 +294,7 @@ export class PythonRuntimeManager { if (payload.type === 'stdout') { pendingRun.stdout += payload.chunk; + pendingRun.onStdout?.(payload.chunk); return; } @@ -440,6 +442,7 @@ export class PythonRuntimeManager { } this.activeRequestId = request.requestId; + this.startTimeoutForRequest(request.requestId); this.worker.postMessage(request); } @@ -454,9 +457,23 @@ export class PythonRuntimeManager { } this.activeRequestId = nextRequest.requestId; + this.startTimeoutForRequest(nextRequest.requestId); this.worker.postMessage(nextRequest); } + private startTimeoutForRequest(requestId: string): void { + const pendingRun = this.pendingRuns.get(requestId); + if (!pendingRun || pendingRun.timeoutMs <= 0) { + return; + } + + pendingRun.timeoutId = setTimeout(() => { + this.pendingRuns.delete(requestId); + this.resetRuntime(`Python script execution timed out after ${pendingRun.timeoutMs}ms`); + pendingRun.reject(new Error(`Python script execution timed out after ${pendingRun.timeoutMs}ms`)); + }, pendingRun.timeoutMs); + } + private finishRequest(requestId: string): void { if (this.activeRequestId === requestId) { this.activeRequestId = null; diff --git a/src/renderer/python/pythonApiInvokerV1.ts b/src/renderer/python/pythonApiInvokerV1.ts index 5880c30..d96665a 100644 --- a/src/renderer/python/pythonApiInvokerV1.ts +++ b/src/renderer/python/pythonApiInvokerV1.ts @@ -76,6 +76,7 @@ export async function invokePythonApiMethodV1(method: string, args: unknown): Pr } const normalizedArgs = asRecord(args); + const electronApi = getElectronApi(); const [namespace, member] = contract.method.split('.'); if (!namespace || !member) { diff --git a/src/renderer/python/pythonRuntime.worker.ts b/src/renderer/python/pythonRuntime.worker.ts index 63c125b..c49f2b1 100644 --- a/src/renderer/python/pythonRuntime.worker.ts +++ b/src/renderer/python/pythonRuntime.worker.ts @@ -344,11 +344,22 @@ async function bootstrapRuntime(): Promise { }, }); + runtime.globals.set('__bds_push_toast', (message: unknown, toastType?: unknown) => { + postRuntimeMessage({ + type: 'toast', + message: String(message ?? ''), + ...(typeof toastType === 'string' && toastType.length > 0 ? { toastType } : {}), + }); + }); + runtime.globals.set('__bds_api_module_source', generatePythonApiModuleV1()); await runtime.runPythonAsync(` import sys import types +def toast(message, type="info"): + __bds_push_toast(str(message), str(type)) + __bds_api_module = types.ModuleType("bds_api") exec(__bds_api_module_source, __bds_api_module.__dict__) diff --git a/src/renderer/python/runtimeProtocol.ts b/src/renderer/python/runtimeProtocol.ts index 531e640..a49ce24 100644 --- a/src/renderer/python/runtimeProtocol.ts +++ b/src/renderer/python/runtimeProtocol.ts @@ -55,4 +55,5 @@ export type PythonWorkerMessage = | { type: 'entrypoints'; requestId: string; entrypoints: string[] } | { type: 'syntaxResult'; requestId: string; errors: PythonSyntaxError[] } | { type: 'macroResult'; requestId: string; result: MacroResultV1 } - | { type: 'runError'; requestId: string; error: string }; + | { type: 'runError'; requestId: string; error: string } + | { type: 'toast'; message: string; toastType?: string }; diff --git a/tests/engine/GenerationSitemapFeedService.test.ts b/tests/engine/GenerationSitemapFeedService.test.ts index b6c802d..c31c7a4 100644 --- a/tests/engine/GenerationSitemapFeedService.test.ts +++ b/tests/engine/GenerationSitemapFeedService.test.ts @@ -20,6 +20,7 @@ function makePost(overrides: Partial = {}): PostData { content: overrides.content ?? `# ${title}\n\nBody`, status: overrides.status ?? 'published', author: overrides.author, + language: overrides.language, createdAt, updatedAt, publishedAt: overrides.publishedAt, @@ -155,4 +156,31 @@ describe('GenerationSitemapFeedService', () => { expect(result.rssXml).toBe(''); expect(result.atomXml).toBe(''); }); + + it('includes per-post language in RSS dc:language and Atom xml:lang', () => { + const publishedPosts = [ + makePost({ id: '1', slug: 'post-en', title: 'English', language: 'en' }), + makePost({ id: '2', slug: 'post-de', title: 'German', language: 'de' }), + makePost({ id: '3', slug: 'post-no-lang', title: 'Default' }), + ]; + + const result = buildSitemapAndFeeds({ + baseUrl: 'https://example.com', + projectName: 'Test Blog', + maxPostsPerPage: 10, + publishedPosts, + publishedListPosts: publishedPosts, + postIndex: buildIndex(publishedPosts), + includeFeeds: true, + }); + + // RSS should have dc:language per item + expect(result.rssXml).toContain('xmlns:dc='); + expect(result.rssXml).toContain('en'); + expect(result.rssXml).toContain('de'); + + // Atom should have xml:lang on entries with language + expect(result.atomXml).toContain('xml:lang="en"'); + expect(result.atomXml).toContain('xml:lang="de"'); + }); }); diff --git a/tests/engine/MetadataDiffEngine.test.ts b/tests/engine/MetadataDiffEngine.test.ts index a556818..922e0c1 100644 --- a/tests/engine/MetadataDiffEngine.test.ts +++ b/tests/engine/MetadataDiffEngine.test.ts @@ -299,6 +299,89 @@ Content here`); expect(result?.differences.categories?.fileValue).toEqual(['cat1']); }); + it('should detect language differences between DB and file', async () => { + const dbPost = { + id: 'post-1', + projectId: 'test-project', + title: 'Published Post', + slug: 'published-post', + status: 'published', + filePath: '/mock/userData/posts/2024/01/published-post.md', + tags: '[]', + categories: '[]', + language: 'en', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-01-15'), + publishedAt: new Date('2024-01-15'), + }; + + mockPosts.set('post-1', dbPost); + + mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `--- +id: post-1 +projectId: test-project +title: "Published Post" +slug: published-post +status: published +tags: [] +categories: [] +language: fr +createdAt: 2024-01-15T00:00:00.000Z +updatedAt: 2024-01-15T00:00:00.000Z +publishedAt: 2024-01-15T00:00:00.000Z +--- +Content here`); + + const result = await engine.comparePostMetadata('post-1'); + + expect(result).not.toBeNull(); + expect(result?.hasDifferences).toBe(true); + expect(result?.differences.language).toBeDefined(); + expect(result?.differences.language?.dbValue).toBe('en'); + expect(result?.differences.language?.fileValue).toBe('fr'); + }); + + it('should detect missing language in file when DB has language', async () => { + const dbPost = { + id: 'post-1', + projectId: 'test-project', + title: 'Published Post', + slug: 'published-post', + status: 'published', + filePath: '/mock/userData/posts/2024/01/published-post.md', + tags: '[]', + categories: '[]', + language: 'de', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-01-15'), + publishedAt: new Date('2024-01-15'), + }; + + mockPosts.set('post-1', dbPost); + + mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `--- +id: post-1 +projectId: test-project +title: "Published Post" +slug: published-post +status: published +tags: [] +categories: [] +createdAt: 2024-01-15T00:00:00.000Z +updatedAt: 2024-01-15T00:00:00.000Z +publishedAt: 2024-01-15T00:00:00.000Z +--- +Content here`); + + const result = await engine.comparePostMetadata('post-1'); + + expect(result).not.toBeNull(); + expect(result?.hasDifferences).toBe(true); + expect(result?.differences.language).toBeDefined(); + expect(result?.differences.language?.dbValue).toBe('de'); + expect(result?.differences.language?.fileValue).toBe(''); + }); + it('should return hasDifferences=false when metadata matches', async () => { const dbPost = { id: 'post-1', @@ -553,6 +636,47 @@ Content here`); expect(mockLocalDb.update).toHaveBeenCalled(); }); + it('should sync language field from file to database', async () => { + const dbPost = { + id: 'post-1', + projectId: 'test-project', + title: 'Published Post', + slug: 'published-post', + status: 'published', + filePath: '/mock/userData/posts/2024/01/published-post.md', + tags: '[]', + categories: '[]', + language: 'en', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-01-15'), + publishedAt: new Date('2024-01-15'), + }; + mockPosts.set('post-1', dbPost); + + mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `--- +id: post-1 +projectId: test-project +title: "Published Post" +slug: published-post +status: published +language: fr +tags: [] +categories: [] +createdAt: 2024-01-15T00:00:00.000Z +updatedAt: 2024-01-15T00:00:00.000Z +publishedAt: 2024-01-15T00:00:00.000Z +--- +Content here`); + + await engine.syncFileToDb(['post-1'], 'language'); + + expect(mockLocalDb.update).toHaveBeenCalled(); + // Verify the set call includes language + const updateResult = mockLocalDb.update.mock.results[0].value; + const setCall = updateResult.set.mock.calls[0][0]; + expect(setCall.language).toBe('fr'); + }); + it('should report progress on first and final items based on cadence', async () => { const postIds = Array.from({ length: 11 }, (_, i) => `post-${i + 1}`); diff --git a/tests/engine/PostEngine.test.ts b/tests/engine/PostEngine.test.ts index cbfffcf..e614ce7 100644 --- a/tests/engine/PostEngine.test.ts +++ b/tests/engine/PostEngine.test.ts @@ -163,6 +163,22 @@ describe('PostEngine', () => { // Reset the mock implementations vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain()); + vi.mocked(mockLocalDb.insert).mockImplementation(() => ({ + values: vi.fn((data: any) => { + if (data && data.id) { + mockPosts.set(data.id, data); + } + return Promise.resolve(); + }), + }) as any); + vi.mocked(mockLocalDb.update).mockImplementation(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve()), + })), + }) as any); + vi.mocked(mockLocalDb.delete).mockImplementation(() => ({ + where: vi.fn(() => Promise.resolve()), + }) as any); // Reset fs implementations to use mockFiles map (fixes test leakage from other tests) vi.mocked(fs.readFile).mockImplementation(createDefaultFsReadFile(mockFiles) as any); @@ -783,6 +799,94 @@ Original content`); expect(result?.content).toBe('New draft content'); }); + it('should auto-transition published post to draft when language changes', async () => { + const created = await postEngine.createPost({ title: 'Language Draft Test' }); + + vi.mocked(mockLocalDb.select).mockImplementation(() => { + const chain = createSelectChain(); + chain.where = vi.fn().mockReturnValue({ + ...chain, + get: vi.fn().mockResolvedValue({ + id: created.id, + projectId: created.projectId, + title: created.title, + slug: created.slug, + status: 'published', + content: null, + filePath: '/mock/published-lang.md', + tags: '[]', + categories: '[]', + createdAt: created.createdAt, + updatedAt: created.updatedAt, + }), + }); + return chain; + }); + + mockFiles.set('/mock/published-lang.md', `--- +id: ${created.id} +projectId: default +title: ${created.title} +slug: ${created.slug} +status: published +createdAt: ${created.createdAt.toISOString()} +updatedAt: ${created.updatedAt.toISOString()} +tags: [] +categories: [] +--- +Original content`); + + const result = await postEngine.updatePost(created.id, { language: 'fr' }); + + expect(result).not.toBeNull(); + expect(result?.status).toBe('draft'); + expect(result?.language).toBe('fr'); + }); + + it('should auto-transition published post to draft when author changes', async () => { + const created = await postEngine.createPost({ title: 'Author Draft Test' }); + + vi.mocked(mockLocalDb.select).mockImplementation(() => { + const chain = createSelectChain(); + chain.where = vi.fn().mockReturnValue({ + ...chain, + get: vi.fn().mockResolvedValue({ + id: created.id, + projectId: created.projectId, + title: created.title, + slug: created.slug, + status: 'published', + content: null, + filePath: '/mock/published-author.md', + tags: '[]', + categories: '[]', + createdAt: created.createdAt, + updatedAt: created.updatedAt, + }), + }); + return chain; + }); + + mockFiles.set('/mock/published-author.md', `--- +id: ${created.id} +projectId: default +title: ${created.title} +slug: ${created.slug} +status: published +createdAt: ${created.createdAt.toISOString()} +updatedAt: ${created.updatedAt.toISOString()} +tags: [] +categories: [] +--- +Original content`); + + const result = await postEngine.updatePost(created.id, { author: 'New Author' }); + + expect(result).not.toBeNull(); + expect(result?.status).toBe('draft'); + expect(result?.author).toBe('New Author'); + }); + it('should update tags and categories', async () => { const created = await postEngine.createPost({ title: 'Tag Update Test', @@ -3301,4 +3405,106 @@ Content with [link](/posts/other-post)`); expect(result.processedFiles).toBe(0); }); }); + + describe('Post Language', () => { + it('should create a post with no language by default', async () => { + const post = await postEngine.createPost({ title: 'No Language' }); + expect(post.language).toBeUndefined(); + }); + + it('should create a post with explicit language', async () => { + const post = await postEngine.createPost({ title: 'German Post', language: 'de' }); + expect(post.language).toBe('de'); + }); + + it('should update post language', async () => { + const post = await postEngine.createPost({ title: 'Lang Update' }); + + // Mock getPost to return the created post + vi.mocked(mockLocalDb.select).mockImplementation(() => { + const chain = createSelectChain(); + chain.get = vi.fn().mockResolvedValue({ + ...mockPosts.get(post.id), + tags: JSON.stringify([]), + categories: JSON.stringify([]), + }); + return chain; + }); + + const updated = await postEngine.updatePost(post.id, { language: 'fr' }); + expect(updated).not.toBeNull(); + expect(updated!.language).toBe('fr'); + }); + + it('should include language in frontmatter when publishing', async () => { + const post = await postEngine.createPost({ title: 'Publish Lang', language: 'es' }); + const postId = post.id; + + // Verify the post was stored in the mock DB + const stored = mockPosts.get(postId); + expect(stored).toBeDefined(); + + // The mock DB stores posts via insert; publishPost calls getPost internally, + // which needs DB select to return the post with content (draft). + vi.mocked(mockLocalDb.select).mockImplementation(() => { + const chain = createSelectChain(); + chain.get = vi.fn().mockImplementation(() => { + const s = mockPosts.get(postId); + if (!s) return Promise.resolve(undefined); + return Promise.resolve(s); + }); + return chain; + }); + + const result = await postEngine.publishPost(postId); + expect(result).not.toBeNull(); + + // Check that the written file contains language in frontmatter + const writtenFiles = Array.from(mockFiles.entries()); + const postFile = writtenFiles.find(([p]) => p.endsWith('.md')); + expect(postFile).toBeDefined(); + expect(postFile![1]).toContain('language: es'); + }); + + it('should read language from frontmatter in published posts', async () => { + const filePath = '/mock/data/posts/2025/01/lang-test.md'; + mockFiles.set(filePath, [ + '---', + 'id: lang-test-post', + 'title: Language Test', + 'slug: lang-test', + 'status: published', + 'language: it', + 'createdAt: 2025-01-15T10:00:00.000Z', + 'updatedAt: 2025-01-15T10:00:00.000Z', + 'tags: []', + 'categories: []', + '---', + 'Content here', + ].join('\n')); + + vi.mocked(mockLocalDb.select).mockImplementation(() => { + const chain = createSelectChain(); + chain.get = vi.fn().mockResolvedValue({ + id: 'lang-test-post', + projectId: 'default', + title: 'Language Test', + slug: 'lang-test', + content: null, + status: 'published', + language: 'it', + createdAt: new Date('2025-01-15T10:00:00.000Z'), + updatedAt: new Date('2025-01-15T10:00:00.000Z'), + filePath, + tags: '[]', + categories: '[]', + }); + return chain; + }); + + const post = await postEngine.getPost('lang-test-post'); + expect(post).not.toBeNull(); + expect(post!.language).toBe('it'); + }); + }); }); diff --git a/tests/engine/TaskManager.test.ts b/tests/engine/TaskManager.test.ts index 29f539d..bfa3a55 100644 --- a/tests/engine/TaskManager.test.ts +++ b/tests/engine/TaskManager.test.ts @@ -340,6 +340,120 @@ describe('TaskManager', () => { }); }); +describe('TaskManager External Tasks', () => { + let taskManager: TaskManager; + + beforeEach(() => { + taskManager = new TaskManager(); + resetMockCounters(); + }); + + it('should create an external task in running state', () => { + taskManager.startExternalTask('ext-1', 'Language detection'); + + const status = taskManager.getTaskStatus('ext-1'); + expect(status).toBeDefined(); + expect(status?.status).toBe('running'); + expect(status?.name).toBe('Language detection'); + expect(status?.progress).toBe(0); + }); + + it('should emit taskCreated and taskStarted for external tasks', () => { + const createdHandler = vi.fn(); + const startedHandler = vi.fn(); + taskManager.on('taskCreated', createdHandler); + taskManager.on('taskStarted', startedHandler); + + taskManager.startExternalTask('ext-2', 'Script run'); + + expect(createdHandler).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'ext-2', status: 'running' })); + expect(startedHandler).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'ext-2', status: 'running' })); + }); + + it('should update progress on an external task', () => { + const progressHandler = vi.fn(); + taskManager.on('taskProgress', progressHandler); + + taskManager.startExternalTask('ext-3', 'Detect languages'); + taskManager.updateExternalTaskProgress('ext-3', 50, 'Halfway done'); + + const status = taskManager.getTaskStatus('ext-3'); + expect(status?.progress).toBe(50); + expect(status?.message).toBe('Halfway done'); + expect(progressHandler).toHaveBeenCalledWith(expect.objectContaining({ + taskId: 'ext-3', + progress: 50, + message: 'Halfway done', + })); + }); + + it('should complete an external task', () => { + const completedHandler = vi.fn(); + taskManager.on('taskCompleted', completedHandler); + + taskManager.startExternalTask('ext-4', 'Run utility'); + taskManager.completeExternalTask('ext-4'); + + const status = taskManager.getTaskStatus('ext-4'); + expect(status?.status).toBe('completed'); + expect(status?.progress).toBe(100); + expect(status?.endTime).toBeInstanceOf(Date); + expect(completedHandler).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'ext-4', status: 'completed' })); + }); + + it('should fail an external task', () => { + const failedHandler = vi.fn(); + taskManager.on('taskFailed', failedHandler); + + taskManager.startExternalTask('ext-5', 'Run utility'); + taskManager.failExternalTask('ext-5', 'Script crashed'); + + const status = taskManager.getTaskStatus('ext-5'); + expect(status?.status).toBe('failed'); + expect(status?.error).toBe('Script crashed'); + expect(status?.endTime).toBeInstanceOf(Date); + expect(failedHandler).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'ext-5', status: 'failed' })); + }); + + it('should ignore updates to non-existent external tasks', () => { + // These should not throw + taskManager.updateExternalTaskProgress('nope', 50, 'test'); + taskManager.completeExternalTask('nope'); + taskManager.failExternalTask('nope', 'error'); + }); + + it('should include external tasks in getAllTasks and getRunningTasks', () => { + taskManager.startExternalTask('ext-6', 'Running script'); + + expect(taskManager.getAllTasks()).toHaveLength(1); + expect(taskManager.getRunningTasks()).toHaveLength(1); + + taskManager.completeExternalTask('ext-6'); + + expect(taskManager.getAllTasks()).toHaveLength(1); + expect(taskManager.getRunningTasks()).toHaveLength(0); + }); + + it('should allow cancellation of external tasks', () => { + taskManager.startExternalTask('ext-7', 'Long script'); + + const cancelled = taskManager.cancelTask('ext-7'); + expect(cancelled).toBe(true); + + const status = taskManager.getTaskStatus('ext-7'); + expect(status?.status).toBe('cancelled'); + }); + + it('should be clearable like regular tasks', () => { + taskManager.startExternalTask('ext-8', 'Script'); + taskManager.completeExternalTask('ext-8'); + + expect(taskManager.getAllTasks()).toHaveLength(1); + taskManager.clearCompletedTasks(); + expect(taskManager.getAllTasks()).toHaveLength(0); + }); +}); + describe('TaskManager Concurrency', () => { let taskManager: TaskManager; const MAX_CONCURRENT = 3; diff --git a/tests/renderer/components/ScriptsView.test.tsx b/tests/renderer/components/ScriptsView.test.tsx index a4f440a..5f703aa 100644 --- a/tests/renderer/components/ScriptsView.test.tsx +++ b/tests/renderer/components/ScriptsView.test.tsx @@ -79,6 +79,9 @@ describe('ScriptsView', () => { updatedAt: '2026-02-22T00:00:00.000Z', }), getAll: vi.fn(), + startTask: vi.fn().mockResolvedValue(undefined), + completeTask: vi.fn().mockResolvedValue(undefined), + failTask: vi.fn().mockResolvedValue(undefined), }, }; @@ -246,17 +249,18 @@ describe('ScriptsView', () => { fireEvent.click(screen.getByRole('button', { name: 'Run Script' })); await vi.waitFor(() => { - expect(executeMock).toHaveBeenCalledWith('print("hello")', { + expect(executeMock).toHaveBeenCalledWith('print("hello")', expect.objectContaining({ cacheKey: expect.stringMatching(/^script-1:1:/), entrypoint: 'render', - }); + timeoutMs: 0, + })); }); 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'); + expect(state.panelOutputEntries.some(e => e.message.includes('2'))).toBe(true); }); it('checks syntax manually and writes editor markers for syntax errors', async () => { @@ -360,4 +364,77 @@ describe('ScriptsView', () => { expect(useAppStore.getState().tabs).toEqual([]); }); }); + + it('runs utility script without timeout and creates a task', async () => { + const startTaskMock = vi.fn().mockResolvedValue(undefined); + const completeTaskMock = vi.fn().mockResolvedValue(undefined); + (window as any).electronAPI.scripts.startTask = startTaskMock; + (window as any).electronAPI.scripts.completeTask = completeTaskMock; + (window as any).electronAPI.scripts.failTask = vi.fn(); + + render(); + + await screen.findByLabelText('Script Content'); + fireEvent.click(screen.getByRole('button', { name: 'Run Script' })); + + await vi.waitFor(() => { + expect(executeMock).toHaveBeenCalledWith('print("hello")', expect.objectContaining({ + timeoutMs: 0, + })); + expect(startTaskMock).toHaveBeenCalledWith(expect.stringContaining('script-'), 'Hello Script'); + expect(completeTaskMock).toHaveBeenCalledWith(expect.stringContaining('script-')); + }); + }); + + it('reports failure to task manager when utility script errors', async () => { + executeMock.mockRejectedValueOnce(new Error('Script crashed')); + const startTaskMock = vi.fn().mockResolvedValue(undefined); + const failTaskMock = vi.fn().mockResolvedValue(undefined); + (window as any).electronAPI.scripts.startTask = startTaskMock; + (window as any).electronAPI.scripts.completeTask = vi.fn(); + (window as any).electronAPI.scripts.failTask = failTaskMock; + + render(); + + await screen.findByLabelText('Script Content'); + fireEvent.click(screen.getByRole('button', { name: 'Run Script' })); + + await vi.waitFor(() => { + expect(failTaskMock).toHaveBeenCalledWith(expect.stringContaining('script-'), 'Script crashed'); + }); + }); + + it('runs macro/transform scripts without timeout but no task', async () => { + (window as any).electronAPI.scripts.get = vi.fn().mockResolvedValue({ + id: 'script-1', + projectId: 'default', + slug: 'hello-script', + title: 'Hello Script', + kind: 'macro', + 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', + }); + + const startTaskMock = vi.fn(); + (window as any).electronAPI.scripts.startTask = startTaskMock; + (window as any).electronAPI.scripts.completeTask = vi.fn(); + (window as any).electronAPI.scripts.failTask = vi.fn(); + + render(); + + await screen.findByLabelText('Script Content'); + fireEvent.click(screen.getByRole('button', { name: 'Run Script' })); + + await vi.waitFor(() => { + expect(executeMock).toHaveBeenCalledWith('print("hello")', expect.objectContaining({ + timeoutMs: 0, + })); + expect(startTaskMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/renderer/python/PythonRuntimeManager.test.ts b/tests/renderer/python/PythonRuntimeManager.test.ts index c2ea96c..e1d9f6a 100644 --- a/tests/renderer/python/PythonRuntimeManager.test.ts +++ b/tests/renderer/python/PythonRuntimeManager.test.ts @@ -502,4 +502,115 @@ describe('PythonRuntimeManager', () => { worker.emitMessage({ type: 'runResult', requestId: runRequest.requestId, result: 'done' }); await expect(runPromise).resolves.toEqual({ result: 'done', stdout: '' }); }); + + it('does not time out when timeoutMs is 0', 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('long_running()', { timeoutMs: 0 }); + await Promise.resolve(); + + // Advance time well past any default timeout — script must still be pending + vi.advanceTimersByTime(60_000); + expect(worker.terminated).toBe(false); + + const request = worker.postedMessages[0] as { requestId: string }; + worker.emitMessage({ type: 'runResult', requestId: request.requestId, result: 'done' }); + + await expect(runPromise).resolves.toEqual({ result: 'done', stdout: '' }); + }); + + it('queued inspectEntrypoints with timeoutMs 0 does not kill running execute', async () => { + const worker = new MockWorker(); + const manager = new PythonRuntimeManager(() => worker as unknown as Worker); + + const initPromise = manager.initialize(); + worker.emitMessage({ type: 'ready' }); + await initPromise; + + // Start a long-running execute with no timeout + const runPromise = manager.execute('long_running()', { timeoutMs: 0 }); + await Promise.resolve(); + + // Queue inspectEntrypoints (default timeout) while execute is running + const inspectPromise = manager.inspectEntrypoints('def render(): pass'); + await Promise.resolve(); + + // Advance past the default 5000ms timeout + vi.advanceTimersByTime(6000); + + // Worker must still be alive — the queued inspect must not kill it + expect(worker.terminated).toBe(false); + + // Finish the execute + const runRequest = worker.postedMessages[0] as { requestId: string }; + worker.emitMessage({ type: 'runResult', requestId: runRequest.requestId, result: 'done' }); + await expect(runPromise).resolves.toEqual({ result: 'done', stdout: '' }); + + // Now the inspect request dispatches — respond to it + await Promise.resolve(); + const inspectRequest = worker.postedMessages[1] as { requestId: string }; + worker.emitMessage({ type: 'entrypoints', requestId: inspectRequest.requestId, entrypoints: ['render'] }); + await expect(inspectPromise).resolves.toEqual(['render']); + }); + + it('calls onStdout callback for each stdout chunk during 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 stdoutChunks: string[] = []; + const runPromise = manager.execute('print("a")\nprint("b")', { + onStdout: (chunk) => { stdoutChunks.push(chunk); }, + }); + await Promise.resolve(); + + const request = worker.postedMessages[0] as { requestId: string }; + worker.emitMessage({ type: 'stdout', requestId: request.requestId, chunk: 'a\n' }); + worker.emitMessage({ type: 'stdout', requestId: request.requestId, chunk: 'b\n' }); + worker.emitMessage({ type: 'runResult', requestId: request.requestId, result: '' }); + + const result = await runPromise; + expect(stdoutChunks).toEqual(['a\n', 'b\n']); + expect(result.stdout).toBe('a\nb\n'); + }); + + it('calls onToast handler when worker sends a toast message', async () => { + const worker = new MockWorker(); + const toasts: Array<{ message: string; toastType?: string }> = []; + const manager = new PythonRuntimeManager( + () => worker as unknown as Worker, + { + onToast: (message, toastType) => { toasts.push({ message, toastType }); }, + } + ); + + const initPromise = manager.initialize(); + worker.emitMessage({ type: 'ready' }); + await initPromise; + + const runPromise = manager.execute('toast("hello")'); + await Promise.resolve(); + + const request = worker.postedMessages[0] as { requestId: string }; + worker.emitMessage({ type: 'toast', message: 'hello', toastType: 'success' }); + worker.emitMessage({ type: 'toast', message: 'oops', toastType: 'error' }); + worker.emitMessage({ type: 'toast', message: 'note' }); + + expect(toasts).toEqual([ + { message: 'hello', toastType: 'success' }, + { message: 'oops', toastType: 'error' }, + { message: 'note', toastType: undefined }, + ]); + + worker.emitMessage({ type: 'runResult', requestId: request.requestId, result: '' }); + await expect(runPromise).resolves.toEqual({ result: '', stdout: '' }); + }); }); diff --git a/tests/renderer/python/generateApiDocumentationMarkdownV1.test.ts b/tests/renderer/python/generateApiDocumentationMarkdownV1.test.ts index d1578f4..676ad34 100644 --- a/tests/renderer/python/generateApiDocumentationMarkdownV1.test.ts +++ b/tests/renderer/python/generateApiDocumentationMarkdownV1.test.ts @@ -37,8 +37,9 @@ describe('generateApiDocumentationMarkdownV1', () => { expect(markdown).toContain('## publish'); expect(markdown).toContain('### publish.uploadSite'); expect(markdown).toContain('- [publish](#publish)'); - // chat namespace should not be present - expect(markdown).not.toContain('## chat'); + // chat namespace now contains detectPostLanguage + expect(markdown).toContain('## chat'); + expect(markdown).toContain('### chat.detectPostLanguage'); }); it('includes a dedicated Data Structures section with core object shapes', () => { diff --git a/tests/renderer/python/pythonApiContractV1.test.ts b/tests/renderer/python/pythonApiContractV1.test.ts index ba1347a..d9273bf 100644 --- a/tests/renderer/python/pythonApiContractV1.test.ts +++ b/tests/renderer/python/pythonApiContractV1.test.ts @@ -59,15 +59,15 @@ describe('pythonApiContractV1', () => { }); }); - it('does not include chat namespace (removed in v1.7.0)', () => { + it('only exposes detectPostLanguage from chat namespace', () => { const methodNames = listPythonApiMethodNames(); const chatMethods = methodNames.filter((m) => m.startsWith('chat.')); - expect(chatMethods).toHaveLength(0); + expect(chatMethods).toEqual(['chat.detectPostLanguage']); }); it('contains semantic version metadata for compatibility checks', () => { expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({ - version: '1.9.0', + version: '1.10.0', generatedAt: expect.any(String), }); }); @@ -100,7 +100,8 @@ describe('generatePythonApiModuleV1', () => { expect(moduleCode).toContain('async def upload_site(self, credentials):'); expect(moduleCode).toContain('class BdsApi:'); expect(moduleCode).toContain('bds = BdsApi(_transport)'); - expect(moduleCode).not.toContain('class ChatApi:'); + expect(moduleCode).toContain('class ChatApi:'); + expect(moduleCode).toContain('async def detect_post_language(self, title, content):'); }); it('escapes python keyword method names to valid identifiers', () => {