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:
47
API.md
47
API.md
@@ -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.
|
||||||
|
|||||||
1
drizzle/0011_loving_alex_wilder.sql
Normal file
1
drizzle/0011_loving_alex_wilder.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `posts` ADD `language` text;
|
||||||
1439
drizzle/meta/0011_snapshot.json
Normal file
1439
drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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)}" />`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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> }) => {
|
||||||
|
|||||||
@@ -417,6 +417,15 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 l’auteur",
|
"editor.placeholder.author": "Nom de l’auteur",
|
||||||
"editor.placeholder.categories": "Ajouter des catégories...",
|
"editor.placeholder.categories": "Ajouter des catégories...",
|
||||||
@@ -879,6 +886,10 @@
|
|||||||
"editor.media.quickActions.button": "✨ Analyser avec l’IA",
|
"editor.media.quickActions.button": "✨ Analyser avec l’IA",
|
||||||
"editor.media.quickActions.aiTitle": "Titre suggéré par l’IA",
|
"editor.media.quickActions.aiTitle": "Titre suggéré par l’IA",
|
||||||
"editor.media.quickActions.aiDescription": "Générez automatiquement un titre, un texte alternatif et une légende.",
|
"editor.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",
|
||||||
|
|||||||
@@ -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 dall’IA",
|
"editor.media.quickActions.aiTitle": "Titolo suggerito dall’IA",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: '' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user