Feat/language detection (#31)

* feat: implementation of language detection

* run utility scripts in tasks

* fix: addiitonal fixes for background utilities

* feat: toast() also for utility scripts

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-03 14:36:15 +01:00
committed by GitHub
parent 5747925503
commit 32b66e1677
37 changed files with 2616 additions and 55 deletions

47
API.md
View File

@@ -1,6 +1,6 @@
# API Documentation # 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. 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) - [app](#app)
- [meta](#meta) - [meta](#meta)
- [tags](#tags) - [tags](#tags)
- [chat](#chat)
- [sync](#sync) - [sync](#sync)
- [publish](#publish) - [publish](#publish)
- [Data Structures](#data-structures) - [Data Structures](#data-structures)
@@ -378,6 +379,7 @@ result = await bds.posts.create(data={})
'content': 'value', 'content': 'value',
'status': 'draft', 'status': 'draft',
'author': 'value', 'author': 'value',
'language': 'value',
'createdAt': 'value', 'createdAt': 'value',
'updatedAt': 'value', 'updatedAt': 'value',
'publishedAt': 'value', 'publishedAt': 'value',
@@ -421,6 +423,7 @@ None # or
'content': 'value', 'content': 'value',
'status': 'draft', 'status': 'draft',
'author': 'value', 'author': 'value',
'language': 'value',
'createdAt': 'value', 'createdAt': 'value',
'updatedAt': 'value', 'updatedAt': 'value',
'publishedAt': 'value', 'publishedAt': 'value',
@@ -488,6 +491,7 @@ None # or
'content': 'value', 'content': 'value',
'status': 'draft', 'status': 'draft',
'author': 'value', 'author': 'value',
'language': 'value',
'createdAt': 'value', 'createdAt': 'value',
'updatedAt': 'value', 'updatedAt': 'value',
'publishedAt': 'value', 'publishedAt': 'value',
@@ -581,6 +585,7 @@ result = await bds.posts.get_by_status(status='status')
'content': 'value', 'content': 'value',
'status': 'draft', 'status': 'draft',
'author': 'value', 'author': 'value',
'language': 'value',
'createdAt': 'value', 'createdAt': 'value',
'updatedAt': 'value', 'updatedAt': 'value',
'publishedAt': 'value', 'publishedAt': 'value',
@@ -624,6 +629,7 @@ None # or
'content': 'value', 'content': 'value',
'status': 'draft', 'status': 'draft',
'author': 'value', 'author': 'value',
'language': 'value',
'createdAt': 'value', 'createdAt': 'value',
'updatedAt': 'value', 'updatedAt': 'value',
'publishedAt': 'value', 'publishedAt': 'value',
@@ -666,6 +672,7 @@ None # or
'content': 'value', 'content': 'value',
'status': 'draft', 'status': 'draft',
'author': 'value', 'author': 'value',
'language': 'value',
'createdAt': 'value', 'createdAt': 'value',
'updatedAt': 'value', 'updatedAt': 'value',
'publishedAt': 'value', 'publishedAt': 'value',
@@ -807,6 +814,7 @@ result = await bds.posts.filter(filter={})
'content': 'value', 'content': 'value',
'status': 'draft', 'status': 'draft',
'author': 'value', 'author': 'value',
'language': 'value',
'createdAt': 'value', 'createdAt': 'value',
'updatedAt': 'value', 'updatedAt': 'value',
'publishedAt': 'value', 'publishedAt': 'value',
@@ -999,6 +1007,7 @@ result = await bds.posts.get_links_to(id='id-1')
'content': 'value', 'content': 'value',
'status': 'draft', 'status': 'draft',
'author': 'value', 'author': 'value',
'language': 'value',
'createdAt': 'value', 'createdAt': 'value',
'updatedAt': 'value', 'updatedAt': 'value',
'publishedAt': 'value', 'publishedAt': 'value',
@@ -1041,6 +1050,7 @@ result = await bds.posts.get_linked_by(id='id-1')
'content': 'value', 'content': 'value',
'status': 'draft', 'status': 'draft',
'author': 'value', 'author': 'value',
'language': 'value',
'createdAt': 'value', 'createdAt': 'value',
'updatedAt': 'value', 'updatedAt': 'value',
'publishedAt': 'value', 'publishedAt': 'value',
@@ -3448,6 +3458,40 @@ result = await bds.tags.sync_from_posts()
[↑ Back to Table of contents](#table-of-contents) [↑ 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 ## sync
**Module APIs** **Module APIs**
@@ -3821,6 +3865,7 @@ Canonical post object used across editor and generation flows.
- content (`string`, required): Markdown body content. - content (`string`, required): Markdown body content.
- status (`'draft' | 'published' | 'archived'`, required): Publication lifecycle state. - status (`'draft' | 'published' | 'archived'`, required): Publication lifecycle state.
- author (`string`, optional): Optional author name. - 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). - createdAt (`string`, required): Creation timestamp (ISO string).
- updatedAt (`string`, required): Last update timestamp (ISO string). - updatedAt (`string`, required): Last update timestamp (ISO string).
- publishedAt (`string`, optional): Publication timestamp for published posts. - publishedAt (`string`, optional): Publication timestamp for published posts.

View File

@@ -0,0 +1 @@
ALTER TABLE `posts` ADD `language` text;

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,13 @@
"when": 1772380619098, "when": 1772380619098,
"tag": "0009_model_catalog_v2", "tag": "0009_model_catalog_v2",
"breakpoints": true "breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1772462693094,
"tag": "0011_loving_alex_wilder",
"breakpoints": true
} }
] ]
} }

View File

@@ -34,6 +34,7 @@ export const posts = sqliteTable('posts', {
tags: text('tags'), // JSON array stored as text tags: text('tags'), // JSON array stored as text
categories: text('categories'), // JSON array stored as text categories: text('categories'), // JSON array stored as text
templateSlug: text('template_slug'), // Optional user template override for this post 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) // Legacy columns (kept for migration compatibility, no longer written)
publishedTitle: text('published_title'), publishedTitle: text('published_title'),
publishedContent: text('published_content'), publishedContent: text('published_content'),

View File

@@ -397,6 +397,7 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
` <guid isPermaLink="true">${escapeXml(permalink)}</guid>`, ` <guid isPermaLink="true">${escapeXml(permalink)}</guid>`,
` <pubDate>${(post.publishedAt || post.updatedAt).toUTCString()}</pubDate>`, ` <pubDate>${(post.publishedAt || post.updatedAt).toUTCString()}</pubDate>`,
post.author ? ` <author>${escapeXml(post.author)}</author>` : null, post.author ? ` <author>${escapeXml(post.author)}</author>` : null,
(post as { language?: string }).language ? ` <dc:language>${escapeXml((post as { language?: string }).language!)}</dc:language>` : null,
` <description><![CDATA[${escapeCdata(excerptXhtml)}]]></description>`, ` <description><![CDATA[${escapeCdata(excerptXhtml)}]]></description>`,
` <content:encoded><![CDATA[${escapeCdata(contentXhtml)}]]></content:encoded>`, ` <content:encoded><![CDATA[${escapeCdata(contentXhtml)}]]></content:encoded>`,
...categories.map((entry) => ` ${entry}`), ...categories.map((entry) => ` ${entry}`),
@@ -406,7 +407,7 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
const rssXml = [ const rssXml = [
'<?xml version="1.0" encoding="UTF-8"?>', '<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">', '<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">',
' <channel>', ' <channel>',
` <title>${escapeXml(feedTitle)}</title>`, ` <title>${escapeXml(feedTitle)}</title>`,
` <link>${escapeXml(baseLink)}</link>`, ` <link>${escapeXml(baseLink)}</link>`,
@@ -430,8 +431,10 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
...(post.categories || []).map((category) => `<category term="${escapeXml(category)}" />`), ...(post.categories || []).map((category) => `<category term="${escapeXml(category)}" />`),
]; ];
const postLanguageAttr = (post as { language?: string }).language ? ` xml:lang="${escapeXml((post as { language?: string }).language!)}"` : '';
return [ return [
' <entry>', ` <entry${postLanguageAttr}>`,
` <title>${escapeXml(post.title)}</title>`, ` <title>${escapeXml(post.title)}</title>`,
` <id>${escapeXml(permalink)}</id>`, ` <id>${escapeXml(permalink)}</id>`,
` <link href="${escapeXml(permalink)}" />`, ` <link href="${escapeXml(permalink)}" />`,

View File

@@ -25,7 +25,7 @@ export interface FieldDifference<T = unknown> {
/** /**
* The fields that can have differences * 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 * Metadata differences for a single post
@@ -248,6 +248,11 @@ export class MetadataDiffEngine extends EventEmitter {
differences.author = { dbValue: dbPost.author || '', fileValue: fileData.author || '' }; differences.author = { dbValue: dbPost.author || '', fileValue: fileData.author || '' };
} }
// Compare language
if ((dbPost.language || '') !== (fileData.language || '')) {
differences.language = { dbValue: dbPost.language || '', fileValue: fileData.language || '' };
}
return { return {
postId: dbPost.id, postId: dbPost.id,
title: dbPost.title, title: dbPost.title,
@@ -279,7 +284,7 @@ export class MetadataDiffEngine extends EventEmitter {
// Get all published posts with file paths // Get all published posts with file paths
const result = await client.execute({ 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 FROM posts
WHERE project_id = ? WHERE project_id = ?
AND status = 'published' AND status = 'published'
@@ -331,6 +336,7 @@ export class MetadataDiffEngine extends EventEmitter {
title: 'Title', title: 'Title',
excerpt: 'Excerpt', excerpt: 'Excerpt',
author: 'Author', author: 'Author',
language: 'Language',
}; };
for (const diff of diffs) { for (const diff of diffs) {
@@ -428,6 +434,9 @@ export class MetadataDiffEngine extends EventEmitter {
if (!field || field === 'author') { if (!field || field === 'author') {
updateData.author = fileData.author || null; updateData.author = fileData.author || null;
} }
if (!field || field === 'language') {
updateData.language = fileData.language || null;
}
// Update database // Update database
await db await db

View File

@@ -62,6 +62,7 @@ export interface TemplatePostEntry {
title: string; title: string;
content: string; content: string;
show_title: boolean; show_title: boolean;
language?: string;
} }
export interface CategoryRenderSettings { export interface CategoryRenderSettings {
@@ -1485,8 +1486,12 @@ export class PageRenderer {
const canonicalPostPathBySlug = mapToRecord(rewriteContext.canonicalPostPathBySlug); 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 = { const context: SinglePostTemplateContext = {
...pageContext, ...pageContext,
language: postLanguage || pageContext.language,
menu_items: pageContext.menu_items ?? [], menu_items: pageContext.menu_items ?? [],
post: { post: {
id: renderablePost.id, id: renderablePost.id,
@@ -1494,6 +1499,7 @@ export class PageRenderer {
title: renderablePost.title, title: renderablePost.title,
content: renderablePost.content, content: renderablePost.content,
show_title: false, show_title: false,
language: postLanguage,
}, },
post_categories: postCategories, post_categories: postCategories,
post_tags: postTags, post_tags: postTags,

View File

@@ -24,6 +24,7 @@ export interface PostData {
content: string; content: string;
status: 'draft' | 'published' | 'archived'; status: 'draft' | 'published' | 'archived';
author?: string; author?: string;
language?: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
publishedAt?: Date; publishedAt?: Date;
@@ -39,6 +40,7 @@ export interface PostMetadata {
excerpt?: string; excerpt?: string;
status: 'draft' | 'published' | 'archived'; status: 'draft' | 'published' | 'archived';
author?: string; author?: string;
language?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
publishedAt?: 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) // Only add optional fields if they have values (gray-matter can't serialize undefined)
if (post.excerpt) metadata.excerpt = post.excerpt; if (post.excerpt) metadata.excerpt = post.excerpt;
if (post.author) metadata.author = post.author; if (post.author) metadata.author = post.author;
if (post.language) metadata.language = post.language;
if (post.publishedAt) metadata.publishedAt = post.publishedAt.toISOString(); if (post.publishedAt) metadata.publishedAt = post.publishedAt.toISOString();
// Use date-based directory structure (posts/YYYY/MM/) // Use date-based directory structure (posts/YYYY/MM/)
@@ -392,6 +395,7 @@ export class PostEngine extends EventEmitter {
content: data.content || '', content: data.content || '',
status: data.status || 'draft', status: data.status || 'draft',
author: data.author, author: data.author,
language: data.language,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
publishedAt: data.publishedAt, publishedAt: data.publishedAt,
@@ -418,6 +422,7 @@ export class PostEngine extends EventEmitter {
checksum, checksum,
tags: JSON.stringify(post.tags), tags: JSON.stringify(post.tags),
categories: JSON.stringify(post.categories), categories: JSON.stringify(post.categories),
language: post.language || null,
}; };
await db.insert(posts).values(dbPost); await db.insert(posts).values(dbPost);
@@ -445,7 +450,9 @@ export class PostEngine extends EventEmitter {
data.title !== undefined || data.title !== undefined ||
data.tags !== undefined || data.tags !== undefined ||
data.categories !== undefined || data.categories !== undefined ||
data.excerpt !== undefined; data.excerpt !== undefined ||
data.language !== undefined ||
data.author !== undefined;
let newStatus = data.status || existing.status; let newStatus = data.status || existing.status;
if (existing.status === 'published' && isContentOrMetadataChange && !data.status) { if (existing.status === 'published' && isContentOrMetadataChange && !data.status) {
@@ -484,6 +491,7 @@ export class PostEngine extends EventEmitter {
checksum, checksum,
tags: JSON.stringify(updated.tags), tags: JSON.stringify(updated.tags),
categories: JSON.stringify(updated.categories), categories: JSON.stringify(updated.categories),
language: updated.language || null,
}) })
.where(eq(posts.id, id)); .where(eq(posts.id, id));
@@ -576,6 +584,7 @@ export class PostEngine extends EventEmitter {
content: body, content: body,
status: dbPost.status as 'draft' | 'published' | 'archived', status: dbPost.status as 'draft' | 'published' | 'archived',
author: dbPost.author || undefined, author: dbPost.author || undefined,
language: (dbPost as { language?: string | null }).language || undefined,
createdAt: dbPost.createdAt, createdAt: dbPost.createdAt,
updatedAt: dbPost.updatedAt, updatedAt: dbPost.updatedAt,
publishedAt: dbPost.publishedAt || undefined, publishedAt: dbPost.publishedAt || undefined,
@@ -1331,6 +1340,7 @@ export class PostEngine extends EventEmitter {
checksum, checksum,
tags: JSON.stringify(published.tags), tags: JSON.stringify(published.tags),
categories: JSON.stringify(published.categories), categories: JSON.stringify(published.categories),
language: published.language || null,
}) })
.where(eq(posts.id, id)); .where(eq(posts.id, id));
@@ -1550,6 +1560,7 @@ export class PostEngine extends EventEmitter {
content: null, content: null,
status: 'published', status: 'published',
author: fileData.author, author: fileData.author,
language: fileData.language || null,
createdAt: fileData.createdAt, createdAt: fileData.createdAt,
updatedAt: fileData.updatedAt, updatedAt: fileData.updatedAt,
publishedAt: nextPublishedAt, publishedAt: nextPublishedAt,
@@ -1579,6 +1590,7 @@ export class PostEngine extends EventEmitter {
content: fileData.content, content: fileData.content,
status: 'published', status: 'published',
author: fileData.author || undefined, author: fileData.author || undefined,
language: fileData.language || undefined,
createdAt: fileData.createdAt, createdAt: fileData.createdAt,
updatedAt: fileData.updatedAt, updatedAt: fileData.updatedAt,
publishedAt: nextPublishedAt || undefined, publishedAt: nextPublishedAt || undefined,
@@ -1630,6 +1642,7 @@ export class PostEngine extends EventEmitter {
content: null, content: null,
status: 'published', status: 'published',
author: fileData.author, author: fileData.author,
language: fileData.language || null,
createdAt: fileData.createdAt, createdAt: fileData.createdAt,
updatedAt: fileData.updatedAt, updatedAt: fileData.updatedAt,
publishedAt, publishedAt,
@@ -1856,6 +1869,7 @@ export class PostEngine extends EventEmitter {
content: null, content: null,
status: 'published', status: 'published',
author: postData.author, author: postData.author,
language: postData.language || null,
createdAt: postData.createdAt, createdAt: postData.createdAt,
updatedAt: postData.updatedAt, updatedAt: postData.updatedAt,
publishedAt: postData.publishedAt || postData.updatedAt, publishedAt: postData.publishedAt || postData.updatedAt,

View File

@@ -27,6 +27,7 @@ export interface Task<T = unknown> {
export class TaskManager extends EventEmitter { export class TaskManager extends EventEmitter {
private tasks: Map<string, TaskProgress> = new Map(); private tasks: Map<string, TaskProgress> = new Map();
private runningTasks: Map<string, AbortController> = new Map(); private runningTasks: Map<string, AbortController> = new Map();
private externalTasks: Set<string> = new Set();
private maxConcurrentTasks = 3; private maxConcurrentTasks = 3;
private taskQueue: Task[] = []; 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 { 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); const controller = this.runningTasks.get(taskId);
if (controller) { if (controller) {
controller.abort(); controller.abort();

View File

@@ -30,6 +30,12 @@ export interface ImageAnalysisResult {
error?: string; error?: string;
} }
export interface LanguageDetectionResult {
success: boolean;
language?: string;
error?: string;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// OneShotTasks // 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 }; 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<LanguageDetectionResult> {
// 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": "<code>" } where <code> 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 };
}
}
} }

View File

@@ -14,6 +14,7 @@ export interface PostFileData {
content: string; content: string;
status: 'draft' | 'published' | 'archived'; status: 'draft' | 'published' | 'archived';
author?: string; author?: string;
language?: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
publishedAt?: Date; publishedAt?: Date;
@@ -28,6 +29,7 @@ interface PostFileMetadata {
excerpt?: string; excerpt?: string;
status: 'draft' | 'published' | 'archived'; status: 'draft' | 'published' | 'archived';
author?: string; author?: string;
language?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
publishedAt?: string; publishedAt?: string;
@@ -62,6 +64,7 @@ export async function readPostFile(filePath: string): Promise<PostFileData | nul
content: body, content: body,
status: metadata.status, status: metadata.status,
author: metadata.author, author: metadata.author,
language: metadata.language || undefined,
createdAt: new Date(metadata.createdAt), createdAt: new Date(metadata.createdAt),
updatedAt: new Date(metadata.updatedAt), updatedAt: new Date(metadata.updatedAt),
publishedAt: metadata.publishedAt ? new Date(metadata.publishedAt) : undefined, publishedAt: metadata.publishedAt ? new Date(metadata.publishedAt) : undefined,

View File

@@ -881,6 +881,19 @@ export function registerChatHandlers(): void {
} }
}); });
// ============ Post Language Detection ============
// Detect the language of a post from its title and content
ipcMain.handle('chat:detectPostLanguage', async (_, title: string, content: string) => {
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 ============ // ============ A2UI Actions ============
ipcMain.handle('a2ui:dispatch', async (_, action: { surfaceId: string; componentId: string; action: string; payload?: Record<string, unknown> }) => { ipcMain.handle('a2ui:dispatch', async (_, action: { surfaceId: string; componentId: string; action: string; payload?: Record<string, unknown> }) => {

View File

@@ -416,6 +416,15 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
data.author = metadata.defaultAuthor; 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); return engine.createPost(data);
}); });
@@ -828,6 +837,20 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
return true; 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 ============ // ============ Template Handlers ============
safeHandle('templates:create', async (_, data: CreateTemplateInput) => { safeHandle('templates:create', async (_, data: CreateTemplateInput) => {

View File

@@ -110,6 +110,9 @@ export const electronAPI: ElectronAPI = {
getAll: () => ipcRenderer.invoke('scripts:getAll'), getAll: () => ipcRenderer.invoke('scripts:getAll'),
getEnabledMacroSlugs: () => ipcRenderer.invoke('scripts:getEnabledMacroSlugs'), getEnabledMacroSlugs: () => ipcRenderer.invoke('scripts:getEnabledMacroSlugs'),
rebuildFromFiles: () => ipcRenderer.invoke('scripts:rebuildFromFiles'), 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 // Templates
@@ -376,6 +379,9 @@ export const electronAPI: ElectronAPI = {
// Media Analysis // Media Analysis
analyzeMediaImage: (mediaId: string, language?: string) => ipcRenderer.invoke('chat:analyzeMediaImage', mediaId, language), 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 // Event listeners for streaming/progress
onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => { onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; delta: string }) => callback(data); const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; delta: string }) => callback(data);

View File

@@ -94,6 +94,7 @@ export interface PostData {
content: string; content: string;
status: 'draft' | 'published' | 'archived'; status: 'draft' | 'published' | 'archived';
author?: string; author?: string;
language?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
publishedAt?: string; publishedAt?: string;
@@ -643,6 +644,12 @@ export interface ElectronAPI {
/** Internal: editor macro plugin helper. Not exposed via Python API contract. */ /** Internal: editor macro plugin helper. Not exposed via Python API contract. */
getEnabledMacroSlugs: () => Promise<string[]>; getEnabledMacroSlugs: () => Promise<string[]>;
rebuildFromFiles: () => Promise<void>; rebuildFromFiles: () => Promise<void>;
/** Create a task entry for a running utility script. */
startTask: (taskId: string, name: string) => Promise<void>;
/** Mark a utility script task as completed. */
completeTask: (taskId: string) => Promise<void>;
/** Mark a utility script task as failed. */
failTask: (taskId: string, error: string) => Promise<void>;
}; };
templates: { templates: {
create: (data: { create: (data: {
@@ -894,6 +901,9 @@ export interface ElectronAPI {
// Media Analysis // Media Analysis
analyzeMediaImage: (mediaId: string, language?: string) => Promise<{ success: boolean; title?: string; alt?: string; caption?: string; error?: string }>; 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 // Event listeners for streaming/progress
onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void; onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void;
onToolCall: (callback: (data: ChatToolCall) => void) => () => void; onToolCall: (callback: (data: ChatToolCall) => void) => () => void;

View File

@@ -188,6 +188,9 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
// expensive external API calls that require user oversight and interactive streaming. // 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 // 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. // 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.checkAvailability', 'Check if git is available.', [], 'GitAvailability'),
method('sync.getRepoState', 'Get repository state for active project.', [], 'RepoState'), 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: 'content', type: 'string', required: true, description: 'Markdown body content.' },
{ name: 'status', type: "'draft' | 'published' | 'archived'", required: true, description: 'Publication lifecycle state.' }, { name: 'status', type: "'draft' | 'published' | 'archived'", required: true, description: 'Publication lifecycle state.' },
{ name: 'author', type: 'string', required: false, description: 'Optional author name.' }, { 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: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update 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.' }, { 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 = { export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
version: '1.9.0', version: '1.10.0',
generatedAt: '2026-02-27T00:00:00.000Z', generatedAt: '2026-02-27T00:00:00.000Z',
methods: METHODS_V1, methods: METHODS_V1,
dataStructures: DATA_STRUCTURES_V1, dataStructures: DATA_STRUCTURES_V1,

View File

@@ -192,6 +192,23 @@
gap: 12px; 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 { .editor-body {
flex: 1; flex: 1;
display: flex; display: flex;

View File

@@ -75,6 +75,9 @@ const autoSaveManager = new AutoSaveManager({
if ('templateSlug' in changes) { if ('templateSlug' in changes) {
(update as Record<string, unknown>).templateSlug = changes.templateSlug as string || null; (update as Record<string, unknown>).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); const updated = await window.electronAPI?.posts.update(id, update);
if (updated) { if (updated) {
@@ -196,8 +199,10 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const [tags, setTags] = useState<string[]>([]); const [tags, setTags] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(['article']); const [selectedCategories, setSelectedCategories] = useState<string[]>(['article']);
const [templateSlug, setTemplateSlug] = useState(''); const [templateSlug, setTemplateSlug] = useState('');
const [postLanguage, setPostLanguage] = useState('');
const [availablePostTemplates, setAvailablePostTemplates] = useState<Array<{ slug: string; title: string }>>([]); const [availablePostTemplates, setAvailablePostTemplates] = useState<Array<{ slug: string; title: string }>>([]);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isDetectingLanguage, setIsDetectingLanguage] = useState(false);
const [hasPublishedVersion, setHasPublishedVersion] = useState(false); const [hasPublishedVersion, setHasPublishedVersion] = useState(false);
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode); const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
@@ -326,6 +331,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
setTags(post.tags); setTags(post.tags);
setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']); setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']);
setTemplateSlug((post as PostData & { templateSlug?: string }).templateSlug || ''); setTemplateSlug((post as PostData & { templateSlug?: string }).templateSlug || '');
setPostLanguage(post.language || '');
setMetadataExpanded(post.title === ''); setMetadataExpanded(post.title === '');
markClean(postId); markClean(postId);
// Mark as initialized AFTER setting local state // Mark as initialized AFTER setting local state
@@ -347,7 +353,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const titleChanged = title !== post.title; const titleChanged = title !== post.title;
const authorChanged = author !== (post.author || ''); const authorChanged = author !== (post.author || '');
const templateSlugChanged = templateSlug !== ((post as PostData & { templateSlug?: string }).templateSlug || ''); 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(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort()) ||
JSON.stringify(selectedCategories.slice().sort()) !== JSON.stringify(post.categories.slice().sort()); JSON.stringify(selectedCategories.slice().sort()) !== JSON.stringify(post.categories.slice().sort());
@@ -362,11 +369,12 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
tags: tags.join(', '), tags: tags.join(', '),
categories: selectedCategories, categories: selectedCategories,
templateSlug: templateSlug || undefined, templateSlug: templateSlug || undefined,
language: postLanguage || undefined,
}); });
} else { } else {
markClean(postId); 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 // Handle editor mode change and persist preference
const handleEditorModeChange = (mode: EditorMode) => { const handleEditorModeChange = (mode: EditorMode) => {
@@ -386,6 +394,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
title, title,
content, content,
author: author || undefined, author: author || undefined,
language: postLanguage || undefined,
tags, tags,
categories: selectedCategories.length > 0 ? selectedCategories : ['article'], categories: selectedCategories.length > 0 ? selectedCategories : ['article'],
templateSlug: templateSlug || null, templateSlug: templateSlug || null,
@@ -409,6 +418,24 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
} }
}, [postId, title, content, author, tags, selectedCategories, isDirty, isSaving, updatePost, markClean, showErrorModal]); }, [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 () => { const handlePublish = async () => {
await handleSave(); await handleSave();
try { try {
@@ -791,6 +818,30 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
placeholder={tr('editor.placeholder.author')} placeholder={tr('editor.placeholder.author')}
/> />
</div> </div>
<div className="editor-field">
<label>{tr('editor.field.language')}</label>
<div className="editor-language-row">
<select
value={postLanguage}
onChange={(e) => setPostLanguage(e.target.value)}
>
<option value="">{tr('editor.field.languageDefault')}</option>
<option value="en">{tr('language.en')}</option>
<option value="de">{tr('language.de')}</option>
<option value="fr">{tr('language.fr')}</option>
<option value="it">{tr('language.it')}</option>
<option value="es">{tr('language.es')}</option>
</select>
<button
className="secondary compact"
onClick={handleDetectLanguage}
disabled={isDetectingLanguage || (!title && !content)}
title={tr('editor.post.quickActions.detectLanguageDescription')}
>
{isDetectingLanguage ? tr('editor.post.quickActions.detecting') : '🤖'}
</button>
</div>
</div>
<div className="editor-field-row"> <div className="editor-field-row">
<div className="editor-field"> <div className="editor-field">
<label>{tr('editor.field.slug')}</label> <label>{tr('editor.field.slug')}</label>

View File

@@ -363,11 +363,27 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
setIsRunning(true); 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 { try {
const runtimeManager = getPythonRuntimeManager(); const runtimeManager = getPythonRuntimeManager();
const result = await runtimeManager.execute(scriptContent, { const result = await runtimeManager.execute(scriptContent, {
cacheKey: buildCacheKey(script, scriptContent), cacheKey: buildCacheKey(script, scriptContent),
entrypoint, 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(); const now = new Date().toISOString();
@@ -380,21 +396,21 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
}); });
} }
if (result.stdout.trim().length > 0) { if (isUtility && taskId) {
appendPanelOutputEntry({ await window.electronAPI?.scripts.completeTask(taskId);
id: `output-${Date.now()}-stdout`,
message: result.stdout,
createdAt: now,
kind: 'stdout',
});
} }
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
appendPanelOutputEntry({ appendPanelOutputEntry({
id: `output-${Date.now()}-error`, id: `output-${Date.now()}-error`,
message: error instanceof Error ? error.message : String(error), message: errorMessage,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
kind: 'error', kind: 'error',
}); });
if (isUtility && taskId) {
await window.electronAPI?.scripts.failTask(taskId, errorMessage);
}
} finally { } finally {
setIsRunning(false); setIsRunning(false);
} }

View File

@@ -517,6 +517,13 @@
"editor.field.content": "Inhalt", "editor.field.content": "Inhalt",
"editor.field.template": "Vorlage", "editor.field.template": "Vorlage",
"editor.field.templateDefault": "Standard", "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.tags": "Tags hinzufügen...",
"editor.placeholder.author": "Autorenname", "editor.placeholder.author": "Autorenname",
"editor.placeholder.categories": "Kategorien hinzufügen...", "editor.placeholder.categories": "Kategorien hinzufügen...",
@@ -879,6 +886,10 @@
"editor.media.quickActions.button": "⚡ Schnellaktionen", "editor.media.quickActions.button": "⚡ Schnellaktionen",
"editor.media.quickActions.aiTitle": "KI: Titel, Alt-Text und Bildunterschrift erzeugen", "editor.media.quickActions.aiTitle": "KI: Titel, Alt-Text und Bildunterschrift erzeugen",
"editor.media.quickActions.aiDescription": "Analysiert das Bild und schlägt Metadaten vor", "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.replaceFile": "Datei ersetzen",
"editor.media.field.fileName": "Dateiname", "editor.media.field.fileName": "Dateiname",
"editor.media.field.type": "Typ", "editor.media.field.type": "Typ",

View File

@@ -517,6 +517,13 @@
"editor.field.content": "Content", "editor.field.content": "Content",
"editor.field.template": "Template", "editor.field.template": "Template",
"editor.field.templateDefault": "Default", "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.tags": "Add tags...",
"editor.placeholder.author": "Author name", "editor.placeholder.author": "Author name",
"editor.placeholder.categories": "Add categories...", "editor.placeholder.categories": "Add categories...",
@@ -879,6 +886,10 @@
"editor.media.quickActions.button": "⚡ Quick Actions", "editor.media.quickActions.button": "⚡ Quick Actions",
"editor.media.quickActions.aiTitle": "AI: Generate Title, Alt & Caption", "editor.media.quickActions.aiTitle": "AI: Generate Title, Alt & Caption",
"editor.media.quickActions.aiDescription": "Analyzes the image to suggest metadata", "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.replaceFile": "Replace File",
"editor.media.field.fileName": "File Name", "editor.media.field.fileName": "File Name",
"editor.media.field.type": "Type", "editor.media.field.type": "Type",

View File

@@ -517,6 +517,13 @@
"editor.field.content": "Contenido", "editor.field.content": "Contenido",
"editor.field.template": "Plantilla", "editor.field.template": "Plantilla",
"editor.field.templateDefault": "Predeterminada", "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.tags": "Agregar etiquetas...",
"editor.placeholder.author": "Nombre del autor", "editor.placeholder.author": "Nombre del autor",
"editor.placeholder.categories": "Agregar categorías...", "editor.placeholder.categories": "Agregar categorías...",
@@ -879,6 +886,10 @@
"editor.media.quickActions.button": "✨ Analizar con IA", "editor.media.quickActions.button": "✨ Analizar con IA",
"editor.media.quickActions.aiTitle": "Título sugerido por 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.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.replaceFile": "Reemplazar archivo",
"editor.media.field.fileName": "Nombre de archivo", "editor.media.field.fileName": "Nombre de archivo",
"editor.media.field.type": "Tipo", "editor.media.field.type": "Tipo",

View File

@@ -517,6 +517,13 @@
"editor.field.content": "Contenu", "editor.field.content": "Contenu",
"editor.field.template": "Modèle", "editor.field.template": "Modèle",
"editor.field.templateDefault": "Par défaut", "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.tags": "Ajouter des étiquettes...",
"editor.placeholder.author": "Nom de lauteur", "editor.placeholder.author": "Nom de lauteur",
"editor.placeholder.categories": "Ajouter des catégories...", "editor.placeholder.categories": "Ajouter des catégories...",
@@ -879,6 +886,10 @@
"editor.media.quickActions.button": "✨ Analyser avec lIA", "editor.media.quickActions.button": "✨ Analyser avec lIA",
"editor.media.quickActions.aiTitle": "Titre suggéré par lIA", "editor.media.quickActions.aiTitle": "Titre suggéré par lIA",
"editor.media.quickActions.aiDescription": "Générez automatiquement un titre, un texte alternatif et une légende.", "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.replaceFile": "Remplacer le fichier",
"editor.media.field.fileName": "Nom du fichier", "editor.media.field.fileName": "Nom du fichier",
"editor.media.field.type": "Type", "editor.media.field.type": "Type",

View File

@@ -517,6 +517,13 @@
"editor.field.content": "Contenuto", "editor.field.content": "Contenuto",
"editor.field.template": "Modello", "editor.field.template": "Modello",
"editor.field.templateDefault": "Predefinito", "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.tags": "Aggiungi tag...",
"editor.placeholder.author": "Nome autore", "editor.placeholder.author": "Nome autore",
"editor.placeholder.categories": "Aggiungi categorie...", "editor.placeholder.categories": "Aggiungi categorie...",
@@ -879,6 +886,10 @@
"editor.media.quickActions.button": "✨ Analizza con IA", "editor.media.quickActions.button": "✨ Analizza con IA",
"editor.media.quickActions.aiTitle": "Titolo suggerito dallIA", "editor.media.quickActions.aiTitle": "Titolo suggerito dallIA",
"editor.media.quickActions.aiDescription": "Genera automaticamente titolo, testo alternativo e didascalia.", "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.replaceFile": "Sostituisci file",
"editor.media.field.fileName": "Nome file", "editor.media.field.fileName": "Nome file",
"editor.media.field.type": "Tipo", "editor.media.field.type": "Tipo",

View File

@@ -3,12 +3,22 @@ import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol
import type { PythonSyntaxError } from './runtimeProtocol'; import type { PythonSyntaxError } from './runtimeProtocol';
import { parseMacroContextV1, parseMacroResultV1, type MacroContextV1, type MacroResultV1 } from './abiV1'; import { parseMacroContextV1, parseMacroResultV1, type MacroContextV1, type MacroResultV1 } from './abiV1';
import { invokePythonApiMethodV1 } from './pythonApiInvokerV1'; import { invokePythonApiMethodV1 } from './pythonApiInvokerV1';
import { showToast } from '../components/Toast';
type WorkerFactory = () => Worker; type WorkerFactory = () => Worker;
type PythonApiInvoker = (method: string, args: unknown) => Promise<unknown>; type PythonApiInvoker = (method: string, args: unknown) => Promise<unknown>;
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 { interface PythonRuntimeManagerOptions {
invokeApiCall?: PythonApiInvoker; invokeApiCall?: PythonApiInvoker;
onToast?: ToastHandler;
} }
interface InitializeDeferred { interface InitializeDeferred {
@@ -22,6 +32,8 @@ interface PendingRun {
resolve: (value: PythonRunResult | PythonMacroV1Result | string[] | PythonSyntaxCheckResult) => void; resolve: (value: PythonRunResult | PythonMacroV1Result | string[] | PythonSyntaxCheckResult) => void;
reject: (error: Error) => void; reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout> | null; timeoutId: ReturnType<typeof setTimeout> | null;
timeoutMs: number;
onStdout?: (chunk: string) => void;
} }
export interface PythonRunResult { export interface PythonRunResult {
@@ -33,6 +45,7 @@ export interface PythonExecuteOptions {
timeoutMs?: number; timeoutMs?: number;
cacheKey?: string; cacheKey?: string;
entrypoint?: string; entrypoint?: string;
onStdout?: (chunk: string) => void;
} }
export interface PythonMacroSourceOptions { export interface PythonMacroSourceOptions {
@@ -65,12 +78,14 @@ export class PythonRuntimeManager {
private activeRequestId: string | null = null; private activeRequestId: string | null = null;
private requestCounter = 0; private requestCounter = 0;
private readonly invokeApiCall: PythonApiInvoker; private readonly invokeApiCall: PythonApiInvoker;
private readonly onToast: ToastHandler;
constructor( constructor(
private readonly workerFactory: WorkerFactory = createPythonRuntimeWorker, private readonly workerFactory: WorkerFactory = createPythonRuntimeWorker,
options: PythonRuntimeManagerOptions = {} options: PythonRuntimeManagerOptions = {}
) { ) {
this.invokeApiCall = options.invokeApiCall ?? invokePythonApiMethodV1; this.invokeApiCall = options.invokeApiCall ?? invokePythonApiMethodV1;
this.onToast = options.onToast ?? defaultToastHandler;
} }
initialize(): Promise<void> { initialize(): Promise<void> {
@@ -116,18 +131,14 @@ export class PythonRuntimeManager {
const timeoutMs = options?.timeoutMs ?? 5000; const timeoutMs = options?.timeoutMs ?? 5000;
return new Promise<PythonRunResult>((resolve, reject) => { return new Promise<PythonRunResult>((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pendingRuns.delete(requestId);
this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pendingRuns.set(requestId, { this.pendingRuns.set(requestId, {
kind: 'run', kind: 'run',
stdout: '', stdout: '',
resolve: (value) => resolve(value as PythonRunResult), resolve: (value) => resolve(value as PythonRunResult),
reject, reject,
timeoutId, timeoutId: null,
timeoutMs,
onStdout: options?.onStdout,
}); });
const message: PythonWorkerRequest = { const message: PythonWorkerRequest = {
@@ -155,18 +166,13 @@ export class PythonRuntimeManager {
const timeoutMs = options?.timeoutMs ?? 5000; const timeoutMs = options?.timeoutMs ?? 5000;
return new Promise<PythonMacroV1Result>((resolve, reject) => { return new Promise<PythonMacroV1Result>((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pendingRuns.delete(requestId);
this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pendingRuns.set(requestId, { this.pendingRuns.set(requestId, {
kind: 'macro-v1', kind: 'macro-v1',
stdout: '', stdout: '',
resolve: (value) => resolve(value as PythonMacroV1Result), resolve: (value) => resolve(value as PythonMacroV1Result),
reject, reject,
timeoutId, timeoutId: null,
timeoutMs,
}); });
const message: PythonWorkerRequest = { const message: PythonWorkerRequest = {
@@ -194,18 +200,13 @@ export class PythonRuntimeManager {
const timeoutMs = options?.timeoutMs ?? 5000; const timeoutMs = options?.timeoutMs ?? 5000;
return new Promise<string[]>((resolve, reject) => { return new Promise<string[]>((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pendingRuns.delete(requestId);
this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pendingRuns.set(requestId, { this.pendingRuns.set(requestId, {
kind: 'inspect-entrypoints', kind: 'inspect-entrypoints',
stdout: '', stdout: '',
resolve: (value) => resolve(value as string[]), resolve: (value) => resolve(value as string[]),
reject, reject,
timeoutId, timeoutId: null,
timeoutMs,
}); });
const message: PythonWorkerRequest = { const message: PythonWorkerRequest = {
@@ -230,18 +231,13 @@ export class PythonRuntimeManager {
const timeoutMs = options?.timeoutMs ?? 5000; const timeoutMs = options?.timeoutMs ?? 5000;
return new Promise<PythonSyntaxCheckResult>((resolve, reject) => { return new Promise<PythonSyntaxCheckResult>((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pendingRuns.delete(requestId);
this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pendingRuns.set(requestId, { this.pendingRuns.set(requestId, {
kind: 'syntax-check', kind: 'syntax-check',
stdout: '', stdout: '',
resolve: (value) => resolve(value as PythonSyntaxCheckResult), resolve: (value) => resolve(value as PythonSyntaxCheckResult),
reject, reject,
timeoutId, timeoutId: null,
timeoutMs,
}); });
const message: PythonWorkerRequest = { const message: PythonWorkerRequest = {
@@ -282,6 +278,11 @@ export class PythonRuntimeManager {
return; return;
} }
if (payload.type === 'toast') {
this.onToast(payload.message, payload.toastType);
return;
}
const pendingRun = this.pendingRuns.get(payload.requestId); const pendingRun = this.pendingRuns.get(payload.requestId);
if (!pendingRun) { if (!pendingRun) {
if (this.activeRequestId === payload.requestId && payload.type !== 'stdout') { if (this.activeRequestId === payload.requestId && payload.type !== 'stdout') {
@@ -293,6 +294,7 @@ export class PythonRuntimeManager {
if (payload.type === 'stdout') { if (payload.type === 'stdout') {
pendingRun.stdout += payload.chunk; pendingRun.stdout += payload.chunk;
pendingRun.onStdout?.(payload.chunk);
return; return;
} }
@@ -440,6 +442,7 @@ export class PythonRuntimeManager {
} }
this.activeRequestId = request.requestId; this.activeRequestId = request.requestId;
this.startTimeoutForRequest(request.requestId);
this.worker.postMessage(request); this.worker.postMessage(request);
} }
@@ -454,9 +457,23 @@ export class PythonRuntimeManager {
} }
this.activeRequestId = nextRequest.requestId; this.activeRequestId = nextRequest.requestId;
this.startTimeoutForRequest(nextRequest.requestId);
this.worker.postMessage(nextRequest); 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 { private finishRequest(requestId: string): void {
if (this.activeRequestId === requestId) { if (this.activeRequestId === requestId) {
this.activeRequestId = null; this.activeRequestId = null;

View File

@@ -76,6 +76,7 @@ export async function invokePythonApiMethodV1(method: string, args: unknown): Pr
} }
const normalizedArgs = asRecord(args); const normalizedArgs = asRecord(args);
const electronApi = getElectronApi(); const electronApi = getElectronApi();
const [namespace, member] = contract.method.split('.'); const [namespace, member] = contract.method.split('.');
if (!namespace || !member) { if (!namespace || !member) {

View File

@@ -344,11 +344,22 @@ async function bootstrapRuntime(): Promise<void> {
}, },
}); });
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()); runtime.globals.set('__bds_api_module_source', generatePythonApiModuleV1());
await runtime.runPythonAsync(` await runtime.runPythonAsync(`
import sys import sys
import types import types
def toast(message, type="info"):
__bds_push_toast(str(message), str(type))
__bds_api_module = types.ModuleType("bds_api") __bds_api_module = types.ModuleType("bds_api")
exec(__bds_api_module_source, __bds_api_module.__dict__) exec(__bds_api_module_source, __bds_api_module.__dict__)

View File

@@ -55,4 +55,5 @@ export type PythonWorkerMessage =
| { type: 'entrypoints'; requestId: string; entrypoints: string[] } | { type: 'entrypoints'; requestId: string; entrypoints: string[] }
| { type: 'syntaxResult'; requestId: string; errors: PythonSyntaxError[] } | { type: 'syntaxResult'; requestId: string; errors: PythonSyntaxError[] }
| { type: 'macroResult'; requestId: string; result: MacroResultV1 } | { type: 'macroResult'; requestId: string; result: MacroResultV1 }
| { type: 'runError'; requestId: string; error: string }; | { type: 'runError'; requestId: string; error: string }
| { type: 'toast'; message: string; toastType?: string };

View File

@@ -20,6 +20,7 @@ function makePost(overrides: Partial<PostData> = {}): PostData {
content: overrides.content ?? `# ${title}\n\nBody`, content: overrides.content ?? `# ${title}\n\nBody`,
status: overrides.status ?? 'published', status: overrides.status ?? 'published',
author: overrides.author, author: overrides.author,
language: overrides.language,
createdAt, createdAt,
updatedAt, updatedAt,
publishedAt: overrides.publishedAt, publishedAt: overrides.publishedAt,
@@ -155,4 +156,31 @@ describe('GenerationSitemapFeedService', () => {
expect(result.rssXml).toBe(''); expect(result.rssXml).toBe('');
expect(result.atomXml).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('<dc:language>en</dc:language>');
expect(result.rssXml).toContain('<dc:language>de</dc:language>');
// Atom should have xml:lang on entries with language
expect(result.atomXml).toContain('xml:lang="en"');
expect(result.atomXml).toContain('xml:lang="de"');
});
}); });

View File

@@ -299,6 +299,89 @@ Content here`);
expect(result?.differences.categories?.fileValue).toEqual(['cat1']); 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 () => { it('should return hasDifferences=false when metadata matches', async () => {
const dbPost = { const dbPost = {
id: 'post-1', id: 'post-1',
@@ -553,6 +636,47 @@ Content here`);
expect(mockLocalDb.update).toHaveBeenCalled(); 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 () => { it('should report progress on first and final items based on cadence', async () => {
const postIds = Array.from({ length: 11 }, (_, i) => `post-${i + 1}`); const postIds = Array.from({ length: 11 }, (_, i) => `post-${i + 1}`);

View File

@@ -163,6 +163,22 @@ describe('PostEngine', () => {
// Reset the mock implementations // Reset the mock implementations
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain()); 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) // Reset fs implementations to use mockFiles map (fixes test leakage from other tests)
vi.mocked(fs.readFile).mockImplementation(createDefaultFsReadFile(mockFiles) as any); vi.mocked(fs.readFile).mockImplementation(createDefaultFsReadFile(mockFiles) as any);
@@ -783,6 +799,94 @@ Original content`);
expect(result?.content).toBe('New draft 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 () => { it('should update tags and categories', async () => {
const created = await postEngine.createPost({ const created = await postEngine.createPost({
title: 'Tag Update Test', title: 'Tag Update Test',
@@ -3301,4 +3405,106 @@ Content with [link](/posts/other-post)`);
expect(result.processedFiles).toBe(0); 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');
});
});
}); });

View File

@@ -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', () => { describe('TaskManager Concurrency', () => {
let taskManager: TaskManager; let taskManager: TaskManager;
const MAX_CONCURRENT = 3; const MAX_CONCURRENT = 3;

View File

@@ -79,6 +79,9 @@ describe('ScriptsView', () => {
updatedAt: '2026-02-22T00:00:00.000Z', updatedAt: '2026-02-22T00:00:00.000Z',
}), }),
getAll: vi.fn(), 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' })); fireEvent.click(screen.getByRole('button', { name: 'Run Script' }));
await vi.waitFor(() => { await vi.waitFor(() => {
expect(executeMock).toHaveBeenCalledWith('print("hello")', { expect(executeMock).toHaveBeenCalledWith('print("hello")', expect.objectContaining({
cacheKey: expect.stringMatching(/^script-1:1:/), cacheKey: expect.stringMatching(/^script-1:1:/),
entrypoint: 'render', entrypoint: 'render',
}); timeoutMs: 0,
}));
}); });
const state = useAppStore.getState(); const state = useAppStore.getState();
expect(state.panelVisible).toBe(false); expect(state.panelVisible).toBe(false);
expect(state.panelActiveTab).toBe('tasks'); expect(state.panelActiveTab).toBe('tasks');
expect(state.panelOutputEntries.length).toBeGreaterThan(0); 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 () => { it('checks syntax manually and writes editor markers for syntax errors', async () => {
@@ -360,4 +364,77 @@ describe('ScriptsView', () => {
expect(useAppStore.getState().tabs).toEqual([]); 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(<ScriptsView scriptId="script-1" />);
await screen.findByLabelText('Script Content');
fireEvent.click(screen.getByRole('button', { name: 'Run Script' }));
await vi.waitFor(() => {
expect(executeMock).toHaveBeenCalledWith('print("hello")', 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(<ScriptsView scriptId="script-1" />);
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(<ScriptsView scriptId="script-1" />);
await screen.findByLabelText('Script Content');
fireEvent.click(screen.getByRole('button', { name: 'Run Script' }));
await vi.waitFor(() => {
expect(executeMock).toHaveBeenCalledWith('print("hello")', expect.objectContaining({
timeoutMs: 0,
}));
expect(startTaskMock).not.toHaveBeenCalled();
});
});
}); });

View File

@@ -502,4 +502,115 @@ describe('PythonRuntimeManager', () => {
worker.emitMessage({ type: 'runResult', requestId: runRequest.requestId, result: 'done' }); worker.emitMessage({ type: 'runResult', requestId: runRequest.requestId, result: 'done' });
await expect(runPromise).resolves.toEqual({ result: 'done', stdout: '' }); 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: '' });
});
}); });

View File

@@ -37,8 +37,9 @@ describe('generateApiDocumentationMarkdownV1', () => {
expect(markdown).toContain('## publish'); expect(markdown).toContain('## publish');
expect(markdown).toContain('### publish.uploadSite'); expect(markdown).toContain('### publish.uploadSite');
expect(markdown).toContain('- [publish](#publish)'); expect(markdown).toContain('- [publish](#publish)');
// chat namespace should not be present // chat namespace now contains detectPostLanguage
expect(markdown).not.toContain('## chat'); expect(markdown).toContain('## chat');
expect(markdown).toContain('### chat.detectPostLanguage');
}); });
it('includes a dedicated Data Structures section with core object shapes', () => { it('includes a dedicated Data Structures section with core object shapes', () => {

View File

@@ -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 methodNames = listPythonApiMethodNames();
const chatMethods = methodNames.filter((m) => m.startsWith('chat.')); 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', () => { it('contains semantic version metadata for compatibility checks', () => {
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({ expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
version: '1.9.0', version: '1.10.0',
generatedAt: expect.any(String), generatedAt: expect.any(String),
}); });
}); });
@@ -100,7 +100,8 @@ describe('generatePythonApiModuleV1', () => {
expect(moduleCode).toContain('async def upload_site(self, credentials):'); expect(moduleCode).toContain('async def upload_site(self, credentials):');
expect(moduleCode).toContain('class BdsApi:'); expect(moduleCode).toContain('class BdsApi:');
expect(moduleCode).toContain('bds = BdsApi(_transport)'); 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', () => { it('escapes python keyword method names to valid identifiers', () => {