Feature/post media translations (#42)

* chore: updated todo with translation ideas

* feat: first take at the implementation of translations

* fix: small addition for the translation feature

* feat: support language switching in the editor and preview

* feat: better handling of long bodies by not running them through a json envelope

* fix: unknown macros have better fallback

* feat: api for python to get translations

* fix: strip dumb prefix of content in translation

* feat: extend meta diff for translations

* feat: hook up translations to rebuild-from-disk

* feat: generation of the website prefers project language, falling back to canonical language

* fix: crashes during rendering

* feat: translation validation report

* fix: made the translation validation actually work

* chore: reorganization of menu

* fix: some topics cleanup

* chore: updated doc

* feat: translations for media

* feat: more aligned in UI/UX

* feat: edit translations possible

* chore: added full multi-language todo

* chore: updated todo for clarity

* feat: implementation of full multi-linguality

* fix: page creation creates pages

* fix: flags on every page

* fix: better prompt

* feat: made MCP server aware of language content

* feat: python tools for translations

* fix: better fill-in-translations

* fix: better prompt for translation. maybe.

* fix: losing posts from search due to translation process

* fix: translation validation handles in-db content and fill-in of missing translations fixed to flush

* fix: faster scanning for infilling of missing translations

* chore: updated agent instructions

* feat: calendar and tag cloud respect current language now

* fix: retries going up

* fix: got metadata-diff and rebuild into sync

* fix: extended meta-diff for timestamps

* fix: made website validation look at translated content, too

* fix: multi-lingual search

* chore: refactor Editor.tsx into two separate editors

* feat: do language detection when no explicit language given

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-09 14:43:18 +01:00
committed by GitHub
parent f1c9038803
commit b855d61524
116 changed files with 19954 additions and 2094 deletions

View File

@@ -35,6 +35,7 @@ export const posts = sqliteTable('posts', {
categories: text('categories'), // JSON array stored as text
templateSlug: text('template_slug'), // Optional user template override for this post
language: text('language'), // Optional per-post language override (ISO code, e.g. 'en', 'de')
doNotTranslate: integer('do_not_translate', { mode: 'boolean' }).notNull().default(false), // Exclude from translation
// Legacy columns (kept for migration compatibility, no longer written)
publishedTitle: text('published_title'),
publishedContent: text('published_content'),
@@ -46,6 +47,24 @@ export const posts = sqliteTable('posts', {
projectSlugIdx: uniqueIndex('posts_project_slug_idx').on(table.projectId, table.slug),
}));
export const postTranslations = sqliteTable('post_translations', {
id: text('id').primaryKey(),
projectId: text('project_id').notNull(),
translationFor: text('translation_for').notNull(),
language: text('language').notNull(),
title: text('title').notNull(),
excerpt: text('excerpt'),
content: text('content'),
status: text('status', { enum: ['draft', 'published', 'archived'] }).notNull().default('draft'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
publishedAt: integer('published_at', { mode: 'timestamp' }),
filePath: text('file_path').notNull().default(''),
checksum: text('checksum'),
}, (table) => ({
translationLanguageIdx: uniqueIndex('post_translations_translation_language_idx').on(table.translationFor, table.language),
}));
// Media table - stores metadata for images and other media
export const media = sqliteTable('media', {
projectId: text('project_id').notNull(),
@@ -66,8 +85,28 @@ export const media = sqliteTable('media', {
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
checksum: text('checksum'),
tags: text('tags'), // JSON array stored as text
language: text('language'), // Optional per-media language override (ISO code, e.g. 'en', 'de')
});
// Media translations table - stores localized metadata for media items
// The binary asset remains shared; only title, alt, and caption vary by language.
export const mediaTranslations = sqliteTable('media_translations', {
id: text('id').primaryKey(),
projectId: text('project_id').notNull(),
translationFor: text('translation_for').notNull(),
language: text('language').notNull(),
title: text('title'),
alt: text('alt'),
caption: text('caption'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({
translationLanguageIdx: uniqueIndex('media_translations_translation_language_idx').on(table.translationFor, table.language),
}));
export type MediaTranslation = typeof mediaTranslations.$inferSelect;
export type NewMediaTranslation = typeof mediaTranslations.$inferInsert;
// App settings - stores application configuration
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
@@ -294,6 +333,8 @@ export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
export type PostTranslation = typeof postTranslations.$inferSelect;
export type NewPostTranslation = typeof postTranslations.$inferInsert;
export type Media = typeof media.$inferSelect;
export type NewMedia = typeof media.$inferInsert;
export type Setting = typeof settings.$inferSelect;

View File

@@ -1,6 +1,6 @@
import * as path from 'path';
import * as fs from 'fs/promises';
import type { PostEngine, PostData } from './PostEngine';
import type { PostData, PostTranslationData } from './PostEngine';
import type { MediaEngine, MediaData } from './MediaEngine';
import type { PostMediaEngine } from './PostMediaEngine';
import {
@@ -15,7 +15,7 @@ import { getPicoStylesheetHref, sanitizePicoTheme, type PicoThemeName } from '..
import type { MenuDocument } from './MenuEngine';
import type { ProjectMetadata } from './MetaEngine';
import { loadPublishedGenerationSets } from './GenerationPostSnapshotService';
import { buildCalendarArchiveData, buildSitemapAndFeeds, collectSitemapArchiveMetadata } from './GenerationSitemapFeedService';
import { buildCalendarArchiveData, buildSitemapAndFeeds, collectSitemapArchiveMetadata, buildMultiLanguageSitemap } from './GenerationSitemapFeedService';
import { buildTargetedValidationPlan, planMissingValidationPaths } from './ValidationApplyPlannerService';
import { compareSitemapToHtml } from './SiteValidationDiffService';
import {
@@ -58,6 +58,7 @@ export interface BlogGenerationOptions {
baseUrl: string;
maxPostsPerPage?: number;
language?: string;
blogLanguages?: string[];
pageTitle?: string;
picoTheme?: PicoThemeName;
categoryMetadata?: Record<string, CategoryMetadata>;
@@ -194,17 +195,79 @@ function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
}
type PublishedTranslationVariant = PostData & {
translationSourceSlug: string;
translationCanonicalLanguage?: string;
translationFilePath: string;
};
interface BlogGenerationPostEngineContract {
getPostsFiltered: (filter: { status?: 'draft' | 'published' | 'archived'; excludeCategories?: string[] }) => Promise<PostData[]>;
getPublishedVersion: (id: string) => Promise<PostData | null>;
getPost: (postId: string) => Promise<PostData | null>;
hasPublishedVersion: (postId: string) => Promise<boolean>;
getLinkedBy?: (postId: string) => Promise<{ id: string; title: string; slug: string }[]>;
getPostTranslations?: (postId: string) => Promise<PostTranslationData[]>;
setProjectContext: (projectId: string, dataDir?: string) => void;
}
export class BlogGenerationEngine {
private readonly postEngine: PostEngine;
private readonly postEngine: BlogGenerationPostEngineContract;
private readonly mediaEngine: MediaEngine;
private readonly postMediaEngine: PostMediaEngine;
constructor(postEngine: PostEngine, mediaEngine: MediaEngine, postMediaEngine: PostMediaEngine) {
constructor(postEngine: BlogGenerationPostEngineContract, mediaEngine: MediaEngine, postMediaEngine: PostMediaEngine) {
this.postEngine = postEngine;
this.mediaEngine = mediaEngine;
this.postMediaEngine = postMediaEngine;
}
private buildPublishedTranslationVariant(sourcePost: PostData, translation: PostTranslationData): PublishedTranslationVariant {
const canonicalLanguage = typeof sourcePost.language === 'string' ? sourcePost.language.trim() : '';
const variantLanguages = Array.from(new Set([
canonicalLanguage,
...(Array.isArray(sourcePost.availableLanguages) ? sourcePost.availableLanguages : []),
translation.language,
].filter((language) => typeof language === 'string' && language.trim().length > 0)));
return {
...sourcePost,
id: translation.id,
slug: `${sourcePost.slug}.${translation.language}`,
title: translation.title,
excerpt: translation.excerpt,
content: translation.content,
language: translation.language,
updatedAt: translation.updatedAt,
publishedAt: translation.publishedAt ?? sourcePost.publishedAt,
availableLanguages: variantLanguages,
translationSourceSlug: sourcePost.slug,
translationCanonicalLanguage: canonicalLanguage || undefined,
translationFilePath: translation.filePath,
};
}
private async buildPublishedRoutePosts(publishedPosts: PostData[]): Promise<PostData[]> {
const routePosts: PostData[] = [...publishedPosts];
if (typeof this.postEngine.getPostTranslations !== 'function') {
return routePosts;
}
for (const post of publishedPosts) {
const translations = await this.postEngine.getPostTranslations(post.id);
for (const translation of translations) {
if (translation.status !== 'published') {
continue;
}
routePosts.push(this.buildPublishedTranslationVariant(post, translation));
}
}
return routePosts;
}
async generate(options: BlogGenerationOptions, onProgress: (progress: number, message?: string) => void): Promise<BlogGenerationResult> {
onProgress(0, 'Loading posts...');
@@ -226,6 +289,7 @@ export class BlogGenerationEngine {
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
const { publishedPosts, publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories);
const publishedRoutePosts = await this.buildPublishedRoutePosts(publishedPosts);
onProgress(3, `Found ${publishedPosts.length} published posts`);
@@ -250,7 +314,7 @@ export class BlogGenerationEngine {
projectName: options.projectName,
projectDescription: options.projectDescription,
maxPostsPerPage,
publishedPosts,
publishedPosts: publishedRoutePosts,
publishedListPosts,
postIndex: generationPostIndex,
includeFeeds: true,
@@ -273,7 +337,7 @@ export class BlogGenerationEngine {
const archiveMetadata = collectSitemapArchiveMetadata({
baseUrl: options.baseUrl,
maxPostsPerPage,
publishedPosts,
publishedPosts: publishedRoutePosts,
publishedListPosts,
});
@@ -368,7 +432,7 @@ export class BlogGenerationEngine {
const renderRoute = createPreviewBackedGenerationRouteRenderer({
options,
maxPostsPerPage,
publishedPostsForLookup: publishedPosts,
publishedPostsForLookup: publishedRoutePosts,
engines: {
postEngine: this.postEngine,
mediaEngine: this.mediaEngine,
@@ -400,7 +464,7 @@ export class BlogGenerationEngine {
});
pagesGenerated += await generatePageRoutes({
projectId: options.projectId,
posts: publishedPosts,
posts: publishedRoutePosts,
renderRoute,
writePage,
onPageGenerated: reportUnitProgress,
@@ -411,7 +475,7 @@ export class BlogGenerationEngine {
onProgress(35, 'Generating single post pages...');
pagesGenerated += await generateSinglePostPages({
projectId: options.projectId,
posts: publishedPosts,
posts: publishedRoutePosts,
renderRoute,
writePage,
onPageGenerated: reportUnitProgress,
@@ -464,6 +528,173 @@ export class BlogGenerationEngine {
});
}
// --- Alternative language subtree generation ---
const mainLanguage = (options.language ?? 'en').trim().toLowerCase();
const additionalLanguages = (options.blogLanguages ?? [])
.map((lang) => lang.trim().toLowerCase())
.filter((lang) => lang.length > 0 && lang !== mainLanguage);
for (const lang of additionalLanguages) {
onProgress(85, `Generating ${lang} language subtree...`);
// Filter out doNotTranslate posts
const langPosts = publishedPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
const langListPosts = publishedListPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
const langPostIndex = buildGenerationPostIndex(langListPosts);
const langArchiveMetadata = collectSitemapArchiveMetadata({
baseUrl: options.baseUrl,
maxPostsPerPage,
publishedPosts: langPosts,
publishedListPosts: langListPosts,
});
// Build per-language feeds
if (includeCore) {
const langFeedResult = buildSitemapAndFeeds({
baseUrl: `${options.baseUrl}/${lang}`,
projectName: options.projectName,
projectDescription: options.projectDescription,
maxPostsPerPage,
publishedPosts: langPosts,
publishedListPosts: langListPosts,
postIndex: langPostIndex,
includeFeeds: true,
feedLanguage: lang,
});
const langRssPath = path.join(htmlDir, lang, 'rss.xml');
const langAtomPath = path.join(htmlDir, lang, 'atom.xml');
await fs.mkdir(path.join(htmlDir, lang), { recursive: true });
await writeFileIfHashChanged({ projectId: options.projectId, filePath: langRssPath, relativePath: `${lang}/rss.xml`, content: langFeedResult.rssXml });
await writeFileIfHashChanged({ projectId: options.projectId, filePath: langAtomPath, relativePath: `${lang}/atom.xml`, content: langFeedResult.atomXml });
}
const langRenderRoute = createPreviewBackedGenerationRouteRenderer({
options: { ...options, language: lang },
maxPostsPerPage,
publishedPostsForLookup: langPosts,
languagePrefix: `/${lang}`,
engines: {
postEngine: this.postEngine,
mediaEngine: this.mediaEngine,
postMediaEngine: this.postMediaEngine,
},
});
const langWritePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({
projectId,
htmlDir,
urlPath: `${lang}/${urlPath}`,
content,
knownDirectories: knownOutputDirectories,
hashCache: generatedHashCache,
refreshHashTimestampOnUnchanged: true,
});
const langReportProgress = (message: string) => reportUnitProgress(`[${lang}] ${message}`);
if (includeCore) {
pagesGenerated += await generateRootPages({
projectId: options.projectId,
posts: langListPosts,
maxPostsPerPage,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated: langReportProgress,
});
pagesGenerated += await generatePageRoutes({
projectId: options.projectId,
posts: langPosts,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated: langReportProgress,
});
}
if (includeSingle) {
pagesGenerated += await generateSinglePostPages({
projectId: options.projectId,
posts: langPosts,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated: langReportProgress,
});
}
if (includeCategory) {
pagesGenerated += await generateCategoryPages({
projectId: options.projectId,
posts: langListPosts,
allCategories: langArchiveMetadata.allCategories,
maxPostsPerPage,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated: langReportProgress,
postsByCategory: langPostIndex.postsByCategory,
});
}
if (includeTag) {
pagesGenerated += await generateTagPages({
projectId: options.projectId,
posts: langListPosts,
allTags: langArchiveMetadata.allTags,
maxPostsPerPage,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated: langReportProgress,
postsByTag: langPostIndex.postsByTag,
});
}
if (includeDate) {
pagesGenerated += await generateDateArchivePages({
projectId: options.projectId,
posts: langListPosts,
yearsMap: langArchiveMetadata.years,
yearMonthsMap: langArchiveMetadata.yearMonths,
yearMonthDaysMap: langArchiveMetadata.yearMonthDays,
maxPostsPerPage,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated: langReportProgress,
postsByYear: langPostIndex.postsByYear,
postsByYearMonth: langPostIndex.postsByYearMonth,
postsByYearMonthDay: langPostIndex.postsByYearMonthDay,
});
}
}
// --- Combined sitemap with hreflang (if multiple languages) ---
if (includeCore && additionalLanguages.length > 0) {
const allLanguages = [mainLanguage, ...additionalLanguages];
const langFilteredPosts = publishedPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
const doNotTranslateIds = new Set(
publishedPosts
.filter((p) => (p as PostData & { doNotTranslate?: boolean }).doNotTranslate)
.map((p) => p.id),
);
const hreflangSitemapXml = buildMultiLanguageSitemap({
baseUrl: options.baseUrl,
mainLanguage,
allLanguages,
translatablePosts: langFilteredPosts,
doNotTranslatePosts: publishedPosts.filter((p) => doNotTranslateIds.has(p.id)),
publishedListPosts,
maxPostsPerPage,
postIndex: generationPostIndex,
});
sitemapWritten = await writeFileIfHashChanged({
projectId: options.projectId,
filePath: sitemapPath,
relativePath: 'sitemap.xml',
content: hreflangSitemapXml,
});
}
onProgress(100, `Site generated (${publishedPosts.length} posts, ${pagesGenerated} pages)`);
return {
@@ -535,6 +766,7 @@ export class BlogGenerationEngine {
.map(([category]) => category);
const { publishedPosts, publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories);
const publishedRoutePosts = await this.buildPublishedRoutePosts(publishedPosts);
const generationPostIndex = buildGenerationPostIndex(publishedListPosts);
const { sitemapXml } = buildSitemapAndFeeds({
@@ -542,7 +774,7 @@ export class BlogGenerationEngine {
projectName: options.projectName,
projectDescription: options.projectDescription,
maxPostsPerPage,
publishedPosts,
publishedPosts: publishedRoutePosts,
publishedListPosts,
postIndex: generationPostIndex,
includeFeeds: false,
@@ -551,20 +783,111 @@ export class BlogGenerationEngine {
const htmlDir = path.join(options.dataDir, 'html');
await fs.mkdir(htmlDir, { recursive: true });
const sitemapPath = path.join(htmlDir, 'sitemap.xml');
// --- Build per-language expected paths ---
const mainLanguage = (options.language ?? 'en').trim().toLowerCase();
const additionalLanguages = (options.blogLanguages ?? [])
.map((lang) => lang.trim().toLowerCase())
.filter((lang) => lang.length > 0 && lang !== mainLanguage);
let sitemapToWrite = sitemapXml;
const additionalExpectedPaths: string[] = [];
const additionalPostTimestampChecks: Array<{
postUrlPath: string;
postFilePath: string;
generatedUpdatedAtMs?: number;
}> = [];
if (additionalLanguages.length > 0) {
const langPosts = publishedPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
const langListPosts = publishedListPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
for (const lang of additionalLanguages) {
const langPostIndex = buildGenerationPostIndex(langListPosts);
const langSitemapResult = buildSitemapAndFeeds({
baseUrl: `${options.baseUrl}/${lang}`,
projectName: options.projectName,
projectDescription: options.projectDescription,
maxPostsPerPage,
publishedPosts: langPosts,
publishedListPosts: langListPosts,
postIndex: langPostIndex,
includeFeeds: false,
});
// Extract expected paths from the per-language sitemap, stripping base URL
const langLocMatches = langSitemapResult.sitemapXml.matchAll(/<loc>(.*?)<\/loc>/g);
for (const match of langLocMatches) {
const loc = match[1]?.trim();
if (!loc) continue;
try {
const locUrl = new URL(loc);
const base = new URL(options.baseUrl);
let locPath = locUrl.pathname.replace(/\/+$/, '');
const basePath = base.pathname.replace(/\/+$/, '');
if (basePath && locPath.startsWith(basePath)) {
locPath = locPath.slice(basePath.length);
}
additionalExpectedPaths.push(locPath || '/');
} catch {
additionalExpectedPaths.push(loc);
}
}
// Build per-language post timestamp checks
for (const post of langPosts) {
const createdAt = resolvePostCreatedAt(post);
const year = String(createdAt.getFullYear());
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const postFilePath = path.join(options.dataDir, 'posts', year, month, `${post.slug}.md`);
const postUrlPath = `/${lang}${buildCanonicalPostPath(post)}`;
const relativePath = `${postUrlPath.replace(/^\//, '')}/index.html`;
const generatedRecord = await getGeneratedFileHashRecord(options.projectId, relativePath);
additionalPostTimestampChecks.push({
postUrlPath,
postFilePath,
generatedUpdatedAtMs: generatedRecord?.updatedAt,
});
}
}
// Write multi-language sitemap
const allLanguages = [mainLanguage, ...additionalLanguages];
const langFilteredPosts = publishedPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
const doNotTranslateIds = new Set(
publishedPosts
.filter((p) => (p as PostData & { doNotTranslate?: boolean }).doNotTranslate)
.map((p) => p.id),
);
sitemapToWrite = buildMultiLanguageSitemap({
baseUrl: options.baseUrl,
mainLanguage,
allLanguages,
translatablePosts: langFilteredPosts,
doNotTranslatePosts: publishedPosts.filter((p) => doNotTranslateIds.has(p.id)),
publishedListPosts,
maxPostsPerPage,
postIndex: generationPostIndex,
});
}
const sitemapChanged = await writeFileIfHashChanged({
projectId: options.projectId,
filePath: sitemapPath,
relativePath: 'sitemap.xml',
content: sitemapXml,
content: sitemapToWrite,
});
onProgress(50, 'Comparing sitemap to html pages...');
const postTimestampChecks = await Promise.all(publishedPosts.map(async (post) => {
const postTimestampChecks = await Promise.all(publishedRoutePosts.map(async (post) => {
const createdAt = resolvePostCreatedAt(post);
const year = String(createdAt.getFullYear());
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const postFilePath = path.join(options.dataDir, 'posts', year, month, `${post.slug}.md`);
const postFilePath = (post as PublishedTranslationVariant).translationFilePath
?? path.join(options.dataDir, 'posts', year, month, `${post.slug}.md`);
const postUrlPath = buildCanonicalPostPath(post);
const relativePath = `${postUrlPath.replace(/^\//, '')}/index.html`;
const generatedRecord = await getGeneratedFileHashRecord(options.projectId, relativePath);
@@ -580,7 +903,8 @@ export class BlogGenerationEngine {
sitemapXml,
baseUrl: options.baseUrl,
htmlDir,
postTimestampChecks,
postTimestampChecks: [...postTimestampChecks, ...additionalPostTimestampChecks],
additionalExpectedPaths,
});
onProgress(
@@ -613,7 +937,12 @@ export class BlogGenerationEngine {
onProgress(10, 'Planning validation apply steps...');
const missingPathPlan = planMissingValidationPaths(rerenderPaths);
const mainLanguage = (options.language ?? 'en').trim().toLowerCase();
const additionalLanguages = (options.blogLanguages ?? [])
.map((lang) => lang.trim().toLowerCase())
.filter((lang) => lang.length > 0 && lang !== mainLanguage);
const missingPathPlan = planMissingValidationPaths(rerenderPaths, additionalLanguages);
onProgress(20, 'Deleting extra URLs...');
@@ -687,13 +1016,14 @@ export class BlogGenerationEngine {
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
const { publishedPosts, publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories);
const publishedRoutePosts = await this.buildPublishedRoutePosts(publishedPosts);
const generationPostIndex = buildGenerationPostIndex(publishedListPosts);
const { allCategories, allTags, years, yearMonths, yearMonthDays } = buildApplyValidationArchives(publishedListPosts);
const targetedPlan = buildTargetedValidationPlan({
initialPlan: missingPathPlan,
publishedPosts,
publishedPosts: publishedRoutePosts,
allCategories,
allTags,
availableYearMonths: yearMonths.keys(),
@@ -706,7 +1036,7 @@ export class BlogGenerationEngine {
const renderRoute = createPreviewBackedGenerationRouteRenderer({
options,
maxPostsPerPage,
publishedPostsForLookup: publishedPosts,
publishedPostsForLookup: publishedRoutePosts,
engines: {
postEngine: this.postEngine,
mediaEngine: this.mediaEngine,
@@ -725,7 +1055,7 @@ export class BlogGenerationEngine {
};
const { requestedSinglePosts, requestedPagePosts } = selectRequestedPosts({
publishedPosts,
publishedPosts: publishedRoutePosts,
requestedPostIds: targetedPlan.requestedPostIds,
requestedPageSlugs: targetedPlan.requestedPageSlugs,
});
@@ -819,6 +1149,136 @@ export class BlogGenerationEngine {
postsByYearMonthDay: generationPostIndex.postsByYearMonthDay,
});
}
// --- Render missing per-language subtree pages ---
for (const [lang, langMissingPlan] of missingPathPlan.languagePlans) {
const langPosts = publishedPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
const langListPosts = publishedListPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
const langPostIndex = buildGenerationPostIndex(langListPosts);
const langArchives = buildApplyValidationArchives(langListPosts);
const langTargetedPlan = buildTargetedValidationPlan({
initialPlan: langMissingPlan,
publishedPosts: langPosts,
allCategories: langArchives.allCategories,
allTags: langArchives.allTags,
availableYearMonths: langArchives.yearMonths.keys(),
availableYearMonthDays: langArchives.yearMonthDays.keys(),
});
const langRenderRoute = createPreviewBackedGenerationRouteRenderer({
options: { ...options, language: lang },
maxPostsPerPage,
publishedPostsForLookup: langPosts,
languagePrefix: `/${lang}`,
engines: {
postEngine: this.postEngine,
mediaEngine: this.mediaEngine,
postMediaEngine: this.postMediaEngine,
},
});
const langWritePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({
projectId,
htmlDir,
urlPath: `${lang}/${urlPath}`,
content,
refreshHashTimestampOnUnchanged: true,
});
if (langTargetedPlan.requestRootRoutes) {
renderedUrlCount += await generateRootPages({
projectId: options.projectId,
posts: langListPosts,
maxPostsPerPage,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated,
});
const langRequestedPagePosts = selectRequestedPosts({
publishedPosts: langPosts,
requestedPostIds: new Set(),
requestedPageSlugs: langTargetedPlan.requestedPageSlugs,
}).requestedPagePosts;
if (langRequestedPagePosts.length > 0) {
renderedUrlCount += await generatePageRoutes({
projectId: options.projectId,
posts: langRequestedPagePosts,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated,
});
}
}
if (langTargetedPlan.requestedCategorySet.size > 0) {
renderedUrlCount += await generateCategoryPages({
projectId: options.projectId,
posts: langListPosts,
allCategories: langTargetedPlan.requestedCategorySet,
maxPostsPerPage,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated,
postsByCategory: langPostIndex.postsByCategory,
});
}
if (langTargetedPlan.requestedTagSet.size > 0) {
renderedUrlCount += await generateTagPages({
projectId: options.projectId,
posts: langListPosts,
allTags: langTargetedPlan.requestedTagSet,
maxPostsPerPage,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated,
postsByTag: langPostIndex.postsByTag,
});
}
const langRequestedSinglePosts = selectRequestedPosts({
publishedPosts: langPosts,
requestedPostIds: langTargetedPlan.requestedPostIds,
requestedPageSlugs: new Set(),
}).requestedSinglePosts;
if (langRequestedSinglePosts.length > 0) {
renderedUrlCount += await generateSinglePostPages({
projectId: options.projectId,
posts: langRequestedSinglePosts,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated,
});
}
const langRequestedArchives = buildRequestedArchiveMaps({
requestedYears: langTargetedPlan.requestedYears,
requestedYearMonths: langTargetedPlan.requestedYearMonths,
requestedYearMonthDays: langTargetedPlan.requestedYearMonthDays,
years: langArchives.years,
yearMonths: langArchives.yearMonths,
yearMonthDays: langArchives.yearMonthDays,
});
if (langRequestedArchives.requestedYearsMap.size > 0 || langRequestedArchives.requestedYearMonthsMap.size > 0 || langRequestedArchives.requestedYearMonthDaysMap.size > 0) {
renderedUrlCount += await generateDateArchivePages({
projectId: options.projectId,
posts: langListPosts,
yearsMap: langRequestedArchives.requestedYearsMap,
yearMonthsMap: langRequestedArchives.requestedYearMonthsMap,
yearMonthDaysMap: langRequestedArchives.requestedYearMonthDaysMap,
maxPostsPerPage,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated,
postsByYear: langPostIndex.postsByYear,
postsByYearMonth: langPostIndex.postsByYearMonth,
postsByYearMonthDay: langPostIndex.postsByYearMonthDay,
});
}
}
}
if (renderedUrlCount > 0 || deletedUrlCount > 0) {

View File

@@ -7,6 +7,7 @@ import type { PostData } from './PostEngine';
import type { PicoThemeName } from '../shared/picoThemes';
import type { CategoryMetadata } from './BlogGenerationEngine';
import { PreviewServer } from './PreviewServer';
import type { PostTranslationData } from './PostEngine';
interface RenderContext {
projectContext: {
@@ -55,12 +56,14 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
};
maxPostsPerPage: number;
publishedPostsForLookup: PostData[];
languagePrefix?: string;
engines: {
postEngine: {
getPostsFiltered: (filter: Parameters<PreviewServer['renderRouteForContext']>[1] extends never ? never : any) => Promise<PostData[]>;
getPublishedVersion: (postId: string) => Promise<PostData | null>;
findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>;
getPost: (postId: string) => Promise<PostData | null>;
getPostTranslation?: (postId: string, language: string) => Promise<PostTranslationData | null>;
hasPublishedVersion: (postId: string) => Promise<boolean>;
getLinkedBy?: (postId: string) => Promise<{ id: string; title: string; slug: string }[]>;
setProjectContext: (projectId: string, dataDir?: string) => void;
@@ -176,6 +179,9 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
return match ?? null;
},
getPost: (postId: string) => params.engines.postEngine.getPost(postId),
getPostTranslation: params.engines.postEngine.getPostTranslation
? (postId: string, language: string) => params.engines.postEngine.getPostTranslation!(postId, language)
: undefined,
hasPublishedVersion: (postId: string) => params.engines.postEngine.hasPublishedVersion(postId),
getLinkedBy: params.engines.postEngine.getLinkedBy
? (postId: string) => params.engines.postEngine.getLinkedBy!(postId)
@@ -218,7 +224,7 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
userTemplatesDir: path.join(params.options.dataDir, 'templates'),
});
const htmlRewriteContextPromise: Promise<{ canonicalPostPathBySlug: Map<string, string>; canonicalMediaPathBySourcePath: Map<string, string> }> = (async () => {
const htmlRewriteContextPromise: Promise<{ canonicalPostPathBySlug: Map<string, string>; canonicalMediaPathBySourcePath: Map<string, string>; languagePrefix?: string }> = (async () => {
const canonicalPostPathBySlug = new Map<string, string>();
for (const post of params.publishedPostsForLookup) {
canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post));
@@ -241,6 +247,7 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
return {
canonicalPostPathBySlug,
canonicalMediaPathBySourcePath,
languagePrefix: params.languagePrefix,
};
})();

View File

@@ -17,6 +17,7 @@ interface BuildSitemapAndFeedsParams {
publishedListPosts: PostData[];
postIndex: GenerationPostIndexLike;
includeFeeds: boolean;
feedLanguage?: string;
}
export interface SitemapFeedBuildResult {
@@ -419,13 +420,14 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
` <title>${escapeXml(feedTitle)}</title>`,
` <link>${escapeXml(baseLink)}</link>`,
` <description>${escapeXml(feedDescription)}</description>`,
params.feedLanguage ? ` <language>${escapeXml(params.feedLanguage)}</language>` : null,
` <lastBuildDate>${feedUpdatedAt.toUTCString()}</lastBuildDate>`,
' <generator>bDS</generator>',
...rssItems,
' </channel>',
'</rss>',
'',
].join('\n');
].filter(Boolean).join('\n');
const atomEntries = feedPosts.map((post) => {
const createdAt = resolvePostCreatedAt(post);
@@ -455,9 +457,11 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
].filter(Boolean).join('\n');
});
const atomFeedLangAttr = params.feedLanguage ? ` xml:lang="${escapeXml(params.feedLanguage)}"` : '';
const atomXml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<feed xmlns="http://www.w3.org/2005/Atom">',
`<feed xmlns="http://www.w3.org/2005/Atom"${atomFeedLangAttr}>`,
` <title>${escapeXml(feedTitle)}</title>`,
` <subtitle>${escapeXml(feedDescription)}</subtitle>`,
` <id>${escapeXml(baseLink)}</id>`,
@@ -482,3 +486,170 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
feedPosts,
};
}
interface MultiLanguageSitemapParams {
baseUrl: string;
mainLanguage: string;
allLanguages: string[];
translatablePosts: PostData[];
doNotTranslatePosts: PostData[];
publishedListPosts: PostData[];
maxPostsPerPage: number;
postIndex: GenerationPostIndexLike;
}
function buildHreflangLinks(baseUrl: string, urlPath: string, mainLanguage: string, languages: string[]): string[] {
const links: string[] = [];
for (const lang of languages) {
const prefix = lang === mainLanguage ? '' : `/${lang}`;
const href = `${baseUrl}${prefix}${urlPath}`;
const canonicalHref = href.endsWith('/') ? href : `${href}/`;
links.push(` <xhtml:link rel="alternate" hreflang="${escapeXml(lang)}" href="${escapeXml(canonicalHref)}" />`);
}
const xDefaultHref = `${baseUrl}${urlPath}`;
const canonicalXDefault = xDefaultHref.endsWith('/') ? xDefaultHref : `${xDefaultHref}/`;
links.push(` <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml(canonicalXDefault)}" />`);
return links;
}
function buildMultiLanguageSitemapUrl(
loc: string,
lastmod: string,
changefreq: string,
priority: string,
hreflangLinks: string[],
): string {
const canonicalLoc = (() => {
try {
const parsed = new URL(loc);
if (!parsed.pathname.endsWith('/')) {
parsed.pathname = `${parsed.pathname}/`;
}
return parsed.toString();
} catch {
return loc.endsWith('/') ? loc : `${loc}/`;
}
})();
return [
' <url>',
` <loc>${escapeXml(canonicalLoc)}</loc>`,
` <lastmod>${escapeXml(lastmod)}</lastmod>`,
` <changefreq>${changefreq}</changefreq>`,
` <priority>${priority}</priority>`,
...hreflangLinks,
' </url>',
].join('\n');
}
export function buildMultiLanguageSitemap(params: MultiLanguageSitemapParams): string {
const {
baseUrl,
mainLanguage,
allLanguages,
translatablePosts,
doNotTranslatePosts,
publishedListPosts,
maxPostsPerPage,
postIndex,
} = params;
const now = new Date().toISOString();
const latestPostUpdatedAt = publishedListPosts[0]?.updatedAt.toISOString() || now;
const urls: string[] = [];
// Root page — all languages
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0',
buildHreflangLinks(baseUrl, '/', mainLanguage, allLanguages),
));
// Root pagination
const totalListPages = Math.max(1, Math.ceil(publishedListPosts.length / maxPostsPerPage));
for (let page = 2; page <= totalListPages; page++) {
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}/page/${page}`, latestPostUpdatedAt, 'daily', '0.9',
buildHreflangLinks(baseUrl, `/page/${page}`, mainLanguage, allLanguages),
));
}
// Translatable posts — all languages
for (const post of translatablePosts) {
const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}${canonicalPath}`, post.updatedAt.toISOString(), 'monthly', '0.8',
buildHreflangLinks(baseUrl, canonicalPath, mainLanguage, allLanguages),
));
}
// Do-not-translate posts — main language only
for (const post of doNotTranslatePosts) {
const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}${canonicalPath}`, post.updatedAt.toISOString(), 'monthly', '0.8',
buildHreflangLinks(baseUrl, canonicalPath, mainLanguage, [mainLanguage]),
));
}
// Page posts (category 'page') — respecting doNotTranslate
const allPublishedPosts = [...translatablePosts, ...doNotTranslatePosts];
for (const post of allPublishedPosts) {
const categories = Array.isArray(post.categories) ? post.categories : [];
if (!categories.includes('page')) continue;
const trimmedSlug = (post.slug || '').replace(/^\/+|\/+$/g, '');
if (trimmedSlug.length === 0) continue;
const isTranslatable = !(post as PostData & { doNotTranslate?: boolean }).doNotTranslate;
const langs = isTranslatable ? allLanguages : [mainLanguage];
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}/${trimmedSlug}`, post.updatedAt.toISOString(), 'weekly', '0.7',
buildHreflangLinks(baseUrl, `/${trimmedSlug}`, mainLanguage, langs),
));
}
// Archives — all languages
for (const [year, lastmod] of Array.from(postIndex.postsByYear.entries()).sort((a, b) => b[0] - a[0])) {
const lastmodStr = lastmod instanceof Date ? lastmod.toISOString() : latestPostUpdatedAt;
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}/${year}`, lastmodStr, 'monthly', '0.5',
buildHreflangLinks(baseUrl, `/${year}`, mainLanguage, allLanguages),
));
}
for (const [ym] of Array.from(postIndex.postsByYearMonth.entries()).sort().reverse()) {
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}/${ym}`, latestPostUpdatedAt, 'monthly', '0.5',
buildHreflangLinks(baseUrl, `/${ym}`, mainLanguage, allLanguages),
));
}
for (const [ymd] of Array.from(postIndex.postsByYearMonthDay.entries()).sort().reverse()) {
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}/${ymd}`, latestPostUpdatedAt, 'monthly', '0.4',
buildHreflangLinks(baseUrl, `/${ymd}`, mainLanguage, allLanguages),
));
}
// Categories — all languages
for (const category of Array.from(postIndex.postsByCategory.keys()).sort()) {
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6',
buildHreflangLinks(baseUrl, `/category/${encodeURIComponent(category)}`, mainLanguage, allLanguages),
));
}
// Tags — all languages
for (const tag of Array.from(postIndex.postsByTag.keys()).sort()) {
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6',
buildHreflangLinks(baseUrl, `/tag/${encodeURIComponent(tag)}`, mainLanguage, allLanguages),
));
}
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
...urls,
'</urlset>',
'',
].join('\n');
}

View File

@@ -622,6 +622,7 @@ export class ImportExecutionEngine extends EventEmitter {
publishedAt,
tags: resolvedTags,
categories: resolvedCategories,
availableLanguages: [],
};
// Write to filesystem first (for published posts)

View File

@@ -22,6 +22,7 @@ import type {
SearchResult,
PaginatedResult,
PaginationOptions,
PostTranslationData,
} from './PostEngine';
import type { MediaData } from './MediaEngine';
import type { CreateScriptInput, ScriptData, ScriptValidationResult } from './ScriptEngine';
@@ -76,6 +77,8 @@ interface PostEngineContract {
getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
getPostCounts: (groupBy: Array<'year' | 'month' | 'tag' | 'category' | 'status'>, filter?: { year?: number; month?: number; status?: string; category?: string; tags?: string[] }) => Promise<{ groups: Record<string, string | number>[]; totalPosts: number }>;
getPostTranslation: (postId: string, language: string) => Promise<PostTranslationData | null>;
getPostTranslations: (postId: string) => Promise<PostTranslationData[]>;
}
interface MediaEngineContract {
@@ -174,6 +177,7 @@ export class MCPServer {
this.registerResources(server);
this.registerResourceTemplates(server);
this.registerReadTools(server);
this.registerMediaTranslationTools(server);
this.registerProposalTools(server);
this.registerAcceptDiscardTools(server);
this.registerPrompts(server);
@@ -512,6 +516,8 @@ export class MCPServer {
query: z.string().optional().describe('Full-text search query'),
category: z.string().optional().describe('Filter by category'),
tags: z.array(z.string()).optional().describe('Filter by tags (all must match)'),
language: z.string().optional().describe('Require posts that are available in this language'),
missingTranslationLanguage: z.string().optional().describe('Require posts missing this translation language'),
year: z.number().optional().describe('Filter by year'),
month: z.number().optional().describe('Filter by month (1-12). Requires year.'),
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by status'),
@@ -527,7 +533,7 @@ export class MCPServer {
};
}
const hasFilters = args.category || args.tags || args.year || args.month || args.status;
const hasFilters = args.category || args.tags || args.language || args.missingTranslationLanguage || args.year || args.month || args.status;
const offset = args.offset ?? 0;
const limit = args.limit ?? 50;
@@ -543,6 +549,8 @@ export class MCPServer {
const filter: PostFilter = {};
if (args.category) filter.categories = [args.category];
if (args.tags) filter.tags = args.tags;
if (args.language) filter.language = args.language;
if (args.missingTranslationLanguage) filter.missingTranslationLanguage = args.missingTranslationLanguage;
if (args.year) filter.year = args.year;
if (args.month) filter.month = args.month;
if (args.status) filter.status = args.status;
@@ -611,9 +619,10 @@ export class MCPServer {
// ── read_post_by_slug ──
server.registerTool('read_post_by_slug', {
title: 'Read Post by Slug',
description: 'Read the full content and metadata of a specific blog post by its slug. Includes backlinks and outlinks. Useful when you know the slug but not the ID.',
description: 'Read the full content and metadata of a specific blog post by its slug. Includes backlinks and outlinks. Optionally specify a language to read a translation instead of the canonical post.',
inputSchema: {
slug: z.string().describe('The slug of the post to read'),
language: z.string().optional().describe('Language code to read a specific translation (e.g., "en", "fr"). Omit to read the canonical post.'),
},
annotations: { readOnlyHint: true, openWorldHint: false },
}, async (args) => {
@@ -624,6 +633,38 @@ export class MCPServer {
isError: true,
};
}
// If a language is requested and it differs from the canonical language, fetch translation
if (args.language && args.language !== post.language) {
const translation = await this.deps.postEngine.getPostTranslation(post.id, args.language);
if (!translation) {
return {
content: [{ type: 'text' as const, text: JSON.stringify({ error: `No ${args.language} translation found for "${args.slug}"` }) }],
isError: true,
};
}
const [backlinks, linksTo] = await Promise.all([
this.deps.postEngine.getLinkedBy(post.id),
this.deps.postEngine.getLinksTo(post.id),
]);
return {
content: [{ type: 'text' as const, text: JSON.stringify({
post: {
id: post.id, title: translation.title, slug: post.slug,
content: translation.content, excerpt: translation.excerpt,
status: post.status, author: post.author,
language: translation.language,
canonicalLanguage: post.language,
categories: post.categories, tags: post.tags, availableLanguages: post.availableLanguages,
createdAt: post.createdAt, updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })),
},
}) }],
};
}
const [backlinks, linksTo] = await Promise.all([
this.deps.postEngine.getLinkedBy(post.id),
this.deps.postEngine.getLinksTo(post.id),
@@ -634,7 +675,7 @@ export class MCPServer {
id: post.id, title: post.title, slug: post.slug,
content: post.content, excerpt: post.excerpt,
status: post.status, author: post.author,
categories: post.categories, tags: post.tags,
categories: post.categories, tags: post.tags, availableLanguages: post.availableLanguages,
createdAt: post.createdAt, updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
@@ -645,6 +686,87 @@ export class MCPServer {
});
}
private registerMediaTranslationTools(server: McpServer): void {
// ── get_post_translations ──
server.registerTool('get_post_translations', {
title: 'Get Post Translations',
description: 'List all available translations for a blog post. Returns translation records with language, title, content, excerpt, and status.',
inputSchema: {
slug: z.string().describe('The slug of the canonical post'),
},
annotations: { readOnlyHint: true, openWorldHint: false },
}, async (args) => {
const post = await this.deps.postEngine.getPostBySlug(args.slug);
if (!post) {
return {
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Post with slug "${args.slug}" not found` }) }],
isError: true,
};
}
const translations = await this.deps.postEngine.getPostTranslations(post.id);
return {
content: [{ type: 'text' as const, text: JSON.stringify({
translations: translations.map(t => ({
id: t.id,
language: t.language,
title: t.title,
excerpt: t.excerpt,
content: t.content,
status: t.status,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
})),
}) }],
};
});
// ── get_media_translations ──
server.registerTool('get_media_translations', {
title: 'Get Media Translations',
description: 'List all available translations for a media item. Returns translation records with language, title, alt, and caption.',
inputSchema: {
mediaId: z.string().describe('The ID of the media item'),
},
annotations: { readOnlyHint: true, openWorldHint: false },
}, async (args) => {
const mediaEngine = this.deps.mediaEngine as import('./MediaEngine').MediaEngine;
const translations = await mediaEngine.getMediaTranslations(args.mediaId);
return { content: [{ type: 'text' as const, text: JSON.stringify({ translations }) }] };
});
// ── upsert_media_translation ──
registerAppTool(server, 'upsert_media_translation', {
title: 'Upsert Media Translation',
description: 'Create or update a translation of media metadata (title, alt text, caption) for a specific language.',
inputSchema: {
mediaId: z.string().describe('The ID of the media item to translate'),
language: z.string().describe('Target language code (e.g., "fr", "de", "es")'),
title: z.string().optional().describe('Translated title'),
alt: z.string().optional().describe('Translated alt text'),
caption: z.string().optional().describe('Translated caption'),
},
annotations: { readOnlyHint: false, destructiveHint: false },
_meta: { ui: { resourceUri: 'ui://bds/review-media-translation' } },
}, async (args: { mediaId: string; language: string; title?: string; alt?: string; caption?: string }) => {
try {
const mediaEngine = this.deps.mediaEngine as import('./MediaEngine').MediaEngine;
const translation = await mediaEngine.upsertMediaTranslation(args.mediaId, args.language, {
title: args.title,
alt: args.alt,
caption: args.caption,
});
return {
content: [{ type: 'text' as const, text: JSON.stringify({ translation }) }],
};
} catch (error) {
return {
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Failed to upsert media translation: ${error instanceof Error ? error.message : String(error)}` }) }],
isError: true,
};
}
});
}
private registerProposalTools(server: McpServer): void {
// ── draft_post ──
registerAppTool(server, 'draft_post', {

View File

@@ -6,7 +6,7 @@ import * as crypto from 'crypto';
import { eq, and, gte, lte, lt, desc } from 'drizzle-orm';
import { app } from 'electron';
import { getDatabase } from '../database';
import { media, Media, NewMedia, postMedia } from '../database/schema';
import { media, Media, NewMedia, postMedia, mediaTranslations } from '../database/schema';
import { stemText, stemQuery, SupportedLanguage } from './stemmer';
import { CliNotifier, NoopNotifier } from './CliNotifier';
@@ -33,10 +33,24 @@ export interface MediaData {
alt?: string;
caption?: string;
author?: string;
language?: string;
createdAt: Date;
updatedAt: Date;
tags: string[];
linkedPostIds?: string[]; // Posts this media is linked to
availableLanguages: string[];
}
export interface MediaTranslationData {
id: string;
projectId: string;
translationFor: string;
language: string;
title?: string;
alt?: string;
caption?: string;
createdAt: Date;
updatedAt: Date;
}
export interface MediaMetadata {
@@ -50,6 +64,7 @@ export interface MediaMetadata {
alt?: string;
caption?: string;
author?: string;
language?: string;
createdAt: string;
updatedAt: string;
tags: string[];
@@ -62,6 +77,8 @@ export interface MediaFilter {
endDate?: Date;
year?: number;
month?: number;
language?: string;
missingTranslationLanguage?: string;
}
export interface MediaSearchResult {
@@ -348,6 +365,7 @@ export class MediaEngine extends EventEmitter {
alt: mediaData.alt,
caption: mediaData.caption,
author: mediaData.author,
language: mediaData.language,
createdAt: mediaData.createdAt.toISOString(),
updatedAt: mediaData.updatedAt.toISOString(),
tags: mediaData.tags,
@@ -369,6 +387,7 @@ export class MediaEngine extends EventEmitter {
if (metadata.alt) lines.push(`alt: "${metadata.alt}"`);
if (metadata.caption) lines.push(`caption: "${metadata.caption}"`);
if (metadata.author) lines.push(`author: "${metadata.author}"`);
if (metadata.language) lines.push(`language: ${metadata.language}`);
lines.push(`createdAt: ${metadata.createdAt}`);
lines.push(`updatedAt: ${metadata.updatedAt}`);
@@ -445,6 +464,9 @@ export class MediaEngine extends EventEmitter {
case 'author':
metadata.author = value;
break;
case 'language':
metadata.language = value;
break;
case 'createdAt':
metadata.createdAt = value;
break;
@@ -550,9 +572,11 @@ export class MediaEngine extends EventEmitter {
alt: metadata?.alt,
caption: metadata?.caption,
author: metadata?.author,
language: metadata?.language,
createdAt,
updatedAt,
tags: metadata?.tags || [],
availableLanguages: metadata?.language ? [metadata.language] : [],
};
const sidecarPath = await this.writeSidecarFile(mediaData, destPath);
@@ -578,6 +602,7 @@ export class MediaEngine extends EventEmitter {
alt: mediaData.alt,
caption: mediaData.caption,
author: mediaData.author,
language: mediaData.language,
filePath: destPath,
sidecarPath,
createdAt: mediaData.createdAt,
@@ -643,6 +668,7 @@ export class MediaEngine extends EventEmitter {
alt: updated.alt,
caption: updated.caption,
author: updated.author,
language: updated.language,
updatedAt: updated.updatedAt,
tags: JSON.stringify(updated.tags),
})
@@ -765,6 +791,22 @@ export class MediaEngine extends EventEmitter {
const { postMedia } = await import('../database/schema');
await db.delete(postMedia).where(eq(postMedia.mediaId, id));
// Delete media translations (cascade cleanup)
await db.delete(mediaTranslations).where(eq(mediaTranslations.translationFor, id));
// Delete translated sidecar files
try {
const mediaDir = path.dirname(existing.filePath);
const entries = await fs.readdir(mediaDir);
const basename = path.basename(existing.filePath);
for (const entry of entries) {
const translatedSidecarPattern = new RegExp(`^${basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.[a-z]{2}\\.meta$`);
if (translatedSidecarPattern.test(entry)) {
try { await fs.unlink(path.join(mediaDir, entry)); } catch { /* ignore */ }
}
}
} catch { /* directory may not exist */ }
await db.delete(media).where(eq(media.id, id));
// Delete from FTS index
@@ -783,6 +825,13 @@ export class MediaEngine extends EventEmitter {
return null;
}
const translations = await db.select().from(mediaTranslations).where(eq(mediaTranslations.translationFor, id)).all();
const canonicalLang = dbMedia.language || undefined;
const translationLangs = translations.map(t => t.language);
const availableLanguages = canonicalLang
? [canonicalLang, ...translationLangs]
: translationLangs.length > 0 ? translationLangs : [];
return {
id: dbMedia.id,
filename: dbMedia.filename,
@@ -795,9 +844,11 @@ export class MediaEngine extends EventEmitter {
alt: dbMedia.alt || undefined,
caption: dbMedia.caption || undefined,
author: dbMedia.author || undefined,
language: canonicalLang,
createdAt: dbMedia.createdAt,
updatedAt: dbMedia.updatedAt,
tags: JSON.parse(dbMedia.tags || '[]'),
availableLanguages,
};
}
@@ -822,9 +873,11 @@ export class MediaEngine extends EventEmitter {
alt: dbMedia.alt || undefined,
caption: dbMedia.caption || undefined,
author: dbMedia.author || undefined,
language: dbMedia.language || undefined,
createdAt: dbMedia.createdAt,
updatedAt: dbMedia.updatedAt,
tags: JSON.parse(dbMedia.tags || '[]'),
availableLanguages: [] as string[],
}));
}
@@ -884,9 +937,11 @@ export class MediaEngine extends EventEmitter {
alt: dbMedia.alt || undefined,
caption: dbMedia.caption || undefined,
author: dbMedia.author || undefined,
language: dbMedia.language || undefined,
createdAt: dbMedia.createdAt,
updatedAt: dbMedia.updatedAt,
tags: JSON.parse(dbMedia.tags || '[]'),
availableLanguages: [] as string[],
};
// Client-side filtering for tags (JSON array)
@@ -1033,6 +1088,10 @@ export class MediaEngine extends EventEmitter {
await db.delete(postMedia).where(eq(postMedia.projectId, this.currentProjectId));
console.log(`Deleted post-media links for project ${this.currentProjectId}`);
// Delete all media translations for the current project
await db.delete(mediaTranslations).where(eq(mediaTranslations.projectId, this.currentProjectId));
console.log(`Deleted media translations for project ${this.currentProjectId}`);
// Delete all FTS entries for the current project
const client = getDatabase().getLocalClient();
if (client) {
@@ -1046,7 +1105,14 @@ export class MediaEngine extends EventEmitter {
onProgress(5, 'Scanning media directory...');
// Recursively find all .meta files in the media directory tree
// Canonical sidecars: <file>.meta Translation sidecars: <file>.<lang>.meta
const metaFiles: string[] = [];
const translationMetaFiles: string[] = [];
const isTranslationSidecar = (name: string) => {
// e.g. photo.jpg.fr.meta → parts = ['photo', 'jpg', 'fr', 'meta']
const parts = name.split('.');
return parts.length >= 4 && parts[parts.length - 1] === 'meta' && parts[parts.length - 2].length <= 5;
};
const scanDir = async (dir: string) => {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
@@ -1055,7 +1121,11 @@ export class MediaEngine extends EventEmitter {
if (entry.isDirectory()) {
await scanDir(fullPath);
} else if (entry.name.endsWith('.meta')) {
metaFiles.push(fullPath);
if (isTranslationSidecar(entry.name)) {
translationMetaFiles.push(fullPath);
} else {
metaFiles.push(fullPath);
}
}
}
} catch {
@@ -1101,6 +1171,8 @@ export class MediaEngine extends EventEmitter {
title: metadata.title,
alt: metadata.alt,
caption: metadata.caption,
author: metadata.author || null,
language: metadata.language || null,
filePath: mediaFilePath,
sidecarPath,
createdAt: new Date(metadata.createdAt),
@@ -1144,6 +1216,33 @@ export class MediaEngine extends EventEmitter {
}
}
// Import media translation sidecars
if (translationMetaFiles.length > 0) {
onProgress(92, `Importing ${translationMetaFiles.length} media translation(s)...`);
for (const tPath of translationMetaFiles) {
const tData = await this.readTranslatedSidecarFile(tPath);
if (tData?.translationFor && tData?.language) {
// Find the canonical media by id
const canonical = await db.select({ id: media.id }).from(media)
.where(eq(media.id, tData.translationFor))
.get();
if (canonical) {
await db.insert(mediaTranslations).values({
id: uuidv4(),
projectId: this.currentProjectId,
translationFor: tData.translationFor,
language: tData.language,
title: tData.title || null,
alt: tData.alt || null,
caption: tData.caption || null,
createdAt: new Date(),
updatedAt: new Date(),
});
}
}
}
}
onProgress(100, 'Database rebuild complete');
this.emit('databaseRebuilt');
},
@@ -1307,6 +1406,198 @@ export class MediaEngine extends EventEmitter {
await taskManager.runTask(task);
}
// ─── Media Translation CRUD ─────────────────────────────────────────
async getMediaTranslation(mediaId: string, language: string): Promise<MediaTranslationData | null> {
const db = getDatabase().getLocal();
const rows = await db.select().from(mediaTranslations)
.where(eq(mediaTranslations.translationFor, mediaId))
.all();
const row = rows.find(r => r.language === language.toLowerCase());
if (!row) return null;
return this.toMediaTranslationData(row);
}
async getMediaTranslations(mediaId: string): Promise<MediaTranslationData[]> {
const db = getDatabase().getLocal();
const rows = await db.select().from(mediaTranslations)
.where(eq(mediaTranslations.translationFor, mediaId))
.all();
return rows.map(r => this.toMediaTranslationData(r));
}
async upsertMediaTranslation(
mediaId: string,
language: string,
data: { title?: string; alt?: string; caption?: string },
): Promise<MediaTranslationData> {
const db = getDatabase().getLocal();
const normalizedLang = language.toLowerCase();
// Verify media exists
const mediaItem = await db.select().from(media).where(eq(media.id, mediaId)).get();
if (!mediaItem) {
throw new Error('Media item not found');
}
// Reject if language matches canonical
const canonicalLang = mediaItem.language?.toLowerCase();
if (canonicalLang && canonicalLang === normalizedLang) {
throw new Error('Translation language must differ from canonical media language');
}
const now = new Date();
// Check for existing translation
const existing = await this.getMediaTranslation(mediaId, normalizedLang);
if (existing) {
// Update existing
await db.update(mediaTranslations)
.set({
title: data.title ?? existing.title,
alt: data.alt ?? existing.alt,
caption: data.caption ?? existing.caption,
updatedAt: now,
})
.where(eq(mediaTranslations.id, existing.id));
const updated: MediaTranslationData = {
...existing,
title: data.title ?? existing.title,
alt: data.alt ?? existing.alt,
caption: data.caption ?? existing.caption,
updatedAt: now,
};
// Write translated sidecar
await this.writeTranslatedSidecarFile(mediaItem.filePath, updated);
this.emit('mediaTranslationUpdated', updated);
return updated;
}
// Create new
const id = uuidv4();
const newTranslation: MediaTranslationData = {
id,
projectId: this.currentProjectId,
translationFor: mediaId,
language: normalizedLang,
title: data.title,
alt: data.alt,
caption: data.caption,
createdAt: now,
updatedAt: now,
};
await db.insert(mediaTranslations).values({
id,
projectId: this.currentProjectId,
translationFor: mediaId,
language: normalizedLang,
title: data.title,
alt: data.alt,
caption: data.caption,
createdAt: now,
updatedAt: now,
});
// Write translated sidecar
await this.writeTranslatedSidecarFile(mediaItem.filePath, newTranslation);
this.emit('mediaTranslationCreated', newTranslation);
return newTranslation;
}
async deleteMediaTranslation(mediaId: string, language: string): Promise<boolean> {
const normalizedLang = language.toLowerCase();
const existing = await this.getMediaTranslation(mediaId, normalizedLang);
if (!existing) return false;
const db = getDatabase().getLocal();
await db.delete(mediaTranslations).where(eq(mediaTranslations.id, existing.id));
// Delete translated sidecar file
const mediaItem = await db.select().from(media).where(eq(media.id, mediaId)).get();
if (mediaItem) {
const sidecarPath = `${mediaItem.filePath}.${normalizedLang}.meta`;
try { await fs.unlink(sidecarPath); } catch { /* ignore */ }
}
this.emit('mediaTranslationDeleted', { mediaId, language: normalizedLang });
return true;
}
private toMediaTranslationData(row: typeof mediaTranslations.$inferSelect): MediaTranslationData {
return {
id: row.id,
projectId: row.projectId,
translationFor: row.translationFor,
language: row.language,
title: row.title || undefined,
alt: row.alt || undefined,
caption: row.caption || undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
// ─── Translated Sidecar I/O ─────────────────────────────────────────
private async writeTranslatedSidecarFile(
mediaFilePath: string,
translation: MediaTranslationData,
): Promise<string> {
const sidecarPath = `${mediaFilePath}.${translation.language}.meta`;
const lines = [
'---',
`translationFor: ${translation.translationFor}`,
`language: ${translation.language}`,
];
if (translation.title) lines.push(`title: "${translation.title}"`);
if (translation.alt) lines.push(`alt: "${translation.alt}"`);
if (translation.caption) lines.push(`caption: "${translation.caption}"`);
lines.push('---');
await fs.writeFile(sidecarPath, lines.join('\n'), 'utf-8');
return sidecarPath;
}
async readTranslatedSidecarFile(sidecarPath: string): Promise<{ translationFor?: string; language?: string; title?: string; alt?: string; caption?: string } | null> {
try {
try { await fs.access(sidecarPath); } catch { return null; }
const content = await fs.readFile(sidecarPath, 'utf-8');
const result: { translationFor?: string; language?: string; title?: string; alt?: string; caption?: string } = {};
for (const line of content.split('\n')) {
if (line === '---') continue;
const colonIndex = line.indexOf(':');
if (colonIndex === -1) continue;
const key = line.substring(0, colonIndex).trim();
let value = line.substring(colonIndex + 1).trim();
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
switch (key) {
case 'translationFor': result.translationFor = value; break;
case 'language': result.language = value; break;
case 'title': result.title = value; break;
case 'alt': result.alt = value; break;
case 'caption': result.caption = value; break;
}
}
return result;
} catch {
return null;
}
}
}

View File

@@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm';
import { getDatabase } from '../database';
import { posts, projects } from '../database/schema';
import { sanitizePicoTheme, type PicoThemeName } from '../shared/picoThemes';
import { SUPPORTED_RENDER_LANGUAGES, type SupportedLanguage } from '../shared/i18n';
import {
normalizeTaxonomyTerm,
normalizeNonEmptyTaxonomyTerm,
@@ -29,6 +30,7 @@ export interface ProjectMetadata {
categoryMetadata?: Record<string, CategoryMetadata>; // Per-category metadata for UI/rendering
categorySettings?: Record<string, CategoryRenderSettings>; // Per-category list rendering preferences
semanticSimilarityEnabled?: boolean; // Enable local ONNX embedding-based semantic similarity
blogLanguages?: string[]; // Languages the blog is rendered in (mainLanguage is always included)
}
export interface CategoryRenderSettings {
@@ -103,6 +105,19 @@ function sanitizeCategoryTitle(value: unknown, fallback: string): string {
type RawCategoryMetadataInput = Record<string, CategoryMetadata | CategoryRenderSettings>;
const supportedLanguageSet = new Set<string>(SUPPORTED_RENDER_LANGUAGES);
function sanitizeBlogLanguages(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const filtered = value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim().toLowerCase())
.filter((item) => item.length > 0 && supportedLanguageSet.has(item));
return filtered.length > 0 ? [...new Set(filtered)] : undefined;
}
function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage);
const publicUrl = sanitizePublicUrl(metadata.publicUrl);
@@ -112,6 +127,7 @@ function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
const pythonRuntimeMode = metadata.pythonRuntimeMode === 'main-thread' ? 'main-thread' : 'webworker';
const picoTheme = sanitizePicoTheme(metadata.picoTheme);
const categoryMetadata = normalizeCategoryMetadata(metadata.categoryMetadata ?? metadata.categorySettings);
const blogLanguages = sanitizeBlogLanguages(metadata.blogLanguages);
return {
...metadata,
publicUrl,
@@ -121,6 +137,7 @@ function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
picoTheme,
categoryMetadata,
categorySettings: undefined,
blogLanguages,
};
}
@@ -349,6 +366,7 @@ export class MetaEngine extends EventEmitter {
picoTheme: normalizedUpdates.picoTheme,
categoryMetadata: normalizedUpdates.categoryMetadata,
semanticSimilarityEnabled: normalizedUpdates.semanticSimilarityEnabled,
blogLanguages: normalizedUpdates.blogLanguages,
});
} else {
this.projectMetadata = normalizeProjectMetadata({

View File

@@ -11,8 +11,9 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import { eq, and } from 'drizzle-orm';
import { getDatabase } from '../database';
import { posts, media, scripts, templates } from '../database/schema';
import { posts, postTranslations, media, scripts, templates } from '../database/schema';
import { readPostFile, PostFileData } from './postFileUtils';
import { readPostTranslationFile } from './postTranslationFileUtils';
import { taskManager } from './TaskManager';
import type { PostEngine } from './PostEngine';
import type { MediaEngine } from './MediaEngine';
@@ -32,7 +33,7 @@ export interface FieldDifference<T = unknown> {
/**
* The fields that can have differences for posts
*/
export type DiffField = 'tags' | 'categories' | 'title' | 'excerpt' | 'author' | 'language';
export type DiffField = 'tags' | 'categories' | 'title' | 'excerpt' | 'author' | 'language' | 'translationFor' | 'doNotTranslate' | 'status' | 'templateSlug' | 'createdAt' | 'updatedAt' | 'publishedAt';
/**
* Metadata differences for a single post
@@ -41,6 +42,8 @@ export interface PostMetadataDiff {
postId: string;
title: string;
slug: string;
variant?: 'post' | 'translation';
translationLanguage?: string;
filePath?: string;
hasDifferences: boolean;
fileMissing?: boolean;
@@ -70,6 +73,9 @@ export interface OrphanFile {
slug: string;
title?: string;
id?: string;
variant?: 'post' | 'translation';
translationFor?: string;
language?: string;
}
/**
@@ -85,7 +91,7 @@ export interface ScanResult {
// ── Media diff types ──
export type MediaDiffField = 'title' | 'alt' | 'caption' | 'author' | 'tags';
export type MediaDiffField = 'title' | 'alt' | 'caption' | 'author' | 'tags' | 'language';
export interface MediaMetadataDiff {
mediaId: string;
@@ -354,7 +360,80 @@ export class MetadataDiffEngine extends EventEmitter {
.get();
if (!dbPost) {
return null;
const dbTranslation = await db
.select()
.from(postTranslations)
.where(and(eq(postTranslations.id, postId), eq(postTranslations.projectId, this.currentProjectId)))
.get();
if (!dbTranslation) {
return null;
}
if (!dbTranslation.filePath || dbTranslation.status === 'draft') {
return null;
}
const sourcePost = await db
.select()
.from(posts)
.where(and(eq(posts.id, dbTranslation.translationFor), eq(posts.projectId, this.currentProjectId)))
.get();
const translationSlug = sourcePost?.slug
? `${sourcePost.slug}.${dbTranslation.language}`
: path.basename(dbTranslation.filePath, path.extname(dbTranslation.filePath));
const translationTitle = `${dbTranslation.title} [${dbTranslation.language}]`;
const translationFileData = await readPostTranslationFile(dbTranslation.filePath);
if (!translationFileData) {
const missingDiffs: Partial<Record<DiffField, FieldDifference>> = {
translationFor: { dbValue: dbTranslation.translationFor, fileValue: null },
language: { dbValue: dbTranslation.language, fileValue: null },
title: { dbValue: dbTranslation.title, fileValue: null },
};
if (dbTranslation.excerpt) {
missingDiffs.excerpt = { dbValue: dbTranslation.excerpt, fileValue: null };
}
return {
postId: dbTranslation.id,
title: translationTitle,
slug: translationSlug,
variant: 'translation',
translationLanguage: dbTranslation.language,
filePath: dbTranslation.filePath,
hasDifferences: true,
fileMissing: true,
differences: missingDiffs,
};
}
const translationDiffs: Partial<Record<DiffField, FieldDifference>> = {};
if (dbTranslation.translationFor !== translationFileData.translationFor) {
translationDiffs.translationFor = { dbValue: dbTranslation.translationFor, fileValue: translationFileData.translationFor };
}
if (dbTranslation.language !== translationFileData.language) {
translationDiffs.language = { dbValue: dbTranslation.language, fileValue: translationFileData.language };
}
if (dbTranslation.title !== translationFileData.title) {
translationDiffs.title = { dbValue: dbTranslation.title, fileValue: translationFileData.title };
}
if ((dbTranslation.excerpt || '') !== (translationFileData.excerpt || '')) {
translationDiffs.excerpt = { dbValue: dbTranslation.excerpt || '', fileValue: translationFileData.excerpt || '' };
}
return {
postId: dbTranslation.id,
title: translationTitle,
slug: translationSlug,
variant: 'translation',
translationLanguage: dbTranslation.language,
filePath: dbTranslation.filePath,
hasDifferences: Object.keys(translationDiffs).length > 0,
differences: translationDiffs,
};
}
// Skip drafts - they don't have files
@@ -375,6 +454,8 @@ export class MetadataDiffEngine extends EventEmitter {
if (dbPost.excerpt) missingDiffs.excerpt = { dbValue: dbPost.excerpt, fileValue: null };
if (dbPost.author) missingDiffs.author = { dbValue: dbPost.author, fileValue: null };
if (dbPost.language) missingDiffs.language = { dbValue: dbPost.language, fileValue: null };
if (dbPost.doNotTranslate) missingDiffs.doNotTranslate = { dbValue: true, fileValue: null };
if (dbPost.templateSlug) missingDiffs.templateSlug = { dbValue: dbPost.templateSlug, fileValue: null };
return {
postId: dbPost.id,
title: dbPost.title,
@@ -425,6 +506,35 @@ export class MetadataDiffEngine extends EventEmitter {
differences.language = { dbValue: dbPost.language || '', fileValue: fileData.language || '' };
}
// Compare doNotTranslate
const dbDoNotTranslate = Boolean(dbPost.doNotTranslate);
const fileDoNotTranslate = Boolean(fileData.doNotTranslate);
if (dbDoNotTranslate !== fileDoNotTranslate) {
differences.doNotTranslate = { dbValue: dbDoNotTranslate, fileValue: fileDoNotTranslate };
}
// Compare status
const fileStatus = fileData.status || 'published';
if (dbPost.status !== fileStatus) {
differences.status = { dbValue: dbPost.status, fileValue: fileStatus };
}
// Compare templateSlug
if ((dbPost.templateSlug || '') !== (fileData.templateSlug || '')) {
differences.templateSlug = { dbValue: dbPost.templateSlug || '', fileValue: fileData.templateSlug || '' };
}
// Compare timestamps (second precision — DB stores integer seconds)
if (!this.datesEqualSeconds(dbPost.createdAt, fileData.createdAt)) {
differences.createdAt = { dbValue: dbPost.createdAt?.toISOString() || '', fileValue: fileData.createdAt?.toISOString() || '' };
}
if (!this.datesEqualSeconds(dbPost.updatedAt, fileData.updatedAt)) {
differences.updatedAt = { dbValue: dbPost.updatedAt?.toISOString() || '', fileValue: fileData.updatedAt?.toISOString() || '' };
}
if (!this.datesEqualSeconds(dbPost.publishedAt, fileData.publishedAt)) {
differences.publishedAt = { dbValue: dbPost.publishedAt?.toISOString() || '', fileValue: fileData.publishedAt?.toISOString() || '' };
}
return {
postId: dbPost.id,
title: dbPost.title,
@@ -445,6 +555,13 @@ export class MetadataDiffEngine extends EventEmitter {
return sortedA.every((val, idx) => val === sortedB[idx]);
}
/** Compare two dates at second precision (SQLite stores integer seconds). */
private datesEqualSeconds(a: Date | null | undefined, b: Date | null | undefined): boolean {
if (!a && !b) return true;
if (!a || !b) return false;
return Math.floor(a.getTime() / 1000) === Math.floor(b.getTime() / 1000);
}
/**
* Scan all published posts and find metadata differences.
* When postsBaseDir is provided, also scans the filesystem to detect
@@ -468,8 +585,19 @@ export class MetadataDiffEngine extends EventEmitter {
args: [this.currentProjectId],
});
const translationResult = await client.execute({
sql: `SELECT id, translation_for, language, title, excerpt, file_path
FROM post_translations
WHERE project_id = ?
AND status = 'published'
AND file_path IS NOT NULL
AND file_path != ''`,
args: [this.currentProjectId],
});
const publishedPosts = result.rows;
const total = publishedPosts.length;
const publishedTranslations = translationResult.rows;
const total = publishedPosts.length + publishedTranslations.length;
const differences: PostMetadataDiff[] = [];
onProgress(0, total, `Scanning ${total} published posts...`);
@@ -488,11 +616,28 @@ export class MetadataDiffEngine extends EventEmitter {
differences.push(diff);
}
if ((i + 1) % 10 === 0 || i === total - 1) {
if ((i + 1) % 10 === 0 || i === publishedPosts.length - 1) {
onProgress(i + 1, total, `Scanned ${i + 1}/${total} posts, found ${differences.length} with differences`);
}
}
for (let i = 0; i < publishedTranslations.length; i++) {
const row = publishedTranslations[i];
const translationId = row.id as string;
const filePath = row.file_path as string;
if (filePath) knownFilePaths.add(filePath);
const diff = await this.comparePostMetadata(translationId);
if (diff && diff.hasDifferences) {
differences.push(diff);
}
const processed = publishedPosts.length + i + 1;
if (processed % 10 === 0 || processed === total) {
onProgress(processed, total, `Scanned ${processed}/${total} posts, found ${differences.length} with differences`);
}
}
// Also include file_paths from non-published posts so we don't flag them as orphans
const allPostsResult = await client.execute({
sql: `SELECT file_path FROM posts WHERE project_id = ? AND file_path IS NOT NULL AND file_path != ''`,
@@ -502,6 +647,14 @@ export class MetadataDiffEngine extends EventEmitter {
knownFilePaths.add(row.file_path as string);
}
const allTranslationsResult = await client.execute({
sql: `SELECT file_path FROM post_translations WHERE project_id = ? AND file_path IS NOT NULL AND file_path != ''`,
args: [this.currentProjectId],
});
for (const row of allTranslationsResult.rows) {
knownFilePaths.add(row.file_path as string);
}
// Scan filesystem for orphan files
const orphanFiles = await this.findOrphanFiles(postsBaseDir, knownFilePaths, onProgress, total);
@@ -564,19 +717,31 @@ export class MetadataDiffEngine extends EventEmitter {
const slug = path.basename(filePath, path.extname(filePath));
let title: string | undefined;
let id: string | undefined;
let variant: 'post' | 'translation' | undefined;
let translationFor: string | undefined;
let language: string | undefined;
// Try to read frontmatter for metadata
try {
const fileData = await readPostFile(filePath);
if (fileData) {
title = fileData.title;
id = fileData.id;
const translationData = await readPostTranslationFile(filePath);
if (translationData) {
title = translationData.title;
variant = 'translation';
translationFor = translationData.translationFor;
language = translationData.language;
} else {
const fileData = await readPostFile(filePath);
if (fileData) {
title = fileData.title;
id = fileData.id;
variant = 'post';
}
}
} catch {
// Couldn't parse file, still report it as orphan
}
orphanFiles.push({ filePath, slug, title, id });
orphanFiles.push({ filePath, slug, title, id, variant, translationFor, language });
if ((i + 1) % 10 === 0 || i === orphanPaths.length - 1) {
onProgress(scannedSoFar + i + 1, scannedSoFar + orphanPaths.length,
@@ -600,6 +765,13 @@ export class MetadataDiffEngine extends EventEmitter {
excerpt: 'Excerpt',
author: 'Author',
language: 'Language',
translationFor: 'Translation Source',
doNotTranslate: 'Do Not Translate',
status: 'Status',
templateSlug: 'Template',
createdAt: 'Created At',
updatedAt: 'Updated At',
publishedAt: 'Published At',
};
for (const diff of diffs) {
@@ -641,7 +813,16 @@ export class MetadataDiffEngine extends EventEmitter {
return this.runSyncLoop(
postIds,
onProgress,
async (postId) => postEngine.syncPublishedPostFile(postId),
async (postId) => {
const syncedPost = await postEngine.syncPublishedPostFile(postId);
if (syncedPost) {
return true;
}
if (typeof postEngine.syncPublishedPostTranslationFile === 'function') {
return postEngine.syncPublishedPostTranslationFile(postId);
}
return false;
},
(postId) => `[MetadataDiffEngine] Failed to sync post ${postId} to file:`
);
}
@@ -667,45 +848,99 @@ export class MetadataDiffEngine extends EventEmitter {
.where(and(eq(posts.id, postId), eq(posts.projectId, this.currentProjectId)))
.get();
if (!dbPost || !dbPost.filePath) {
if (dbPost?.filePath) {
const fileData = await readPostFile(dbPost.filePath);
if (!fileData) {
return false;
}
const updateData: Record<string, unknown> = {};
if (!field || field === 'tags') {
updateData.tags = JSON.stringify(fileData.tags || []);
}
if (!field || field === 'categories') {
updateData.categories = JSON.stringify(fileData.categories || []);
}
if (!field || field === 'title') {
updateData.title = fileData.title;
}
if (!field || field === 'excerpt') {
updateData.excerpt = fileData.excerpt || null;
}
if (!field || field === 'author') {
updateData.author = fileData.author || null;
}
if (!field || field === 'language') {
updateData.language = fileData.language || null;
}
if (!field || field === 'doNotTranslate') {
updateData.doNotTranslate = fileData.doNotTranslate === true;
}
if (!field || field === 'status') {
updateData.status = fileData.status || 'published';
}
if (!field || field === 'templateSlug') {
updateData.templateSlug = fileData.templateSlug || null;
}
if (!field || field === 'createdAt') {
updateData.createdAt = fileData.createdAt;
}
if (!field || field === 'updatedAt') {
updateData.updatedAt = fileData.updatedAt;
}
if (!field || field === 'publishedAt') {
updateData.publishedAt = fileData.publishedAt || null;
}
// For single-field syncs of non-timestamp fields, mark record as recently modified
if (field && field !== 'createdAt' && field !== 'updatedAt' && field !== 'publishedAt') {
updateData.updatedAt = new Date();
}
await db
.update(posts)
.set(updateData)
.where(eq(posts.id, postId));
return true;
}
const dbTranslation = await db
.select()
.from(postTranslations)
.where(and(eq(postTranslations.id, postId), eq(postTranslations.projectId, this.currentProjectId)))
.get();
if (!dbTranslation?.filePath) {
return false;
}
// Read file metadata
const fileData = await readPostFile(dbPost.filePath);
if (!fileData) {
const translationFileData = await readPostTranslationFile(dbTranslation.filePath);
if (!translationFileData) {
return false;
}
// Build update object based on field or all fields
const updateData: Record<string, unknown> = {
const translationUpdateData: Record<string, unknown> = {
updatedAt: new Date(),
};
if (!field || field === 'tags') {
updateData.tags = JSON.stringify(fileData.tags || []);
}
if (!field || field === 'categories') {
updateData.categories = JSON.stringify(fileData.categories || []);
}
if (!field || field === 'title') {
updateData.title = fileData.title;
}
if (!field || field === 'excerpt') {
updateData.excerpt = fileData.excerpt || null;
}
if (!field || field === 'author') {
updateData.author = fileData.author || null;
if (!field || field === 'translationFor') {
translationUpdateData.translationFor = translationFileData.translationFor;
}
if (!field || field === 'language') {
updateData.language = fileData.language || null;
translationUpdateData.language = translationFileData.language || null;
}
if (!field || field === 'title') {
translationUpdateData.title = translationFileData.title;
}
if (!field || field === 'excerpt') {
translationUpdateData.excerpt = translationFileData.excerpt || null;
}
// Update database
await db
.update(posts)
.set(updateData)
.where(eq(posts.id, postId));
.update(postTranslations)
.set(translationUpdateData)
.where(eq(postTranslations.id, postId));
return true;
},
@@ -738,6 +973,7 @@ export class MetadataDiffEngine extends EventEmitter {
if (dbMedia.alt) missingDiffs.alt = { dbValue: dbMedia.alt, fileValue: null };
if (dbMedia.caption) missingDiffs.caption = { dbValue: dbMedia.caption, fileValue: null };
if (dbMedia.author) missingDiffs.author = { dbValue: dbMedia.author, fileValue: null };
if (dbMedia.language) missingDiffs.language = { dbValue: dbMedia.language, fileValue: null };
const dbTags: string[] = JSON.parse(dbMedia.tags || '[]');
if (dbTags.length > 0) missingDiffs.tags = { dbValue: dbTags, fileValue: null };
return {
@@ -764,6 +1000,9 @@ export class MetadataDiffEngine extends EventEmitter {
if ((dbMedia.author || '') !== (sidecar.author || '')) {
differences.author = { dbValue: dbMedia.author || '', fileValue: sidecar.author || '' };
}
if ((dbMedia.language || '') !== (sidecar.language || '')) {
differences.language = { dbValue: dbMedia.language || '', fileValue: sidecar.language || '' };
}
const dbTags: string[] = JSON.parse(dbMedia.tags || '[]');
const sidecarTags = sidecar.tags || [];
@@ -829,6 +1068,7 @@ export class MetadataDiffEngine extends EventEmitter {
caption: 'Caption',
author: 'Author',
tags: 'Tags',
language: 'Language',
};
for (const diff of diffs) {
@@ -910,6 +1150,7 @@ export class MetadataDiffEngine extends EventEmitter {
if (!field || field === 'alt') updateData.alt = sidecar.alt || null;
if (!field || field === 'caption') updateData.caption = sidecar.caption || null;
if (!field || field === 'author') updateData.author = sidecar.author || null;
if (!field || field === 'language') updateData.language = sidecar.language || null;
if (!field || field === 'tags') updateData.tags = JSON.stringify(sidecar.tags || []);
await db.update(media).set(updateData).where(eq(media.id, mediaId));
@@ -1506,7 +1747,10 @@ export class MetadataDiffEngine extends EventEmitter {
for (let i = 0; i < filePaths.length; i++) {
try {
const imported = await postEngine.importOrphanFile(filePaths[i]);
const translationData = await readPostTranslationFile(filePaths[i]);
const imported = translationData && typeof postEngine.importOrphanTranslationFile === 'function'
? await postEngine.importOrphanTranslationFile(filePaths[i])
: await postEngine.importOrphanFile(filePaths[i]);
if (imported) {
success++;
} else {

View File

@@ -3,13 +3,14 @@ import fs from 'node:fs';
import { marked } from 'marked';
import { Liquid } from 'liquidjs';
import type { MediaData } from './MediaEngine';
import type { PostTranslationData } from './PostEngine';
import type { PostData } from './PostEngine';
import type { MenuDocument, MenuItemData } from './MenuEngine';
import { PICO_THEME_NAMES } from '../shared/picoThemes';
import { CODE_ENHANCEMENTS_RUNTIME_JS } from './assets/codeEnhancementsRuntime';
import { CALENDAR_RUNTIME_JS } from './assets/calendarRuntime';
import { TAG_CLOUD_RUNTIME_JS } from './assets/tagCloudRuntime';
import { resolveRenderLanguageFromProjectPreferences, translateRender } from '../shared/i18n';
import { resolveRenderLanguageFromProjectPreferences, translateRender, getRenderTranslations } from '../shared/i18n';
function readLocalAsset(filename: string): string {
const candidates = [
@@ -54,6 +55,7 @@ export interface PythonMacroRendererContract {
export interface HtmlRewriteContext {
canonicalPostPathBySlug: Map<string, string>;
canonicalMediaPathBySourcePath: Map<string, string>;
languagePrefix?: string;
}
export interface TemplatePostEntry {
@@ -98,6 +100,9 @@ export type DateArchiveContext = {
export interface PostListTemplateContext {
page_title: string;
language: string;
blog_languages: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
current_language: string;
language_prefix: string;
menu_items: TemplateMenuItem[];
pico_stylesheet_href?: string;
html_theme_attribute?: string;
@@ -133,9 +138,17 @@ export interface BacklinkEntry {
path: string;
}
export interface AlternateLinkEntry {
href: string;
hreflang: string;
}
export interface SinglePostTemplateContext {
page_title: string;
language: string;
blog_languages: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
current_language: string;
language_prefix: string;
menu_items: TemplateMenuItem[];
pico_stylesheet_href?: string;
html_theme_attribute?: string;
@@ -149,6 +162,7 @@ export interface SinglePostTemplateContext {
canonical_media_path_by_source_path: Record<string, string>;
post_data_json_by_id: Record<string, string>;
backlinks: BacklinkEntry[];
alternate_links: AlternateLinkEntry[];
}
export interface NotFoundTemplateContext {
@@ -175,6 +189,7 @@ export interface RoutePagination {
export interface MediaEngineContract {
getAllMedia: () => Promise<MediaData[]>;
getMediaTranslation?: (mediaId: string, language: string) => Promise<{ title?: string; alt?: string; caption?: string } | null>;
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
}
@@ -185,6 +200,7 @@ export interface PostMediaEngineContract {
export interface PostEngineContract {
getPost: (id: string) => Promise<PostData | null>;
getPostTranslation?: (postId: string, language: string) => Promise<PostTranslationData | null>;
getPostsFiltered?: (filter: { status?: 'draft' | 'published' | 'archived' }) => Promise<PostData[]>;
}
@@ -815,6 +831,14 @@ export function rewriteRenderedHtmlUrls(html: string, rewriteContext: HtmlRewrit
});
}
export function applyLanguagePrefixToHtml(html: string, languagePrefix: string): string {
if (!languagePrefix) return html;
return html.replace(/\bhref=(['"])(\/(?!media\/|assets\/).*?)\1/gi, (_fullMatch, quote: string, href: string) => {
if (href.startsWith(languagePrefix + '/') || href === languagePrefix) return `href=${quote}${href}${quote}`;
return `href=${quote}${languagePrefix}${href}${quote}`;
});
}
export function renderMacro(
name: string,
params: Record<string, string>,
@@ -890,6 +914,7 @@ export async function replaceAllMacrosAsync(
renderLanguage: string,
pythonMacroRenderer?: PythonMacroRendererContract | null,
postDataJson?: string | null,
languagePrefix?: string,
): Promise<string> {
const macroRegex = /\[\[(\w+)(?:\s+([^\]]+))?\]\]/g;
const matches: Array<{ fullMatch: string; name: string; rawParams: string | undefined; start: number; end: number }> = [];
@@ -942,12 +967,15 @@ export async function replaceAllMacrosAsync(
const pythonScript = scriptsBySlug.get(normalizeMacroName(m.name));
if (pythonScript && pythonMacroRenderer) {
try {
const resolvedLang = resolveRenderLanguageFromProjectPreferences(renderLanguage);
const context = {
env: {
isPreview: false,
mainLanguage: renderLanguage,
languagePrefix: languagePrefix ?? '',
hook: m.name,
source: { kind: 'macro', id: pythonScript.id },
translations: getRenderTranslations(resolvedLang),
},
params: params,
};
@@ -967,7 +995,7 @@ export async function replaceAllMacrosAsync(
rendered.push('');
}
} else {
rendered.push('');
rendered.push(m.fullMatch);
}
}
@@ -1172,10 +1200,11 @@ export class PageRenderer {
return translateRender(resolved, key);
});
this.liquid.registerFilter('markdown', async (value: unknown, postIdArg: unknown, postDataJsonByIdArg: unknown, canonicalPostsArg: unknown, canonicalMediaArg: unknown, renderLanguageArg: unknown) => {
this.liquid.registerFilter('markdown', async (value: unknown, postIdArg: unknown, postDataJsonByIdArg: unknown, canonicalPostsArg: unknown, canonicalMediaArg: unknown, renderLanguageArg: unknown, languagePrefixArg: unknown) => {
const content = typeof value === 'string' ? value : '';
const postId = typeof postIdArg === 'string' ? postIdArg : '';
const renderLanguage = typeof renderLanguageArg === 'string' ? renderLanguageArg : 'en';
const langPrefix = typeof languagePrefixArg === 'string' ? languagePrefixArg : '';
const postDataJsonById = (postDataJsonByIdArg && typeof postDataJsonByIdArg === 'object' && !Array.isArray(postDataJsonByIdArg))
? postDataJsonByIdArg as Record<string, string>
: {};
@@ -1202,7 +1231,7 @@ export class PageRenderer {
: null;
const withMacros = await replaceAllMacrosAsync(
content, postId, mediaItems, linkedMediaIds, tagUsage, renderLanguage, this.pythonMacroRenderer, postDataJson,
content, postId, mediaItems, linkedMediaIds, tagUsage, renderLanguage, this.pythonMacroRenderer, postDataJson, langPrefix,
);
const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false });
@@ -1247,6 +1276,9 @@ export class PageRenderer {
basePathname: string;
page_title: string;
language: string;
blog_languages?: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
current_language?: string;
language_prefix?: string;
menu_items?: TemplateMenuItem[];
pico_stylesheet_href?: string;
html_theme_attribute?: string;
@@ -1377,6 +1409,9 @@ export class PageRenderer {
return {
page_title: options.page_title,
language: options.language,
blog_languages: options.blog_languages ?? [],
current_language: options.current_language ?? options.language,
language_prefix: options.language_prefix ?? '',
menu_items: options.menu_items ?? [],
pico_stylesheet_href: options.pico_stylesheet_href,
html_theme_attribute: options.html_theme_attribute,
@@ -1419,13 +1454,47 @@ export class PageRenderer {
};
}
async resolveRenderablePost(post: PostData, postEngine: PostEngineContract): Promise<PostData> {
if (post.status === 'published' && !post.content) {
const fullPost = await postEngine.getPost(post.id);
return fullPost ?? post;
async resolveRenderablePost(post: PostData, postEngine: PostEngineContract, preferredLanguage?: string): Promise<PostData> {
// Pre-built translation variants (from blog generation) already have content and
// translationSourceSlug set — skip hydration and language resolution entirely.
const variantPost = post as PostData & { translationSourceSlug?: string };
if (variantPost.translationSourceSlug) {
return post;
}
return post;
const hydratedPost = post.status === 'published' && !post.content
? (await postEngine.getPost(post.id)) ?? post
: post;
const requestedLanguage = preferredLanguage?.trim().toLowerCase();
const canonicalLanguage = hydratedPost.language?.trim().toLowerCase();
if (!requestedLanguage || requestedLanguage === canonicalLanguage || !postEngine.getPostTranslation) {
return hydratedPost;
}
const translation = await postEngine.getPostTranslation(hydratedPost.id, requestedLanguage);
if (!translation || !translation.content) {
return hydratedPost;
}
const availableLanguages = Array.from(new Set([
...(Array.isArray(hydratedPost.availableLanguages) ? hydratedPost.availableLanguages : []),
requestedLanguage,
canonicalLanguage,
].filter((language): language is string => typeof language === 'string' && language.length > 0)));
return {
...hydratedPost,
title: translation.title,
excerpt: translation.excerpt,
content: translation.content,
language: translation.language,
updatedAt: translation.updatedAt,
publishedAt: translation.publishedAt ?? hydratedPost.publishedAt,
availableLanguages,
translationSourceSlug: hydratedPost.slug,
translationCanonicalLanguage: canonicalLanguage || undefined,
} as PostData;
}
async renderPostList(
@@ -1438,6 +1507,9 @@ export class PageRenderer {
basePathname: string;
page_title: string;
language: string;
blog_languages?: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
current_language?: string;
language_prefix?: string;
menu_items?: TemplateMenuItem[];
pico_stylesheet_href?: string;
html_theme_attribute?: string;
@@ -1452,7 +1524,7 @@ export class PageRenderer {
}
const renderablePosts = postEngine
? await Promise.all(posts.map(async (post) => this.resolveRenderablePost(post, postEngine)))
? await Promise.all(posts.map(async (post) => this.resolveRenderablePost(post, postEngine, options.language)))
: posts;
const templateContext = this.buildListTemplateContext(
renderablePosts,
@@ -1465,7 +1537,8 @@ export class PageRenderer {
routeCategory ?? undefined,
options.categorySettings as Record<string, { listTemplateSlug?: string | null }> | undefined,
);
return this.liquid.renderFile(listTemplateName, templateContext);
const html = await this.liquid.renderFile(listTemplateName, templateContext);
return rewriteContext.languagePrefix ? applyLanguagePrefixToHtml(html, rewriteContext.languagePrefix) : html;
}
async renderSinglePost(
@@ -1474,6 +1547,9 @@ export class PageRenderer {
pageContext: {
page_title: string;
language: string;
blog_languages?: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
current_language?: string;
language_prefix?: string;
menu_items?: TemplateMenuItem[];
pico_stylesheet_href?: string;
html_theme_attribute?: string;
@@ -1481,11 +1557,12 @@ export class PageRenderer {
tagSettings?: Record<string, { postTemplateSlug?: string | null }>;
categorySettings?: Record<string, { postTemplateSlug?: string | null }>;
backlinks?: BacklinkEntry[];
alternate_links?: AlternateLinkEntry[];
},
postEngine?: PostEngineContract,
): Promise<string> {
const renderablePost = postEngine
? await this.resolveRenderablePost(post, postEngine)
? await this.resolveRenderablePost(post, postEngine, pageContext.language)
: post;
const postCategories = Array.isArray(renderablePost.categories)
@@ -1503,6 +1580,9 @@ export class PageRenderer {
const context: SinglePostTemplateContext = {
...pageContext,
language: postLanguage || pageContext.language,
blog_languages: pageContext.blog_languages ?? [],
current_language: pageContext.current_language ?? pageContext.language,
language_prefix: pageContext.language_prefix ?? '',
menu_items: pageContext.menu_items ?? [],
post: {
id: renderablePost.id,
@@ -1523,6 +1603,7 @@ export class PageRenderer {
[renderablePost.id]: JSON.stringify(serializePostDataForMacro(renderablePost)),
},
backlinks: pageContext.backlinks ?? [],
alternate_links: pageContext.alternate_links ?? [],
};
const postTemplateName = resolvePostTemplateName(
@@ -1530,7 +1611,8 @@ export class PageRenderer {
pageContext.tagSettings,
pageContext.categorySettings,
);
return this.liquid.renderFile(postTemplateName, context);
const html = await this.liquid.renderFile(postTemplateName, context);
return rewriteContext.languagePrefix ? applyLanguagePrefixToHtml(html, rewriteContext.languagePrefix) : html;
}
async renderNotFound(context: NotFoundTemplateContext): Promise<string> {

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ import path from 'node:path';
import { type CategoryMetadata, type ProjectMetadata } from './MetaEngine';
import { type MediaData } from './MediaEngine';
import { type MenuDocument } from './MenuEngine';
import { type PostData, type PostFilter } from './PostEngine';
import { type PostData, type PostFilter, type PostTranslationData } from './PostEngine';
import {
PageRenderer,
PREVIEW_ASSETS,
@@ -20,8 +20,10 @@ import {
type PythonMacroRendererContract,
} from './PageRenderer';
import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes';
import { POST_LANGUAGE_FLAGS, type SupportedLanguage } from '../shared/i18n';
import { renderRouteWithSharedContext } from './SharedRouteRenderer';
import {
findPublishedPostBySlug,
findSinglePostBySlug,
loadPostsForDayPage,
loadPublishedSnapshots,
@@ -40,6 +42,9 @@ interface ActiveProjectContext {
interface PostEngineContract {
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
getPost: (id: string) => Promise<PostData | null>;
getPostTranslation?: (postId: string, language: string) => Promise<PostTranslationData | null>;
getPostTranslations?: (postId: string) => Promise<PostTranslationData[]>;
getPublishedTranslationLanguagesByPost?: () => Promise<Map<string, string[]>>;
hasPublishedVersion: (id: string) => Promise<boolean>;
getPublishedVersion: (id: string) => Promise<PostData | null>;
findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>;
@@ -86,6 +91,9 @@ export class PreviewServer {
private readonly tagColorByNameCache = new Map<string, Promise<Record<string, string>>>();
private server: Server | null = null;
private port: number | null = null;
private isStopping = false;
private inflightRequests = 0;
private drainResolve: (() => void) | null = null;
constructor(dependencies?: Partial<PreviewServerDependencies>) {
if (!dependencies?.postEngine) throw new Error('PreviewServer: postEngine not provided');
@@ -141,6 +149,13 @@ export class PreviewServer {
}
async stop(): Promise<void> {
this.isStopping = true;
// Wait for in-flight requests to finish before closing the server.
if (this.inflightRequests > 0) {
await new Promise<void>((resolve) => { this.drainResolve = resolve; });
}
if (!this.server) {
this.port = null;
return;
@@ -183,7 +198,8 @@ export class PreviewServer {
requestTheme?: string | null;
htmlThemeAttribute?: string;
allowEmptyArchiveRender?: boolean;
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string };
preferredLanguage?: string;
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string; lang?: string; preferredLanguage?: string };
},
): Promise<string | null> {
return renderRouteWithSharedContext(pathname, options, {
@@ -203,12 +219,18 @@ export class PreviewServer {
loadPublishedSnapshotsPage: (filter, pagination) => loadPublishedSnapshotsPage(this.postEngine, filter, pagination),
loadPublishedSnapshots: (filter, pagination) => loadPublishedSnapshots(this.postEngine, filter, pagination),
loadPostsForDayPage: (year, month, day, pagination) => loadPostsForDayPage(this.postEngine, year, month, day, pagination),
findPublishedPostBySlug: (slug, dateFilter) => findPublishedPostBySlug(this.postEngine, slug, dateFilter),
findSinglePostBySlug: (slug, singlePostOptions, dateFilter) => findSinglePostBySlug(this.postEngine, slug, singlePostOptions, dateFilter),
getLinkedBy: this.postEngine.getLinkedBy ? (postId) => this.postEngine.getLinkedBy!(postId) : undefined,
});
}
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (this.isStopping) {
this.respond(res, 503, 'Service Unavailable');
return;
}
const remoteAddress = req.socket.remoteAddress;
const isLocal = remoteAddress === '127.0.0.1'
|| remoteAddress === '::1'
@@ -224,6 +246,7 @@ export class PreviewServer {
return;
}
this.inflightRequests++;
try {
const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/');
@@ -251,7 +274,8 @@ export class PreviewServer {
const menuItems = buildTemplateMenuItems(menu, categoryMetadata);
const categorySettings = this.resolveCategorySettings(metadata);
const listExcludedCategories = this.resolveListExcludedCategories(categorySettings);
const language = metadata?.mainLanguage?.trim() || 'en';
const requestLanguage = requestUrl.searchParams.get('lang')?.trim().toLowerCase() || undefined;
const language = requestLanguage || metadata?.mainLanguage?.trim() || 'en';
const pageTitle = resolvePageTitle(metadata, context.projectName, context.projectDescription);
const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage);
const requestTheme = sanitizePicoTheme(requestUrl.searchParams.get('theme'));
@@ -262,18 +286,52 @@ export class PreviewServer {
const picoStylesheetHref = getPicoStylesheetHref(appliedTheme);
const htmlRewriteContext = await this.buildHtmlRewriteContext();
if (pathname === '/calendar.json') {
// Detect language-prefixed paths for alternative language previews
const mainLanguage = metadata?.mainLanguage?.trim().toLowerCase() || 'en';
const blogLanguages: string[] = Array.isArray((metadata as { blogLanguages?: unknown })?.blogLanguages)
? (metadata as { blogLanguages: string[] }).blogLanguages
: [];
const alternativeLanguages = blogLanguages
.map((lang) => lang.trim().toLowerCase())
.filter((lang) => lang.length > 0 && lang !== mainLanguage);
let routePathname = pathname;
let languagePrefix = '';
let routeLanguage = requestLanguage;
const langPrefixMatch = pathname.match(/^\/([a-z]{2})(\/.*|$)/);
if (langPrefixMatch && alternativeLanguages.includes(langPrefixMatch[1])) {
languagePrefix = `/${langPrefixMatch[1]}`;
routeLanguage = langPrefixMatch[1];
routePathname = langPrefixMatch[2] || '/';
htmlRewriteContext.languagePrefix = languagePrefix;
}
if (pathname === '/calendar.json' || routePathname === '/calendar.json') {
const calendarJson = await this.resolveCalendarJson(context.dataDir, listExcludedCategories);
this.respondAsset(res, 'application/json; charset=utf-8', calendarJson);
return;
}
if (pathname === '/__style-preview') {
const rawBlogLanguages: string[] = Array.isArray((metadata as { blogLanguages?: unknown })?.blogLanguages)
? (metadata as { blogLanguages: string[] }).blogLanguages
: [];
const allBlogLanguages = rawBlogLanguages.length > 0
? (rawBlogLanguages.includes(mainLanguage) ? rawBlogLanguages : [mainLanguage, ...rawBlogLanguages])
: [];
const stylePreviewBlogLanguages = allBlogLanguages.length > 0
? allBlogLanguages.map((lang) => ({
code: lang,
flag: POST_LANGUAGE_FLAGS[lang as SupportedLanguage] ?? '',
href_prefix: lang === mainLanguage ? '' : `/${lang}`,
is_current: lang === mainLanguage,
}))
: [];
const stylePreviewHtml = await this.renderStylePreview(htmlRewriteContext, {
pageTitle,
language,
menuItems,
picoStylesheetHref,
blogLanguages: stylePreviewBlogLanguages,
htmlThemeAttribute: previewThemeMode && previewThemeMode !== 'auto' ? `data-theme="${previewThemeMode}"` : undefined,
}, categorySettings, listExcludedCategories);
this.respond(res, 200, stylePreviewHtml);
@@ -292,16 +350,20 @@ export class PreviewServer {
return;
}
const result = await this.renderRouteForContext(pathname, {
const result = await this.renderRouteForContext(routePathname, {
projectContext: context,
metadata,
menu,
htmlRewriteContext,
maxPostsPerPage,
requestTheme,
htmlThemeAttribute: undefined,
preferredLanguage: routeLanguage,
singlePostOptions: {
useDraftContent,
draftPostId,
lang: requestLanguage,
preferredLanguage: routeLanguage,
},
});
if (!result) {
@@ -320,12 +382,18 @@ export class PreviewServer {
} catch (error) {
console.error('[PreviewServer] Request failed:', error);
this.respond(res, 500, 'Internal Server Error');
} finally {
this.inflightRequests--;
if (this.inflightRequests === 0 && this.drainResolve) {
this.drainResolve();
this.drainResolve = null;
}
}
}
private async renderStylePreview(
rewriteContext: HtmlRewriteContext,
pageContext: { pageTitle: string; language: string; menuItems: ReturnType<typeof buildTemplateMenuItems>; picoStylesheetHref: string; htmlThemeAttribute?: string },
pageContext: { pageTitle: string; language: string; menuItems: ReturnType<typeof buildTemplateMenuItems>; picoStylesheetHref: string; blogLanguages?: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>; htmlThemeAttribute?: string },
categorySettings: Record<string, CategoryRenderSettings>,
listExcludedCategories: string[],
): Promise<string> {
@@ -353,6 +421,7 @@ export class PreviewServer {
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -363,8 +432,20 @@ export class PreviewServer {
const publishedPosts = await loadPublishedSnapshots(this.postEngine, { status: 'published' });
const canonicalPostPathBySlug = new Map<string, string>();
const translationMap = this.postEngine.getPublishedTranslationLanguagesByPost
? await this.postEngine.getPublishedTranslationLanguagesByPost()
: new Map<string, string[]>();
for (const post of publishedPosts) {
canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post));
const languages = translationMap.get(post.id);
if (languages) {
for (const lang of languages) {
const variantSlug = `${post.slug}.${lang}`;
canonicalPostPathBySlug.set(variantSlug, buildCanonicalPostPath({ ...post, slug: variantSlug }));
}
}
}
const canonicalMediaPathBySourcePath = new Map<string, string>();

View File

@@ -1,11 +1,13 @@
import type { MenuDocument } from './MenuEngine';
import type { ProjectMetadata } from './MetaEngine';
import { getPicoStylesheetHref, sanitizePicoTheme } from '../shared/picoThemes';
import { POST_LANGUAGE_FLAGS, type SupportedLanguage } from '../shared/i18n';
import {
buildTemplateMenuItems,
clampMaxPostsPerPage,
parseRoutePagination,
resolvePageTitle,
type AlternateLinkEntry,
type BacklinkEntry,
type PostEngineContract,
type CategoryRenderSettings,
@@ -32,7 +34,8 @@ export interface SharedRouteRenderOptions {
requestTheme?: string | null;
htmlThemeAttribute?: string;
allowEmptyArchiveRender?: boolean;
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string };
preferredLanguage?: string;
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string; lang?: string; preferredLanguage?: string };
}
export interface SharedRouteRenderServices<TCategoryMetadata> {
@@ -77,9 +80,13 @@ export interface SharedRouteRenderServices<TCategoryMetadata> {
day: number,
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
) => Promise<{ posts: PostData[]; totalPosts: number }>;
findPublishedPostBySlug: (
slug: string,
dateFilter?: { year: number; month: number },
) => Promise<PostData | null>;
findSinglePostBySlug: (
slug: string,
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string; lang?: string; preferredLanguage?: string },
dateFilter?: { year: number; month: number; day?: number },
) => Promise<PostData | null>;
getLinkedBy?: (postId: string) => Promise<{ id: string; title: string; slug: string }[]>;
@@ -114,6 +121,48 @@ async function resolveBacklinks(
.filter((entry): entry is BacklinkEntry => entry !== null);
}
function resolveAlternateLinks(
post: PostData,
rewriteContext: HtmlRewriteContext,
): AlternateLinkEntry[] {
const variantPost = post as PostData & {
translationSourceSlug?: string;
translationCanonicalLanguage?: string;
};
const sourceSlug = typeof variantPost.translationSourceSlug === 'string' && variantPost.translationSourceSlug.trim().length > 0
? variantPost.translationSourceSlug.trim()
: post.slug;
const canonicalLanguage = typeof variantPost.translationCanonicalLanguage === 'string' && variantPost.translationCanonicalLanguage.trim().length > 0
? variantPost.translationCanonicalLanguage.trim()
: (post.language || '').trim();
const linkByLanguage = new Map<string, string>();
const currentLanguage = (post.language || canonicalLanguage).trim();
const currentHref = rewriteContext.canonicalPostPathBySlug.get(post.slug);
if (currentLanguage && currentHref) {
linkByLanguage.set(currentLanguage, currentHref);
}
const canonicalHref = rewriteContext.canonicalPostPathBySlug.get(sourceSlug);
if (canonicalLanguage && canonicalHref) {
linkByLanguage.set(canonicalLanguage, canonicalHref);
}
const languages = Array.from(new Set((Array.isArray(post.availableLanguages) ? post.availableLanguages : [])
.filter((language) => typeof language === 'string' && language.trim().length > 0)));
for (const language of languages) {
if (linkByLanguage.has(language)) {
continue;
}
const href = rewriteContext.canonicalPostPathBySlug.get(`${sourceSlug}.${language}`);
if (href) {
linkByLanguage.set(language, href);
}
}
return Array.from(linkByLanguage.entries()).map(([hreflang, href]) => ({ hreflang, href }));
}
async function resolveRouteWithSharedServices(
pathname: string,
maxPostsPerPage: number,
@@ -121,6 +170,9 @@ async function resolveRouteWithSharedServices(
pageContext: {
pageTitle: string;
language: string;
blogLanguages: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
currentLanguage: string;
languagePrefix: string;
menuItems: ReturnType<typeof buildTemplateMenuItems>;
picoStylesheetHref: string;
htmlThemeAttribute?: string;
@@ -132,7 +184,7 @@ async function resolveRouteWithSharedServices(
listExcludedCategories: string[],
services: SharedRouteRenderServices<CategoryMetadata>,
allowEmptyArchiveRender: boolean,
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string; lang?: string; preferredLanguage?: string },
): Promise<string | null> {
const routePagination = parseRoutePagination(pathname);
if (!routePagination) {
@@ -157,6 +209,9 @@ async function resolveRouteWithSharedServices(
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
current_language: pageContext.currentLanguage,
language_prefix: pageContext.languagePrefix,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -177,6 +232,9 @@ async function resolveRouteWithSharedServices(
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
current_language: pageContext.currentLanguage,
language_prefix: pageContext.languagePrefix,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -198,6 +256,9 @@ async function resolveRouteWithSharedServices(
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
current_language: pageContext.currentLanguage,
language_prefix: pageContext.languagePrefix,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -211,12 +272,18 @@ async function resolveRouteWithSharedServices(
const month = Number(daySlugMatch[2]);
const day = Number(daySlugMatch[3]);
const slug = daySlugMatch[4];
const post = await services.findSinglePostBySlug(slug, singlePostOptions, { year, month, day });
const post = await services.findSinglePostBySlug(slug, {
...singlePostOptions,
preferredLanguage: singlePostOptions?.preferredLanguage ?? pageContext.language,
}, { year, month, day });
if (!post) return null;
const backlinks = await resolveBacklinks(post.id, rewriteContext, services.getLinkedBy);
return services.pageRenderer.renderSinglePost(post, rewriteContext, {
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
current_language: pageContext.currentLanguage,
language_prefix: pageContext.languagePrefix,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -224,6 +291,7 @@ async function resolveRouteWithSharedServices(
tagSettings: tagTemplateSettings,
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
backlinks,
alternate_links: resolveAlternateLinks(post, rewriteContext),
}, services.postEngineForMacros);
}
@@ -245,6 +313,9 @@ async function resolveRouteWithSharedServices(
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
current_language: pageContext.currentLanguage,
language_prefix: pageContext.languagePrefix,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -267,6 +338,9 @@ async function resolveRouteWithSharedServices(
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
current_language: pageContext.currentLanguage,
language_prefix: pageContext.languagePrefix,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -287,6 +361,9 @@ async function resolveRouteWithSharedServices(
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
current_language: pageContext.currentLanguage,
language_prefix: pageContext.languagePrefix,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -297,13 +374,17 @@ async function resolveRouteWithSharedServices(
const pageSlugMatch = pagedPathname.match(/^\/([^/]+)$/);
if (pageSlugMatch) {
const slug = pageSlugMatch[1];
const pages = await services.loadPublishedSnapshots({ status: 'published', categories: ['page'] }, { maxPostsPerPage });
const pagePost = pages.find((candidate) => candidate.slug === slug) || null;
if (!pagePost) return null;
const pagePost = await services.findPublishedPostBySlug(slug);
if (!pagePost || !(Array.isArray(pagePost.categories) && pagePost.categories.includes('page'))) {
return null;
}
const backlinks = await resolveBacklinks(pagePost.id, rewriteContext, services.getLinkedBy);
return services.pageRenderer.renderSinglePost(pagePost, rewriteContext, {
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
current_language: pageContext.currentLanguage,
language_prefix: pageContext.languagePrefix,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -311,6 +392,7 @@ async function resolveRouteWithSharedServices(
tagSettings: tagTemplateSettings,
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
backlinks,
alternate_links: resolveAlternateLinks(pagePost, rewriteContext),
}, services.postEngineForMacros);
}
@@ -343,7 +425,7 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
const menuItems = buildTemplateMenuItems(menu, categoryMetadata as Record<string, { title?: string }>);
const categorySettings = services.resolveCategorySettings(metadata ?? null);
const listExcludedCategories = services.resolveListExcludedCategories(categorySettings);
const language = metadata?.mainLanguage?.trim() || 'en';
const language = options.preferredLanguage?.trim().toLowerCase() || metadata?.mainLanguage?.trim() || 'en';
const pageTitle = resolvePageTitle(metadata ?? null, options.projectContext.projectName, options.projectContext.projectDescription);
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage ?? metadata?.maxPostsPerPage);
const appliedTheme = sanitizePicoTheme(options.requestTheme)
@@ -354,9 +436,32 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
const tagTemplateSettings = await services.resolveTagTemplateSettings?.(options.projectContext) ?? {};
const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/');
const languagePrefix = htmlRewriteContext.languagePrefix ?? '';
const currentLanguage = languagePrefix
? languagePrefix.replace(/^\//, '')
: language;
const mainLang = metadata?.mainLanguage?.trim().toLowerCase() || 'en';
const rawBlogLanguages: string[] = Array.isArray((metadata as { blogLanguages?: unknown })?.blogLanguages)
? (metadata as { blogLanguages: string[] }).blogLanguages
: [];
const allBlogLanguages = rawBlogLanguages.length > 0
? (rawBlogLanguages.includes(mainLang) ? rawBlogLanguages : [mainLang, ...rawBlogLanguages])
: [];
const blogLanguages = allBlogLanguages.length > 0
? allBlogLanguages.map((lang) => ({
code: lang,
flag: POST_LANGUAGE_FLAGS[lang as SupportedLanguage] ?? '',
href_prefix: lang === mainLang ? '' : `/${lang}`,
is_current: lang === currentLanguage,
}))
: [];
return resolveRouteWithSharedServices(normalizedPathname, maxPostsPerPage, htmlRewriteContext, {
pageTitle,
language,
blogLanguages,
currentLanguage,
languagePrefix,
menuItems,
picoStylesheetHref,
htmlThemeAttribute: options.htmlThemeAttribute,

View File

@@ -1,12 +1,20 @@
import type { PostData, PostFilter } from './PostEngine';
import type { PostData, PostFilter, PostTranslationData } from './PostEngine';
export interface SharedSnapshotPostEngine {
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
getPost: (id: string) => Promise<PostData | null>;
getPublishedVersion: (id: string) => Promise<PostData | null>;
getPostTranslation?: (postId: string, language: string) => Promise<PostTranslationData | null>;
findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>;
}
interface SinglePostPreviewOptions {
useDraftContent?: boolean;
draftPostId?: string;
lang?: string;
preferredLanguage?: string;
}
function buildSnapshotBaseFilter(filter: PostFilter): PostFilter {
const baseFilter: PostFilter = {};
@@ -166,25 +174,64 @@ export async function findPublishedPostBySlug(
export async function findSinglePostBySlug(
postEngine: SharedSnapshotPostEngine,
slug: string,
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
singlePostOptions?: SinglePostPreviewOptions,
dateFilter?: { year: number; month: number; day?: number },
): Promise<PostData | null> {
let resolvedPost: PostData | null = null;
if (singlePostOptions?.useDraftContent && singlePostOptions.draftPostId) {
const draftCandidate = await postEngine.getPost(singlePostOptions.draftPostId);
if (draftCandidate && draftCandidate.status === 'draft' && draftCandidate.slug === slug) {
if (!dateFilter) {
return draftCandidate;
}
const sameYear = draftCandidate.createdAt.getFullYear() === dateFilter.year;
const sameMonth = draftCandidate.createdAt.getMonth() === dateFilter.month - 1;
const sameDay = dateFilter.day === undefined || draftCandidate.createdAt.getDate() === dateFilter.day;
if (sameYear && sameMonth && sameDay) {
return draftCandidate;
resolvedPost = draftCandidate;
} else {
const sameYear = draftCandidate.createdAt.getFullYear() === dateFilter.year;
const sameMonth = draftCandidate.createdAt.getMonth() === dateFilter.month - 1;
const sameDay = dateFilter.day === undefined || draftCandidate.createdAt.getDate() === dateFilter.day;
if (sameYear && sameMonth && sameDay) {
resolvedPost = draftCandidate;
}
}
}
}
const fallbackDateFilter = dateFilter ? { year: dateFilter.year, month: dateFilter.month } : undefined;
return findPublishedPostBySlug(postEngine, slug, fallbackDateFilter);
if (!resolvedPost) {
const fallbackDateFilter = dateFilter ? { year: dateFilter.year, month: dateFilter.month } : undefined;
resolvedPost = await findPublishedPostBySlug(postEngine, slug, fallbackDateFilter);
}
if (!resolvedPost) {
return null;
}
const requestedLanguage = (singlePostOptions?.lang ?? singlePostOptions?.preferredLanguage)?.trim().toLowerCase();
if (!requestedLanguage || requestedLanguage === (resolvedPost.language || '').trim().toLowerCase() || !postEngine.getPostTranslation) {
return resolvedPost;
}
const translation = await postEngine.getPostTranslation(resolvedPost.id, requestedLanguage);
if (!translation) {
return resolvedPost;
}
const availableLanguages = Array.from(new Set([
...((Array.isArray(resolvedPost.availableLanguages) ? resolvedPost.availableLanguages : [])),
requestedLanguage,
(resolvedPost.language || '').trim(),
].filter((language): language is string => typeof language === 'string' && language.length > 0)));
return {
...resolvedPost,
id: translation.id,
slug: `${resolvedPost.slug}.${translation.language}`,
title: translation.title,
excerpt: translation.excerpt,
content: translation.content,
language: translation.language,
updatedAt: translation.updatedAt,
publishedAt: translation.publishedAt ?? resolvedPost.publishedAt,
availableLanguages,
translationSourceSlug: resolvedPost.slug,
translationCanonicalLanguage: resolvedPost.language,
} as PostData;
}

View File

@@ -20,6 +20,7 @@ interface CompareSitemapToHtmlParams {
baseUrl: string;
htmlDir: string;
postTimestampChecks?: PostTimestampCheck[];
additionalExpectedPaths?: string[];
}
function normalizeUrlPath(urlPath: string): string {
@@ -123,6 +124,12 @@ export async function compareSitemapToHtml(params: CompareSitemapToHtmlParams):
.map((value) => normalizeUrlPath(value)),
);
if (Array.isArray(params.additionalExpectedPaths)) {
for (const p of params.additionalExpectedPaths) {
expectedPathSet.add(normalizeUrlPath(p));
}
}
const { existingHtmlPathSet, zeroByteHtmlPathSet } = await collectHtmlIndexPaths(params.htmlDir);
const missingUrlPaths = Array.from(expectedPathSet)

View File

@@ -17,6 +17,7 @@ export interface MissingPathPlan {
requestedPageSlugs: Set<string>;
requestRootRoutes: boolean;
requiresFallbackSectionRender: boolean;
languagePlans: Map<string, MissingPathPlan>;
}
export interface TargetedValidationPlan {
@@ -58,35 +59,32 @@ function decodePathSegment(value: string): string {
}
}
export function planMissingValidationPaths(missingPaths: string[]): MissingPathPlan {
const requestedCategories = new Set<string>();
const requestedTags = new Set<string>();
const requestedYears = new Set<number>();
const requestedYearMonths = new Set<string>();
const requestedYearMonthDays = new Set<string>();
const requestedPostRoutes: RequestedPostRoute[] = [];
const requestedPageSlugs = new Set<string>();
let requestRootRoutes = false;
let requiresFallbackSectionRender = false;
for (const missingPath of missingPaths) {
const normalizedPath = normalizeUrlPath(missingPath);
if (normalizedPath === '/' || /^\/page\/\d+$/.test(normalizedPath)) {
requestRootRoutes = true;
continue;
function classifyPath(
normalizedPath: string,
requestedCategories: Set<string>,
requestedTags: Set<string>,
requestedYears: Set<number>,
requestedYearMonths: Set<string>,
requestedYearMonthDays: Set<string>,
requestedPostRoutes: RequestedPostRoute[],
requestedPageSlugs: Set<string>,
state: { requestRootRoutes: boolean; requiresFallbackSectionRender: boolean },
): void {
if (normalizedPath === '/' || /^\/(page\/\d+)$/.test(normalizedPath)) {
state.requestRootRoutes = true;
return;
}
const categoryMatch = normalizedPath.match(/^\/category\/([^/]+)(?:\/page\/\d+)?$/);
if (categoryMatch) {
requestedCategories.add(decodePathSegment(categoryMatch[1]));
continue;
return;
}
const tagMatch = normalizedPath.match(/^\/tag\/([^/]+)(?:\/page\/\d+)?$/);
if (tagMatch) {
requestedTags.add(decodePathSegment(tagMatch[1]));
continue;
return;
}
const singleMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})\/(\d{2})\/([^/]+)$/);
@@ -97,48 +95,131 @@ export function planMissingValidationPaths(missingPaths: string[]): MissingPathP
day: Number(singleMatch[3]),
slug: decodePathSegment(singleMatch[4]),
});
continue;
return;
}
const yearMatch = normalizedPath.match(/^\/(\d{4})(?:\/page\/\d+)?$/);
if (yearMatch) {
requestedYears.add(Number(yearMatch[1]));
continue;
return;
}
const monthMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})(?:\/page\/\d+)?$/);
if (monthMatch) {
requestedYearMonths.add(`${monthMatch[1]}/${monthMatch[2]}`);
continue;
return;
}
const dayMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})\/(\d{2})(?:\/page\/\d+)?$/);
if (dayMatch) {
requestedYearMonthDays.add(`${dayMatch[1]}/${dayMatch[2]}/${dayMatch[3]}`);
continue;
return;
}
const pageMatch = normalizedPath.match(/^\/([^/]+)$/);
if (pageMatch) {
requestedPageSlugs.add(decodePathSegment(pageMatch[1]));
return;
}
state.requiresFallbackSectionRender = true;
}
function createEmptyPlan(): {
requestedCategories: Set<string>;
requestedTags: Set<string>;
requestedYears: Set<number>;
requestedYearMonths: Set<string>;
requestedYearMonthDays: Set<string>;
requestedPostRoutes: RequestedPostRoute[];
requestedPageSlugs: Set<string>;
state: { requestRootRoutes: boolean; requiresFallbackSectionRender: boolean };
} {
return {
requestedCategories: new Set<string>(),
requestedTags: new Set<string>(),
requestedYears: new Set<number>(),
requestedYearMonths: new Set<string>(),
requestedYearMonthDays: new Set<string>(),
requestedPostRoutes: [],
requestedPageSlugs: new Set<string>(),
state: { requestRootRoutes: false, requiresFallbackSectionRender: false },
};
}
function finalizePlan(plan: ReturnType<typeof createEmptyPlan>, languagePlans: Map<string, MissingPathPlan>): MissingPathPlan {
return {
requestedCategories: plan.requestedCategories,
requestedTags: plan.requestedTags,
requestedYears: plan.requestedYears,
requestedYearMonths: plan.requestedYearMonths,
requestedYearMonthDays: plan.requestedYearMonthDays,
requestedPostRoutes: plan.requestedPostRoutes,
requestedPageSlugs: plan.requestedPageSlugs,
requestRootRoutes: plan.state.requestRootRoutes,
requiresFallbackSectionRender: plan.state.requiresFallbackSectionRender,
languagePlans,
};
}
export function planMissingValidationPaths(missingPaths: string[], additionalLanguages?: string[]): MissingPathPlan {
const mainPlan = createEmptyPlan();
const langPlanMap = new Map<string, ReturnType<typeof createEmptyPlan>>();
const knownLanguages = new Set((additionalLanguages ?? []).map((l) => l.trim().toLowerCase()).filter((l) => l.length > 0));
for (const missingPath of missingPaths) {
const normalizedPath = normalizeUrlPath(missingPath);
// Check for language prefix
const langPrefixMatch = normalizedPath.match(/^\/([a-z]{2,3})(?:(\/.+))?$/);
if (langPrefixMatch && knownLanguages.has(langPrefixMatch[1])) {
const lang = langPrefixMatch[1];
const strippedPath = langPrefixMatch[2] ? normalizeUrlPath(langPrefixMatch[2]) : '/';
if (!langPlanMap.has(lang)) {
langPlanMap.set(lang, createEmptyPlan());
}
const lp = langPlanMap.get(lang)!;
classifyPath(
strippedPath,
lp.requestedCategories,
lp.requestedTags,
lp.requestedYears,
lp.requestedYearMonths,
lp.requestedYearMonthDays,
lp.requestedPostRoutes,
lp.requestedPageSlugs,
lp.state,
);
if (lp.state.requiresFallbackSectionRender) {
mainPlan.state.requiresFallbackSectionRender = true;
break;
}
continue;
}
requiresFallbackSectionRender = true;
break;
classifyPath(
normalizedPath,
mainPlan.requestedCategories,
mainPlan.requestedTags,
mainPlan.requestedYears,
mainPlan.requestedYearMonths,
mainPlan.requestedYearMonthDays,
mainPlan.requestedPostRoutes,
mainPlan.requestedPageSlugs,
mainPlan.state,
);
if (mainPlan.state.requiresFallbackSectionRender) {
break;
}
}
return {
requestedCategories,
requestedTags,
requestedYears,
requestedYearMonths,
requestedYearMonthDays,
requestedPostRoutes,
requestedPageSlugs,
requestRootRoutes,
requiresFallbackSectionRender,
};
const languagePlans = new Map<string, MissingPathPlan>();
for (const [lang, lp] of langPlanMap) {
languagePlans.set(lang, finalizePlan(lp, new Map()));
}
return finalizePlan(mainPlan, languagePlans);
}
interface BuildTargetedValidationPlanParams {

View File

@@ -167,12 +167,14 @@ export function createBlogTools(deps: BlogToolDeps) {
query: z.string().describe('The search query text to find in posts'),
category: z.string().optional().describe('Optional category to filter by'),
tags: z.array(z.string()).optional().describe('Optional array of tags to filter by (all must match)'),
language: z.string().optional().describe('Require posts that are available in this language'),
missingTranslationLanguage: z.string().optional().describe('Require posts that are missing this translation language'),
year: z.number().optional().describe('Filter to posts created in this year'),
month: z.number().optional().describe('Filter to posts created in this month (1-12). Requires year.'),
limit: z.number().optional().describe('Maximum number of results to return (default: 10)'),
offset: z.number().optional().describe('Offset for pagination (default: 0)'),
}),
execute: async ({ query, category, tags, year, month, limit: lim, offset: off }) => {
execute: async ({ query, category, tags, language, missingTranslationLanguage, year, month, limit: lim, offset: off }) => {
if (month !== undefined && year === undefined) {
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
}
@@ -180,6 +182,8 @@ export function createBlogTools(deps: BlogToolDeps) {
const filter: PostFilter = {};
if (category) filter.categories = [category];
if (tags && tags.length > 0) filter.tags = tags;
if (language) filter.language = language;
if (missingTranslationLanguage) filter.missingTranslationLanguage = missingTranslationLanguage;
if (year !== undefined) filter.year = year;
if (month !== undefined && year !== undefined) filter.month = month;
@@ -194,6 +198,7 @@ export function createBlogTools(deps: BlogToolDeps) {
id: p.id, title: p.title, slug: p.slug,
excerpt: p.excerpt, status: p.status,
categories: p.categories, tags: p.tags,
availableLanguages: p.availableLanguages,
createdAt: p.createdAt, updatedAt: p.updatedAt,
})),
postEngine,
@@ -232,6 +237,7 @@ export function createBlogTools(deps: BlogToolDeps) {
content: post.content, excerpt: post.excerpt,
status: post.status, author: post.author,
categories: post.categories, tags: post.tags,
availableLanguages: post.availableLanguages,
createdAt: post.createdAt, updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
@@ -260,6 +266,7 @@ export function createBlogTools(deps: BlogToolDeps) {
content: post.content, excerpt: post.excerpt,
status: post.status, author: post.author,
categories: post.categories, tags: post.tags,
availableLanguages: post.availableLanguages,
createdAt: post.createdAt, updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
@@ -275,12 +282,14 @@ export function createBlogTools(deps: BlogToolDeps) {
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by post status'),
category: z.string().optional().describe('Filter by category'),
tags: z.array(z.string()).optional().describe('Filter by tags (posts must have all specified tags)'),
language: z.string().optional().describe('Require posts that are available in this language'),
missingTranslationLanguage: z.string().optional().describe('Require posts that are missing this translation language'),
year: z.number().optional().describe('Filter to posts created in this year'),
month: z.number().optional().describe('Filter to posts created in this month (1-12). Requires year.'),
limit: z.number().optional().describe('Maximum number of results (default: 20)'),
offset: z.number().optional().describe('Offset for pagination (default: 0)'),
}),
execute: async ({ status, category, tags, year, month, limit: lim, offset: off }) => {
execute: async ({ status, category, tags, language, missingTranslationLanguage, year, month, limit: lim, offset: off }) => {
if (month !== undefined && year === undefined) {
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
}
@@ -289,6 +298,8 @@ export function createBlogTools(deps: BlogToolDeps) {
if (status) filter.status = status;
if (tags) filter.tags = tags;
if (category) filter.categories = [category];
if (language) filter.language = language;
if (missingTranslationLanguage) filter.missingTranslationLanguage = missingTranslationLanguage;
if (year !== undefined) filter.year = year;
if (month !== undefined && year !== undefined) filter.month = month;
@@ -317,7 +328,7 @@ export function createBlogTools(deps: BlogToolDeps) {
pageItems.map(p => ({
id: p.id, title: p.title, slug: p.slug,
status: p.status, categories: p.categories,
tags: p.tags, createdAt: p.createdAt, updatedAt: p.updatedAt,
tags: p.tags, availableLanguages: p.availableLanguages, createdAt: p.createdAt, updatedAt: p.updatedAt,
})),
postEngine,
);

View File

@@ -0,0 +1,32 @@
/**
* Retry helper with exponential backoff for AI API calls.
*
* Delays: baseDelayMs * 2^attempt (e.g. 5s, 10s, 20s for base=5000).
*/
export interface RetryOptions {
/** Maximum number of retries after the initial attempt (default: 3). */
maxRetries?: number;
/** Base delay in ms before the first retry (default: 5000). Doubles each retry. */
baseDelayMs?: number;
}
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
export async function retryWithBackoff<T extends { success: boolean }>(
fn: () => Promise<T>,
options?: RetryOptions,
): Promise<T> {
const maxRetries = options?.maxRetries ?? 3;
const baseDelayMs = options?.baseDelayMs ?? 5000;
let result = await fn();
for (let attempt = 0; attempt < maxRetries && !result.success; attempt++) {
const delay = baseDelayMs * Math.pow(2, attempt);
await sleep(delay);
result = await fn();
}
return result;
}

View File

@@ -46,6 +46,33 @@ export interface PostAnalysisResult {
error?: string;
}
export interface PostTranslationResult {
success: boolean;
translation?: Awaited<ReturnType<PostEngine['upsertPostTranslation']>>;
error?: string;
warning?: string;
}
export interface MediaTranslationResult {
success: boolean;
translation?: Awaited<ReturnType<MediaEngine['upsertMediaTranslation']>>;
error?: string;
}
export function normalizeTranslatedMarkdownBody(content: string, sourceContent: string): string {
const normalizedContent = content.trim();
if (!normalizedContent) {
return '';
}
const leadingLabelPattern = /^(content|inhalt|contenu|contenuto|contenido):\s*\n\s*\n/i;
if (!leadingLabelPattern.test(normalizedContent) || leadingLabelPattern.test(sourceContent.trim())) {
return normalizedContent;
}
return normalizedContent.replace(leadingLabelPattern, '');
}
// ---------------------------------------------------------------------------
// OneShotTasks
// ---------------------------------------------------------------------------
@@ -442,4 +469,271 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
return { success: false, error: (error as Error).message };
}
}
async translatePost(postId: string, targetLanguage: string, options?: { autoPublish?: boolean }): Promise<PostTranslationResult> {
if (!this.postEngine) {
return { success: false, error: 'Post engine not available' };
}
const post = await this.postEngine.getPost(postId);
if (!post) {
return { success: false, error: 'Post not found' };
}
if (!post.content || post.content.trim().length === 0) {
return { success: false, error: 'Post has no content to translate' };
}
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;
}
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.' };
}
// Auto-detect source language if not explicitly set on the post
let sourceLanguage = post.language || '';
if (!sourceLanguage) {
const detection = await this.detectPostLanguage(post.title, post.content || '');
if (detection.success && detection.language) {
sourceLanguage = detection.language;
await this.postEngine.updatePost(postId, { language: sourceLanguage });
} else {
sourceLanguage = 'en';
}
}
const metadataSystemPrompt = `You translate blog post metadata. Return ONLY valid JSON with keys title and excerpt. Do not add commentary. Do not invent or add any text that is not present in the source. Only translate the given text. Translate from ${sourceLanguage} to ${targetLanguage}.`;
const metadataUserPrompt = [
`Title: ${post.title}`,
`Excerpt: ${post.excerpt || ''}`,
].join('\n\n');
const contentSystemPrompt = `You translate blog post Markdown bodies. Return ONLY the translated Markdown body, with no JSON envelope and no commentary. Preserve Markdown structure. Leave text inside fenced code blocks untranslated. Keep markdown link text and URLs unchanged (e.g. [link text](URL) stays as-is). The body may contain mixed languages — always translate the surrounding prose from ${sourceLanguage} to ${targetLanguage}, even if some parts (like link text) are already in the target language. Do not invent, add, or generate any text that is not present in the source. Only translate the exact text provided. If the content contains only macro calls, shortcodes, or other non-translatable tokens, return them unchanged. If the source body is empty, return an empty string.`;
const contentUserPrompt = post.content;
try {
const model = this.providers.resolveModel(modelId);
const { text: metadataText } = await generateText({
model,
system: metadataSystemPrompt,
prompt: metadataUserPrompt,
maxOutputTokens: 500,
maxRetries: 2,
});
const { text: translatedContent } = await generateText({
model,
system: contentSystemPrompt,
prompt: contentUserPrompt,
maxOutputTokens: 4000,
maxRetries: 2,
});
let parsed: { title?: string; excerpt?: string } | null = null;
const jsonMatch = metadataText.match(/\{[\s\S]*\}/);
if (jsonMatch) {
try {
parsed = JSON.parse(jsonMatch[0]);
} catch {
parsed = null;
}
}
const normalizedTranslatedContent = normalizeTranslatedMarkdownBody(translatedContent || '', post.content);
// Collect warnings for partial failures
const warnings: string[] = [];
if (!parsed) {
warnings.push('metadata JSON parsing failed, title/excerpt kept as original');
}
if (normalizedTranslatedContent.trim() === post.content.trim()) {
warnings.push('translated content is identical to source');
}
const translation = await this.postEngine.upsertPostTranslation(postId, targetLanguage, {
title: parsed?.title || post.title,
excerpt: parsed?.excerpt || post.excerpt || undefined,
content: normalizedTranslatedContent,
status: options?.autoPublish ? 'published' : undefined,
});
return {
success: true,
translation,
warning: warnings.length > 0 ? warnings.join('; ') : undefined,
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Detect the language of media metadata (title, alt, caption).
* Uses the configured title model (lightweight, text-only).
*/
async detectMediaLanguage(
title: string,
alt: string,
caption: string,
): Promise<LanguageDetectionResult> {
const combined = [title, alt, caption].filter(Boolean).join('\n');
if (!combined.trim()) {
return { success: false, error: 'No metadata text provided for language detection' };
}
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;
}
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 supportedLanguages = ['en', 'de', 'fr', 'it', 'es'];
const systemPrompt = `You are a language detection assistant. Given image metadata (title, alt text, caption), determine the language. 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}\nAlt: ${alt}\nCaption: ${caption}`;
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 };
}
}
/**
* Translate media metadata (title, alt, caption) into a target language.
* Persists the result as a media translation via MediaEngine.
*/
async translateMediaMetadata(
mediaId: string,
targetLanguage: string,
): Promise<MediaTranslationResult> {
const mediaItem = await this.mediaEngine.getMedia(mediaId);
if (!mediaItem) {
return { success: false, error: 'Media item not found' };
}
const hasMetadata = mediaItem.title || mediaItem.alt || mediaItem.caption;
if (!hasMetadata) {
return { success: false, error: 'Media item has no metadata to translate' };
}
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;
}
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.' };
}
// Auto-detect source language if not explicitly set on the media
let sourceLanguage = mediaItem.language || '';
if (!sourceLanguage) {
const detection = await this.detectMediaLanguage(
mediaItem.title || '',
mediaItem.alt || '',
mediaItem.caption || '',
);
if (detection.success && detection.language) {
sourceLanguage = detection.language;
await this.mediaEngine.updateMedia(mediaId, { language: sourceLanguage });
} else {
sourceLanguage = 'en';
}
}
const systemPrompt = `You translate image metadata. Return ONLY valid JSON with keys title, alt, caption. Do not add commentary. Translate from ${sourceLanguage} to ${targetLanguage}. If a field is null or empty, return it as null.`;
const userPrompt = [
`Title: ${mediaItem.title || ''}`,
`Alt: ${mediaItem.alt || ''}`,
`Caption: ${mediaItem.caption || ''}`,
].join('\n');
try {
const model = this.providers.resolveModel(modelId);
const { text } = await generateText({
model,
system: systemPrompt,
prompt: userPrompt,
maxOutputTokens: 300,
maxRetries: 2,
});
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) return { success: false, error: 'Invalid response format from AI' };
const parsed = JSON.parse(jsonMatch[0]);
const translation = await this.mediaEngine.upsertMediaTranslation(mediaId, targetLanguage, {
title: parsed.title || undefined,
alt: parsed.alt || undefined,
caption: parsed.caption || undefined,
});
return { success: true, translation };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
}

View File

@@ -143,3 +143,8 @@ main { display: grid; gap: 1rem; }
.preview-pagination .spacer { flex: 1; }
.not-found { display: grid; place-items: center; min-height: 48vh; }
.not-found article { max-width: 32rem; text-align: center; }
.language-switcher { position: fixed; right: .75rem; top: 1.5rem; display: flex; flex-direction: column; gap: .1rem; z-index: 100; }
.language-switcher-badge { display: block; padding: .05rem .1rem; font-size: .85rem; line-height: 1.1; text-decoration: none; border: 1px solid transparent; border-radius: .15rem; cursor: pointer; opacity: .7; transition: opacity .15s ease-in-out; }
.language-switcher-badge:hover,
.language-switcher-badge:focus-visible { opacity: 1; border-color: var(--pico-color, var(--color)); }
.language-switcher-badge-current { opacity: 1; border-color: var(--pico-primary, var(--primary)); }

View File

@@ -9,6 +9,8 @@ export const CALENDAR_RUNTIME_JS = String.raw`(() => {
return;
}
const languagePrefix = document.documentElement.getAttribute('data-language-prefix') || '';
const labels = {
loading: panel.getAttribute('data-i18n-loading') || 'Loading calendar…',
error: panel.getAttribute('data-i18n-error') || 'Calendar data could not be loaded.',
@@ -70,7 +72,7 @@ export const CALENDAR_RUNTIME_JS = String.raw`(() => {
if (!pathname) {
return;
}
window.location.assign(pathname);
window.location.assign(languagePrefix + pathname);
}
function parseInitialYearMonth() {
@@ -86,7 +88,10 @@ export const CALENDAR_RUNTIME_JS = String.raw`(() => {
: null;
if (!Number.isInteger(selectedYear) || !Number.isInteger(selectedMonth)) {
const pathname = window.location.pathname || '';
const rawPathname = window.location.pathname || '';
const pathname = languagePrefix && rawPathname.startsWith(languagePrefix + '/')
? rawPathname.slice(languagePrefix.length)
: rawPathname;
const parts = pathname.split('/').filter(Boolean);
const pathYear = Number(parts[0]);
const pathMonth = Number(parts[1]);
@@ -104,7 +109,7 @@ export const CALENDAR_RUNTIME_JS = String.raw`(() => {
}
async function loadCalendarData() {
const response = await fetch('/calendar.json', { cache: 'no-store' });
const response = await fetch(languagePrefix + '/calendar.json', { cache: 'no-store' });
if (!response.ok) {
throw new Error('calendar.json request failed');
}

View File

@@ -174,6 +174,7 @@ export const TAG_CLOUD_RUNTIME_JS = String.raw`(function () {
return;
}
const langPrefix = document.documentElement.getAttribute('data-language-prefix') || '';
const rawWords = container.getAttribute('data-tag-cloud-words');
const words = parseWords(rawWords);
if (words.length === 0) {
@@ -246,7 +247,7 @@ export const TAG_CLOUD_RUNTIME_JS = String.raw`(function () {
textNode.addEventListener('click', () => {
if (word && typeof word.url === 'string' && word.url.length > 0) {
window.location.assign(word.url);
window.location.assign(langPrefix + word.url);
}
});

View File

@@ -21,6 +21,7 @@ export {
stemQuery,
prepareForFTS,
getSupportedLanguages,
isoToStemmerLanguage,
type SupportedLanguage,
} from './stemmer';
export {

View File

@@ -105,6 +105,9 @@ const METHOD_NAME_MAP: Record<string, string> = {
'posts.getAll': 'getAllPosts',
'posts.getByStatus': 'getPostsByStatus',
'posts.publish': 'publishPost',
'posts.getTranslation': 'getPostTranslation',
'posts.getTranslations': 'getPostTranslations',
'posts.publishTranslation': 'publishPostTranslation',
'posts.discard': 'discardChanges',
'posts.hasPublishedVersion': 'hasPublishedVersion',
'posts.rebuildFromFiles': 'rebuildDatabaseFromFiles',

View File

@@ -15,6 +15,8 @@ export interface PostFileData {
status: 'draft' | 'published' | 'archived';
author?: string;
language?: string;
doNotTranslate?: boolean;
templateSlug?: string;
createdAt: Date;
updatedAt: Date;
publishedAt?: Date;
@@ -30,6 +32,8 @@ interface PostFileMetadata {
status: 'draft' | 'published' | 'archived';
author?: string;
language?: string;
doNotTranslate?: boolean;
templateSlug?: string;
createdAt: string;
updatedAt: string;
publishedAt?: string;
@@ -65,6 +69,8 @@ export async function readPostFile(filePath: string): Promise<PostFileData | nul
status: metadata.status,
author: metadata.author,
language: metadata.language || undefined,
doNotTranslate: metadata.doNotTranslate === true,
templateSlug: metadata.templateSlug || undefined,
createdAt: new Date(metadata.createdAt),
updatedAt: new Date(metadata.updatedAt),
publishedAt: metadata.publishedAt ? new Date(metadata.publishedAt) : undefined,

View File

@@ -0,0 +1,46 @@
import * as fs from 'fs/promises';
import matter from 'gray-matter';
export interface PostTranslationFileData {
translationFor: string;
language: string;
title: string;
excerpt?: string;
content: string;
}
interface PostTranslationFileMetadata {
translationFor?: string;
language?: string;
title?: string;
excerpt?: string;
}
export async function readPostTranslationFile(filePath: string): Promise<PostTranslationFileData | null> {
try {
try {
await fs.access(filePath);
} catch {
return null;
}
const fileContent = await fs.readFile(filePath, 'utf-8');
const { data, content } = matter(fileContent);
const metadata = data as PostTranslationFileMetadata;
if (!metadata.translationFor || !metadata.language || !metadata.title) {
return null;
}
return {
translationFor: metadata.translationFor,
language: metadata.language,
title: metadata.title,
excerpt: metadata.excerpt || undefined,
content,
};
} catch (error) {
console.error(`Failed to parse post translation file: ${filePath}`, error);
return null;
}
}

View File

@@ -195,6 +195,13 @@ import json as _json
import inspect as _inspect
_macro_ctx = _json.loads(__bds_macro_context_json)
_bds_translations = _macro_ctx.get('env', {}).get('translations', {})
def T(key, **kwargs):
val = _bds_translations.get(key, key)
if kwargs:
for k, v in kwargs.items():
val = val.replace('{' + k + '}', str(v))
return val
_macro_ep = __bds_macro_entrypoint
_macro_fn = globals().get(_macro_ep)
if _macro_fn is None or not callable(_macro_fn):

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="{{ language }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
<body>
<main>

View File

@@ -8,8 +8,12 @@
<link rel="stylesheet" href="/assets/highlight.min.css" />
<link rel="stylesheet" href="/assets/vanilla-calendar.min.css" />
<link rel="stylesheet" href="/assets/bds.css" />
<link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml" />
<link rel="alternate" type="application/atom+xml" title="Atom" href="/atom.xml" />
{% assign feed_prefix = language_prefix | default: '' %}
<link rel="alternate" type="application/rss+xml" title="RSS" href="{{ feed_prefix }}/rss.xml" />
<link rel="alternate" type="application/atom+xml" title="Atom" href="{{ feed_prefix }}/atom.xml" />
{% for alternate_link in alternate_links %}
<link rel="alternate" hreflang="{{ alternate_link.hreflang | escape }}" href="{{ alternate_link.href | escape }}" />
{% endfor %}
<script defer src="/assets/highlight.min.js"></script>
<script defer src="/assets/code-enhancements.js"></script>
<script defer src="/assets/d3.layout.cloud.js"></script>

View File

@@ -0,0 +1,18 @@
{% if blog_languages.size > 1 %}
<nav class="language-switcher" aria-label="{{ 'render.languageSwitcher.ariaLabel' | i18n: language }}">
{% for lang in blog_languages %}
{% if lang.is_current %}
<span class="language-switcher-badge language-switcher-badge-current" aria-current="true" title="{{ lang.code }}">{{ lang.flag }}</span>
{% else %}
<a class="language-switcher-badge" href="{{ lang.href_prefix | default: '/' }}" data-lang-prefix="{{ lang.href_prefix }}" title="{{ lang.code }}">{{ lang.flag }}</a>
{% endif %}
{% endfor %}
</nav>
<script>
(function(){
var links=document.querySelectorAll('.language-switcher-badge[data-lang-prefix]');
var path=location.pathname.replace(/^\/[a-z]{2}(?=\/|$)/,'') || '/';
links.forEach(function(a){a.href=(a.dataset.langPrefix||'')+path;});
}());
</script>
{% endif %}

View File

@@ -1,8 +1,9 @@
<!doctype html>
<html lang="{{ language }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix %}
<body>
<main>
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language %}
{% if archive_context %}
{% if show_archive_range_heading and min_date and max_date %}
{% if archive_context.kind == 'tag' or archive_context.kind == 'category' %}
@@ -44,7 +45,7 @@
{% endif %}
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
{% endif %}
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}
</div>
{% endfor %}
</div>
@@ -59,7 +60,7 @@
{% endif %}
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
{% endif %}
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}
</div>
{% endfor %}
{% endif %}

View File

@@ -1,8 +1,9 @@
<!doctype html>
<html lang="{{ language }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, alternate_links: alternate_links, language_prefix: language_prefix %}
<body>
<main>
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language %}
<h1>{{ post.title }}</h1>
{% render 'partials/menu', menu_items: menu_items, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month %}
{% if post_categories.size > 0 or post_tags.size > 0 %}
@@ -17,7 +18,7 @@
</div>
{% endif %}
<article class="single-post" data-template="single-post">
<div class="post">{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}</div>
<div class="post">{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}</div>
</article>
{% if backlinks.size > 0 %}
<div class="single-post-backlinks" aria-label="{{ 'render.backlinks.ariaLabel' | i18n: language }}">

View File

@@ -8,11 +8,18 @@ import {
} from '../engine/BlogGenerationEngine';
import { resolvePageTitle } from '../engine/PageRenderer';
import type { EngineBundle } from '../engine/EngineBundle';
import type { TranslationValidationReport } from '../shared/electronApi';
import { autoTranslatePost, autoTranslateMediaMetadata } from './chatHandlers';
import { v4 as uuidv4 } from 'uuid';
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundle): void {
const resolveBlogGenerationBaseOptions = async (): Promise<BlogGenerationOptions> => {
const resolveActiveProjectContext = async (): Promise<{
project: NonNullable<Awaited<ReturnType<EngineBundle['projectEngine']['getActiveProject']>>>;
dataDir: string;
metadata: Awaited<ReturnType<EngineBundle['metaEngine']['getProjectMetadata']>>;
}> => {
const projectEngine = bundle.projectEngine;
const postEngine = bundle.postEngine;
const metaEngine = bundle.metaEngine;
@@ -37,6 +44,17 @@ export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundl
}
const metadata = await metaEngine.getProjectMetadata();
return {
project,
dataDir,
metadata,
};
};
const resolveBlogGenerationBaseOptions = async (): Promise<BlogGenerationOptions> => {
const menuEngine = bundle.menuEngine;
const { project, dataDir, metadata } = await resolveActiveProjectContext();
const menu = await menuEngine.getMenu();
const baseUrl = resolvePublicBaseUrl(metadata?.publicUrl);
if (!baseUrl) {
@@ -60,6 +78,7 @@ export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundl
baseUrl,
maxPostsPerPage: metadata?.maxPostsPerPage,
language,
blogLanguages: Array.isArray(metadata?.blogLanguages) ? metadata.blogLanguages : [],
pageTitle,
picoTheme: metadata?.picoTheme,
categoryMetadata: (metadata as any)?.categoryMetadata,
@@ -145,6 +164,142 @@ export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundl
});
});
safeHandle('blog:validateTranslations', async () => {
await resolveActiveProjectContext();
const taskTimestamp = Date.now();
return bundle.taskManager.runTask({
id: `translation-validate-${taskTimestamp}`,
name: 'Validate Translations',
execute: async (onProgress) => {
onProgress(0, 'Validating translations...');
const result = await bundle.postEngine.validateTranslations();
onProgress(100, 'Translation validation complete');
return result;
},
});
});
safeHandle('blog:fixInvalidTranslations', async (_event, report: TranslationValidationReport) => {
await resolveActiveProjectContext();
const taskTimestamp = Date.now();
return bundle.taskManager.runTask({
id: `translation-fix-${taskTimestamp}`,
name: 'Fix Invalid Translations',
execute: async (onProgress) => {
onProgress(0, 'Fixing invalid translations...');
const result = await bundle.postEngine.fixInvalidTranslations(report);
onProgress(100, 'Invalid translations fixed');
return result;
},
});
});
safeHandle('blog:fillMissingTranslations', async () => {
const { metadata } = await resolveActiveProjectContext();
const blogLanguages = metadata?.blogLanguages || [];
const mainLang = metadata?.mainLanguage || 'en';
if (blogLanguages.length === 0 || (blogLanguages.length === 1 && blogLanguages[0] === mainLang)) {
return { taskStarted: false };
}
// Start the task immediately — scanning happens inside with progress
bundle.taskManager.runTask({
id: uuidv4(),
name: 'Fill missing translations',
execute: async (onProgress) => {
onProgress(0, 'Scanning posts…');
// Use missingTranslationLanguage filter per language instead of N+1 queries
const postItems: Array<{ postId: string; postTitle: string; targetLang: string }> = [];
for (let i = 0; i < blogLanguages.length; i++) {
const lang = blogLanguages[i];
const postsNeedingLang = await bundle.postEngine.getPostsFiltered({
status: 'published',
missingTranslationLanguage: lang,
});
for (const post of postsNeedingLang) {
if (!post.doNotTranslate) {
postItems.push({ postId: post.id, postTitle: post.title, targetLang: lang });
}
}
onProgress(
Math.round(((i + 1) / blogLanguages.length) * 10),
`Scanning posts… (${i + 1}/${blogLanguages.length} languages)`,
);
}
onProgress(10, 'Scanning media…');
// Collect missing media translations
const allPublished = await bundle.postEngine.getPostsFiltered({ status: 'published' });
const publishedPosts = allPublished.filter((p) => !p.doNotTranslate);
const mediaItems: Array<{ mediaId: string; targetLang: string }> = [];
const seenMediaLang = new Set<string>();
for (let i = 0; i < publishedPosts.length; i++) {
const post = publishedPosts[i];
const postLang = post.language || mainLang;
const links = await bundle.postMediaEngine.getLinkedMediaForPost(post.id);
for (const link of links) {
const mediaTranslations = await bundle.mediaEngine.getMediaTranslations(link.mediaId);
const existingLangs = new Set(mediaTranslations.map((t) => t.language));
for (const lang of blogLanguages) {
const key = `${link.mediaId}:${lang}`;
if (lang !== postLang && !existingLangs.has(lang) && !seenMediaLang.has(key)) {
seenMediaLang.add(key);
mediaItems.push({ mediaId: link.mediaId, targetLang: lang });
}
}
}
onProgress(10 + Math.round(((i + 1) / publishedPosts.length) * 5), `Scanning media… (${i + 1}/${publishedPosts.length})`);
}
const totalItems = postItems.length + mediaItems.length;
if (totalItems === 0) {
onProgress(100, 'All translations are up to date');
return;
}
onProgress(15, `Found ${postItems.length} posts and ${mediaItems.length} media to translate`);
let completed = 0;
let failed = 0;
let warned = 0;
for (const item of postItems) {
onProgress(15 + Math.round((completed / totalItems) * 85), `Translating "${item.postTitle}" → ${item.targetLang}`);
const result = await autoTranslatePost(item.postId, item.targetLang, { autoPublish: true });
if (!result.success) {
failed++;
console.error(`[FillMissing] post "${item.postTitle}" → ${item.targetLang} failed:`, result.error);
} else if (result.warning) {
warned++;
console.warn(`[FillMissing] post "${item.postTitle}" → ${item.targetLang}: ${result.warning}`);
}
completed++;
}
for (const item of mediaItems) {
onProgress(15 + Math.round((completed / totalItems) * 85), `Translating media ${item.mediaId.slice(0, 8)}… → ${item.targetLang}`);
const result = await autoTranslateMediaMetadata(item.mediaId, item.targetLang);
if (!result.success) {
failed++;
console.error(`[FillMissing] media ${item.mediaId.slice(0, 8)}… → ${item.targetLang} failed:`, result.error);
}
completed++;
}
const parts: string[] = ['Done'];
if (failed > 0) parts.push(`${failed} failed`);
if (warned > 0) parts.push(`${warned} warnings`);
onProgress(100, parts.length > 1 ? `${parts[0]} (${parts.slice(1).join(', ')})` : parts[0]);
},
}).catch(() => { /* errors tracked via task panel */ });
return { taskStarted: true };
});
safeHandle('blog:regenerateCalendar', async () => {
const blogGenerationEngine = bundle.blogGenerationEngine;
const baseOptions = await resolveBlogGenerationBaseOptions();

View File

@@ -10,6 +10,7 @@ import { SecureKeyStore } from '../engine/SecureKeyStore';
import { ProviderRegistry } from '../engine/ai/providers';
import { ChatService } from '../engine/ai/chat';
import { OneShotTasks } from '../engine/ai/tasks';
import { retryWithBackoff } from '../engine/ai/retry';
import { getDatabase } from '../database';
import type { EngineBundle } from '../engine/EngineBundle';
import type { BlogToolDeps } from '../engine/ai/blog-tools';
@@ -907,6 +908,43 @@ export function registerChatHandlers(): void {
}
});
// Translate a post and persist/update its translation record
ipcMain.handle('chat:translatePost', async (_, postId: string, targetLanguage: string) => {
try {
await ensureInitialized();
return await getOneShotTasks().translatePost(postId, targetLanguage || 'en');
} catch (error) {
console.error('[Chat IPC] Error translating post:', error);
return { success: false, error: (error as Error).message };
}
});
// ============ Media Language Detection ============
// Detect the language of media metadata (title, alt, caption)
ipcMain.handle('chat:detectMediaLanguage', async (_, title: string, alt: string, caption: string) => {
try {
await ensureInitialized();
return await getOneShotTasks().detectMediaLanguage(title, alt, caption);
} catch (error) {
console.error('[Chat IPC] Error detecting media language:', error);
return { success: false, error: (error as Error).message };
}
});
// ============ Media Metadata Translation ============
// Translate media metadata (title, alt, caption) into a target language
ipcMain.handle('chat:translateMediaMetadata', async (_, mediaId: string, targetLanguage: string) => {
try {
await ensureInitialized();
return await getOneShotTasks().translateMediaMetadata(mediaId, targetLanguage);
} catch (error) {
console.error('[Chat IPC] Error translating media metadata:', error);
return { success: false, error: (error as Error).message };
}
});
// ============ A2UI Actions ============
ipcMain.handle('a2ui:dispatch', async (_, action: { surfaceId: string; componentId: string; action: string; payload?: Record<string, unknown> }) => {
@@ -922,6 +960,31 @@ export function registerChatHandlers(): void {
});
}
/**
* Translate a post for auto-translation workflows (called from event handlers).
* Returns the result of translatePost or an error object if AI is not initialized.
*/
export async function autoTranslatePost(postId: string, targetLanguage: string, options?: { autoPublish?: boolean }): Promise<{ success: boolean; error?: string; warning?: string }> {
try {
await ensureInitialized();
return await retryWithBackoff(() => getOneShotTasks().translatePost(postId, targetLanguage, options));
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Translate media metadata for auto-translation workflows (called from event handlers).
*/
export async function autoTranslateMediaMetadata(mediaId: string, targetLanguage: string): Promise<{ success: boolean; error?: string }> {
try {
await ensureInitialized();
return await retryWithBackoff(() => getOneShotTasks().translateMediaMetadata(mediaId, targetLanguage));
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Cleanup chat resources
*/

View File

@@ -21,6 +21,8 @@ import { registerEmbeddingHandlers } from './embeddingHandlers';
import { isOfflineModeActive } from './chatHandlers';
import type { EngineBundle } from '../engine/EngineBundle';
import { resolveUiLanguageFromSystemLocale, translateMenu } from '../shared/i18n';
import { autoTranslatePost, autoTranslateMediaMetadata } from './chatHandlers';
import { v4 as uuidv4 } from 'uuid';
/**
* Wrap an IPC handler so that "Database is closing" errors during shutdown
@@ -433,6 +435,7 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
const stemmerLang = isoToStemmerLanguage(metadata.mainLanguage);
postEngine.setSearchLanguage(stemmerLang);
mediaEngine.setSearchLanguage(stemmerLang);
postEngine.setMainLanguage(metadata.mainLanguage);
}
}
@@ -475,6 +478,7 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
const stemmerLang = isoToStemmerLanguage(metadata.mainLanguage);
postEngine.setSearchLanguage(stemmerLang);
mediaEngine.setSearchLanguage(stemmerLang);
postEngine.setMainLanguage(metadata.mainLanguage);
}
}
@@ -537,7 +541,27 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
return engine.getPostBySlug(slug);
});
safeHandle('posts:getPreviewUrl', async (_, id: string, options?: { draft?: boolean }) => {
safeHandle('posts:getTranslation', async (_, postId: string, language: string) => {
const engine = bundle.postEngine;
return engine.getPostTranslation(postId, language);
});
safeHandle('posts:getTranslations', async (_, postId: string) => {
const engine = bundle.postEngine;
return engine.getPostTranslations(postId);
});
safeHandle('posts:upsertTranslation', async (_, postId: string, language: string, data: Record<string, unknown>) => {
const engine = bundle.postEngine;
return engine.upsertPostTranslation(postId, language, data as never);
});
safeHandle('posts:publishTranslation', async (_, postId: string, language: string) => {
const engine = bundle.postEngine;
return engine.publishPostTranslation(postId, language);
});
safeHandle('posts:getPreviewUrl', async (_, id: string, options?: { draft?: boolean; lang?: string }) => {
const engine = bundle.postEngine;
const post = await engine.getPost(id);
@@ -548,7 +572,15 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
if (options?.draft) {
return `http://127.0.0.1:4123${canonicalPath}?draft=true&postId=${encodeURIComponent(id)}`;
const params = new URLSearchParams({ draft: 'true', postId: id });
if (options.lang?.trim()) {
params.set('lang', options.lang.trim().toLowerCase());
}
return `http://127.0.0.1:4123${canonicalPath}?${params.toString()}`;
}
if (options?.lang?.trim()) {
return `http://127.0.0.1:4123${canonicalPath}?lang=${encodeURIComponent(options.lang.trim().toLowerCase())}`;
}
return `http://127.0.0.1:4123${canonicalPath}`;
@@ -872,6 +904,28 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
return engine.regenerateMissingThumbnails();
});
// ============ Media Translation Handlers ============
safeHandle('media:getTranslation', async (_, mediaId: string, language: string) => {
const engine = bundle.mediaEngine;
return engine.getMediaTranslation(mediaId, language);
});
safeHandle('media:getTranslations', async (_, mediaId: string) => {
const engine = bundle.mediaEngine;
return engine.getMediaTranslations(mediaId);
});
safeHandle('media:upsertTranslation', async (_, mediaId: string, language: string, data: { title?: string; alt?: string; caption?: string }) => {
const engine = bundle.mediaEngine;
return engine.upsertMediaTranslation(mediaId, language, data);
});
safeHandle('media:deleteTranslation', async (_, mediaId: string, language: string) => {
const engine = bundle.mediaEngine;
return engine.deleteMediaTranslation(mediaId, language);
});
// ============ Script Handlers ============
safeHandle('scripts:create', async (_, data: CreateScriptInput) => {
@@ -1810,10 +1864,65 @@ export function registerEventForwarding(bundle: EngineBundle): void {
postEngine.on('rebuildStarted', forwardEvent('posts:rebuildStarted'));
postEngine.on('databaseRebuilt', forwardEvent('posts:databaseRebuilt'));
// Auto-translate: when a canonical post is created or updated, enqueue
// translation tasks for each blog language that does not yet have a translation.
const enqueueAutoTranslations = (post: PostData) => {
if (post.doNotTranslate) return;
metaEngine.getProjectMetadata().then(async (metadata) => {
if (!metadata) return;
const blogLanguages = metadata.blogLanguages || [];
const mainLang = metadata.mainLanguage || 'en';
const postLang = post.language || mainLang;
const targetLanguages = blogLanguages.filter((lang) => lang !== postLang);
if (targetLanguages.length === 0) return;
const existingTranslations = await postEngine.getPostTranslations(post.id);
const existingLangs = new Set(existingTranslations.map((t) => t.language));
const missingLanguages = targetLanguages.filter((lang) => !existingLangs.has(lang));
if (missingLanguages.length === 0) return;
const groupId = uuidv4();
for (const targetLang of missingLanguages) {
bundle.taskManager.runTask({
id: uuidv4(),
name: `Translate "${post.title}" → ${targetLang}`,
groupId,
groupName: `Auto-translate: ${post.title}`,
execute: async (onProgress) => {
onProgress(10, `Translating to ${targetLang}...`);
const result = await autoTranslatePost(post.id, targetLang);
if (!result.success) {
throw new Error(result.error || `Translation to ${targetLang} failed`);
}
onProgress(70, `Translating linked media...`);
// Cascade: translate linked media metadata
const links = await bundle.postMediaEngine.getLinkedMediaForPost(post.id);
for (const link of links) {
const mediaTranslations = await bundle.mediaEngine.getMediaTranslations(link.mediaId);
const hasLang = mediaTranslations.some((t) => t.language === targetLang);
if (!hasLang) {
await autoTranslateMediaMetadata(link.mediaId, targetLang).catch(() => {});
}
}
onProgress(100, 'Done');
},
}).catch((error) => {
console.error(`[Auto-translate] Failed for ${post.id}${targetLang}:`, error);
});
}
}).catch(() => {});
};
postEngine.on('postCreated', (post: PostData) => enqueueAutoTranslations(post));
postEngine.on('postUpdated', (post: PostData) => enqueueAutoTranslations(post));
mediaEngine.on('mediaImported', forwardEvent('media:imported'));
mediaEngine.on('mediaUpdated', forwardEvent('media:updated'));
mediaEngine.on('mediaDeleted', forwardEvent('media:deleted'));
mediaEngine.on('mediaFileReplaced', forwardEvent('media:fileReplaced'));
mediaEngine.on('mediaTranslationCreated', forwardEvent('media:translationCreated'));
mediaEngine.on('mediaTranslationUpdated', forwardEvent('media:translationUpdated'));
mediaEngine.on('mediaTranslationDeleted', forwardEvent('media:translationDeleted'));
mediaEngine.on('rebuildStarted', forwardEvent('media:rebuildStarted'));
mediaEngine.on('databaseRebuilt', forwardEvent('media:databaseRebuilt'));

View File

@@ -47,7 +47,7 @@ export function registerMetadataDiffHandlers(safeHandle: SafeHandle, bundle: Eng
safeHandle('metadataDiff:syncFileToDb', async (_, postIds: string[], field: string, groupLabel: string) => {
await withProjectContext(bundle);
return engine().runSyncFileToDbTask(postIds, field as 'tags' | 'categories' | 'title' | 'excerpt' | 'author', groupLabel);
return engine().runSyncFileToDbTask(postIds, field as 'tags' | 'categories' | 'title' | 'excerpt' | 'author' | 'language' | 'translationFor', groupLabel);
});
// ── Media ──

View File

@@ -522,6 +522,7 @@ async function initializeActiveProjectContext(): Promise<void> {
const postEngine = bundle!.postEngine as {
setProjectContext?: (projectId: string, dataDir?: string) => void;
setSearchLanguage?: (language: string) => void;
setMainLanguage?: (language: string) => void;
};
const mediaEngine = bundle!.mediaEngine as {
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
@@ -553,6 +554,7 @@ async function initializeActiveProjectContext(): Promise<void> {
const stemmerLang = isoToStemmerLanguage(metadata.mainLanguage);
postEngine.setSearchLanguage?.(stemmerLang);
mediaEngine.setSearchLanguage?.(stemmerLang);
postEngine.setMainLanguage?.(metadata.mainLanguage);
}
} catch (error) {
console.error('Failed to initialize active project context:', error);
@@ -684,15 +686,16 @@ function createApplicationMenu(): Menu {
};
}
return {
const item: MenuItemConstructorOptions = {
label: translatedLabel,
accelerator: definition.accelerator,
id: definition.id,
enabled: definition.enabled,
click: async () => {
await triggerMenuAction(action);
},
};
if (definition.accelerator) item.accelerator = definition.accelerator;
if (definition.id) item.id = definition.id;
if (definition.enabled !== undefined) item.enabled = definition.enabled;
return item;
};
const buildSharedGroupMenuItems = (groupLabel: string): MenuItemConstructorOptions[] => {

View File

@@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron';
import type { ElectronAPI } from './shared/electronApi';
import type { GitInitProgress } from './shared/electronApi';
import type { SiteValidationReport } from './shared/electronApi';
import type { TranslationValidationReport } from './shared/electronApi';
// Expose protected methods that allow the renderer process to use
// ipcRenderer without exposing the entire object
@@ -55,7 +56,11 @@ export const electronAPI: ElectronAPI = {
delete: (id: string) => ipcRenderer.invoke('posts:delete', id),
get: (id: string) => ipcRenderer.invoke('posts:get', id),
getBySlug: (slug: string) => ipcRenderer.invoke('posts:getBySlug', slug),
getPreviewUrl: (id: string, options?: { draft?: boolean }) => ipcRenderer.invoke('posts:getPreviewUrl', id, options),
getTranslation: (postId: string, language: string) => ipcRenderer.invoke('posts:getTranslation', postId, language),
getTranslations: (postId: string) => ipcRenderer.invoke('posts:getTranslations', postId),
upsertTranslation: (postId: string, language: string, data: unknown) => ipcRenderer.invoke('posts:upsertTranslation', postId, language, data),
publishTranslation: (postId: string, language: string) => ipcRenderer.invoke('posts:publishTranslation', postId, language),
getPreviewUrl: (id: string, options?: { draft?: boolean; lang?: string }) => ipcRenderer.invoke('posts:getPreviewUrl', id, options),
getAll: (options?: { limit?: number; offset?: number }) => ipcRenderer.invoke('posts:getAll', options),
getByStatus: (status: string) => ipcRenderer.invoke('posts:getByStatus', status),
publish: (id: string) => ipcRenderer.invoke('posts:publish', id),
@@ -100,6 +105,10 @@ export const electronAPI: ElectronAPI = {
getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => ipcRenderer.invoke('media:getThumbnail', id, size),
regenerateThumbnails: (id: string) => ipcRenderer.invoke('media:regenerateThumbnails', id),
regenerateMissingThumbnails: () => ipcRenderer.invoke('media:regenerateMissingThumbnails'),
getTranslation: (mediaId: string, language: string) => ipcRenderer.invoke('media:getTranslation', mediaId, language),
getTranslations: (mediaId: string) => ipcRenderer.invoke('media:getTranslations', mediaId),
upsertTranslation: (mediaId: string, language: string, data: unknown) => ipcRenderer.invoke('media:upsertTranslation', mediaId, language, data),
deleteTranslation: (mediaId: string, language: string) => ipcRenderer.invoke('media:deleteTranslation', mediaId, language),
},
// Scripts
@@ -300,6 +309,9 @@ export const electronAPI: ElectronAPI = {
blog: {
generateSitemap: () => ipcRenderer.invoke('blog:generateSitemap'),
validateSite: () => ipcRenderer.invoke('blog:validateSite'),
validateTranslations: () => ipcRenderer.invoke('blog:validateTranslations'),
fixInvalidTranslations: (report: TranslationValidationReport) => ipcRenderer.invoke('blog:fixInvalidTranslations', report),
fillMissingTranslations: () => ipcRenderer.invoke('blog:fillMissingTranslations'),
applyValidation: (report: SiteValidationReport) => ipcRenderer.invoke('blog:applyValidation', report),
regenerateCalendar: () => ipcRenderer.invoke('blog:regenerateCalendar'),
},
@@ -396,6 +408,15 @@ export const electronAPI: ElectronAPI = {
// Post Analysis (title, excerpt, slug suggestions)
analyzePost: (postId: string, language?: string) => ipcRenderer.invoke('chat:analyzePost', postId, language),
// Post Translation
translatePost: (postId: string, targetLanguage: string) => ipcRenderer.invoke('chat:translatePost', postId, targetLanguage),
// Media Language Detection
detectMediaLanguage: (mediaId: string) => ipcRenderer.invoke('chat:detectMediaLanguage', mediaId),
// Media Metadata Translation
translateMediaMetadata: (mediaId: string, targetLanguage: string) => ipcRenderer.invoke('chat:translateMediaMetadata', mediaId, targetLanguage),
// Event listeners for streaming/progress
onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; delta: string }) => callback(data);

View File

@@ -96,18 +96,37 @@ export interface PostData {
status: 'draft' | 'published' | 'archived';
author?: string;
language?: string;
doNotTranslate?: boolean;
createdAt: string;
updatedAt: string;
publishedAt?: string;
tags: string[];
categories: string[];
availableLanguages: string[];
templateSlug?: string;
}
export interface PostTranslationData {
id: string;
projectId: string;
translationFor: string;
language: string;
title: string;
excerpt?: string;
content: string;
status: 'draft' | 'published' | 'archived';
createdAt: string;
updatedAt: string;
publishedAt?: string;
filePath: string;
}
export interface PostFilter {
status?: 'draft' | 'published' | 'archived';
tags?: string[];
categories?: string[];
language?: string;
missingTranslationLanguage?: string;
year?: number;
month?: number;
from?: string;
@@ -134,15 +153,31 @@ export interface MediaData {
alt?: string;
caption?: string;
author?: string;
language?: string;
createdAt: string;
updatedAt: string;
tags: string[];
availableLanguages: string[];
}
export interface MediaTranslationData {
id: string;
projectId: string;
translationFor: string;
language: string;
title?: string;
alt?: string;
caption?: string;
createdAt: string;
updatedAt: string;
}
export interface MediaFilter {
tags?: string[];
year?: number;
month?: number;
language?: string;
missingTranslationLanguage?: string;
}
export interface MediaSearchResult {
@@ -533,6 +568,29 @@ export interface SiteValidationReport {
existingHtmlUrlCount: number;
}
export interface TranslationValidationIssue {
issue: 'same-language-as-canonical' | 'missing-source-post' | 'do-not-translate-has-translations' | 'content-in-database';
translationId?: string;
translationFor: string;
canonicalLanguage?: string;
translationLanguage: string;
title?: string;
filePath?: string;
}
export interface TranslationValidationReport {
checkedDatabaseRowCount: number;
checkedFilesystemFileCount: number;
invalidDatabaseRows: TranslationValidationIssue[];
invalidFilesystemFiles: TranslationValidationIssue[];
}
export interface TranslationValidationFixResult {
deletedDatabaseRows: number;
deletedFiles: number;
flushedTranslations: number;
}
export interface SiteValidationApplyResult {
renderedUrlCount: number;
deletedUrlCount: number;
@@ -596,7 +654,11 @@ export interface ElectronAPI {
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<PostData | null>;
getBySlug: (slug: string) => Promise<PostData | null>;
getPreviewUrl: (id: string, options?: { draft?: boolean }) => Promise<string | null>;
getTranslation: (postId: string, language: string) => Promise<PostTranslationData | null>;
getTranslations: (postId: string) => Promise<PostTranslationData[]>;
upsertTranslation: (postId: string, language: string, data: Partial<PostTranslationData>) => Promise<PostTranslationData>;
publishTranslation: (postId: string, language: string) => Promise<PostTranslationData | null>;
getPreviewUrl: (id: string, options?: { draft?: boolean; lang?: string }) => Promise<string | null>;
getAll: (options?: { limit?: number; offset?: number }) => Promise<PaginatedPostsResult>;
getByStatus: (status: string) => Promise<PostData[]>;
publish: (id: string) => Promise<PostData | null>;
@@ -639,6 +701,10 @@ export interface ElectronAPI {
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
getTags: () => Promise<string[]>;
getTagsWithCounts: () => Promise<TagCount[]>;
getTranslation: (mediaId: string, language: string) => Promise<MediaTranslationData | null>;
getTranslations: (mediaId: string) => Promise<MediaTranslationData[]>;
upsertTranslation: (mediaId: string, language: string, data: Partial<MediaTranslationData>) => Promise<MediaTranslationData>;
deleteTranslation: (mediaId: string, language: string) => Promise<boolean>;
};
scripts: {
create: (data: {
@@ -747,7 +813,7 @@ export interface ElectronAPI {
syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>;
getProjectMetadata: () => Promise<ProjectMetadata | null>;
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record<string, CategoryMetadata>; categorySettings?: Record<string, CategoryRenderSettings>; semanticSimilarityEnabled?: boolean }) => Promise<ProjectMetadata | null>;
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record<string, CategoryMetadata>; categorySettings?: Record<string, CategoryRenderSettings>; semanticSimilarityEnabled?: boolean; blogLanguages?: string[] }) => Promise<ProjectMetadata | null>;
getPublishingPreferences: () => Promise<PublishingPreferences | null>;
setPublishingPreferences: (prefs: PublishingPreferences) => Promise<void>;
clearPublishingPreferences: () => Promise<void>;
@@ -897,6 +963,9 @@ export interface ElectronAPI {
pagesGenerated: number;
}>;
validateSite: () => Promise<SiteValidationReport>;
validateTranslations: () => Promise<TranslationValidationReport>;
fixInvalidTranslations: (report: TranslationValidationReport) => Promise<TranslationValidationFixResult>;
fillMissingTranslations: () => Promise<{ taskStarted: boolean }>;
applyValidation: (report: SiteValidationReport) => Promise<SiteValidationApplyResult>;
regenerateCalendar: () => Promise<CalendarRegenerationResult>;
};
@@ -997,6 +1066,15 @@ export interface ElectronAPI {
// Post Analysis (title, excerpt, slug suggestions)
analyzePost: (postId: string, language?: string) => Promise<{ success: boolean; title?: string; excerpt?: string; slug?: string; error?: string }>;
// Post Translation
translatePost: (postId: string, targetLanguage: string) => Promise<{ success: boolean; translation?: PostTranslationData; error?: string }>;
// Media Language Detection
detectMediaLanguage: (mediaId: string) => Promise<{ success: boolean; language?: string; error?: string }>;
// Media Metadata Translation
translateMediaMetadata: (mediaId: string, targetLanguage: string) => Promise<{ success: boolean; translation?: MediaTranslationData; error?: string }>;
// Event listeners for streaming/progress
onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void;
onToolCall: (callback: (data: ChatToolCall) => void) => () => void;

View File

@@ -7,6 +7,20 @@ import esJson from './i18n/locales/es.json';
export type SupportedLanguage = 'en' | 'de' | 'fr' | 'it' | 'es';
export const SUPPORTED_RENDER_LANGUAGES: SupportedLanguage[] = ['en', 'de', 'fr', 'it', 'es'];
/**
* Canonical list of languages supported for post and media translations.
* Used by both AI tasks and the Blog Languages UI.
*/
export const SUPPORTED_POST_LANGUAGES: readonly SupportedLanguage[] = SUPPORTED_RENDER_LANGUAGES;
export const POST_LANGUAGE_FLAGS: Record<SupportedLanguage, string> = {
en: '🇬🇧',
de: '🇩🇪',
fr: '🇫🇷',
it: '🇮🇹',
es: '🇪🇸',
};
type TranslationMap = Record<string, string>;
const en = enJson as TranslationMap;
@@ -47,6 +61,10 @@ export function resolveUiLanguageFromSystemLocale(systemLocale: string | undefin
return normalizeLanguage(systemLocale);
}
export function getRenderTranslations(language: SupportedLanguage): Record<string, string> {
return catalog[language] ?? catalog.en;
}
export function translateRender(language: SupportedLanguage, key: string): string {
return catalog[language]?.[key] ?? key;
}

View File

@@ -42,6 +42,7 @@
"menu.item.generateSitemap": "Site rendern",
"menu.item.regenerateCalendar": "Kalender neu erzeugen",
"menu.item.validateSite": "Website validieren",
"menu.item.validateTranslations": "Übersetzungen validieren",
"menu.item.findDuplicates": "Doppelte Beiträge finden",
"menu.item.uploadSite": "Website hochladen",
"menu.item.about": "Über Blogging Desktop Server",
@@ -67,6 +68,7 @@
"render.taxonomy.ariaLabel": "Taxonomie",
"render.backlinks.label": "Verlinkt von",
"render.backlinks.ariaLabel": "Rückverweise",
"render.languageSwitcher.ariaLabel": "Sprache",
"render.video.youtubeTitle": "YouTube-Video",
"render.video.vimeoTitle": "Vimeo-Video",
"render.month.1": "Januar",
@@ -91,5 +93,6 @@
"task.rebuildEmbeddingIndex.name": "Embedding-Index neu aufbauen",
"task.rebuildEmbeddingIndex.clearing": "Index wird geleert…",
"task.duplicateSearch.name": "Doppelte Beiträge finden",
"task.duplicateSearch.searching": "Prüfe: {checked}/{total}"
"task.duplicateSearch.searching": "Prüfe: {checked}/{total}",
"menu.item.fillMissingTranslations": "Fehlende Übersetzungen ausfüllen"
}

View File

@@ -42,6 +42,7 @@
"menu.item.generateSitemap": "Render Site",
"menu.item.regenerateCalendar": "Regenerate Calendar",
"menu.item.validateSite": "Validate Site",
"menu.item.validateTranslations": "Validate Translations",
"menu.item.findDuplicates": "Find Duplicate Posts",
"menu.item.uploadSite": "Upload Site",
"menu.item.about": "About Blogging Desktop Server",
@@ -67,6 +68,7 @@
"render.taxonomy.ariaLabel": "Taxonomy",
"render.backlinks.label": "Linked from",
"render.backlinks.ariaLabel": "Backlinks",
"render.languageSwitcher.ariaLabel": "Language",
"render.video.youtubeTitle": "YouTube video",
"render.video.vimeoTitle": "Vimeo video",
"render.month.1": "January",
@@ -91,5 +93,6 @@
"task.rebuildEmbeddingIndex.name": "Rebuild Embedding Index",
"task.rebuildEmbeddingIndex.clearing": "Clearing index…",
"task.duplicateSearch.name": "Find Duplicate Posts",
"task.duplicateSearch.searching": "Checking: {checked}/{total}"
"task.duplicateSearch.searching": "Checking: {checked}/{total}",
"menu.item.fillMissingTranslations": "Fill Missing Translations"
}

View File

@@ -42,6 +42,7 @@
"menu.item.generateSitemap": "Renderizar sitio",
"menu.item.regenerateCalendar": "Regenerar calendario",
"menu.item.validateSite": "Validar sitio",
"menu.item.validateTranslations": "Validar traducciones",
"menu.item.findDuplicates": "Buscar entradas duplicadas",
"menu.item.uploadSite": "Subir sitio",
"menu.item.about": "Acerca de Blogging Desktop Server",
@@ -67,6 +68,7 @@
"render.taxonomy.ariaLabel": "Taxonomía",
"render.backlinks.label": "Enlazado desde",
"render.backlinks.ariaLabel": "Retroenlaces",
"render.languageSwitcher.ariaLabel": "Idioma",
"render.video.youtubeTitle": "Vídeo de YouTube",
"render.video.vimeoTitle": "Vídeo de Vimeo",
"render.month.1": "enero",
@@ -91,5 +93,6 @@
"task.rebuildEmbeddingIndex.name": "Reconstruir índice de embeddings",
"task.rebuildEmbeddingIndex.clearing": "Vaciando índice…",
"task.duplicateSearch.name": "Buscar entradas duplicadas",
"task.duplicateSearch.searching": "Comprobando: {checked}/{total}"
"task.duplicateSearch.searching": "Comprobando: {checked}/{total}",
"menu.item.fillMissingTranslations": "Completar traducciones faltantes"
}

View File

@@ -42,6 +42,7 @@
"menu.item.generateSitemap": "Rendre le site",
"menu.item.regenerateCalendar": "Régénérer le calendrier",
"menu.item.validateSite": "Valider le site",
"menu.item.validateTranslations": "Valider les traductions",
"menu.item.findDuplicates": "Trouver les articles en double",
"menu.item.uploadSite": "Publier le site",
"menu.item.about": "À propos de Blogging Desktop Server",
@@ -67,6 +68,7 @@
"render.taxonomy.ariaLabel": "Taxonomie",
"render.backlinks.label": "Lié depuis",
"render.backlinks.ariaLabel": "Rétroliens",
"render.languageSwitcher.ariaLabel": "Langue",
"render.video.youtubeTitle": "Vidéo YouTube",
"render.video.vimeoTitle": "Vidéo Vimeo",
"render.month.1": "janvier",
@@ -91,5 +93,6 @@
"task.rebuildEmbeddingIndex.name": "Reconstruire l'index d'embeddings",
"task.rebuildEmbeddingIndex.clearing": "Vidage de l'index…",
"task.duplicateSearch.name": "Trouver les articles en double",
"task.duplicateSearch.searching": "Vérification : {checked}/{total}"
"task.duplicateSearch.searching": "Vérification : {checked}/{total}",
"menu.item.fillMissingTranslations": "Compléter les traductions manquantes"
}

View File

@@ -42,6 +42,7 @@
"menu.item.generateSitemap": "Renderizza sito",
"menu.item.regenerateCalendar": "Rigenera calendario",
"menu.item.validateSite": "Valida sito",
"menu.item.validateTranslations": "Valida traduzioni",
"menu.item.findDuplicates": "Trova post duplicati",
"menu.item.uploadSite": "Carica sito",
"menu.item.about": "Informazioni su Blogging Desktop Server",
@@ -67,6 +68,7 @@
"render.taxonomy.ariaLabel": "Tassonomia",
"render.backlinks.label": "Collegato da",
"render.backlinks.ariaLabel": "Retrocollegamenti",
"render.languageSwitcher.ariaLabel": "Lingua",
"render.video.youtubeTitle": "Video YouTube",
"render.video.vimeoTitle": "Video Vimeo",
"render.month.1": "gennaio",
@@ -91,5 +93,6 @@
"task.rebuildEmbeddingIndex.name": "Ricostruisci indice embeddings",
"task.rebuildEmbeddingIndex.clearing": "Svuotamento indice…",
"task.duplicateSearch.name": "Trova post duplicati",
"task.duplicateSearch.searching": "Controllo: {checked}/{total}"
"task.duplicateSearch.searching": "Controllo: {checked}/{total}",
"menu.item.fillMissingTranslations": "Completa le traduzioni mancanti"
}

View File

@@ -37,9 +37,11 @@ export type AppMenuAction =
| 'generateSitemap'
| 'regenerateCalendar'
| 'validateSite'
| 'validateTranslations'
| 'uploadSite'
| 'openDocumentation'
| 'openApiDocumentation'
| 'fillMissingTranslations'
| 'findDuplicates'
| 'about'
| 'viewOnGitHub'
@@ -126,18 +128,20 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
{ label: 'menu.item.publishSelected', action: 'publishSelected', accelerator: 'CmdOrCtrl+Shift+P' },
{ label: '', action: 'blog-separator-1', separator: true },
{ label: 'menu.item.previewPost', action: 'previewPost', id: APP_MENU_ITEM_IDS.previewPost, enabled: false, accelerator: 'CmdOrCtrl+Shift+V' },
{ label: 'menu.item.editMenu', action: 'editMenu' },
{ label: '', action: 'blog-separator-2', separator: true },
{ label: 'menu.item.rebuildDatabase', action: 'rebuildDatabase' },
{ label: 'menu.item.reindexText', action: 'reindexText' },
{ label: 'menu.item.rebuildEmbeddingIndex', action: 'rebuildEmbeddingIndex' },
{ label: '', action: 'blog-separator-3', separator: true },
{ label: 'menu.item.metadataDiff', action: 'metadataDiff' },
{ label: 'menu.item.editMenu', action: 'editMenu' },
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
{ label: 'menu.item.regenerateCalendar', action: 'regenerateCalendar' },
{ label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Shift+L' },
{ label: 'menu.item.validateTranslations', action: 'validateTranslations' },
{ label: 'menu.item.fillMissingTranslations', action: 'fillMissingTranslations' },
{ label: 'menu.item.findDuplicates', action: 'findDuplicates' },
{ label: '', action: 'blog-separator-4', separator: true },
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
{ label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Shift+L' },
{ label: 'menu.item.uploadSite', action: 'uploadSite', accelerator: 'CmdOrCtrl+Shift+U' },
],
},
@@ -177,6 +181,8 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial<Record<AppMenuAction, string>> =
generateSitemap: 'menu:generateSitemap',
regenerateCalendar: 'menu:regenerateCalendar',
validateSite: 'menu:validateSite',
validateTranslations: 'menu:validateTranslations',
fillMissingTranslations: 'menu:fillMissingTranslations',
findDuplicates: 'menu:findDuplicates',
uploadSite: 'menu:uploadSite',
openDocumentation: 'menu:openDocumentation',

View File

@@ -82,16 +82,19 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('posts.delete', 'Delete a post by id.', [requiredString('id')], 'boolean'),
method('posts.get', 'Fetch one post by id.', [requiredString('postId')], 'PostData | null'),
method('posts.getBySlug', 'Fetch one post by slug.', [requiredString('slug')], 'PostData | null'),
method('posts.getPreviewUrl', 'Get preview URL for post.', [requiredString('id'), optionalObject('options')], 'string | null'),
method('posts.getPreviewUrl', 'Get preview URL for post. options may include draft=true and lang=<language-code>.', [requiredString('id'), optionalObject('options')], 'string | null'),
method('posts.getAll', 'Fetch posts with pagination.', [optionalObject('options')], 'PaginatedPostsResult'),
method('posts.getByStatus', 'Fetch posts by status.', [requiredString('status')], 'PostData[]'),
method('posts.publish', 'Publish a post by id.', [requiredString('id')], 'PostData | null'),
method('posts.getTranslation', 'Get a single translation for a post by language.', [requiredString('postId'), requiredString('language')], 'PostTranslationData | null'),
method('posts.getTranslations', 'Get all translations for a post.', [requiredString('postId')], 'PostTranslationData[]'),
method('posts.publishTranslation', 'Publish a specific translation of a post.', [requiredString('postId'), requiredString('language')], 'PostTranslationData | null'),
method('posts.discard', 'Discard draft changes for post.', [requiredString('id')], 'PostData | null'),
method('posts.hasPublishedVersion', 'Check if post has published version.', [requiredString('id')], 'boolean'),
method('posts.rebuildFromFiles', 'Rebuild posts database from files.', [], 'void'),
method('posts.reindexText', 'Reindex post search text.', [], 'void'),
method('posts.search', 'Search posts by free-text query.', [requiredString('query')], 'SearchResult[]'),
method('posts.filter', 'Filter posts by criteria.', [requiredObject('filter')], 'PostData[]'),
method('posts.filter', 'Filter posts by criteria, including optional language and missingTranslationLanguage filters.', [requiredObject('filter')], 'PostData[]'),
method('posts.getTags', 'Get all post tags.', [], 'string[]'),
method('posts.getCategories', 'Get all post categories.', [], 'string[]'),
method('posts.getByYearMonth', 'Get post counts grouped by year/month.', [], 'Array<{ year: number; month: number; count: number } >'),
@@ -123,6 +126,11 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('media.getTags', 'Get all media tags.', [], 'string[]'),
method('media.getTagsWithCounts', 'Get media tags with counts.', [], 'TagCount[]'),
method('media.getTranslation', 'Get a single translation for a media item by language.', [requiredString('mediaId'), requiredString('language')], 'MediaTranslationData | null'),
method('media.getTranslations', 'Get all translations for a media item.', [requiredString('mediaId')], 'MediaTranslationData[]'),
method('media.upsertTranslation', 'Create or update a media translation for a specific language.', [requiredString('mediaId'), requiredString('language'), requiredObject('data')], 'MediaTranslationData'),
method('media.deleteTranslation', 'Delete a media translation by language.', [requiredString('mediaId'), requiredString('language')], 'boolean'),
method('scripts.create', 'Create script. data must include: title (str), kind ("macro"|"utility"|"transform"), content (str). Optional: slug (str), entrypoint (str, defaults to "render"), enabled (bool).', [requiredObject('data')], 'ScriptData'),
method('scripts.update', 'Update script by id. data may include any of: title, kind, content, slug, entrypoint, enabled.', [requiredString('id'), requiredObject('data')], 'ScriptData | null'),
method('scripts.delete', 'Delete script by id.', [requiredString('id')], 'boolean'),
@@ -192,6 +200,9 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('chat.analyzeMediaImage', 'Analyze an image and generate title, alt text, and caption using AI.', [requiredString('mediaId'), optionalString('language')], 'ImageAnalysisResult'),
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('chat.analyzePost', 'Analyze a post and generate suggested title, excerpt, and slug using AI.', [requiredString('postId'), optionalString('language')], 'PostAnalysisResult'),
method('chat.translatePost', 'Translate a post into a target language and save it as a translation draft.', [requiredString('postId'), requiredString('targetLanguage')], 'PostTranslationResult'),
method('chat.detectMediaLanguage', 'Detect the language of media metadata from its title, alt text, and caption.', [requiredString('title'), requiredString('alt'), requiredString('caption')], '{ success: boolean; language?: string; error?: string }'),
method('chat.translateMediaMetadata', 'Translate media metadata (title, alt, caption) into a target language using AI.', [requiredString('mediaId'), requiredString('targetLanguage')], 'MediaTranslationResult'),
method('sync.checkAvailability', 'Check if git is available.', [], 'GitAvailability'),
method('sync.getRepoState', 'Get repository state for active project.', [], 'RepoState'),
@@ -257,6 +268,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{ name: 'publishedAt', type: 'string', required: false, description: 'Publication timestamp for published posts.' },
{ name: 'tags', type: 'string[]', required: true, description: 'List of tag names.' },
{ name: 'categories', type: 'string[]', required: true, description: 'List of category names.' },
{ name: 'availableLanguages', type: 'string[]', required: true, description: 'Canonical language plus all available translation language codes for this post.' },
],
},
{
@@ -437,6 +449,33 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{ name: 'error', type: 'string', required: false, description: 'Error message when analysis failed.' },
],
},
{
name: 'PostTranslationData',
description: 'Stored translation draft or published translation for a post.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Translation identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project identifier.' },
{ name: 'translationFor', type: 'string', required: true, description: 'Source post identifier this translation belongs to.' },
{ name: 'language', type: 'string', required: true, description: 'Target language code for the translation.' },
{ name: 'title', type: 'string', required: true, description: 'Translated title.' },
{ name: 'excerpt', type: 'string', required: false, description: 'Translated excerpt.' },
{ name: 'content', type: 'string', required: true, description: 'Translated Markdown content.' },
{ name: 'status', type: "'draft' | 'published' | 'archived'", required: true, description: 'Translation lifecycle state.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp.' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp.' },
{ name: 'publishedAt', type: 'string', required: false, description: 'Publish timestamp when the translation is published.' },
{ name: 'filePath', type: 'string', required: true, description: 'Translation file path on disk.' },
],
},
{
name: 'PostTranslationResult',
description: 'Result from AI post translation containing the saved translation draft.',
fields: [
{ name: 'success', type: 'boolean', required: true, description: 'Whether the translation succeeded.' },
{ name: 'translation', type: 'PostTranslationData', required: false, description: 'Saved translation draft when successful.' },
{ name: 'error', type: 'string', required: false, description: 'Error message when translation failed.' },
],
},
{
name: 'SimilarPost',
description: 'A post with its semantic similarity score relative to a reference post.',
@@ -465,7 +504,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
];
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
version: '1.13.0',
version: '1.15.0',
generatedAt: '2026-03-07T00:00:00.000Z',
methods: METHODS_V1,
dataStructures: DATA_STRUCTURES_V1,

View File

@@ -4,6 +4,7 @@ import { useAppStore, PostData, MediaData, TaskProgress } from './store';
import { loadTabsForProject, saveTabsForProject } from './utils';
import { openSingletonToolTab } from './navigation/tabPolicy';
import { persistSiteValidationReport } from './navigation/siteValidationPersistence';
import { persistTranslationValidationReport } from './navigation/translationValidationPersistence';
import { persistDuplicatesResult } from './navigation/duplicatesPersistence';
import { executeActivityClick } from './navigation/activityExecution';
import { handleBlogmarkCreatedEvent } from './navigation/blogmarkHandling';
@@ -416,7 +417,9 @@ const App: React.FC = () => {
window.electronAPI?.posts.rebuildFromFiles(),
window.electronAPI?.media.rebuildFromFiles(),
window.electronAPI?.scripts.rebuildFromFiles(),
window.electronAPI?.templates.rebuildFromFiles(),
]);
await window.electronAPI?.posts.rebuildLinks();
await window.electronAPI?.media.regenerateMissingThumbnails();
} catch (error) {
console.error('Database rebuild failed:', error);
@@ -508,6 +511,49 @@ const App: React.FC = () => {
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:validateTranslations', () => {
const validateAndOpen = async () => {
try {
const report = await window.electronAPI?.blog.validateTranslations();
const projectId = useAppStore.getState().activeProject?.id;
if (projectId && report) {
persistTranslationValidationReport(projectId, report);
window.dispatchEvent(new CustomEvent('bds:translation-validation-updated', {
detail: { projectId },
}));
}
openSingletonToolTab(openTab, 'translation-validation');
} catch (error) {
console.error('Translation validation failed:', error);
showToast.error(tr('translationValidation.error.validate'));
}
};
void validateAndOpen();
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:fillMissingTranslations', () => {
const fillMissing = async () => {
try {
const result = await window.electronAPI?.blog.fillMissingTranslations();
if (result) {
if (!result.taskStarted) {
showToast.info(tr('blog.fillMissing.nothingToDo'));
} else {
showToast.success(tr('blog.fillMissing.started'));
}
}
} catch (error) {
console.error('Fill missing translations failed:', error);
showToast.error(tr('blog.fillMissing.error'));
}
};
void fillMissing();
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:previewPost', async () => {
try {

View File

@@ -115,11 +115,16 @@
gap: 16px;
}
.metadata-toggle-header {
display: flex;
align-items: center;
gap: 8px;
}
.metadata-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 4px;
background: none;
border: none;
@@ -130,6 +135,7 @@
letter-spacing: 0.5px;
cursor: pointer;
transition: color 0.15s;
flex-shrink: 0;
}
.metadata-toggle:hover {
@@ -202,10 +208,12 @@
display: flex;
gap: 6px;
align-items: center;
flex-wrap: nowrap;
}
.editor-language-row select {
flex: 1;
min-width: 0;
}
.editor-language-row button.compact {
@@ -215,6 +223,53 @@
line-height: 1;
}
.editor-translations-flags {
display: flex;
gap: 4px;
align-items: center;
flex: 1;
min-width: 0;
overflow-x: auto;
}
.editor-translation-flag {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
width: 24px;
height: 24px;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
font-size: 14px;
line-height: 1;
cursor: pointer;
flex: 0 0 auto;
}
.editor-translation-flag.status-published {
opacity: 1;
}
.editor-translation-flag.status-draft {
opacity: 0.82;
}
.editor-translation-flag.status-archived {
opacity: 0.45;
filter: grayscale(0.35);
}
.editor-translation-flag.active {
border-color: var(--vscode-testing-iconQueued, #cca700);
background: color-mix(in srgb, var(--vscode-testing-iconQueued, #cca700) 14%, transparent);
}
.editor-translation-flag:hover {
background: color-mix(in srgb, var(--vscode-list-hoverBackground) 75%, transparent);
}
.editor-body {
flex: 1;
display: flex;
@@ -1037,6 +1092,11 @@
overflow: hidden;
}
.quick-actions-divider {
height: 1px;
background: var(--vscode-dropdown-border, #454545);
}
.quick-action-item {
display: flex;
align-items: flex-start;
@@ -1081,3 +1141,115 @@
font-size: 11px;
opacity: 0.7;
}
.translation-modal-backdrop {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
z-index: 10000;
}
.translation-modal {
width: min(460px, calc(100vw - 32px));
background: var(--vscode-editor-background);
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
}
.translation-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.translation-modal-header h2 {
margin: 0;
font-size: 16px;
}
.translation-modal-close {
background: none;
border: none;
color: var(--vscode-descriptionForeground);
cursor: pointer;
font-size: 20px;
line-height: 1;
padding: 4px 6px;
}
.translation-modal-close:hover {
color: var(--vscode-foreground);
}
.translation-modal-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
}
.translation-modal-copy {
margin: 0;
font-size: 13px;
color: var(--vscode-descriptionForeground);
}
.translation-modal-label {
font-size: 12px;
font-weight: 600;
}
.translation-modal-select {
width: 100%;
}
.translation-modal-status-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 6px;
background: color-mix(in srgb, var(--vscode-editorWidget-background) 88%, transparent);
border: 1px solid var(--vscode-panel-border);
}
.translation-modal-flag {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 999px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
font-size: 16px;
}
.translation-modal-status-copy {
display: flex;
flex-direction: column;
gap: 2px;
}
.translation-modal-status-copy strong {
font-size: 13px;
}
.translation-modal-status-copy small {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.translation-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 18px 18px;
border-top: 1px solid var(--vscode-panel-border);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,863 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useAppStore } from '../../store';
import { showToast } from '../Toast';
import { AISuggestionsModal } from '../AISuggestionsModal/AISuggestionsModal';
import { openEntityTab } from '../../navigation/tabPolicy';
import { useI18n } from '../../i18n';
import { SUPPORTED_POST_LANGUAGES, POST_LANGUAGE_FLAGS } from '../../../main/shared/i18n';
import { getMediaDisplayName } from './editorUtils';
export const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
const { t: tr } = useI18n();
const { media, updateMedia, showErrorModal, showConfirmDeleteModal, openTab } = useAppStore();
const activeProjectId = useAppStore((s) => s.activeProject?.id ?? null);
const item = media.find(m => m.id === mediaId);
const [title, setTitle] = useState(item?.title || '');
const [alt, setAlt] = useState(item?.alt || '');
const [caption, setCaption] = useState(item?.caption || '');
const [author, setAuthor] = useState(item?.author || '');
const [tags, setTags] = useState(item?.tags.join(', ') || '');
const [linkedPosts, setLinkedPosts] = useState<{ postId: string; sortOrder: number }[]>([]);
const [postTitles, setPostTitles] = useState<Map<string, string>>(new Map());
const [showPostPicker, setShowPostPicker] = useState(false);
const [postSearchQuery, setPostSearchQuery] = useState('');
const [pickerPosts, setPickerPosts] = useState<{ id: string; title: string }[]>([]);
// Quick action menu state
const [showQuickActions, setShowQuickActions] = useState(false);
const [projectLanguage, setProjectLanguage] = useState('en');
const quickActionsRef = useRef<HTMLDivElement>(null);
// AI suggestions modal state
const [showAISuggestionsModal, setShowAISuggestionsModal] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [aiSuggestionFields, setAISuggestionFields] = useState<Array<{ key: string; label: string; currentValue: string; suggestedValue?: string }>>([]);
const [aiError, setAIError] = useState<string | undefined>(undefined);
// Translation state
const [mediaLanguage, setMediaLanguage] = useState(item?.language || '');
const [mediaTranslations, setMediaTranslations] = useState<import('../../../main/shared/electronApi').MediaTranslationData[]>([]);
const [isTranslating, setIsTranslating] = useState(false);
const [isDetectingLanguage, setIsDetectingLanguage] = useState(false);
const [showMediaTranslationModal, setShowMediaTranslationModal] = useState(false);
const [translationTargetLanguage, setTranslationTargetLanguage] = useState('');
const [editingTranslation, setEditingTranslation] = useState<{ language: string; title: string; alt: string; caption: string } | null>(null);
// Load project language setting
useEffect(() => {
if (!activeProjectId) return;
window.electronAPI?.meta.getProjectMetadata().then(metadata => {
if (metadata?.mainLanguage) {
setProjectLanguage(metadata.mainLanguage);
}
});
}, [activeProjectId]);
// Load media translations
const loadMediaTranslations = useCallback(async () => {
if (!mediaId) return;
const result = await window.electronAPI?.media.getTranslations?.(mediaId);
setMediaTranslations(result || []);
}, [mediaId]);
useEffect(() => {
loadMediaTranslations();
}, [loadMediaTranslations]);
// Handle language change on canonical media
const handleLanguageChange = async (newLanguage: string) => {
setMediaLanguage(newLanguage);
try {
const updated = await window.electronAPI?.media.update(item!.id, { language: newLanguage || undefined });
if (updated) {
updateMedia(item!.id, updated as Partial<typeof item>);
}
} catch (error) {
console.error('Failed to update media language:', error);
}
};
// Detect media language from metadata
const handleDetectLanguage = async () => {
if (!item || isDetectingLanguage) return;
setIsDetectingLanguage(true);
try {
const result = await window.electronAPI?.chat.detectMediaLanguage(
title || item.title || '',
alt || item.alt || '',
caption || item.caption || '',
);
if (result?.success && result.language) {
setMediaLanguage(result.language);
const updated = await window.electronAPI?.media.update(item.id, { language: result.language });
if (updated) {
updateMedia(item.id, updated as Partial<typeof item>);
}
showToast.success(tr('editor.media.toast.languageDetected', { language: tr(`language.${result.language}`) }));
} else {
showToast.error(result?.error || tr('editor.media.error.detectLanguage'));
}
} catch (error) {
console.error('Failed to detect media language:', error);
showToast.error(tr('editor.media.error.detectLanguage'));
} finally {
setIsDetectingLanguage(false);
}
};
// Translate media metadata with AI
const handleTranslateMedia = async (targetLanguage: string) => {
if (!item || isTranslating) return;
setIsTranslating(true);
try {
const result = await window.electronAPI?.chat.translateMediaMetadata(item.id, targetLanguage);
if (result?.success) {
await loadMediaTranslations();
showToast.success(tr('editor.media.translations.translateSuccess', { language: tr(`language.${targetLanguage}`) }));
} else {
showToast.error(result?.error || tr('editor.media.translations.translateFailed'));
}
} catch (error) {
console.error('Failed to translate media metadata:', error);
showToast.error(tr('editor.media.translations.translateFailed'));
} finally {
setIsTranslating(false);
}
};
// Open translation modal (like posts)
const handleOpenMediaTranslationModal = () => {
const preferred = translationTargetLanguage
|| availableTranslationLanguages[0]
|| '';
setShowQuickActions(false);
setTranslationTargetLanguage(preferred);
setShowMediaTranslationModal(true);
};
const handleCloseMediaTranslationModal = () => {
setShowMediaTranslationModal(false);
};
const handleConfirmMediaTranslation = () => {
if (!translationTargetLanguage) return;
setShowMediaTranslationModal(false);
void handleTranslateMedia(translationTargetLanguage);
};
// Open edit modal for an existing translation
const handleOpenEditTranslation = (translation: import('../../../main/shared/electronApi').MediaTranslationData) => {
setEditingTranslation({
language: translation.language,
title: translation.title || '',
alt: translation.alt || '',
caption: translation.caption || '',
});
};
// Save edits to a translation
const handleSaveEditTranslation = async () => {
if (!item || !editingTranslation) return;
try {
await window.electronAPI?.media.upsertTranslation(item.id, editingTranslation.language, {
title: editingTranslation.title || undefined,
alt: editingTranslation.alt || undefined,
caption: editingTranslation.caption || undefined,
});
await loadMediaTranslations();
setEditingTranslation(null);
showToast.success(tr('editor.media.translations.saved', { language: tr(`language.${editingTranslation.language}`) }));
} catch (error) {
console.error('Failed to save media translation:', error);
showToast.error(tr('editor.media.translations.saveFailed'));
}
};
// Delete a media translation
const handleDeleteTranslation = async (language: string) => {
if (!item) return;
try {
await window.electronAPI?.media.deleteTranslation?.(item.id, language);
await loadMediaTranslations();
showToast.success(tr('editor.media.translations.deleted', { language: tr(`language.${language}`) }));
} catch (error) {
console.error('Failed to delete media translation:', error);
showToast.error(tr('editor.media.translations.deleteFailed'));
}
};
// Available languages for translation (exclude canonical)
const availableTranslationLanguages = SUPPORTED_POST_LANGUAGES.filter(
lang => lang !== mediaLanguage && !mediaTranslations.find(t => t.language === lang)
);
// Close quick actions menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (quickActionsRef.current && !quickActionsRef.current.contains(event.target as Node)) {
setShowQuickActions(false);
}
};
if (showQuickActions) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [showQuickActions]);
// Handle AI image analysis for alt text and caption
const handleAIAnalysis = async () => {
if (!item || isAnalyzing) return;
setShowQuickActions(false);
setShowAISuggestionsModal(true);
setIsAnalyzing(true);
setAISuggestionFields([]);
setAIError(undefined);
try {
const result = await window.electronAPI?.chat.analyzeMediaImage(item.id, projectLanguage);
if (result?.success) {
setAISuggestionFields([
{ key: 'title', label: tr('aiSuggestions.titleField'), currentValue: title, suggestedValue: result.title },
{ key: 'alt', label: tr('aiSuggestions.altField'), currentValue: alt, suggestedValue: result.alt },
{ key: 'caption', label: tr('aiSuggestions.captionField'), currentValue: caption, suggestedValue: result.caption },
]);
} else {
setAIError(result?.error || tr('editor.media.error.analyzeImage'));
}
} catch (error) {
console.error('Failed to analyze image:', error);
setAIError((error as Error).message || tr('editor.media.error.analyzeImage'));
} finally {
setIsAnalyzing(false);
}
};
// Handle applying AI suggestions
const handleApplyAISuggestions = (values: Record<string, string>) => {
if (values.title) setTitle(values.title);
if (values.alt) setAlt(values.alt);
if (values.caption) setCaption(values.caption);
setShowAISuggestionsModal(false);
if (Object.keys(values).length > 0) {
showToast.success(tr('editor.media.toast.aiApplied'));
}
};
// Close AI suggestions modal
const handleCloseAISuggestionsModal = () => {
setShowAISuggestionsModal(false);
setAISuggestions(null);
setAIError(undefined);
};
// Load linked posts for this media and fetch their titles
useEffect(() => {
const loadLinkedPosts = async () => {
if (!mediaId || !activeProjectId) return;
try {
const links = await window.electronAPI?.postMedia.getForMedia(mediaId);
if (links) {
setLinkedPosts(links.map(l => ({ postId: l.postId, sortOrder: l.sortOrder })));
// Fetch titles for linked posts
const titles = new Map<string, string>();
for (const link of links) {
const post = await window.electronAPI?.posts.get(link.postId);
if (post) {
titles.set(link.postId, post.title || tr('editor.untitled'));
}
}
setPostTitles(titles);
}
} catch (error) {
console.error('Failed to load linked posts:', error);
}
};
loadLinkedPosts();
}, [mediaId, activeProjectId]);
// Fetch posts for the picker when it opens
useEffect(() => {
if (!showPostPicker) return;
const loadPickerPosts = async () => {
try {
const result = await window.electronAPI?.posts.getAll({ limit: 100, offset: 0 });
if (result?.items) {
setPickerPosts(result.items.map(p => ({ id: p.id, title: p.title || tr('editor.untitled') })));
}
} catch (error) {
console.error('Failed to load posts for picker:', error);
}
};
loadPickerPosts();
}, [showPostPicker]);
// Get post titles for display
const getPostTitle = (postId: string): string => {
return postTitles.get(postId) || tr('sidebar.loading');
};
// Handle linking to a new post
const handleLinkToPost = async (postId: string, postTitle: string) => {
try {
await window.electronAPI?.postMedia.link(postId, mediaId);
setLinkedPosts([...linkedPosts, { postId, sortOrder: linkedPosts.length }]);
setPostTitles(prev => new Map(prev).set(postId, postTitle));
setShowPostPicker(false);
setPostSearchQuery('');
showToast.success(tr('editor.media.toast.linkedToPost'));
} catch (error) {
console.error('Failed to link to post:', error);
showToast.error(tr('editor.media.toast.linkFailed'));
}
};
// Handle unlinking from a post
const handleUnlinkFromPost = async (postId: string) => {
try {
await window.electronAPI?.postMedia.unlink(postId, mediaId);
setLinkedPosts(linkedPosts.filter(l => l.postId !== postId));
showToast.success(tr('editor.media.toast.unlinkedFromPost'));
} catch (error) {
console.error('Failed to unlink from post:', error);
showToast.error(tr('editor.media.toast.unlinkFailed'));
}
};
// Handle click on a post to navigate to it
const handlePostClick = (postId: string) => {
openEntityTab(openTab, 'post', postId, 'preview');
};
// Get unlinked posts for picker, filtered by search
const unlinkedPosts = pickerPosts.filter(
p => !linkedPosts.find(l => l.postId === p.id)
).filter(
p => !postSearchQuery || p.title.toLowerCase().includes(postSearchQuery.toLowerCase())
);
useEffect(() => {
if (item) {
setTitle(item.title || '');
setAlt(item.alt || '');
setCaption(item.caption || '');
setAuthor(item.author || '');
setTags(item.tags.join(', '));
setMediaLanguage(item.language || '');
}
}, [item?.id]);
if (!item) {
return <div className="editor-empty">{tr('editor.media.notFound')}</div>;
}
const handleSave = async () => {
try {
const updated = await window.electronAPI?.media.update(item.id, {
title,
alt,
caption,
author: author || undefined,
language: mediaLanguage || undefined,
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
});
if (updated) {
updateMedia(item.id, updated as Partial<typeof item>);
showToast.success(tr('editor.media.toast.updated'));
}
} catch (error) {
console.error('Failed to update media:', error);
const err = error as Error;
showErrorModal({
title: tr('editor.media.error.updateTitle'),
message: err.message || tr('editor.media.error.updateMessage'),
stack: err.stack,
});
}
};
const handleReplaceFile = async () => {
try {
const updated = await window.electronAPI?.media.replaceFileDialog(item.id);
if (updated) {
updateMedia(item.id, updated as Partial<typeof item>);
showToast.success(tr('editor.media.toast.fileReplaced'));
}
// null means user cancelled or file unchanged - no action needed
} catch (error) {
console.error('Failed to replace media file:', error);
const err = error as Error;
showErrorModal({
title: tr('editor.media.error.replaceTitle'),
message: err.message || tr('editor.media.error.replaceMessage'),
stack: err.stack,
});
}
};
const handleDelete = async () => {
try {
// Fetch posts that link to this media
const linkedPostsList = await window.electronAPI?.postMedia.getForMedia(mediaId);
// Build references array
const references: Array<{ id: string; title: string; type: 'post' | 'media' | 'link' }> = [];
// Add posts that use this media - fetch titles from database
if (linkedPostsList && linkedPostsList.length > 0) {
for (const link of linkedPostsList) {
const post = await window.electronAPI?.posts.get(link.postId);
if (post) {
references.push({
id: post.id,
title: post.title || tr('editor.untitled'),
type: 'post',
});
}
}
}
// Show confirmation modal
showConfirmDeleteModal({
itemType: 'media',
itemTitle: getMediaDisplayName(item),
references,
onConfirm: async () => {
try {
await window.electronAPI?.media.delete(item.id);
useAppStore.getState().removeMedia(item.id);
showToast.success(tr('editor.media.toast.deleted'));
} catch (error) {
console.error('Failed to delete media:', error);
const err = error as Error;
showErrorModal({
title: tr('editor.error.deleteTitle'),
message: err.message || tr('editor.media.error.deleteMessage'),
stack: err.stack,
});
}
},
});
} catch (error) {
console.error('Failed to fetch media references:', error);
const err = error as Error;
showErrorModal({
title: tr('errorModal.error'),
message: err.message || tr('editor.media.error.fetchReferencesMessage'),
stack: err.stack,
});
}
};
return (
<div className="editor">
<div className="editor-header">
<div className="editor-tabs">
<div className="editor-tab active">
<span className="editor-tab-title">{getMediaDisplayName(item)}</span>
</div>
</div>
<div className="editor-actions">
{/* Quick Actions Dropdown */}
<div className="quick-actions-wrapper" ref={quickActionsRef}>
<button
className="secondary quick-actions-btn"
onClick={() => setShowQuickActions(!showQuickActions)}
disabled={isAnalyzing || isDetectingLanguage || isTranslating}
title={tr('editor.media.quickActions.title')}
>
{(isAnalyzing || isDetectingLanguage || isTranslating) ? tr('editor.media.quickActions.analyzing') : tr('editor.media.quickActions.button')}
</button>
{showQuickActions && (
<div className="quick-actions-menu">
{item.mimeType.startsWith('image/') && (
<button
className="quick-action-item"
onClick={handleAIAnalysis}
disabled={isAnalyzing}
>
<span className="quick-action-icon">🤖</span>
<span className="quick-action-text">
<strong>{tr('editor.media.quickActions.aiTitle')}</strong>
<small>{tr('editor.media.quickActions.aiDescription')}</small>
</span>
</button>
)}
{item.mimeType.startsWith('image/') && <div className="quick-actions-divider" />}
<button
className="quick-action-item"
onClick={() => { setShowQuickActions(false); void handleDetectLanguage(); }}
disabled={isDetectingLanguage || (!title && !alt && !caption)}
>
<span className="quick-action-icon">🔍</span>
<span className="quick-action-text">
<strong>{tr('editor.media.quickActions.detectLanguageTitle')}</strong>
<small>{tr('editor.media.quickActions.detectLanguageDescription')}</small>
</span>
</button>
<div className="quick-actions-divider" />
<button
className="quick-action-item"
onClick={handleOpenMediaTranslationModal}
disabled={isTranslating || !mediaLanguage || availableTranslationLanguages.length === 0}
>
<span className="quick-action-icon">🌍</span>
<span className="quick-action-text">
<strong>{tr('editor.media.quickActions.translateTitle')}</strong>
<small>{tr('editor.media.quickActions.translateDescription')}</small>
</span>
</button>
</div>
)}
</div>
<button onClick={handleReplaceFile} className="secondary">{tr('editor.media.replaceFile')}</button>
<button onClick={handleSave}>{tr('common.save')}</button>
<button onClick={handleDelete} className="secondary danger">{tr('editor.delete')}</button>
</div>
</div>
<div className="editor-content media-editor">
<div className="media-preview">
{item.mimeType.startsWith('image/') ? (
<div className="media-preview-image">
<img
src={`bds-media://${item.id}?t=${item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt}`}
alt={item.alt || item.originalName}
onError={(e) => {
// Fallback to placeholder if image fails to load
const target = e.target as HTMLImageElement;
target.style.display = 'none';
target.parentElement?.classList.add('has-error');
}}
/>
</div>
) : (
<div className="media-preview-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
</svg>
<span>{item.originalName}</span>
</div>
)}
</div>
<div className="media-details">
<div className="editor-field">
<label>{tr('editor.media.field.fileName')}</label>
<input type="text" value={item.originalName} disabled className="disabled" />
</div>
<div className="editor-field">
<label>{tr('editor.media.field.type')}</label>
<input type="text" value={item.mimeType} disabled className="disabled" />
</div>
<div className="editor-field-row">
<div className="editor-field">
<label>{tr('editor.media.field.size')}</label>
<input type="text" value={`${(item.size / 1024).toFixed(1)} KB`} disabled className="disabled" />
</div>
{item.width && item.height && (
<div className="editor-field">
<label>{tr('editor.media.field.dimensions')}</label>
<input type="text" value={`${item.width} × ${item.height}`} disabled className="disabled" />
</div>
)}
</div>
<div className="editor-field">
<label>{tr('editor.media.field.title')}</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={tr('editor.media.placeholder.title')}
/>
</div>
<div className="editor-field">
<label>{tr('editor.media.field.altText')}</label>
<input
type="text"
value={alt}
onChange={(e) => setAlt(e.target.value)}
placeholder={tr('editor.media.placeholder.altText')}
/>
</div>
<div className="editor-field">
<label>{tr('editor.media.field.caption')}</label>
<textarea
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder={tr('editor.media.placeholder.caption')}
rows={3}
/>
</div>
<div className="editor-field">
<label>{tr('editor.media.field.tags')}</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder={tr('editor.media.placeholder.tags')}
/>
</div>
<div className="editor-field">
<label>{tr('editor.media.field.author')}</label>
<input
type="text"
value={author}
onChange={(e) => setAuthor(e.target.value)}
placeholder={tr('editor.media.placeholder.author')}
/>
</div>
{/* Language & Translations Section */}
<div className="editor-field">
<label>{tr('editor.media.field.language')}</label>
<select
value={mediaLanguage}
onChange={(e) => handleLanguageChange(e.target.value)}
>
<option value="">{tr('editor.media.field.languageNone')}</option>
{SUPPORTED_POST_LANGUAGES.map((lang) => (
<option key={lang} value={lang}>{tr(`language.${lang}`)}</option>
))}
</select>
</div>
{mediaLanguage && (
<div className="editor-field media-translations-section">
<label>{tr('editor.media.translations.title')}</label>
{mediaTranslations.length === 0 ? (
<div className="no-linked-posts">{tr('editor.media.translations.none')}</div>
) : (
<div className="linked-posts-list">
{mediaTranslations.map((translation) => (
<div key={translation.language} className="linked-post-item">
<span
className="linked-post-title"
style={{ cursor: 'pointer' }}
onClick={() => handleOpenEditTranslation(translation)}
title={tr('editor.media.translations.editTitle', { language: tr(`language.${translation.language}`) })}
>
{POST_LANGUAGE_FLAGS[translation.language as keyof typeof POST_LANGUAGE_FLAGS] || '🏳️'}{' '}
{tr(`language.${translation.language}`)}
{translation.title && `${translation.title}`}
</span>
<button
className="secondary"
onClick={() => handleTranslateMedia(translation.language)}
disabled={isTranslating}
title={tr('editor.media.translations.refreshTitle')}
style={{ marginRight: '4px', fontSize: '0.8em', padding: '2px 6px' }}
>
{tr('editor.media.translations.refresh')}
</button>
<button
className="unlink-btn"
onClick={() => handleDeleteTranslation(translation.language)}
title={tr('editor.media.translations.deleteTitle')}
>
×
</button>
</div>
))}
</div>
)}
</div>
)}
{/* Linked Posts Section */}
<div className="editor-field linked-posts-section">
<label>
{tr('editor.media.linkedPosts')}
<button
className="add-link-btn"
onClick={() => setShowPostPicker(!showPostPicker)}
title={tr('editor.media.linkToPostTitle')}
>
{tr('editor.media.linkAction')}
</button>
</label>
{showPostPicker && (
<div className="post-picker">
<div className="post-picker-search">
<input
type="text"
placeholder={tr('editor.media.searchPosts')}
value={postSearchQuery}
onChange={(e) => setPostSearchQuery(e.target.value)}
autoFocus
/>
</div>
{unlinkedPosts.length === 0 ? (
<div className="no-posts">{postSearchQuery ? tr('editor.media.noMatchingPosts') : tr('editor.media.noPostsToLink')}</div>
) : (
<div className="post-picker-list">
{unlinkedPosts.slice(0, 10).map(post => (
<div
key={post.id}
className="post-picker-item"
onClick={() => handleLinkToPost(post.id, post.title)}
>
{post.title}
</div>
))}
{unlinkedPosts.length > 10 && (
<div className="post-picker-more">
{tr('editor.media.morePosts', { count: unlinkedPosts.length - 10 })}
</div>
)}
</div>
)}
</div>
)}
{linkedPosts.length === 0 ? (
<div className="no-linked-posts">{tr('editor.media.notLinked')}</div>
) : (
<div className="linked-posts-list">
{linkedPosts.map(({ postId }) => (
<div key={postId} className="linked-post-item">
<span
className="linked-post-title"
onClick={() => handlePostClick(postId)}
title={tr('editor.media.openPost')}
>
📄 {getPostTitle(postId)}
</span>
<button
className="unlink-btn"
onClick={() => handleUnlinkFromPost(postId)}
title={tr('editor.media.unlinkFromPost')}
>
×
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* AI Suggestions Modal */}
<AISuggestionsModal
isOpen={showAISuggestionsModal}
isLoading={isAnalyzing}
fields={aiSuggestionFields}
modalTitle={tr('aiSuggestions.title')}
loadingText={tr('aiSuggestions.analyzing')}
emptyText={tr('aiSuggestions.empty')}
error={aiError}
onConfirm={handleApplyAISuggestions}
onClose={handleCloseAISuggestionsModal}
/>
{/* Translation Modal */}
{showMediaTranslationModal && (
<div className="translation-modal-backdrop" onClick={handleCloseMediaTranslationModal}>
<div className="translation-modal" onClick={(event) => event.stopPropagation()}>
<div className="translation-modal-header">
<h2>{tr('editor.media.translations.title')}</h2>
<button className="translation-modal-close" onClick={handleCloseMediaTranslationModal} title={tr('common.cancel')}>×</button>
</div>
<div className="translation-modal-body">
<label className="translation-modal-label" htmlFor="media-translation-target-language">{tr('editor.media.translations.selectTarget')}</label>
<p className="translation-modal-copy">{tr('editor.media.translations.currentLanguage', { language: tr(`language.${mediaLanguage}`) })}</p>
<select
id="media-translation-target-language"
className="translation-modal-select"
value={translationTargetLanguage}
onChange={(e) => setTranslationTargetLanguage(e.target.value)}
>
{SUPPORTED_POST_LANGUAGES
.filter(lang => lang !== mediaLanguage)
.map((lang) => {
const existing = mediaTranslations.find(t => t.language === lang);
return (
<option key={lang} value={lang}>
{tr(`language.${lang}`)}{existing ? ` (${tr('editor.media.translations.refresh')})` : ''}
</option>
);
})}
</select>
{translationTargetLanguage && (
<div className="translation-modal-status-row">
<span className="translation-modal-flag" aria-hidden="true">{POST_LANGUAGE_FLAGS[translationTargetLanguage as keyof typeof POST_LANGUAGE_FLAGS] || '🏳️'}</span>
<span className="translation-modal-status-copy">
<strong>{tr(`language.${translationTargetLanguage}`)}</strong>
<small>
{mediaTranslations.find(t => t.language === translationTargetLanguage)
? tr('editor.media.translations.refresh')
: tr('editor.media.translations.none')}
</small>
</span>
</div>
)}
</div>
<div className="translation-modal-footer">
<button className="secondary" onClick={handleCloseMediaTranslationModal}>{tr('common.cancel')}</button>
<button
onClick={handleConfirmMediaTranslation}
disabled={!translationTargetLanguage || isTranslating}
title={tr('editor.media.quickActions.translateDescription')}
>
{isTranslating ? tr('editor.media.translations.translating') : tr('editor.media.translations.translateButton')}
</button>
</div>
</div>
</div>
)}
{/* Edit Translation Modal */}
{editingTranslation && (
<div className="translation-modal-backdrop" onClick={() => setEditingTranslation(null)}>
<div className="translation-modal" onClick={(event) => event.stopPropagation()}>
<div className="translation-modal-header">
<h2>{tr('editor.media.translations.editTitle', { language: tr(`language.${editingTranslation.language}`) })}</h2>
<button className="translation-modal-close" onClick={() => setEditingTranslation(null)} title={tr('common.cancel')}>×</button>
</div>
<div className="translation-modal-body">
<div className="editor-field">
<label htmlFor="edit-translation-title">{tr('editor.media.field.title')}</label>
<input
id="edit-translation-title"
type="text"
value={editingTranslation.title}
onChange={(e) => setEditingTranslation({ ...editingTranslation, title: e.target.value })}
placeholder={tr('editor.media.placeholder.title')}
/>
</div>
<div className="editor-field">
<label htmlFor="edit-translation-alt">{tr('editor.media.field.altText')}</label>
<input
id="edit-translation-alt"
type="text"
value={editingTranslation.alt}
onChange={(e) => setEditingTranslation({ ...editingTranslation, alt: e.target.value })}
placeholder={tr('editor.media.placeholder.altText')}
/>
</div>
<div className="editor-field">
<label htmlFor="edit-translation-caption">{tr('editor.media.field.caption')}</label>
<textarea
id="edit-translation-caption"
value={editingTranslation.caption}
onChange={(e) => setEditingTranslation({ ...editingTranslation, caption: e.target.value })}
placeholder={tr('editor.media.placeholder.caption')}
rows={3}
/>
</div>
</div>
<div className="translation-modal-footer">
<button className="secondary" onClick={() => setEditingTranslation(null)}>{tr('common.cancel')}</button>
<button onClick={() => void handleSaveEditTranslation()}>{tr('common.save')}</button>
</div>
</div>
</div>
)}
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
export const UI_DATE_LOCALE: Record<string, string> = {
en: 'en-US',
de: 'de-DE',
fr: 'fr-FR',
it: 'it-IT',
es: 'es-ES',
};
/** Get display name for media: prefer title over originalName */
export function getMediaDisplayName(media: { title?: string; originalName: string }): string {
return media.title || media.originalName;
}

View File

@@ -223,6 +223,7 @@ export const SettingsView: React.FC = () => {
const [defaultProjectPath, setDefaultProjectPath] = useState('');
const [projectMainLanguage, setProjectMainLanguage] = useState<SupportedLanguage>('en');
const [projectDefaultAuthor, setProjectDefaultAuthor] = useState('');
const [projectBlogLanguages, setProjectBlogLanguages] = useState<string[]>([]);
const [projectMaxPostsPerPage, setProjectMaxPostsPerPage] = useState(50);
const [projectBlogmarkCategory, setProjectBlogmarkCategory] = useState('article');
const [projectPythonRuntimeMode, setProjectPythonRuntimeMode] = useState<'webworker' | 'main-thread'>('webworker');
@@ -318,6 +319,11 @@ export const SettingsView: React.FC = () => {
const incomingSemanticSimilarity = (metadata as { semanticSimilarityEnabled?: unknown } | null)?.semanticSimilarityEnabled;
setSemanticSimilarityEnabled(incomingSemanticSimilarity === true);
const incomingBlogLanguages = (metadata as { blogLanguages?: unknown } | null)?.blogLanguages;
setProjectBlogLanguages(Array.isArray(incomingBlogLanguages)
? incomingBlogLanguages.filter((l): l is string => typeof l === 'string')
: []);
const incomingCategoryMetadata = (metadata as any)?.categoryMetadata as Record<string, CategoryMetadata> | undefined;
const incomingLegacyCategorySettings = (metadata as any)?.categorySettings as Record<string, { renderInLists: boolean; showTitle: boolean }> | undefined;
setCategoryMetadata((current) => {
@@ -550,6 +556,7 @@ export const SettingsView: React.FC = () => {
blogmarkCategory: normalizeBlogmarkCategory(projectBlogmarkCategory) || undefined,
pythonRuntimeMode: projectPythonRuntimeMode,
semanticSimilarityEnabled,
blogLanguages: projectBlogLanguages.length > 0 ? projectBlogLanguages : undefined,
categoryMetadata,
});
}
@@ -691,6 +698,37 @@ export const SettingsView: React.FC = () => {
</select>
</SettingRow>
<SettingRow
id="project-blog-languages"
label={t('settings.project.blogLanguagesLabel')}
description={t('settings.project.blogLanguagesDescription')}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{SUPPORTED_RENDER_LANGUAGES.map((language) => {
const isMain = language === projectMainLanguage;
const isChecked = isMain || projectBlogLanguages.includes(language);
return (
<label key={language} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', opacity: isMain ? 0.7 : 1 }}>
<input
type="checkbox"
checked={isChecked}
disabled={isMain}
onChange={(e) => {
if (isMain) return;
setProjectBlogLanguages((prev) =>
e.target.checked
? [...prev.filter((l) => l !== language), language]
: prev.filter((l) => l !== language),
);
}}
/>
{t(RENDER_LANGUAGE_LABEL_KEY[language])}
</label>
);
})}
</div>
</SettingRow>
<SettingRow
id="project-author"
label={t('settings.project.defaultAuthorLabel')}

View File

@@ -125,6 +125,24 @@
text-overflow: ellipsis;
}
.sidebar-item-title-row {
display: flex;
align-items: center;
gap: 6px;
}
.sidebar-item-language-badge {
flex-shrink: 0;
min-width: 18px;
padding: 1px 5px;
border-radius: 999px;
background: color-mix(in srgb, var(--vscode-badge-background) 82%, transparent);
color: var(--vscode-badge-foreground);
font-size: 10px;
font-weight: 700;
text-align: center;
}
.sidebar-item-meta {
font-size: 11px;
color: var(--vscode-descriptionForeground);

View File

@@ -736,6 +736,7 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
await createAndFocusPost({
createPost: async (input) => (await window.electronAPI?.posts.create(input)) as { id: string } | null | undefined,
setSelectedPost: selectPost,
categories: isPagesMode ? [PAGE_CATEGORY] : [],
onError: (error) => {
console.error('Failed to create post:', error);
},
@@ -876,7 +877,14 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
>
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
<div className="sidebar-item-title-row">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
{post.availableLanguages?.length > 1 && (
<span className="sidebar-item-language-badge" title={t('sidebar.languagesAvailable', { count: post.availableLanguages.length })}>
{post.availableLanguages.length}
</span>
)}
</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt, uiLocale)}</div>
</div>
</div>
@@ -904,7 +912,14 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
>
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
<div className="sidebar-item-title-row">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
{post.availableLanguages?.length > 1 && (
<span className="sidebar-item-language-badge" title={t('sidebar.languagesAvailable', { count: post.availableLanguages.length })}>
{post.availableLanguages.length}
</span>
)}
</div>
<div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt, uiLocale)}</div>
</div>
</div>
@@ -932,7 +947,14 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
>
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
<div className="sidebar-item-title-row">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
{post.availableLanguages?.length > 1 && (
<span className="sidebar-item-language-badge" title={t('sidebar.languagesAvailable', { count: post.availableLanguages.length })}>
{post.availableLanguages.length}
</span>
)}
</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt, uiLocale)}</div>
</div>
</div>

View File

@@ -87,6 +87,10 @@ const getTabTitle = (
return tr('siteValidation.tabTitle');
}
if (tab.type === 'translation-validation') {
return tr('translationValidation.tabTitle');
}
if (tab.type === 'find-duplicates') {
return tr('duplicatesView.tabTitle');
}
@@ -184,6 +188,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
<path d="M8 1.5a6.5 6.5 0 1 0 6.5 6.5A6.5 6.5 0 0 0 8 1.5zm0 1a5.5 5.5 0 0 1 4.39 8.82l-.88-.88a.5.5 0 0 0-.7.7l.8.8A5.5 5.5 0 1 1 8 2.5zm2.35 3.15L7 9 5.65 7.65a.5.5 0 1 0-.7.7l1.7 1.7a.5.5 0 0 0 .7 0l3.7-3.7a.5.5 0 1 0-.7-.7z"/>
</svg>
);
case 'translation-validation':
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M2 2.5A1.5 1.5 0 0 1 3.5 1h5A1.5 1.5 0 0 1 10 2.5v1h2.5A1.5 1.5 0 0 1 14 5v7.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 12.5v-10zM3.5 2a.5.5 0 0 0-.5.5v10a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5V5a.5.5 0 0 0-.5-.5H10v1.15a.5.5 0 0 1-.85.35L7.5 4.35 5.85 6A.5.5 0 0 1 5 5.65V2.5a.5.5 0 0 0-.5-.5h-1zm2.5 1.71v.73l1.15-1.14a.5.5 0 0 1 .7 0L9 4.44v-.73a.5.5 0 0 0-.5-.5h-2a.5.5 0 0 0-.5.5zM5.5 8h5v1h-5V8zm0 2h5v1h-5v-1z"/>
</svg>
);
case 'find-duplicates':
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">

View File

@@ -0,0 +1,103 @@
.translation-validation-view {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
height: 100%;
overflow: auto;
background: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
}
.translation-validation-summary h2 {
margin: 0 0 8px 0;
font-size: 1.1rem;
}
.translation-validation-summary p {
margin: 0;
color: var(--vscode-descriptionForeground);
}
.translation-validation-section h3 {
margin: 0 0 8px 0;
font-size: 1rem;
}
.translation-validation-empty,
.translation-validation-status {
margin: 0;
color: var(--vscode-descriptionForeground);
}
.translation-validation-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.translation-validation-card {
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 12px;
background: color-mix(in srgb, var(--vscode-editor-background) 82%, var(--vscode-editorWidget-background) 18%);
}
.translation-validation-card.translation-validation-card-db {
border-left: 4px solid var(--vscode-notificationsWarningIcon-foreground);
}
.translation-validation-card.translation-validation-card-file {
border-left: 4px solid var(--vscode-notificationsErrorIcon-foreground);
}
.translation-validation-card-title {
margin: 0 0 6px 0;
font-weight: 600;
}
.translation-validation-card-meta {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px 10px;
font-size: 12px;
}
.translation-validation-actions {
margin-top: auto;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.translation-validation-revalidate,
.translation-validation-fix {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
}
.translation-validation-revalidate:hover:not(:disabled),
.translation-validation-fix:hover:not(:disabled) {
background-color: var(--vscode-button-hoverBackground);
}
.translation-validation-revalidate:disabled,
.translation-validation-fix:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.translation-validation-card-meta dt {
color: var(--vscode-descriptionForeground);
}
.translation-validation-card-meta dd {
margin: 0;
word-break: break-word;
font-family: var(--vscode-editor-font-family);
}

View File

@@ -0,0 +1,240 @@
import React, { useEffect, useMemo, useState } from 'react';
import type { TranslationValidationIssue, TranslationValidationReport, TranslationValidationFixResult } from '../../../main/shared/electronApi';
import { useAppStore } from '../../store';
import { showToast } from '../Toast';
import { useI18n } from '../../i18n';
import { getPersistedTranslationValidationReport, persistTranslationValidationReport } from '../../navigation/translationValidationPersistence';
import './TranslationValidationView.css';
function getIssueLabel(
issue: TranslationValidationIssue['issue'],
tr: (key: string, vars?: Record<string, string | number>) => string,
): string {
if (issue === 'same-language-as-canonical') {
return tr('translationValidation.issue.sameLanguage');
}
if (issue === 'do-not-translate-has-translations') {
return tr('translationValidation.issue.doNotTranslate');
}
if (issue === 'content-in-database') {
return tr('translationValidation.issue.contentInDatabase');
}
return tr('translationValidation.issue.missingSource');
}
function ValidationIssueCard({
issue,
kind,
}: {
issue: TranslationValidationIssue;
kind: 'db' | 'file';
}): React.JSX.Element {
const { t: tr } = useI18n();
return (
<article className={`translation-validation-card translation-validation-card-${kind}`}>
<p className="translation-validation-card-title">{getIssueLabel(issue.issue, tr)}</p>
<dl className="translation-validation-card-meta">
<dt>{tr('translationValidation.field.translationFor')}</dt>
<dd>{issue.translationFor}</dd>
{issue.translationId ? (
<>
<dt>{tr('translationValidation.field.translationId')}</dt>
<dd>{issue.translationId}</dd>
</>
) : null}
{issue.title ? (
<>
<dt>{tr('translationValidation.field.title')}</dt>
<dd>{issue.title}</dd>
</>
) : null}
<dt>{tr('translationValidation.field.languages')}</dt>
<dd>
{issue.canonicalLanguage
? tr('translationValidation.languagesWithCanonical', {
canonical: issue.canonicalLanguage,
translation: issue.translationLanguage,
})
: issue.translationLanguage}
</dd>
{issue.filePath ? (
<>
<dt>{tr('translationValidation.field.filePath')}</dt>
<dd>{issue.filePath}</dd>
</>
) : null}
</dl>
</article>
);
}
export const TranslationValidationView: React.FC = () => {
const { t: tr } = useI18n();
const { activeProject } = useAppStore();
const [isLoading, setIsLoading] = useState(true);
const [isRevalidating, setIsRevalidating] = useState(false);
const [isFixing, setIsFixing] = useState(false);
const [report, setReport] = useState<TranslationValidationReport | null>(null);
const loadPersistedReport = () => {
setIsLoading(true);
try {
const projectId = activeProject?.id;
if (!projectId) {
setReport(null);
return;
}
setReport(getPersistedTranslationValidationReport(projectId));
} finally {
setIsLoading(false);
}
};
const handleRevalidate = async () => {
setIsRevalidating(true);
try {
const freshReport = await window.electronAPI?.blog.validateTranslations();
const projectId = activeProject?.id;
if (projectId && freshReport) {
persistTranslationValidationReport(projectId, freshReport);
window.dispatchEvent(new CustomEvent('bds:translation-validation-updated', {
detail: { projectId },
}));
}
} catch (error) {
console.error('Translation revalidation failed:', error);
showToast.error(tr('translationValidation.error.validate'));
} finally {
setIsRevalidating(false);
}
};
const handleFix = async () => {
if (!report) return;
setIsFixing(true);
try {
const result = await window.electronAPI.blog.fixInvalidTranslations(report) as TranslationValidationFixResult;
showToast.success(tr('translationValidation.toast.fixSuccess', {
dbRows: result.deletedDatabaseRows,
files: result.deletedFiles,
flushed: result.flushedTranslations,
}));
// Re-validate after fixing to refresh the report
await handleRevalidate();
} catch (error) {
console.error('Fixing invalid translations failed:', error);
showToast.error(tr('translationValidation.error.fix'));
} finally {
setIsFixing(false);
}
};
useEffect(() => {
loadPersistedReport();
}, [activeProject?.id]);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ projectId?: string }>).detail;
if (!activeProject?.id || detail?.projectId !== activeProject.id) {
return;
}
loadPersistedReport();
};
window.addEventListener('bds:translation-validation-updated', handler);
return () => window.removeEventListener('bds:translation-validation-updated', handler);
}, [activeProject?.id]);
const summary = useMemo(() => {
if (!report) {
return null;
}
return tr('translationValidation.summary', {
dbRows: report.checkedDatabaseRowCount,
files: report.checkedFilesystemFileCount,
invalidDb: report.invalidDatabaseRows.length,
invalidFiles: report.invalidFilesystemFiles.length,
});
}, [report, tr]);
const canFix = useMemo(() => {
if (!report) return false;
return report.invalidDatabaseRows.length > 0 || report.invalidFilesystemFiles.length > 0;
}, [report]);
if (isLoading) {
return (
<div className="translation-validation-view">
<p className="translation-validation-status">{tr('translationValidation.loading')}</p>
</div>
);
}
if (!report) {
return (
<div className="translation-validation-view">
<p className="translation-validation-status">{tr('translationValidation.empty')}</p>
</div>
);
}
return (
<div className="translation-validation-view">
<div className="translation-validation-summary">
<h2>{tr('translationValidation.title')}</h2>
<p>{summary}</p>
</div>
<section className="translation-validation-section">
<h3>{tr('translationValidation.databaseTitle')}</h3>
{report.invalidDatabaseRows.length === 0 ? (
<p className="translation-validation-empty">{tr('translationValidation.noneDatabase')}</p>
) : (
<div className="translation-validation-list">
{report.invalidDatabaseRows.map((issue, index) => (
<ValidationIssueCard key={`db:${issue.translationId || issue.translationFor}:${index}`} issue={issue} kind="db" />
))}
</div>
)}
</section>
<section className="translation-validation-section">
<h3>{tr('translationValidation.filesystemTitle')}</h3>
{report.invalidFilesystemFiles.length === 0 ? (
<p className="translation-validation-empty">{tr('translationValidation.noneFilesystem')}</p>
) : (
<div className="translation-validation-list">
{report.invalidFilesystemFiles.map((issue, index) => (
<ValidationIssueCard key={`file:${issue.filePath || issue.translationFor}:${index}`} issue={issue} kind="file" />
))}
</div>
)}
</section>
<div className="translation-validation-actions">
<button
type="button"
className="translation-validation-revalidate"
onClick={handleRevalidate}
disabled={isRevalidating || isFixing}
>
{isRevalidating ? tr('translationValidation.revalidating') : tr('translationValidation.revalidate')}
</button>
<button
type="button"
className="translation-validation-fix"
onClick={handleFix}
disabled={!canFix || isFixing || isRevalidating}
>
{isFixing ? tr('translationValidation.fixing') : tr('translationValidation.fix')}
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { TranslationValidationView } from './TranslationValidationView';

View File

@@ -54,6 +54,33 @@
"siteValidation.error.validate": "Website-Validierung fehlgeschlagen",
"siteValidation.error.apply": "Anwenden der Validierung fehlgeschlagen",
"siteValidation.toast.applySuccess": "Validierung angewendet: {rendered} gerendert, {deleted} gelöscht",
"menu.item.validateTranslations": "Übersetzungen validieren",
"translationValidation.tabTitle": "Übersetzungsvalidierung",
"translationValidation.title": "Übersetzungen validieren",
"translationValidation.summary": "Geprüfte DB-Zeilen: {dbRows} · Geprüfte Dateien: {files} · Ungültige DB-Zeilen: {invalidDb} · Ungültige Dateien: {invalidFiles}",
"translationValidation.loading": "Übersetzungen werden validiert...",
"translationValidation.empty": "Führe Blog -> Übersetzungen validieren aus, um die Übersetzungsintegrität zu prüfen.",
"translationValidation.databaseTitle": "Ungültige Übersetzungszeilen in der Datenbank",
"translationValidation.filesystemTitle": "Ungültige Übersetzungsdateien auf dem Datenträger",
"translationValidation.noneDatabase": "Keine ungültigen Übersetzungszeilen gefunden.",
"translationValidation.noneFilesystem": "Keine ungültigen Übersetzungsdateien gefunden.",
"translationValidation.error.validate": "Übersetzungsvalidierung fehlgeschlagen",
"translationValidation.issue.sameLanguage": "Übersetzungssprache entspricht der kanonischen Beitragssprache",
"translationValidation.issue.missingSource": "Übersetzung verweist auf einen fehlenden Quellbeitrag",
"translationValidation.issue.doNotTranslate": "Beitrag ist als nicht-übersetzen markiert, hat aber Übersetzungen",
"translationValidation.issue.contentInDatabase": "Veröffentlichte Übersetzung hat Inhalt in der DB statt im Dateisystem",
"translationValidation.field.translationFor": "Quellbeitrag",
"translationValidation.field.translationId": "Übersetzungszeile",
"translationValidation.field.title": "Titel",
"translationValidation.field.languages": "Sprachen",
"translationValidation.field.filePath": "Datei",
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
"translationValidation.revalidate": "Erneut validieren",
"translationValidation.revalidating": "Wird validiert…",
"translationValidation.fix": "Probleme beheben",
"translationValidation.fixing": "Wird behoben…",
"translationValidation.toast.fixSuccess": "{dbRows} DB-Zeilen und {files} Dateien gelöscht, {flushed} Übersetzungen auf Disk geschrieben",
"translationValidation.error.fix": "Fehler beim Beheben ungültiger Übersetzungen",
"menuEditor.tabTitle": "Blog-Menü",
"menuEditor.title": "Blog-Menü-Editor",
"menuEditor.description": "Verwalte die zentrale Blog-Navigationsstruktur und speichere sie in meta/menu.opml.",
@@ -419,18 +446,18 @@
"metadataDiff.orphanFiles.badge": "Verwaiste Datei",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Pfad",
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
"metadataDiff.orphanFiles.importButton": "D DB",
"metadataDiff.orphanFiles.importTitle": "Alle verwaisten Dateien in die Datenbank importieren",
"metadataDiff.orphanFiles.importing": "Importiere…",
"metadataDiff.orphanFiles.importSuccess": "{success} verwaiste Dateien importiert{failed}",
"metadataDiff.orphanFiles.importError": "Import der verwaisten Dateien fehlgeschlagen",
"metadataDiff.sync.failed": "fehlgeschlagen",
"metadataDiff.sync.dbToFile.title": "Dateien mit Datenbankwerten aktualisieren",
"metadataDiff.sync.dbToFile.short": "DB\u2192D",
"metadataDiff.sync.dbToFile.short": "DBD",
"metadataDiff.sync.dbToFile.success": "{success} Beiträge in Dateien synchronisiert{fehlgeschlagen}",
"metadataDiff.sync.dbToFile.error": "Synchronisierung in Dateien fehlgeschlagen",
"metadataDiff.sync.fileToDb.title": "Datenbank mit Dateiwerten aktualisieren",
"metadataDiff.sync.fileToDb.short": "D\u2192DB",
"metadataDiff.sync.fileToDb.short": "DDB",
"metadataDiff.sync.fileToDb.success": "{success} Dateien in die Datenbank synchronisiert{fehlgeschlagen}",
"metadataDiff.sync.fileToDb.error": "Synchronisierung in die Datenbank fehlgeschlagen",
"metadataDiff.value.database": "Datenbank",
@@ -461,6 +488,7 @@
"sidebar.published": "Veröffentlicht",
"sidebar.archived": "Archiviert",
"sidebar.untitled": "Ohne Titel",
"sidebar.languagesAvailable": "{count} Sprachen verfugbar",
"sidebar.noMatchingPosts": "Keine passenden Beiträge",
"sidebar.createFirstPost": "Ersten Beitrag erstellen",
"sidebar.loadMore": "Mehr laden ({loaded} von {total})",
@@ -525,6 +553,8 @@
"settings.project.publicUrlPlaceholder": "https://example.com",
"settings.project.mainLanguageLabel": "Hauptsprache",
"settings.project.mainLanguageDescription": "Die primäre Sprache für deine Blog-Inhalte. KI-generierte Titel, Alt-Texte und Bildunterschriften nutzen diese Sprache.",
"settings.project.blogLanguagesLabel": "Blog-Sprachen",
"settings.project.blogLanguagesDescription": "Sprachen, in denen der Blog gerendert wird. Die Hauptsprache ist immer enthalten. Zusätzliche Sprachen erzeugen übersetzte Unterbäume.",
"settings.project.defaultAuthorLabel": "Standardautor",
"settings.project.defaultAuthorDescription": "Der Standard-Autorname für neue Beiträge und Medien. Kann pro Element überschrieben werden.",
"settings.project.defaultAuthorPlaceholder": "Autorenname",
@@ -578,6 +608,27 @@
"editor.previewFrameTitle": "Beitragsvorschau",
"editor.previewLoading": "Vorschau wird geladen...",
"editor.metadata.toggle": "Metadaten",
"editor.translations.title": "Ubersetzungen",
"editor.translations.currentLanguage": "Aktuelle Sprache: {language}",
"editor.translations.none": "Noch keine Ubersetzungen.",
"editor.translations.selectTarget": "Zielsprache auswahlen",
"editor.translations.translateButton": "Ubersetzen nach...",
"editor.translations.translateTitle": "Ubersetzung per KI erstellen oder aktualisieren",
"editor.translations.translating": "Wird ubersetzt...",
"editor.translations.refresh": "Aktualisieren",
"editor.translations.refreshTitle": "Diese Ubersetzung per KI neu erzeugen",
"editor.translations.publish": "Veroffentlichen",
"editor.translations.publishTitle": "Diese Ubersetzung in ihre Markdown-Datei veroffentlichen",
"editor.translations.publishing": "Wird veroffentlicht...",
"editor.translations.missing": "Fehlend: {languages}",
"editor.translations.complete": "Alle unterstutzten Ubersetzungssprachen sind verfugbar.",
"editor.translations.translateSuccess": "Ubersetzung fur {language} aktualisiert",
"editor.translations.translateFailed": "Ubersetzung fehlgeschlagen",
"editor.translations.publishSuccess": "Ubersetzung fur {language} veroffentlicht",
"editor.translations.publishFailed": "Ubersetzung konnte nicht veroffentlicht werden",
"editor.translations.status.draft": "Entwurf",
"editor.translations.status.published": "Veroffentlicht",
"editor.translations.status.archived": "Archiviert",
"editor.excerpt.toggle": "Auszug",
"editor.footer.created": "Erstellt",
"editor.footer.updated": "Aktualisiert",
@@ -927,6 +978,12 @@
"editor.media.quickActions.button": "⚡ Schnellaktionen",
"editor.media.quickActions.aiTitle": "KI: Titel, Alt-Text und Bildunterschrift erzeugen",
"editor.media.quickActions.aiDescription": "Analysiert das Bild und schlägt Metadaten vor",
"editor.media.quickActions.detectLanguageTitle": "Sprache erkennen",
"editor.media.quickActions.detectLanguageDescription": "Sprache aus Metadaten per KI erkennen",
"editor.media.quickActions.translateTitle": "Übersetzen in…",
"editor.media.quickActions.translateDescription": "Übersetzung per KI erstellen oder aktualisieren",
"editor.media.translations.currentLanguage": "Aktuelle Sprache: {language}",
"editor.media.translations.selectTarget": "Zielsprache wählen",
"editor.post.quickActions.title": "Schnellaktionen",
"editor.post.quickActions.analyzing": "⏳ Wird analysiert…",
"editor.post.quickActions.button": "⚡ Schnellaktionen",
@@ -949,6 +1006,24 @@
"editor.media.field.caption": "Bildunterschrift",
"editor.media.field.tags": "Tags (kommagetrennt)",
"editor.media.field.author": "Autor",
"editor.media.field.language": "Sprache",
"editor.media.field.languageNone": "Nicht festgelegt",
"editor.media.translations.title": "Übersetzungen",
"editor.media.translations.none": "Noch keine Übersetzungen.",
"editor.media.translations.translateButton": "Übersetzen in…",
"editor.media.translations.translating": "Übersetze…",
"editor.media.translations.translateSuccess": "Übersetzung aktualisiert für {language}",
"editor.media.translations.translateFailed": "Übersetzung fehlgeschlagen",
"editor.media.translations.refresh": "Aktualisieren",
"editor.media.translations.refreshTitle": "Diese Übersetzung per KI neu generieren",
"editor.media.translations.deleteTitle": "Diese Übersetzung löschen",
"editor.media.translations.deleted": "Übersetzung gelöscht für {language}",
"editor.media.translations.deleteFailed": "Löschen der Übersetzung fehlgeschlagen",
"editor.media.translations.editTitle": "Übersetzung bearbeiten — {language}",
"editor.media.translations.saved": "Übersetzung gespeichert für {language}",
"editor.media.translations.saveFailed": "Speichern der Übersetzung fehlgeschlagen",
"editor.media.toast.languageDetected": "Sprache erkannt: {language}",
"editor.media.error.detectLanguage": "Spracherkennung fehlgeschlagen",
"editor.media.placeholder.title": "Titel für Listen und Suchergebnisse",
"editor.media.placeholder.altText": "Bild für Barrierefreiheit beschreiben",
"editor.media.placeholder.caption": "Bildunterschrift",
@@ -1097,9 +1172,7 @@
"importAnalysis.usedIn": "Verwendet in: {items}{more}",
"importAnalysis.moreSuffix": ", +{count} weitere",
"importAnalysis.noParameters": "(keine Parameter)",
"sidebar.nav.mcp": "MCP-Server",
"settings.mcp.title": "MCP-Server",
"settings.mcp.description": "Konfigurieren Sie den Model Context Protocol Server, der KI-Programmieragenten die Interaktion mit Ihrem Blog ermöglicht.",
"settings.mcp.statusLabel": "Serverstatus",
@@ -1132,5 +1205,9 @@
"duplicatesView.checkAll": "Alle auswählen",
"duplicatesView.uncheckAll": "Alle abwählen",
"duplicatesView.dismissChecked": "Ausgewählte ignorieren ({count})",
"duplicatesView.notEnabled": "Semantische Ähnlichkeit ist nicht aktiviert. Aktivieren Sie sie unter Einstellungen → Technologie."
"duplicatesView.notEnabled": "Semantische Ähnlichkeit ist nicht aktiviert. Aktivieren Sie sie unter Einstellungen → Technologie.",
"editor.doNotTranslateLabel": "Nicht übersetzen",
"blog.fillMissing.nothingToDo": "Alle Übersetzungen sind aktuell.",
"blog.fillMissing.started": "Übersetzungsaufgabe gestartet. Fortschritt im Aufgabenbereich.",
"blog.fillMissing.error": "Fehlende Übersetzungen konnten nicht erstellt werden."
}

View File

@@ -54,6 +54,33 @@
"siteValidation.error.validate": "Site validation failed",
"siteValidation.error.apply": "Applying validation failed",
"siteValidation.toast.applySuccess": "Validation applied: {rendered} rendered, {deleted} deleted",
"menu.item.validateTranslations": "Validate Translations",
"translationValidation.tabTitle": "Translation Validation",
"translationValidation.title": "Validate Translations",
"translationValidation.summary": "Checked DB rows: {dbRows} · Checked files: {files} · Invalid DB rows: {invalidDb} · Invalid files: {invalidFiles}",
"translationValidation.loading": "Validating translations...",
"translationValidation.empty": "Run Blog -> Validate Translations to inspect translation integrity.",
"translationValidation.databaseTitle": "Invalid database translation rows",
"translationValidation.filesystemTitle": "Invalid translation files on disk",
"translationValidation.noneDatabase": "No invalid translation rows found.",
"translationValidation.noneFilesystem": "No invalid translation files found.",
"translationValidation.error.validate": "Translation validation failed",
"translationValidation.issue.sameLanguage": "Translation language matches canonical post language",
"translationValidation.issue.missingSource": "Translation points to a missing source post",
"translationValidation.issue.doNotTranslate": "Post is marked as do-not-translate but has translations",
"translationValidation.issue.contentInDatabase": "Published translation has content stuck in DB instead of filesystem",
"translationValidation.field.translationFor": "Source post",
"translationValidation.field.translationId": "Translation row",
"translationValidation.field.title": "Title",
"translationValidation.field.languages": "Languages",
"translationValidation.field.filePath": "File",
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
"translationValidation.revalidate": "Revalidate",
"translationValidation.revalidating": "Revalidating…",
"translationValidation.fix": "Fix Issues",
"translationValidation.fixing": "Fixing…",
"translationValidation.toast.fixSuccess": "Deleted {dbRows} DB rows and {files} files, flushed {flushed} translations to disk",
"translationValidation.error.fix": "Failed to fix invalid translations",
"menuEditor.tabTitle": "Blog Menu",
"menuEditor.title": "Blog Menu Editor",
"menuEditor.description": "Manage the central blog navigation outline and save it to meta/menu.opml.",
@@ -412,11 +439,11 @@
"metadataDiff.fieldFilter.toggle": "Filter by {field}",
"metadataDiff.sync.failed": "failed",
"metadataDiff.sync.dbToFile.title": "Update files with database values",
"metadataDiff.sync.dbToFile.short": "DB\u2192F",
"metadataDiff.sync.dbToFile.short": "DBF",
"metadataDiff.sync.dbToFile.success": "Synced {success} posts to files{failed}",
"metadataDiff.sync.dbToFile.error": "Failed to sync to files",
"metadataDiff.sync.fileToDb.title": "Update database with file values",
"metadataDiff.sync.fileToDb.short": "F\u2192DB",
"metadataDiff.sync.fileToDb.short": "FDB",
"metadataDiff.sync.fileToDb.success": "Synced {success} files to database{failed}",
"metadataDiff.sync.fileToDb.error": "Failed to sync to database",
"metadataDiff.value.database": "Database",
@@ -433,7 +460,7 @@
"metadataDiff.orphanFiles.badge": "Orphan file",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Path",
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
"metadataDiff.orphanFiles.importButton": "D DB",
"metadataDiff.orphanFiles.importTitle": "Import all orphan files into the database",
"metadataDiff.orphanFiles.importing": "Importing…",
"metadataDiff.orphanFiles.importSuccess": "{success} orphan files imported{failed}",
@@ -461,6 +488,7 @@
"sidebar.published": "Published",
"sidebar.archived": "Archived",
"sidebar.untitled": "Untitled",
"sidebar.languagesAvailable": "{count} languages available",
"sidebar.noMatchingPosts": "No matching posts",
"sidebar.createFirstPost": "Create your first post",
"sidebar.loadMore": "Load more ({loaded} of {total})",
@@ -525,6 +553,8 @@
"settings.project.publicUrlPlaceholder": "https://example.com",
"settings.project.mainLanguageLabel": "Main Language",
"settings.project.mainLanguageDescription": "The primary language for your blog content. AI-generated titles, alt text, and captions will use this language.",
"settings.project.blogLanguagesLabel": "Blog Languages",
"settings.project.blogLanguagesDescription": "Languages the blog is rendered in. The main language is always included. Additional languages generate translated subtrees.",
"settings.project.defaultAuthorLabel": "Default Author",
"settings.project.defaultAuthorDescription": "The default author name for new posts and media. Can be overridden per item.",
"settings.project.defaultAuthorPlaceholder": "Author Name",
@@ -578,6 +608,27 @@
"editor.previewFrameTitle": "Post preview",
"editor.previewLoading": "Loading preview...",
"editor.metadata.toggle": "Metadata",
"editor.translations.title": "Translations",
"editor.translations.currentLanguage": "Current language: {language}",
"editor.translations.none": "No translations yet.",
"editor.translations.selectTarget": "Select target language",
"editor.translations.translateButton": "Translate to...",
"editor.translations.translateTitle": "Create or refresh a translation using AI",
"editor.translations.translating": "Translating...",
"editor.translations.refresh": "Refresh",
"editor.translations.refreshTitle": "Regenerate this translation using AI",
"editor.translations.publish": "Publish",
"editor.translations.publishTitle": "Publish this translation to its markdown file",
"editor.translations.publishing": "Publishing...",
"editor.translations.missing": "Missing: {languages}",
"editor.translations.complete": "All supported translation languages are available.",
"editor.translations.translateSuccess": "Translation updated for {language}",
"editor.translations.translateFailed": "Translation failed",
"editor.translations.publishSuccess": "Published translation for {language}",
"editor.translations.publishFailed": "Publishing translation failed",
"editor.translations.status.draft": "Draft",
"editor.translations.status.published": "Published",
"editor.translations.status.archived": "Archived",
"editor.excerpt.toggle": "Excerpt",
"editor.footer.created": "Created",
"editor.footer.updated": "Updated",
@@ -927,6 +978,12 @@
"editor.media.quickActions.button": "⚡ Quick Actions",
"editor.media.quickActions.aiTitle": "AI: Generate Title, Alt & Caption",
"editor.media.quickActions.aiDescription": "Analyzes the image to suggest metadata",
"editor.media.quickActions.detectLanguageTitle": "Detect Language",
"editor.media.quickActions.detectLanguageDescription": "Detect language from metadata using AI",
"editor.media.quickActions.translateTitle": "Translate to...",
"editor.media.quickActions.translateDescription": "Create or refresh a translation using AI",
"editor.media.translations.currentLanguage": "Current language: {language}",
"editor.media.translations.selectTarget": "Select target language",
"editor.post.quickActions.title": "Quick Actions",
"editor.post.quickActions.analyzing": "⏳ Analyzing…",
"editor.post.quickActions.button": "⚡ Quick Actions",
@@ -949,6 +1006,24 @@
"editor.media.field.caption": "Caption",
"editor.media.field.tags": "Tags (comma-separated)",
"editor.media.field.author": "Author",
"editor.media.field.language": "Language",
"editor.media.field.languageNone": "Not set",
"editor.media.translations.title": "Translations",
"editor.media.translations.none": "No translations yet.",
"editor.media.translations.translateButton": "Translate to…",
"editor.media.translations.translating": "Translating…",
"editor.media.translations.translateSuccess": "Translation updated for {language}",
"editor.media.translations.translateFailed": "Translation failed",
"editor.media.translations.refresh": "Refresh",
"editor.media.translations.refreshTitle": "Regenerate this translation using AI",
"editor.media.translations.deleteTitle": "Delete this translation",
"editor.media.translations.deleted": "Translation deleted for {language}",
"editor.media.translations.deleteFailed": "Failed to delete translation",
"editor.media.translations.editTitle": "Edit Translation — {language}",
"editor.media.translations.saved": "Translation saved for {language}",
"editor.media.translations.saveFailed": "Failed to save translation",
"editor.media.toast.languageDetected": "Language detected: {language}",
"editor.media.error.detectLanguage": "Failed to detect language",
"editor.media.placeholder.title": "Title for lists and search results",
"editor.media.placeholder.altText": "Describe the image for accessibility",
"editor.media.placeholder.caption": "Image caption",
@@ -1097,9 +1172,7 @@
"importAnalysis.usedIn": "Used in: {items}{more}",
"importAnalysis.moreSuffix": ", +{count} more",
"importAnalysis.noParameters": "(no parameters)",
"sidebar.nav.mcp": "MCP Server",
"settings.mcp.title": "MCP Server",
"settings.mcp.description": "Configure the Model Context Protocol server that allows AI coding agents to interact with your blog.",
"settings.mcp.statusLabel": "Server Status",
@@ -1132,5 +1205,9 @@
"duplicatesView.checkAll": "Check All",
"duplicatesView.uncheckAll": "Uncheck All",
"duplicatesView.dismissChecked": "Dismiss Checked ({count})",
"duplicatesView.notEnabled": "Semantic similarity is not enabled. Enable it in Settings → Technology."
"duplicatesView.notEnabled": "Semantic similarity is not enabled. Enable it in Settings → Technology.",
"editor.doNotTranslateLabel": "Do not translate",
"blog.fillMissing.nothingToDo": "All translations are up to date.",
"blog.fillMissing.started": "Translation task started. Check the task panel for progress.",
"blog.fillMissing.error": "Failed to fill missing translations."
}

View File

@@ -54,6 +54,33 @@
"siteValidation.error.validate": "La validación del sitio falló",
"siteValidation.error.apply": "La aplicación de la validación falló",
"siteValidation.toast.applySuccess": "Validación aplicada: {rendered} renderizadas, {deleted} eliminadas",
"menu.item.validateTranslations": "Validar traducciones",
"translationValidation.tabTitle": "Validación de traducciones",
"translationValidation.title": "Validar traducciones",
"translationValidation.summary": "Filas de BD revisadas: {dbRows} · Archivos revisados: {files} · Filas de BD inválidas: {invalidDb} · Archivos inválidos: {invalidFiles}",
"translationValidation.loading": "Validando traducciones...",
"translationValidation.empty": "Ejecuta Blog -> Validar traducciones para inspeccionar la integridad de las traducciones.",
"translationValidation.databaseTitle": "Filas de traducción inválidas en la base de datos",
"translationValidation.filesystemTitle": "Archivos de traducción inválidos en disco",
"translationValidation.noneDatabase": "No se encontraron filas de traducción inválidas.",
"translationValidation.noneFilesystem": "No se encontraron archivos de traducción inválidos.",
"translationValidation.error.validate": "La validación de traducciones falló",
"translationValidation.issue.sameLanguage": "El idioma de la traducción coincide con el idioma canónico de la entrada",
"translationValidation.issue.missingSource": "La traducción apunta a una entrada de origen inexistente",
"translationValidation.issue.doNotTranslate": "La entrada está marcada como no-traducir pero tiene traducciones",
"translationValidation.issue.contentInDatabase": "Traducción publicada con contenido en la BD en lugar del sistema de archivos",
"translationValidation.field.translationFor": "Entrada de origen",
"translationValidation.field.translationId": "Fila de traducción",
"translationValidation.field.title": "Título",
"translationValidation.field.languages": "Idiomas",
"translationValidation.field.filePath": "Archivo",
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
"translationValidation.revalidate": "Revalidar",
"translationValidation.revalidating": "Revalidando…",
"translationValidation.fix": "Corregir problemas",
"translationValidation.fixing": "Corrigiendo…",
"translationValidation.toast.fixSuccess": "{dbRows} filas de BD y {files} archivos eliminados, {flushed} traducciones escritas a disco",
"translationValidation.error.fix": "Error al corregir traducciones inválidas",
"menuEditor.tabTitle": "Menú del blog",
"menuEditor.title": "Editor del menú del blog",
"menuEditor.description": "Gestiona la estructura central de navegación del blog y guárdala en meta/menu.opml.",
@@ -412,7 +439,7 @@
"metadataDiff.fieldFilter.toggle": "Filtrar por {field}",
"metadataDiff.sync.failed": "falló",
"metadataDiff.sync.dbToFile.title": "Actualizar archivos con valores de la base de datos",
"metadataDiff.sync.dbToFile.short": "BD\u2192A",
"metadataDiff.sync.dbToFile.short": "BDA",
"metadataDiff.sync.dbToFile.success": "Se sincronizaron {success} entradas a archivos{falló}",
"metadataDiff.sync.dbToFile.error": "No se pudo sincronizar a archivos",
"metadataDiff.sync.fileToDb.title": "Actualizar base de datos con valores de archivos",
@@ -433,7 +460,7 @@
"metadataDiff.orphanFiles.badge": "Archivo huérfano",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Ruta",
"metadataDiff.orphanFiles.importButton": "D \u2192 BD",
"metadataDiff.orphanFiles.importButton": "D BD",
"metadataDiff.orphanFiles.importTitle": "Importar todos los archivos huérfanos a la base de datos",
"metadataDiff.orphanFiles.importing": "Importando…",
"metadataDiff.orphanFiles.importSuccess": "{success} archivos huérfanos importados{failed}",
@@ -461,6 +488,7 @@
"sidebar.published": "Publicadas",
"sidebar.archived": "Archivadas",
"sidebar.untitled": "Sin título",
"sidebar.languagesAvailable": "{count} idiomas disponibles",
"sidebar.noMatchingPosts": "No hay entradas coincidentes",
"sidebar.createFirstPost": "Crea tu primera entrada",
"sidebar.loadMore": "Cargar más ({loaded} de {total})",
@@ -525,6 +553,8 @@
"settings.project.publicUrlPlaceholder": "https://example.com",
"settings.project.mainLanguageLabel": "Idioma principal",
"settings.project.mainLanguageDescription": "Idioma principal del contenido del blog. Los títulos, textos alternativos y pies generados por IA usarán este idioma.",
"settings.project.blogLanguagesLabel": "Idiomas del blog",
"settings.project.blogLanguagesDescription": "Idiomas en los que se genera el blog. El idioma principal siempre está incluido. Los idiomas adicionales generan subárboles traducidos.",
"settings.project.defaultAuthorLabel": "Autor predeterminado",
"settings.project.defaultAuthorDescription": "Nombre de autor predeterminado para nuevas entradas y medios. Se puede reemplazar por elemento.",
"settings.project.defaultAuthorPlaceholder": "Nombre del autor",
@@ -578,6 +608,27 @@
"editor.previewFrameTitle": "Vista previa de la entrada",
"editor.previewLoading": "Cargando vista previa...",
"editor.metadata.toggle": "Metadatos",
"editor.translations.title": "Traducciones",
"editor.translations.currentLanguage": "Idioma actual: {language}",
"editor.translations.none": "Todavía no hay traducciones.",
"editor.translations.selectTarget": "Selecciona el idioma de destino",
"editor.translations.translateButton": "Traducir a...",
"editor.translations.translateTitle": "Crear o actualizar una traducción con IA",
"editor.translations.translating": "Traduciendo...",
"editor.translations.refresh": "Actualizar",
"editor.translations.refreshTitle": "Regenerar esta traducción con IA",
"editor.translations.publish": "Publicar",
"editor.translations.publishTitle": "Publicar esta traducción en su archivo Markdown",
"editor.translations.publishing": "Publicando...",
"editor.translations.missing": "Faltan: {languages}",
"editor.translations.complete": "Todos los idiomas de traducción compatibles están disponibles.",
"editor.translations.translateSuccess": "Traducción actualizada para {language}",
"editor.translations.translateFailed": "La traducción falló",
"editor.translations.publishSuccess": "Traducción publicada para {language}",
"editor.translations.publishFailed": "No se pudo publicar la traducción",
"editor.translations.status.draft": "Borrador",
"editor.translations.status.published": "Publicada",
"editor.translations.status.archived": "Archivada",
"editor.excerpt.toggle": "Extracto",
"editor.footer.created": "Creado",
"editor.footer.updated": "Actualizado",
@@ -927,6 +978,12 @@
"editor.media.quickActions.button": "✨ Analizar con IA",
"editor.media.quickActions.aiTitle": "Título sugerido por IA",
"editor.media.quickActions.aiDescription": "Genera automáticamente título, texto alternativo y pie de foto.",
"editor.media.quickActions.detectLanguageTitle": "Detectar idioma",
"editor.media.quickActions.detectLanguageDescription": "Detectar el idioma de los metadatos con IA",
"editor.media.quickActions.translateTitle": "Traducir a…",
"editor.media.quickActions.translateDescription": "Crear o actualizar una traducción con IA",
"editor.media.translations.currentLanguage": "Idioma actual: {language}",
"editor.media.translations.selectTarget": "Seleccionar idioma de destino",
"editor.post.quickActions.title": "Acciones rápidas",
"editor.post.quickActions.analyzing": "⏳ Analizando…",
"editor.post.quickActions.button": "⚡ Acciones rápidas",
@@ -949,6 +1006,24 @@
"editor.media.field.caption": "Pie de foto",
"editor.media.field.tags": "Etiquetas",
"editor.media.field.author": "Autor",
"editor.media.field.language": "Idioma",
"editor.media.field.languageNone": "No definido",
"editor.media.translations.title": "Traducciones",
"editor.media.translations.none": "Aún no hay traducciones.",
"editor.media.translations.translateButton": "Traducir a…",
"editor.media.translations.translating": "Traduciendo…",
"editor.media.translations.translateSuccess": "Traducción actualizada para {language}",
"editor.media.translations.translateFailed": "Error en la traducción",
"editor.media.translations.refresh": "Actualizar",
"editor.media.translations.refreshTitle": "Regenerar esta traducción con IA",
"editor.media.translations.deleteTitle": "Eliminar esta traducción",
"editor.media.translations.deleted": "Traducción eliminada para {language}",
"editor.media.translations.deleteFailed": "Error al eliminar la traducción",
"editor.media.translations.editTitle": "Editar traducción — {language}",
"editor.media.translations.saved": "Traducción guardada para {language}",
"editor.media.translations.saveFailed": "Error al guardar la traducción",
"editor.media.toast.languageDetected": "Idioma detectado: {language}",
"editor.media.error.detectLanguage": "Error al detectar el idioma",
"editor.media.placeholder.title": "Introduce un título",
"editor.media.placeholder.altText": "Describe la imagen para accesibilidad",
"editor.media.placeholder.caption": "Añadir pie de foto",
@@ -1097,9 +1172,7 @@
"importAnalysis.usedIn": "Usado en: {items}{more}",
"importAnalysis.moreSuffix": ", +{count} más",
"importAnalysis.noParameters": "(sin parámetros)",
"sidebar.nav.mcp": "Servidor MCP",
"settings.mcp.title": "Servidor MCP",
"settings.mcp.description": "Configure el servidor Model Context Protocol que permite a los agentes de programación IA interactuar con su blog.",
"settings.mcp.statusLabel": "Estado del servidor",
@@ -1132,5 +1205,9 @@
"duplicatesView.checkAll": "Seleccionar todo",
"duplicatesView.uncheckAll": "Deseleccionar todo",
"duplicatesView.dismissChecked": "Descartar seleccionados ({count})",
"duplicatesView.notEnabled": "La similitud semántica no está activada. Actívela en Configuración → Tecnología."
"duplicatesView.notEnabled": "La similitud semántica no está activada. Actívela en Configuración → Tecnología.",
"editor.doNotTranslateLabel": "No traducir",
"blog.fillMissing.nothingToDo": "Todas las traducciones están al día.",
"blog.fillMissing.started": "Tarea de traducción iniciada. Consulte el panel de tareas para ver el progreso.",
"blog.fillMissing.error": "Error al rellenar las traducciones faltantes."
}

View File

@@ -54,6 +54,33 @@
"siteValidation.error.validate": "Échec de la validation du site",
"siteValidation.error.apply": "Échec de lapplication de la validation",
"siteValidation.toast.applySuccess": "Validation appliquée : {rendered} rendues, {deleted} supprimées",
"menu.item.validateTranslations": "Valider les traductions",
"translationValidation.tabTitle": "Validation des traductions",
"translationValidation.title": "Valider les traductions",
"translationValidation.summary": "Lignes BD vérifiées : {dbRows} · Fichiers vérifiés : {files} · Lignes BD invalides : {invalidDb} · Fichiers invalides : {invalidFiles}",
"translationValidation.loading": "Validation des traductions en cours...",
"translationValidation.empty": "Exécutez Blog -> Valider les traductions pour inspecter lintégrité des traductions.",
"translationValidation.databaseTitle": "Lignes de traduction invalides dans la base de données",
"translationValidation.filesystemTitle": "Fichiers de traduction invalides sur le disque",
"translationValidation.noneDatabase": "Aucune ligne de traduction invalide trouvée.",
"translationValidation.noneFilesystem": "Aucun fichier de traduction invalide trouvé.",
"translationValidation.error.validate": "Échec de la validation des traductions",
"translationValidation.issue.sameLanguage": "La langue de traduction correspond à la langue canonique de larticle",
"translationValidation.issue.missingSource": "La traduction pointe vers un article source manquant",
"translationValidation.issue.doNotTranslate": "L'article est marqué ne-pas-traduire mais a des traductions",
"translationValidation.issue.contentInDatabase": "Traduction publiée avec contenu encore en base au lieu du système de fichiers",
"translationValidation.field.translationFor": "Article source",
"translationValidation.field.translationId": "Ligne de traduction",
"translationValidation.field.title": "Titre",
"translationValidation.field.languages": "Langues",
"translationValidation.field.filePath": "Fichier",
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
"translationValidation.revalidate": "Revalider",
"translationValidation.revalidating": "Revalidation…",
"translationValidation.fix": "Corriger les problèmes",
"translationValidation.fixing": "Correction…",
"translationValidation.toast.fixSuccess": "{dbRows} lignes DB et {files} fichiers supprimés, {flushed} traductions écrites sur disque",
"translationValidation.error.fix": "Échec de la correction des traductions invalides",
"menuEditor.tabTitle": "Menu du blog",
"menuEditor.title": "Éditeur du menu du blog",
"menuEditor.description": "Gérez la structure centrale de navigation du blog et enregistrez-la dans meta/menu.opml.",
@@ -419,7 +446,7 @@
"metadataDiff.orphanFiles.badge": "Fichier orphelin",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Chemin",
"metadataDiff.orphanFiles.importButton": "D \u2192 BD",
"metadataDiff.orphanFiles.importButton": "D BD",
"metadataDiff.orphanFiles.importTitle": "Importer tous les fichiers orphelins dans la base de données",
"metadataDiff.orphanFiles.importing": "Importation…",
"metadataDiff.orphanFiles.importSuccess": "{success} fichiers orphelins importés{failed}",
@@ -461,6 +488,7 @@
"sidebar.published": "Publiés",
"sidebar.archived": "Archivés",
"sidebar.untitled": "Sans titre",
"sidebar.languagesAvailable": "{count} langues disponibles",
"sidebar.noMatchingPosts": "Aucun article correspondant",
"sidebar.createFirstPost": "Créer votre premier article",
"sidebar.loadMore": "Charger plus ({loaded} sur {total})",
@@ -525,6 +553,8 @@
"settings.project.publicUrlPlaceholder": "https://example.com",
"settings.project.mainLanguageLabel": "Langue principale",
"settings.project.mainLanguageDescription": "Langue principale de votre contenu. Les titres, textes alternatifs et légendes générés par lIA utiliseront cette langue.",
"settings.project.blogLanguagesLabel": "Langues du blog",
"settings.project.blogLanguagesDescription": "Langues dans lesquelles le blog est rendu. La langue principale est toujours incluse. Les langues supplémentaires génèrent des sous-arborescences traduites.",
"settings.project.defaultAuthorLabel": "Auteur par défaut",
"settings.project.defaultAuthorDescription": "Nom dauteur par défaut pour les nouveaux articles et médias. Peut être remplacé par élément.",
"settings.project.defaultAuthorPlaceholder": "Nom de lauteur",
@@ -578,6 +608,27 @@
"editor.previewFrameTitle": "Aperçu de larticle",
"editor.previewLoading": "Chargement de l'aperçu...",
"editor.metadata.toggle": "Métadonnées",
"editor.translations.title": "Traductions",
"editor.translations.currentLanguage": "Langue actuelle : {language}",
"editor.translations.none": "Aucune traduction pour le moment.",
"editor.translations.selectTarget": "Sélectionner la langue cible",
"editor.translations.translateButton": "Traduire vers...",
"editor.translations.translateTitle": "Créer ou actualiser une traduction avec lIA",
"editor.translations.translating": "Traduction...",
"editor.translations.refresh": "Actualiser",
"editor.translations.refreshTitle": "Regénérer cette traduction avec lIA",
"editor.translations.publish": "Publier",
"editor.translations.publishTitle": "Publier cette traduction dans son fichier Markdown",
"editor.translations.publishing": "Publication...",
"editor.translations.missing": "Manquantes : {languages}",
"editor.translations.complete": "Toutes les langues de traduction prises en charge sont disponibles.",
"editor.translations.translateSuccess": "Traduction mise à jour pour {language}",
"editor.translations.translateFailed": "La traduction a échoué",
"editor.translations.publishSuccess": "Traduction publiée pour {language}",
"editor.translations.publishFailed": "Échec de la publication de la traduction",
"editor.translations.status.draft": "Brouillon",
"editor.translations.status.published": "Publié",
"editor.translations.status.archived": "Archivé",
"editor.excerpt.toggle": "Extrait",
"editor.footer.created": "Créé",
"editor.footer.updated": "Mis à jour",
@@ -927,6 +978,12 @@
"editor.media.quickActions.button": "✨ Analyser avec lIA",
"editor.media.quickActions.aiTitle": "Titre suggéré par lIA",
"editor.media.quickActions.aiDescription": "Générez automatiquement un titre, un texte alternatif et une légende.",
"editor.media.quickActions.detectLanguageTitle": "Détecter la langue",
"editor.media.quickActions.detectLanguageDescription": "Détecter la langue des métadonnées avec lIA",
"editor.media.quickActions.translateTitle": "Traduire en…",
"editor.media.quickActions.translateDescription": "Créer ou actualiser une traduction avec lIA",
"editor.media.translations.currentLanguage": "Langue actuelle : {language}",
"editor.media.translations.selectTarget": "Sélectionner la langue cible",
"editor.post.quickActions.title": "Actions rapides",
"editor.post.quickActions.analyzing": "⏳ Analyse…",
"editor.post.quickActions.button": "⚡ Actions rapides",
@@ -949,6 +1006,24 @@
"editor.media.field.caption": "Légende",
"editor.media.field.tags": "Tags",
"editor.media.field.author": "Auteur",
"editor.media.field.language": "Langue",
"editor.media.field.languageNone": "Non défini",
"editor.media.translations.title": "Traductions",
"editor.media.translations.none": "Aucune traduction pour le moment.",
"editor.media.translations.translateButton": "Traduire en…",
"editor.media.translations.translating": "Traduction…",
"editor.media.translations.translateSuccess": "Traduction mise à jour pour {language}",
"editor.media.translations.translateFailed": "Échec de la traduction",
"editor.media.translations.refresh": "Actualiser",
"editor.media.translations.refreshTitle": "Régénérer cette traduction par IA",
"editor.media.translations.deleteTitle": "Supprimer cette traduction",
"editor.media.translations.deleted": "Traduction supprimée pour {language}",
"editor.media.translations.deleteFailed": "Échec de la suppression de la traduction",
"editor.media.translations.editTitle": "Modifier la traduction — {language}",
"editor.media.translations.saved": "Traduction enregistrée pour {language}",
"editor.media.translations.saveFailed": "Échec de lenregistrement de la traduction",
"editor.media.toast.languageDetected": "Langue détectée : {language}",
"editor.media.error.detectLanguage": "Échec de la détection de la langue",
"editor.media.placeholder.title": "Saisissez un titre",
"editor.media.placeholder.altText": "Décrivez limage pour laccessibilité",
"editor.media.placeholder.caption": "Ajouter une légende",
@@ -1130,5 +1205,9 @@
"duplicatesView.checkAll": "Tout cocher",
"duplicatesView.uncheckAll": "Tout décocher",
"duplicatesView.dismissChecked": "Ignorer cochés ({count})",
"duplicatesView.notEnabled": "La similarité sémantique n'est pas activée. Activez-la dans Paramètres → Technologie."
"duplicatesView.notEnabled": "La similarité sémantique n'est pas activée. Activez-la dans Paramètres → Technologie.",
"editor.doNotTranslateLabel": "Ne pas traduire",
"blog.fillMissing.nothingToDo": "Toutes les traductions sont à jour.",
"blog.fillMissing.started": "Tâche de traduction démarrée. Consultez le panneau des tâches pour le progrès.",
"blog.fillMissing.error": "Échec du remplissage des traductions manquantes."
}

View File

@@ -54,6 +54,33 @@
"siteValidation.error.validate": "Validazione del sito non riuscita",
"siteValidation.error.apply": "Applicazione della validazione non riuscita",
"siteValidation.toast.applySuccess": "Validazione applicata: {rendered} renderizzati, {deleted} eliminati",
"menu.item.validateTranslations": "Valida traduzioni",
"translationValidation.tabTitle": "Validazione traduzioni",
"translationValidation.title": "Valida traduzioni",
"translationValidation.summary": "Righe DB controllate: {dbRows} · File controllati: {files} · Righe DB non valide: {invalidDb} · File non validi: {invalidFiles}",
"translationValidation.loading": "Validazione traduzioni in corso...",
"translationValidation.empty": "Esegui Blog -> Valida traduzioni per controllare lintegrità delle traduzioni.",
"translationValidation.databaseTitle": "Righe di traduzione non valide nel database",
"translationValidation.filesystemTitle": "File di traduzione non validi sul disco",
"translationValidation.noneDatabase": "Nessuna riga di traduzione non valida trovata.",
"translationValidation.noneFilesystem": "Nessun file di traduzione non valido trovato.",
"translationValidation.error.validate": "Validazione traduzioni non riuscita",
"translationValidation.issue.sameLanguage": "La lingua della traduzione coincide con la lingua canonica del post",
"translationValidation.issue.missingSource": "La traduzione punta a un post sorgente mancante",
"translationValidation.issue.doNotTranslate": "Il post è contrassegnato come non-tradurre ma ha traduzioni",
"translationValidation.issue.contentInDatabase": "Traduzione pubblicata con contenuto nel DB invece del filesystem",
"translationValidation.field.translationFor": "Post sorgente",
"translationValidation.field.translationId": "Riga traduzione",
"translationValidation.field.title": "Titolo",
"translationValidation.field.languages": "Lingue",
"translationValidation.field.filePath": "File",
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
"translationValidation.revalidate": "Rivalidare",
"translationValidation.revalidating": "Rivalidazione…",
"translationValidation.fix": "Correggi problemi",
"translationValidation.fixing": "Correzione…",
"translationValidation.toast.fixSuccess": "{dbRows} righe DB e {files} file eliminati, {flushed} traduzioni scritte su disco",
"translationValidation.error.fix": "Correzione delle traduzioni non valide fallita",
"menuEditor.tabTitle": "Menu blog",
"menuEditor.title": "Editor del menu blog",
"menuEditor.description": "Gestisci la struttura centrale di navigazione del blog e salvala in meta/menu.opml.",
@@ -419,18 +446,18 @@
"metadataDiff.orphanFiles.badge": "File orfano",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Percorso",
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
"metadataDiff.orphanFiles.importButton": "D DB",
"metadataDiff.orphanFiles.importTitle": "Importa tutti i file orfani nel database",
"metadataDiff.orphanFiles.importing": "Importazione…",
"metadataDiff.orphanFiles.importSuccess": "{success} file orfani importati{failed}",
"metadataDiff.orphanFiles.importError": "Impossibile importare i file orfani",
"metadataDiff.sync.failed": "fallito",
"metadataDiff.sync.dbToFile.title": "Aggiorna i file con i valori del database",
"metadataDiff.sync.dbToFile.short": "DB\u2192F",
"metadataDiff.sync.dbToFile.short": "DBF",
"metadataDiff.sync.dbToFile.success": "Sincronizzati {success} post nei file{fallito}",
"metadataDiff.sync.dbToFile.error": "Impossibile sincronizzare nei file",
"metadataDiff.sync.fileToDb.title": "Aggiorna il database con i valori dei file",
"metadataDiff.sync.fileToDb.short": "F\u2192DB",
"metadataDiff.sync.fileToDb.short": "FDB",
"metadataDiff.sync.fileToDb.success": "Sincronizzati {success} file nel database{fallito}",
"metadataDiff.sync.fileToDb.error": "Impossibile sincronizzare nel database",
"metadataDiff.value.database": "Database locale",
@@ -461,6 +488,7 @@
"sidebar.published": "Pubblicati",
"sidebar.archived": "Archiviati",
"sidebar.untitled": "Senza titolo",
"sidebar.languagesAvailable": "{count} lingue disponibili",
"sidebar.noMatchingPosts": "Nessun post corrispondente",
"sidebar.createFirstPost": "Crea il tuo primo post",
"sidebar.loadMore": "Carica altro ({loaded} di {total})",
@@ -525,6 +553,8 @@
"settings.project.publicUrlPlaceholder": "https://example.com",
"settings.project.mainLanguageLabel": "Lingua principale",
"settings.project.mainLanguageDescription": "Lingua principale dei contenuti del blog. Titoli, alt text e didascalie generate dallIA useranno questa lingua.",
"settings.project.blogLanguagesLabel": "Lingue del blog",
"settings.project.blogLanguagesDescription": "Lingue in cui viene generato il blog. La lingua principale è sempre inclusa. Le lingue aggiuntive generano sottocartelle tradotte.",
"settings.project.defaultAuthorLabel": "Autore predefinito",
"settings.project.defaultAuthorDescription": "Nome autore predefinito per nuovi post e media. Può essere modificato per singolo elemento.",
"settings.project.defaultAuthorPlaceholder": "Nome autore",
@@ -578,6 +608,27 @@
"editor.previewFrameTitle": "Anteprima post",
"editor.previewLoading": "Caricamento anteprima...",
"editor.metadata.toggle": "Metadati",
"editor.translations.title": "Traduzioni",
"editor.translations.currentLanguage": "Lingua corrente: {language}",
"editor.translations.none": "Nessuna traduzione disponibile.",
"editor.translations.selectTarget": "Seleziona lingua di destinazione",
"editor.translations.translateButton": "Traduci in...",
"editor.translations.translateTitle": "Crea o aggiorna una traduzione con l'IA",
"editor.translations.translating": "Traduzione in corso...",
"editor.translations.refresh": "Aggiorna",
"editor.translations.refreshTitle": "Rigenera questa traduzione con l'IA",
"editor.translations.publish": "Pubblica",
"editor.translations.publishTitle": "Pubblica questa traduzione nel suo file Markdown",
"editor.translations.publishing": "Pubblicazione...",
"editor.translations.missing": "Mancanti: {languages}",
"editor.translations.complete": "Tutte le lingue di traduzione supportate sono disponibili.",
"editor.translations.translateSuccess": "Traduzione aggiornata per {language}",
"editor.translations.translateFailed": "Traduzione non riuscita",
"editor.translations.publishSuccess": "Traduzione pubblicata per {language}",
"editor.translations.publishFailed": "Pubblicazione della traduzione non riuscita",
"editor.translations.status.draft": "Bozza",
"editor.translations.status.published": "Pubblicato",
"editor.translations.status.archived": "Archiviato",
"editor.excerpt.toggle": "Estratto",
"editor.footer.created": "Creato",
"editor.footer.updated": "Aggiornato",
@@ -927,6 +978,12 @@
"editor.media.quickActions.button": "✨ Analizza con IA",
"editor.media.quickActions.aiTitle": "Titolo suggerito dallIA",
"editor.media.quickActions.aiDescription": "Genera automaticamente titolo, testo alternativo e didascalia.",
"editor.media.quickActions.detectLanguageTitle": "Rileva lingua",
"editor.media.quickActions.detectLanguageDescription": "Rileva la lingua dai metadati con lIA",
"editor.media.quickActions.translateTitle": "Traduci in…",
"editor.media.quickActions.translateDescription": "Crea o aggiorna una traduzione con lIA",
"editor.media.translations.currentLanguage": "Lingua corrente: {language}",
"editor.media.translations.selectTarget": "Seleziona la lingua di destinazione",
"editor.post.quickActions.title": "Azioni rapide",
"editor.post.quickActions.analyzing": "⏳ Analisi…",
"editor.post.quickActions.button": "⚡ Azioni rapide",
@@ -949,6 +1006,24 @@
"editor.media.field.caption": "Didascalia",
"editor.media.field.tags": "Tag",
"editor.media.field.author": "Autore",
"editor.media.field.language": "Lingua",
"editor.media.field.languageNone": "Non impostata",
"editor.media.translations.title": "Traduzioni",
"editor.media.translations.none": "Nessuna traduzione ancora.",
"editor.media.translations.translateButton": "Traduci in…",
"editor.media.translations.translating": "Traduzione…",
"editor.media.translations.translateSuccess": "Traduzione aggiornata per {language}",
"editor.media.translations.translateFailed": "Traduzione fallita",
"editor.media.translations.refresh": "Aggiorna",
"editor.media.translations.refreshTitle": "Rigenera questa traduzione tramite IA",
"editor.media.translations.deleteTitle": "Elimina questa traduzione",
"editor.media.translations.deleted": "Traduzione eliminata per {language}",
"editor.media.translations.deleteFailed": "Eliminazione della traduzione fallita",
"editor.media.translations.editTitle": "Modifica traduzione — {language}",
"editor.media.translations.saved": "Traduzione salvata per {language}",
"editor.media.translations.saveFailed": "Salvataggio della traduzione fallito",
"editor.media.toast.languageDetected": "Lingua rilevata: {language}",
"editor.media.error.detectLanguage": "Rilevamento della lingua fallito",
"editor.media.placeholder.title": "Inserisci un titolo",
"editor.media.placeholder.altText": "Descrivi limmagine per laccessibilità",
"editor.media.placeholder.caption": "Aggiungi una didascalia",
@@ -1130,5 +1205,9 @@
"duplicatesView.checkAll": "Seleziona tutto",
"duplicatesView.uncheckAll": "Deseleziona tutto",
"duplicatesView.dismissChecked": "Ignora selezionati ({count})",
"duplicatesView.notEnabled": "La similarità semantica non è abilitata. Abilitala in Impostazioni → Tecnologia."
"duplicatesView.notEnabled": "La similarità semantica non è abilitata. Abilitala in Impostazioni → Tecnologia.",
"editor.doNotTranslateLabel": "Non tradurre",
"blog.fillMissing.nothingToDo": "Tutte le traduzioni sono aggiornate.",
"blog.fillMissing.started": "Attività di traduzione avviata. Controlla il pannello attività per il progresso.",
"blog.fillMissing.error": "Impossibile completare le traduzioni mancanti."
}

View File

@@ -16,6 +16,7 @@ export type EditorRoute =
| 'documentation'
| 'api-documentation'
| 'site-validation'
| 'translation-validation'
| 'scripts'
| 'templates'
| 'find-duplicates';
@@ -34,6 +35,7 @@ export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'da
documentation: 'documentation',
'api-documentation': 'api-documentation',
'site-validation': 'site-validation',
'translation-validation': 'translation-validation',
scripts: 'scripts',
templates: 'templates',
'find-duplicates': 'find-duplicates',

View File

@@ -8,6 +8,7 @@ export interface CreateAndFocusPostOptions {
setSelectedPost: (postId: string) => void;
ensurePostsSidebar?: () => void;
onError?: (error: unknown) => void;
categories?: string[];
}
export async function createAndFocusPost(options: CreateAndFocusPostOptions): Promise<string | null> {
@@ -16,7 +17,7 @@ export async function createAndFocusPost(options: CreateAndFocusPostOptions): Pr
title: '',
content: '',
tags: [],
categories: [],
categories: options.categories ?? [],
});
if (!post) {

View File

@@ -10,6 +10,7 @@ export type SingletonToolTabKey =
| 'api-documentation'
| 'metadata-diff'
| 'site-validation'
| 'translation-validation'
| 'find-duplicates';
export interface CanonicalTabSpec {
@@ -34,6 +35,7 @@ const SINGLETON_TOOL_TAB_REGISTRY: Record<SingletonToolTabKey, CanonicalTabSpec>
'api-documentation': { type: 'api-documentation', id: 'api-documentation', isTransient: false },
'metadata-diff': { type: 'metadata-diff', id: 'metadata-diff', isTransient: false },
'site-validation': { type: 'site-validation', id: 'site-validation', isTransient: false },
'translation-validation': { type: 'translation-validation', id: 'translation-validation', isTransient: false },
'find-duplicates': { type: 'find-duplicates', id: 'find-duplicates', isTransient: false },
};

View File

@@ -0,0 +1,24 @@
import type { TranslationValidationReport } from '../../main/shared/electronApi';
const TRANSLATION_VALIDATION_REPORT_PREFIX = 'bds-translation-validation-report';
function buildStorageKey(projectId: string): string {
return `${TRANSLATION_VALIDATION_REPORT_PREFIX}:${projectId}`;
}
export function persistTranslationValidationReport(projectId: string, report: TranslationValidationReport): void {
localStorage.setItem(buildStorageKey(projectId), JSON.stringify(report));
}
export function getPersistedTranslationValidationReport(projectId: string): TranslationValidationReport | null {
const raw = localStorage.getItem(buildStorageKey(projectId));
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as TranslationValidationReport;
} catch {
return null;
}
}

View File

@@ -13,7 +13,7 @@ import type {
const STORAGE_KEY = 'bds-app-state';
// Tab types
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'scripts' | 'templates' | 'find-duplicates';
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'translation-validation' | 'scripts' | 'templates' | 'find-duplicates';
export interface Tab {
type: TabType;

View File

@@ -4,7 +4,8 @@ export const BDS_EVENT_TEMPLATES_CHANGED = 'bds:templates-changed' as const;
export type BdsWindowEventName =
| typeof BDS_EVENT_SCRIPTS_CHANGED
| typeof BDS_EVENT_TEMPLATES_CHANGED
| 'bds:site-validation-updated';
| 'bds:site-validation-updated'
| 'bds:translation-validation-updated';
export function addWindowEventListener<TDetail = unknown>(
eventName: BdsWindowEventName,