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:
@@ -397,6 +397,7 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
|
||||
` <guid isPermaLink="true">${escapeXml(permalink)}</guid>`,
|
||||
` <pubDate>${(post.publishedAt || post.updatedAt).toUTCString()}</pubDate>`,
|
||||
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>`,
|
||||
` <content:encoded><![CDATA[${escapeCdata(contentXhtml)}]]></content:encoded>`,
|
||||
...categories.map((entry) => ` ${entry}`),
|
||||
@@ -406,7 +407,7 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
|
||||
|
||||
const rssXml = [
|
||||
'<?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>',
|
||||
` <title>${escapeXml(feedTitle)}</title>`,
|
||||
` <link>${escapeXml(baseLink)}</link>`,
|
||||
@@ -430,8 +431,10 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
|
||||
...(post.categories || []).map((category) => `<category term="${escapeXml(category)}" />`),
|
||||
];
|
||||
|
||||
const postLanguageAttr = (post as { language?: string }).language ? ` xml:lang="${escapeXml((post as { language?: string }).language!)}"` : '';
|
||||
|
||||
return [
|
||||
' <entry>',
|
||||
` <entry${postLanguageAttr}>`,
|
||||
` <title>${escapeXml(post.title)}</title>`,
|
||||
` <id>${escapeXml(permalink)}</id>`,
|
||||
` <link href="${escapeXml(permalink)}" />`,
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface FieldDifference<T = unknown> {
|
||||
/**
|
||||
* The fields that can have differences
|
||||
*/
|
||||
export type DiffField = 'tags' | 'categories' | 'title' | 'excerpt' | 'author';
|
||||
export type DiffField = 'tags' | 'categories' | 'title' | 'excerpt' | 'author' | 'language';
|
||||
|
||||
/**
|
||||
* Metadata differences for a single post
|
||||
@@ -248,6 +248,11 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
differences.author = { dbValue: dbPost.author || '', fileValue: fileData.author || '' };
|
||||
}
|
||||
|
||||
// Compare language
|
||||
if ((dbPost.language || '') !== (fileData.language || '')) {
|
||||
differences.language = { dbValue: dbPost.language || '', fileValue: fileData.language || '' };
|
||||
}
|
||||
|
||||
return {
|
||||
postId: dbPost.id,
|
||||
title: dbPost.title,
|
||||
@@ -279,7 +284,7 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
|
||||
// Get all published posts with file paths
|
||||
const result = await client.execute({
|
||||
sql: `SELECT id, title, slug, file_path, tags, categories, excerpt, author
|
||||
sql: `SELECT id, title, slug, file_path, tags, categories, excerpt, author, language
|
||||
FROM posts
|
||||
WHERE project_id = ?
|
||||
AND status = 'published'
|
||||
@@ -331,6 +336,7 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
title: 'Title',
|
||||
excerpt: 'Excerpt',
|
||||
author: 'Author',
|
||||
language: 'Language',
|
||||
};
|
||||
|
||||
for (const diff of diffs) {
|
||||
@@ -428,6 +434,9 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
if (!field || field === 'author') {
|
||||
updateData.author = fileData.author || null;
|
||||
}
|
||||
if (!field || field === 'language') {
|
||||
updateData.language = fileData.language || null;
|
||||
}
|
||||
|
||||
// Update database
|
||||
await db
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface TemplatePostEntry {
|
||||
title: string;
|
||||
content: string;
|
||||
show_title: boolean;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface CategoryRenderSettings {
|
||||
@@ -1485,8 +1486,12 @@ export class PageRenderer {
|
||||
|
||||
const canonicalPostPathBySlug = mapToRecord(rewriteContext.canonicalPostPathBySlug);
|
||||
|
||||
// Per-post language overrides the page-level language when present
|
||||
const postLanguage = (renderablePost as { language?: string }).language;
|
||||
|
||||
const context: SinglePostTemplateContext = {
|
||||
...pageContext,
|
||||
language: postLanguage || pageContext.language,
|
||||
menu_items: pageContext.menu_items ?? [],
|
||||
post: {
|
||||
id: renderablePost.id,
|
||||
@@ -1494,6 +1499,7 @@ export class PageRenderer {
|
||||
title: renderablePost.title,
|
||||
content: renderablePost.content,
|
||||
show_title: false,
|
||||
language: postLanguage,
|
||||
},
|
||||
post_categories: postCategories,
|
||||
post_tags: postTags,
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface PostData {
|
||||
content: string;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
author?: string;
|
||||
language?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
publishedAt?: Date;
|
||||
@@ -39,6 +40,7 @@ export interface PostMetadata {
|
||||
excerpt?: string;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
author?: string;
|
||||
language?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
publishedAt?: string;
|
||||
@@ -319,6 +321,7 @@ export class PostEngine extends EventEmitter {
|
||||
// Only add optional fields if they have values (gray-matter can't serialize undefined)
|
||||
if (post.excerpt) metadata.excerpt = post.excerpt;
|
||||
if (post.author) metadata.author = post.author;
|
||||
if (post.language) metadata.language = post.language;
|
||||
if (post.publishedAt) metadata.publishedAt = post.publishedAt.toISOString();
|
||||
|
||||
// Use date-based directory structure (posts/YYYY/MM/)
|
||||
@@ -392,6 +395,7 @@ export class PostEngine extends EventEmitter {
|
||||
content: data.content || '',
|
||||
status: data.status || 'draft',
|
||||
author: data.author,
|
||||
language: data.language,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
publishedAt: data.publishedAt,
|
||||
@@ -418,6 +422,7 @@ export class PostEngine extends EventEmitter {
|
||||
checksum,
|
||||
tags: JSON.stringify(post.tags),
|
||||
categories: JSON.stringify(post.categories),
|
||||
language: post.language || null,
|
||||
};
|
||||
|
||||
await db.insert(posts).values(dbPost);
|
||||
@@ -445,7 +450,9 @@ export class PostEngine extends EventEmitter {
|
||||
data.title !== undefined ||
|
||||
data.tags !== undefined ||
|
||||
data.categories !== undefined ||
|
||||
data.excerpt !== undefined;
|
||||
data.excerpt !== undefined ||
|
||||
data.language !== undefined ||
|
||||
data.author !== undefined;
|
||||
|
||||
let newStatus = data.status || existing.status;
|
||||
if (existing.status === 'published' && isContentOrMetadataChange && !data.status) {
|
||||
@@ -484,6 +491,7 @@ export class PostEngine extends EventEmitter {
|
||||
checksum,
|
||||
tags: JSON.stringify(updated.tags),
|
||||
categories: JSON.stringify(updated.categories),
|
||||
language: updated.language || null,
|
||||
})
|
||||
.where(eq(posts.id, id));
|
||||
|
||||
@@ -576,6 +584,7 @@ export class PostEngine extends EventEmitter {
|
||||
content: body,
|
||||
status: dbPost.status as 'draft' | 'published' | 'archived',
|
||||
author: dbPost.author || undefined,
|
||||
language: (dbPost as { language?: string | null }).language || undefined,
|
||||
createdAt: dbPost.createdAt,
|
||||
updatedAt: dbPost.updatedAt,
|
||||
publishedAt: dbPost.publishedAt || undefined,
|
||||
@@ -1331,6 +1340,7 @@ export class PostEngine extends EventEmitter {
|
||||
checksum,
|
||||
tags: JSON.stringify(published.tags),
|
||||
categories: JSON.stringify(published.categories),
|
||||
language: published.language || null,
|
||||
})
|
||||
.where(eq(posts.id, id));
|
||||
|
||||
@@ -1550,6 +1560,7 @@ export class PostEngine extends EventEmitter {
|
||||
content: null,
|
||||
status: 'published',
|
||||
author: fileData.author,
|
||||
language: fileData.language || null,
|
||||
createdAt: fileData.createdAt,
|
||||
updatedAt: fileData.updatedAt,
|
||||
publishedAt: nextPublishedAt,
|
||||
@@ -1579,6 +1590,7 @@ export class PostEngine extends EventEmitter {
|
||||
content: fileData.content,
|
||||
status: 'published',
|
||||
author: fileData.author || undefined,
|
||||
language: fileData.language || undefined,
|
||||
createdAt: fileData.createdAt,
|
||||
updatedAt: fileData.updatedAt,
|
||||
publishedAt: nextPublishedAt || undefined,
|
||||
@@ -1630,6 +1642,7 @@ export class PostEngine extends EventEmitter {
|
||||
content: null,
|
||||
status: 'published',
|
||||
author: fileData.author,
|
||||
language: fileData.language || null,
|
||||
createdAt: fileData.createdAt,
|
||||
updatedAt: fileData.updatedAt,
|
||||
publishedAt,
|
||||
@@ -1856,6 +1869,7 @@ export class PostEngine extends EventEmitter {
|
||||
content: null,
|
||||
status: 'published',
|
||||
author: postData.author,
|
||||
language: postData.language || null,
|
||||
createdAt: postData.createdAt,
|
||||
updatedAt: postData.updatedAt,
|
||||
publishedAt: postData.publishedAt || postData.updatedAt,
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface Task<T = unknown> {
|
||||
export class TaskManager extends EventEmitter {
|
||||
private tasks: Map<string, TaskProgress> = new Map();
|
||||
private runningTasks: Map<string, AbortController> = new Map();
|
||||
private externalTasks: Set<string> = new Set();
|
||||
private maxConcurrentTasks = 3;
|
||||
private taskQueue: Task[] = [];
|
||||
|
||||
@@ -136,7 +137,80 @@ export class TaskManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External tasks — lifecycle controlled by the caller (e.g. renderer-side
|
||||
// utility script execution) rather than an execute() callback.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
startExternalTask(taskId: string, name: string): void {
|
||||
const progress: TaskProgress = {
|
||||
taskId,
|
||||
name,
|
||||
status: 'running',
|
||||
progress: 0,
|
||||
message: 'Running…',
|
||||
startTime: new Date(),
|
||||
};
|
||||
|
||||
this.tasks.set(taskId, progress);
|
||||
this.externalTasks.add(taskId);
|
||||
this.emit('taskCreated', progress);
|
||||
this.emit('taskStarted', progress);
|
||||
}
|
||||
|
||||
updateExternalTaskProgress(taskId: string, progress: number, message: string): void {
|
||||
const entry = this.tasks.get(taskId);
|
||||
if (!entry || !this.externalTasks.has(taskId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.progress = progress;
|
||||
entry.message = message;
|
||||
this.emit('taskProgress', { ...entry });
|
||||
}
|
||||
|
||||
completeExternalTask(taskId: string): void {
|
||||
const entry = this.tasks.get(taskId);
|
||||
if (!entry || !this.externalTasks.has(taskId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.status = 'completed';
|
||||
entry.progress = 100;
|
||||
entry.message = 'Completed';
|
||||
entry.endTime = new Date();
|
||||
this.externalTasks.delete(taskId);
|
||||
this.emit('taskCompleted', entry);
|
||||
}
|
||||
|
||||
failExternalTask(taskId: string, error: string): void {
|
||||
const entry = this.tasks.get(taskId);
|
||||
if (!entry || !this.externalTasks.has(taskId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.status = 'failed';
|
||||
entry.error = error;
|
||||
entry.message = `Failed: ${error}`;
|
||||
entry.endTime = new Date();
|
||||
this.externalTasks.delete(taskId);
|
||||
this.emit('taskFailed', entry);
|
||||
}
|
||||
|
||||
cancelTask(taskId: string): boolean {
|
||||
// Check external tasks first
|
||||
if (this.externalTasks.has(taskId)) {
|
||||
this.externalTasks.delete(taskId);
|
||||
const progress = this.tasks.get(taskId);
|
||||
if (progress) {
|
||||
progress.status = 'cancelled';
|
||||
progress.message = 'Cancelled';
|
||||
progress.endTime = new Date();
|
||||
this.emit('taskCancelled', progress);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const controller = this.runningTasks.get(taskId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
|
||||
@@ -30,6 +30,12 @@ export interface ImageAnalysisResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface LanguageDetectionResult {
|
||||
success: boolean;
|
||||
language?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OneShotTasks
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -275,4 +281,70 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the language of a post based on its title and content.
|
||||
* Uses the configured title model (lightweight, text-only).
|
||||
*/
|
||||
async detectPostLanguage(
|
||||
title: string,
|
||||
content: string,
|
||||
): Promise<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;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
author?: string;
|
||||
language?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
publishedAt?: Date;
|
||||
@@ -28,6 +29,7 @@ interface PostFileMetadata {
|
||||
excerpt?: string;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
author?: string;
|
||||
language?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
publishedAt?: string;
|
||||
@@ -62,6 +64,7 @@ export async function readPostFile(filePath: string): Promise<PostFileData | nul
|
||||
content: body,
|
||||
status: metadata.status,
|
||||
author: metadata.author,
|
||||
language: metadata.language || undefined,
|
||||
createdAt: new Date(metadata.createdAt),
|
||||
updatedAt: new Date(metadata.updatedAt),
|
||||
publishedAt: metadata.publishedAt ? new Date(metadata.publishedAt) : undefined,
|
||||
|
||||
Reference in New Issue
Block a user