Feat/language detection (#31)

* feat: implementation of language detection

* run utility scripts in tasks

* fix: addiitonal fixes for background utilities

* feat: toast() also for utility scripts

---------

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

View File

@@ -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)}" />`,

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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();

View File

@@ -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 };
}
}
}

View File

@@ -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,