From 914af9831d2c7ddc165989e2a279cbdddff05518 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Fri, 13 Mar 2026 13:27:45 +0100 Subject: [PATCH] Feat/webworker for incremental render (#51) * feat: web worker for incremental render * feat: optimizing incremental render for date archives * feat: more work on web worker * fix: blogmark process handled defaulting wrong --------- Co-authored-by: hugo --- .../engine/ApplyValidationWorkerService.ts | 208 +++++ src/main/engine/BlogGenerationEngine.ts | 809 ++++++++++++------ .../engine/ValidationApplyPlannerService.ts | 25 +- src/main/ipc/blogHandlers.ts | 68 +- src/main/main.ts | 2 + src/renderer/components/Editor/PostEditor.tsx | 11 + .../ApplyValidationWorkerService.test.ts | 352 ++++++++ tests/engine/BlogGenerationEngine.test.ts | 16 +- .../ValidationApplyPlannerService.test.ts | 6 +- tests/ipc/handlers.test.ts | 7 +- .../EditorCanonicalLanguageFocus.test.tsx | 293 +++++++ 11 files changed, 1485 insertions(+), 312 deletions(-) create mode 100644 src/main/engine/ApplyValidationWorkerService.ts create mode 100644 tests/engine/ApplyValidationWorkerService.test.ts create mode 100644 tests/renderer/components/EditorCanonicalLanguageFocus.test.tsx diff --git a/src/main/engine/ApplyValidationWorkerService.ts b/src/main/engine/ApplyValidationWorkerService.ts new file mode 100644 index 0000000..9369eb9 --- /dev/null +++ b/src/main/engine/ApplyValidationWorkerService.ts @@ -0,0 +1,208 @@ +/** + * Builds targeted worker tasks for the applyValidation flow. + * + * Takes a targeted validation plan and produces GenerationWorkerTask[] + * scoped to only the affected sections (categories, tags, dates, singles, core). + * This gives applyValidation the same worker-pool parallelism as full generation + * while limiting the volume to only what's needed. + */ +import type { PostData } from './PostEngine'; +import type { GenerationPostIndex } from './GenerationPostIndexService'; +import type { TargetedValidationPlan } from './ValidationApplyPlannerService'; +import type { + GenerationWorkerTask, + SerializedPostData, + SerializedMediaData, + SerializedBlogGenerationOptions, +} from './GenerationWorkerData'; +import { + serializePostData, + serializePostMap, + serializeDateMap, +} from './GenerationWorkerData'; + +export interface ApplyValidationWorkerParams { + options: SerializedBlogGenerationOptions; + maxPostsPerPage: number; + htmlDir: string; + mediaItems: SerializedMediaData[]; + backlinksRecord: Record>; + hashMapEntries: Array<[string, string]>; + postFilePathEntries: Array<[string, string]>; + postMediaLinksEntries: Array<[string, Array<{ mediaId: string; sortOrder: number }>]>; +} + +export interface ApplyValidationLanguageParams { + targetedPlan: TargetedValidationPlan; + publishedRoutePosts: PostData[]; + publishedListPosts: PostData[]; + generationPostIndex: GenerationPostIndex; + years: Map; + yearMonths: Map; + yearMonthDays: Map; + languagePrefix?: string; + mainLanguage?: string; +} + +export function buildApplyValidationWorkerTasks( + base: ApplyValidationWorkerParams, + lang: ApplyValidationLanguageParams, +): GenerationWorkerTask[] { + const { targetedPlan, publishedRoutePosts, publishedListPosts, generationPostIndex } = lang; + + const serializedRoutePosts = publishedRoutePosts.map(serializePostData); + const serializedListPosts = publishedListPosts.map(serializePostData); + + const baseTaskData = { + lookupPosts: serializedRoutePosts, + mediaItems: base.mediaItems, + backlinksMap: base.backlinksRecord, + options: lang.languagePrefix + ? { ...base.options, language: lang.languagePrefix.replace(/^\//, '') } + : base.options, + maxPostsPerPage: base.maxPostsPerPage, + htmlDir: base.htmlDir, + hashMapEntries: base.hashMapEntries, + postFilePathEntries: base.postFilePathEntries, + postMediaLinksEntries: base.postMediaLinksEntries, + languagePrefix: lang.languagePrefix, + mainLanguage: lang.mainLanguage, + }; + + const tasks: GenerationWorkerTask[] = []; + let taskCounter = 0; + const langSuffix = lang.languagePrefix ? `-${lang.languagePrefix.replace(/^\//, '')}` : ''; + const nextTaskId = (section: string) => `apply-${section}${langSuffix}-${++taskCounter}`; + + // Core (root + page routes) + if (targetedPlan.requestRootRoutes) { + tasks.push({ + ...baseTaskData, + taskId: nextTaskId('core'), + section: 'core', + posts: serializedListPosts, + }); + } + + // Single posts + if (targetedPlan.requestedPostIds.size > 0) { + const requestedSinglePosts = publishedRoutePosts + .filter((p) => targetedPlan.requestedPostIds.has(p.id)) + .map(serializePostData); + + if (requestedSinglePosts.length > 0) { + tasks.push({ + ...baseTaskData, + taskId: nextTaskId('single'), + section: 'single', + posts: requestedSinglePosts, + }); + } + } + + // Page-slug posts (separate from singles; they're resolved through core) + // Pages are rendered via the 'core' section's generatePageRoutes call, + // so they're covered by the requestRootRoutes task above. + + // Categories + if (targetedPlan.requestedCategorySet.size > 0) { + // Only include the requested categories and their post-index entries + const filteredPostsByCategory = new Map(); + for (const category of targetedPlan.requestedCategorySet) { + const posts = generationPostIndex.postsByCategory.get(category); + if (posts) { + filteredPostsByCategory.set(category, posts); + } + } + + tasks.push({ + ...baseTaskData, + taskId: nextTaskId('category'), + section: 'category', + posts: serializedListPosts, + allCategories: Array.from(targetedPlan.requestedCategorySet), + postsByCategoryEntries: serializePostMap(filteredPostsByCategory), + }); + } + + // Tags + if (targetedPlan.requestedTagSet.size > 0) { + const filteredPostsByTag = new Map(); + for (const tag of targetedPlan.requestedTagSet) { + const posts = generationPostIndex.postsByTag.get(tag); + if (posts) { + filteredPostsByTag.set(tag, posts); + } + } + + tasks.push({ + ...baseTaskData, + taskId: nextTaskId('tag'), + section: 'tag', + posts: serializedListPosts, + allTags: Array.from(targetedPlan.requestedTagSet), + postsByTagEntries: serializePostMap(filteredPostsByTag), + }); + } + + // Date archives + const { requestedYears, requestedYearMonths, requestedYearMonthDays } = targetedPlan; + const hasDateRequests = requestedYears.size > 0 + || requestedYearMonths.size > 0 + || requestedYearMonthDays.size > 0; + + if (hasDateRequests) { + // Filter archive maps to only the requested keys + const filteredYears = new Map(); + for (const year of requestedYears) { + const lastmod = lang.years.get(year); + if (lastmod) filteredYears.set(year, lastmod); + } + + const filteredYearMonths = new Map(); + for (const ym of requestedYearMonths) { + const lastmod = lang.yearMonths.get(ym); + if (lastmod) filteredYearMonths.set(ym, lastmod); + } + + const filteredYearMonthDays = new Map(); + for (const ymd of requestedYearMonthDays) { + const lastmod = lang.yearMonthDays.get(ymd); + if (lastmod) filteredYearMonthDays.set(ymd, lastmod); + } + + // Filter post-index entries to only the requested date keys + const filteredPostsByYear = new Map(); + for (const year of requestedYears) { + const posts = generationPostIndex.postsByYear.get(year); + if (posts) filteredPostsByYear.set(year, posts); + } + + const filteredPostsByYearMonth = new Map(); + for (const ym of requestedYearMonths) { + const posts = generationPostIndex.postsByYearMonth.get(ym); + if (posts) filteredPostsByYearMonth.set(ym, posts); + } + + const filteredPostsByYearMonthDay = new Map(); + for (const ymd of requestedYearMonthDays) { + const posts = generationPostIndex.postsByYearMonthDay.get(ymd); + if (posts) filteredPostsByYearMonthDay.set(ymd, posts); + } + + tasks.push({ + ...baseTaskData, + taskId: nextTaskId('date'), + section: 'date', + posts: serializedListPosts, + yearsEntries: serializeDateMap(filteredYears), + yearMonthsEntries: serializeDateMap(filteredYearMonths), + yearMonthDaysEntries: serializeDateMap(filteredYearMonthDays), + postsByYearEntries: serializePostMap(filteredPostsByYear), + postsByYearMonthEntries: serializePostMap(filteredPostsByYearMonth), + postsByYearMonthDayEntries: serializePostMap(filteredPostsByYearMonthDay), + }); + } + + return tasks; +} diff --git a/src/main/engine/BlogGenerationEngine.ts b/src/main/engine/BlogGenerationEngine.ts index dbf9f6d..04d6e3e 100644 --- a/src/main/engine/BlogGenerationEngine.ts +++ b/src/main/engine/BlogGenerationEngine.ts @@ -44,9 +44,10 @@ import { buildRequestedArchiveMaps, selectRequestedPosts, } from './ApplyValidationDataService'; +import { buildApplyValidationWorkerTasks } from './ApplyValidationWorkerService'; import { getGeneratedFileHashRecord } from '../database/generatedFileHashStore'; import { getAllGeneratedFileHashes, setGeneratedFileHash } from '../database/generatedFileHashStore'; -import { GenerationWorkerPool, type WorkerPoolResult } from './GenerationWorkerPool'; +import { GenerationWorkerPool } from './GenerationWorkerPool'; import { serializePostData, serializeMediaItem, @@ -54,7 +55,6 @@ import { serializePostMap, serializeDateMap, type GenerationWorkerTask, - type SerializedPostData, } from './GenerationWorkerData'; import { readPostTranslationFile } from './postTranslationFileUtils'; @@ -130,6 +130,34 @@ export interface SiteValidationApplyResult { removedEmptyDirCount: number; } +export interface ApplyValidationPreparation { + deletedUrlCount: number; + removedEmptyDirCount: number; + requiresFallbackSectionRender: boolean; + sectionsToRender: BlogGenerationSection[]; + /** Pre-built worker tasks partitioned by section (worker mode only). */ + workerTasksBySection: Map; + /** Data for main-thread fallback rendering (non-worker mode only). */ + targetedRenderData?: ApplyValidationTargetedRenderData; +} + +interface ApplyValidationTargetedRenderData { + options: BlogGenerationOptions; + maxPostsPerPage: number; + htmlDir: string; + publishedPosts: PostData[]; + publishedListPosts: PostData[]; + publishedRoutePosts: PostData[]; + generationPostIndex: GenerationPostIndex; + targetedPlan: import('./ValidationApplyPlannerService').TargetedValidationPlan; + missingPathPlan: import('./ValidationApplyPlannerService').MissingPathPlan; + mainLanguage: string; + additionalLanguages: string[]; + years: Map; + yearMonths: Map; + yearMonthDays: Map; +} + export interface CalendarRegenerationResult { calendarPath: string; changed: boolean; @@ -1543,15 +1571,55 @@ export class BlogGenerationEngine { report: SiteValidationReport, onProgress: (progress: number, message?: string) => void, ): Promise { - onProgress(0, 'Applying validation changes...'); + const preparation = await this.prepareApplyValidation(options, report, (progress, message) => { + const mapped = Math.floor((progress / 100) * 45); + onProgress(mapped, message); + }); + + let renderedUrlCount = 0; + const sections = preparation.sectionsToRender; + for (let i = 0; i < sections.length; i++) { + const section = sections[i]; + const sectionCount = await this.applyValidationForSection(options, preparation, section, (progress, message) => { + const base = 45 + Math.floor((i / Math.max(1, sections.length)) * 45); + const span = Math.max(1, Math.floor(45 / Math.max(1, sections.length))); + const mapped = base + Math.floor((progress / 100) * span); + onProgress(Math.min(90, mapped), message); + }); + renderedUrlCount += sectionCount; + } + + if (renderedUrlCount > 0 || preparation.deletedUrlCount > 0) { + onProgress(90, 'Regenerating calendar data...'); + await this.regenerateCalendar(options, (progress, message) => { + const mappedProgress = 90 + Math.floor((progress / 100) * 9); + onProgress(Math.min(99, mappedProgress), message || 'Regenerating calendar data...'); + }); + } + + onProgress(100, `Apply complete (${preparation.deletedUrlCount} deleted, ${renderedUrlCount} rendered)`); + + return { + renderedUrlCount, + deletedUrlCount: preparation.deletedUrlCount, + removedEmptyDirCount: preparation.removedEmptyDirCount, + }; + } + + // ── Multi-task apply validation: preparation + per-section rendering ─── + + async prepareApplyValidation( + options: BlogGenerationOptions, + report: SiteValidationReport, + onProgress: (progress: number, message?: string) => void, + ): Promise { + onProgress(0, 'Planning validation apply steps...'); const missingPaths = Array.isArray(report.missingUrlPaths) ? report.missingUrlPaths : []; const updatedPostPaths = Array.isArray(report.updatedPostUrlPaths) ? report.updatedPostUrlPaths : []; const rerenderPaths = Array.from(new Set([...missingPaths, ...updatedPostPaths])); const extraPaths = Array.isArray(report.extraUrlPaths) ? report.extraUrlPaths : []; - onProgress(10, 'Planning validation apply steps...'); - const mainLanguage = (options.language ?? 'en').trim().toLowerCase(); const additionalLanguages = (options.blogLanguages ?? []) .map((lang) => lang.trim().toLowerCase()) @@ -1567,7 +1635,6 @@ export class BlogGenerationEngine { const pruneEmptyParents = async (startDir: string): Promise => { let currentDir = startDir; - while (path.resolve(currentDir) !== path.resolve(htmlDir)) { let entries: string[]; try { @@ -1575,11 +1642,7 @@ export class BlogGenerationEngine { } catch { break; } - - if (entries.length > 0) { - break; - } - + if (entries.length > 0) break; await fs.rm(currentDir, { recursive: true, force: true }); removedEmptyDirCount += 1; currentDir = path.dirname(currentDir); @@ -1596,182 +1659,122 @@ export class BlogGenerationEngine { } catch { // ignore missing files and continue } - if (extraPaths.length > 0) { const deleteProgress = 20 + Math.floor(((index + 1) / extraPaths.length) * 25); - onProgress(Math.min(45, deleteProgress), `Deleted ${index + 1}/${extraPaths.length} extra URLs`); + onProgress(Math.min(50, deleteProgress), `Deleted ${index + 1}/${extraPaths.length} extra URLs`); } } - let renderedUrlCount = 0; - if (missingPathPlan.requiresFallbackSectionRender) { - onProgress(50, 'Rendering missing routes (fallback section mode)...'); - const sectionExecutionOrder: BlogGenerationSection[] = ['category', 'tag', 'date', 'core', 'single']; - for (let index = 0; index < sectionExecutionOrder.length; index += 1) { - const section = sectionExecutionOrder[index]; - const generationResult = await this.generate({ - ...options, - maxPostsPerPage: options.maxPostsPerPage, - sections: [section], - }, (progress, message) => { - const base = 50 + Math.floor((index / sectionExecutionOrder.length) * 40); - const span = Math.max(1, Math.floor(40 / sectionExecutionOrder.length)); - const mapped = base + Math.floor((progress / 100) * span); - onProgress(Math.min(90, mapped), message || `Rendering ${section} routes...`); - }); + onProgress(100, 'Preparation complete (fallback section mode)'); + return { + deletedUrlCount, + removedEmptyDirCount, + requiresFallbackSectionRender: true, + sectionsToRender: ['category', 'tag', 'date', 'core', 'single'], + workerTasksBySection: new Map(), + }; + } - renderedUrlCount += generationResult.pagesGenerated; + onProgress(55, 'Loading post data...'); + + const categorySettings = resolveCategorySettings(options.categoryMetadata, options.categorySettings); + const listExcludedCategories = Object.entries(categorySettings) + .filter(([, settings]) => settings.renderInLists === false) + .map(([category]) => category); + + const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage); + const { publishedPosts, publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories); + const { routePosts: publishedRoutePosts, translationsByPost } = await this.buildPublishedRoutePosts(publishedPosts); + const generationPostIndex = buildGenerationPostIndex(publishedListPosts); + + const { allCategories, allTags, years, yearMonths, yearMonthDays } = buildApplyValidationArchives(publishedListPosts); + + const targetedPlan = buildTargetedValidationPlan({ + initialPlan: missingPathPlan, + publishedPosts: publishedRoutePosts, + allCategories, + allTags, + availableYearMonths: yearMonths.keys(), + availableYearMonthDays: yearMonthDays.keys(), + }); + + await fs.mkdir(htmlDir, { recursive: true }); + + const useWorkers = !!options.dbPath; + + // Build worker tasks partitioned by section (worker mode only) + const workerTasksBySection = new Map(); + + if (useWorkers) { + onProgress(65, 'Loading media and serializing worker data...'); + + const rawMedia = await this.mediaEngine.getAllMedia(); + const mediaItems = rawMedia.map(serializeMediaItem); + + let backlinksRecord: Record> = {}; + if (typeof this.postEngine.getAllBacklinks === 'function') { + const blMap = await this.postEngine.getAllBacklinks(); + for (const [postId, links] of blMap) { + backlinksRecord[postId] = links; + } } - } else { - const categorySettings = resolveCategorySettings(options.categoryMetadata, options.categorySettings); - const listExcludedCategories = Object.entries(categorySettings) - .filter(([, settings]) => settings.renderInLists === false) - .map(([category]) => category); - const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage); - const { publishedPosts, publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories); - const { routePosts: publishedRoutePosts } = await this.buildPublishedRoutePosts(publishedPosts); - const generationPostIndex = buildGenerationPostIndex(publishedListPosts); + const serializedOptions = serializeBlogGenerationOptions(options); - const { allCategories, allTags, years, yearMonths, yearMonthDays } = buildApplyValidationArchives(publishedListPosts); + let postFilePathEntries: Array<[string, string]> = []; + if (typeof this.postEngine.getPublishedPostFilePaths === 'function') { + const filePathMap = await this.postEngine.getPublishedPostFilePaths(); + postFilePathEntries = Array.from(filePathMap); + } - const targetedPlan = buildTargetedValidationPlan({ - initialPlan: missingPathPlan, - publishedPosts: publishedRoutePosts, - allCategories, - allTags, - availableYearMonths: yearMonths.keys(), - availableYearMonthDays: yearMonthDays.keys(), - }); + let postMediaLinksEntries: Array<[string, Array<{ mediaId: string; sortOrder: number }>]> = []; + if (typeof this.postMediaEngine.getAllPostMediaLinks === 'function') { + const linksMap = await this.postMediaEngine.getAllPostMediaLinks(); + postMediaLinksEntries = Array.from(linksMap); + } - const htmlDir = path.join(options.dataDir, 'html'); - await fs.mkdir(htmlDir, { recursive: true }); + const generatedHashCache = new Map(); + const existingHashes = await getAllGeneratedFileHashes(options.projectId); + for (const [relativePath, hash] of existingHashes) { + generatedHashCache.set(relativePath, hash); + } + const hashMapEntries: Array<[string, string]> = []; + for (const [relativePath, hash] of generatedHashCache) { + if (hash !== null) hashMapEntries.push([relativePath, hash]); + } - const renderRoute = createPreviewBackedGenerationRouteRenderer({ - options, + const mainLangRoutePosts = this.resolvePostsForLanguage(publishedRoutePosts, mainLanguage, translationsByPost, mainLanguage); + const mainLangListPosts = this.resolvePostsForLanguage(publishedListPosts, mainLanguage, translationsByPost, mainLanguage); + const mainLangPostIndex = buildGenerationPostIndex(mainLangListPosts); + + const baseWorkerParams = { + options: serializedOptions, maxPostsPerPage, - publishedPostsForLookup: publishedRoutePosts, - engines: { - postEngine: this.postEngine, - mediaEngine: this.mediaEngine, - postMediaEngine: this.postMediaEngine, - }, - }); - const writePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({ - projectId, htmlDir, - urlPath, - content, - refreshHashTimestampOnUnchanged: true, - }); - const onPageGenerated = (_message: string) => { - // no-op for applyValidation + mediaItems, + backlinksRecord, + hashMapEntries, + postFilePathEntries, + postMediaLinksEntries, }; - const { requestedSinglePosts, requestedPagePosts } = selectRequestedPosts({ - publishedPosts: publishedRoutePosts, - requestedPostIds: targetedPlan.requestedPostIds, - requestedPageSlugs: targetedPlan.requestedPageSlugs, - }); + onProgress(80, 'Building targeted worker tasks...'); - const { requestedYearsMap, requestedYearMonthsMap, requestedYearMonthDaysMap } = buildRequestedArchiveMaps({ - requestedYears: targetedPlan.requestedYears, - requestedYearMonths: targetedPlan.requestedYearMonths, - requestedYearMonthDays: targetedPlan.requestedYearMonthDays, + const allTasks: GenerationWorkerTask[] = buildApplyValidationWorkerTasks(baseWorkerParams, { + targetedPlan, + publishedRoutePosts: mainLangRoutePosts, + publishedListPosts: mainLangListPosts, + generationPostIndex: mainLangPostIndex, years, yearMonths, yearMonthDays, }); - onProgress( - 48, - `Targeted rerender plan: singles=${requestedSinglePosts.length}, categories=${targetedPlan.requestedCategorySet.size}, tags=${targetedPlan.requestedTagSet.size}, years=${requestedYearsMap.size}, months=${requestedYearMonthsMap.size}, days=${requestedYearMonthDaysMap.size}, root=${targetedPlan.requestRootRoutes ? 1 : 0}, pages=${requestedPagePosts.length}`, - ); - - onProgress(50, 'Rendering targeted missing routes...'); - - if (targetedPlan.requestRootRoutes) { - renderedUrlCount += await generateRootPages({ - projectId: options.projectId, - posts: publishedListPosts, - maxPostsPerPage, - renderRoute, - writePage, - onPageGenerated, - }); - } - - if (requestedPagePosts.length > 0) { - renderedUrlCount += await generatePageRoutes({ - projectId: options.projectId, - posts: requestedPagePosts, - renderRoute, - writePage, - onPageGenerated, - }); - } - - if (targetedPlan.requestedCategorySet.size > 0) { - renderedUrlCount += await generateCategoryPages({ - projectId: options.projectId, - posts: publishedListPosts, - allCategories: targetedPlan.requestedCategorySet, - maxPostsPerPage, - renderRoute, - writePage, - onPageGenerated, - postsByCategory: generationPostIndex.postsByCategory, - }); - } - - if (targetedPlan.requestedTagSet.size > 0) { - renderedUrlCount += await generateTagPages({ - projectId: options.projectId, - posts: publishedListPosts, - allTags: targetedPlan.requestedTagSet, - maxPostsPerPage, - renderRoute, - writePage, - onPageGenerated, - postsByTag: generationPostIndex.postsByTag, - }); - } - - if (requestedSinglePosts.length > 0) { - renderedUrlCount += await generateSinglePostPages({ - projectId: options.projectId, - posts: requestedSinglePosts, - renderRoute, - writePage, - onPageGenerated, - }); - } - - if (requestedYearsMap.size > 0 || requestedYearMonthsMap.size > 0 || requestedYearMonthDaysMap.size > 0) { - renderedUrlCount += await generateDateArchivePages({ - projectId: options.projectId, - posts: publishedListPosts, - yearsMap: requestedYearsMap, - yearMonthsMap: requestedYearMonthsMap, - yearMonthDaysMap: requestedYearMonthDaysMap, - maxPostsPerPage, - renderRoute, - writePage, - onPageGenerated, - postsByYear: generationPostIndex.postsByYear, - postsByYearMonth: generationPostIndex.postsByYearMonth, - 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 langPosts = publishedPosts.filter((p) => !p.doNotTranslate); + const langListPosts = publishedListPosts.filter((p) => !p.doNotTranslate); const langArchives = buildApplyValidationArchives(langListPosts); - const langTargetedPlan = buildTargetedValidationPlan({ initialPlan: langMissingPlan, publishedPosts: langPosts, @@ -1781,138 +1784,386 @@ export class BlogGenerationEngine { 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 resolvedLangPosts = this.resolvePostsForLanguage(langPosts, lang, translationsByPost, mainLanguage); + const resolvedLangListPosts = this.resolvePostsForLanguage(langListPosts, lang, translationsByPost, mainLanguage); + const resolvedLangPostIndex = buildGenerationPostIndex(resolvedLangListPosts); + + const langTasks = buildApplyValidationWorkerTasks( + { ...baseWorkerParams, options: { ...serializedOptions, language: lang } }, + { + targetedPlan: langTargetedPlan, + publishedRoutePosts: resolvedLangPosts, + publishedListPosts: resolvedLangListPosts, + generationPostIndex: resolvedLangPostIndex, + years: langArchives.years, + yearMonths: langArchives.yearMonths, + yearMonthDays: langArchives.yearMonthDays, + languagePrefix: `/${lang}`, + mainLanguage, }, - }); + ); + allTasks.push(...langTasks); + } - const langWritePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({ - projectId, - htmlDir, - urlPath: `${lang}/${urlPath}`, - content, - refreshHashTimestampOnUnchanged: true, - }); + for (const task of allTasks) { + const sectionTasks = workerTasksBySection.get(task.section) ?? []; + sectionTasks.push(task); + workerTasksBySection.set(task.section, sectionTasks); + } + } - if (langTargetedPlan.requestRootRoutes) { + // Determine which sections have work + const sectionsToRender: BlogGenerationSection[] = useWorkers + ? Array.from(workerTasksBySection.keys()) + : this.computeTargetedSectionsToRender(targetedPlan, missingPathPlan, publishedPosts, publishedListPosts); + + onProgress(100, 'Preparation complete'); + + return { + deletedUrlCount, + removedEmptyDirCount, + requiresFallbackSectionRender: false, + sectionsToRender, + workerTasksBySection, + targetedRenderData: !useWorkers ? { + options, + maxPostsPerPage, + htmlDir, + publishedPosts, + publishedListPosts, + publishedRoutePosts, + generationPostIndex, + targetedPlan, + missingPathPlan, + mainLanguage, + additionalLanguages, + years, + yearMonths, + yearMonthDays, + } : undefined, + }; + } + + private computeTargetedSectionsToRender( + targetedPlan: import('./ValidationApplyPlannerService').TargetedValidationPlan, + missingPathPlan: import('./ValidationApplyPlannerService').MissingPathPlan, + publishedPosts: PostData[], + publishedListPosts: PostData[], + ): BlogGenerationSection[] { + const sections = new Set(); + + const checkPlan = (plan: import('./ValidationApplyPlannerService').TargetedValidationPlan) => { + if (plan.requestRootRoutes || plan.requestedPageSlugs.size > 0) sections.add('core'); + if (plan.requestedPostIds.size > 0) sections.add('single'); + if (plan.requestedCategorySet.size > 0) sections.add('category'); + if (plan.requestedTagSet.size > 0) sections.add('tag'); + if (plan.requestedYears.size > 0 || plan.requestedYearMonths.size > 0 || plan.requestedYearMonthDays.size > 0) sections.add('date'); + }; + + checkPlan(targetedPlan); + + for (const [, langMissingPlan] of missingPathPlan.languagePlans) { + const langPosts = publishedPosts.filter((p) => !p.doNotTranslate); + const langListPosts = publishedListPosts.filter((p) => !p.doNotTranslate); + 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(), + }); + checkPlan(langTargetedPlan); + } + + return Array.from(sections); + } + + async applyValidationForSection( + options: BlogGenerationOptions, + preparation: ApplyValidationPreparation, + section: BlogGenerationSection, + onProgress: (progress: number, message?: string) => void, + ): Promise { + if (preparation.requiresFallbackSectionRender) { + const result = await this.generate({ + ...options, + maxPostsPerPage: options.maxPostsPerPage, + sections: [section], + }, (progress, message) => { + onProgress(progress, message || `Rendering ${section} routes...`); + }); + return result.pagesGenerated; + } + + // Worker-based targeted rendering for this section + const sectionTasks = preparation.workerTasksBySection.get(section); + if (sectionTasks && sectionTasks.length > 0) { + onProgress(0, `Dispatching ${sectionTasks.length} targeted ${section} tasks to worker pool...`); + const pool = new GenerationWorkerPool(); + const result = await pool.runTasks(sectionTasks, (message) => { + onProgress(50, message); + }); + + if (result.errors.length > 0) { + console.error(`[ApplyValidation/${section}] ${result.errors.length} task(s) failed:`); + for (const err of result.errors) { + console.error(` [${err.taskId}] ${err.error}`); + } + } + + if (result.hashUpdates.length > 0) { + for (const update of result.hashUpdates) { + await setGeneratedFileHash(options.projectId, update.relativePath, update.hash); + } + } + + onProgress(100, `${section} rendering complete`); + return result.pagesGenerated; + } + + // Main-thread fallback for this section + if (preparation.targetedRenderData) { + return this.applyValidationSectionOnMainThread(preparation.targetedRenderData, section, onProgress); + } + + return 0; + } + + // ── Per-section main-thread targeted apply validation ──────────────── + + private async applyValidationSectionOnMainThread( + data: ApplyValidationTargetedRenderData, + section: BlogGenerationSection, + onProgress: (progress: number, message?: string) => void, + ): Promise { + const { + options, maxPostsPerPage, htmlDir, + publishedPosts, publishedListPosts, publishedRoutePosts, + generationPostIndex, targetedPlan, missingPathPlan, + years, yearMonths, yearMonthDays, + } = data; + + let renderedUrlCount = 0; + + const renderRoute = createPreviewBackedGenerationRouteRenderer({ + options, + maxPostsPerPage, + publishedPostsForLookup: publishedRoutePosts, + engines: { + postEngine: this.postEngine, + mediaEngine: this.mediaEngine, + postMediaEngine: this.postMediaEngine, + }, + }); + const writePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({ + projectId, + htmlDir, + urlPath, + content, + refreshHashTimestampOnUnchanged: true, + }); + const onPageGenerated = (_message: string) => { + // no-op for applyValidation + }; + + const { requestedSinglePosts, requestedPagePosts } = selectRequestedPosts({ + publishedPosts: publishedRoutePosts, + requestedPostIds: targetedPlan.requestedPostIds, + requestedPageSlugs: targetedPlan.requestedPageSlugs, + }); + + const { requestedYearsMap, requestedYearMonthsMap, requestedYearMonthDaysMap } = buildRequestedArchiveMaps({ + requestedYears: targetedPlan.requestedYears, + requestedYearMonths: targetedPlan.requestedYearMonths, + requestedYearMonthDays: targetedPlan.requestedYearMonthDays, + years, + yearMonths, + yearMonthDays, + }); + + onProgress(10, `Rendering ${section} section...`); + + // Main language rendering for this section + switch (section) { + case 'core': + if (targetedPlan.requestRootRoutes) { renderedUrlCount += await generateRootPages({ - projectId: options.projectId, - posts: langListPosts, - maxPostsPerPage, - renderRoute: langRenderRoute, - writePage: langWritePage, - onPageGenerated, + projectId: options.projectId, posts: publishedListPosts, maxPostsPerPage, + renderRoute, writePage, 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 (requestedPagePosts.length > 0) { + renderedUrlCount += await generatePageRoutes({ + projectId: options.projectId, posts: requestedPagePosts, + renderRoute, writePage, onPageGenerated, + }); + } + break; + + case 'single': + if (requestedSinglePosts.length > 0) { + renderedUrlCount += await generateSinglePostPages({ + projectId: options.projectId, posts: requestedSinglePosts, + renderRoute, writePage, onPageGenerated, + }); + } + break; + + case 'category': + if (targetedPlan.requestedCategorySet.size > 0) { + renderedUrlCount += await generateCategoryPages({ + projectId: options.projectId, posts: publishedListPosts, + allCategories: targetedPlan.requestedCategorySet, maxPostsPerPage, + renderRoute, writePage, onPageGenerated, + postsByCategory: generationPostIndex.postsByCategory, + }); + } + break; + + case 'tag': + if (targetedPlan.requestedTagSet.size > 0) { + renderedUrlCount += await generateTagPages({ + projectId: options.projectId, posts: publishedListPosts, + allTags: targetedPlan.requestedTagSet, maxPostsPerPage, + renderRoute, writePage, onPageGenerated, + postsByTag: generationPostIndex.postsByTag, + }); + } + break; + + case 'date': + if (requestedYearsMap.size > 0 || requestedYearMonthsMap.size > 0 || requestedYearMonthDaysMap.size > 0) { + renderedUrlCount += await generateDateArchivePages({ + projectId: options.projectId, posts: publishedListPosts, + yearsMap: requestedYearsMap, yearMonthsMap: requestedYearMonthsMap, + yearMonthDaysMap: requestedYearMonthDaysMap, maxPostsPerPage, + renderRoute, writePage, onPageGenerated, + postsByYear: generationPostIndex.postsByYear, + postsByYearMonth: generationPostIndex.postsByYearMonth, + postsByYearMonthDay: generationPostIndex.postsByYearMonthDay, + }); + } + break; + } + + // Language subtrees for this section + 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, + }); + + switch (section) { + case 'core': + if (langTargetedPlan.requestRootRoutes) { + renderedUrlCount += await generateRootPages({ + projectId: options.projectId, posts: langListPosts, maxPostsPerPage, + renderRoute: langRenderRoute, writePage: langWritePage, onPageGenerated, + }); + const langPagePosts = selectRequestedPosts({ + publishedPosts: langPosts, requestedPostIds: new Set(), requestedPageSlugs: langTargetedPlan.requestedPageSlugs, + }).requestedPagePosts; + if (langPagePosts.length > 0) { + renderedUrlCount += await generatePageRoutes({ + projectId: options.projectId, posts: langPagePosts, + renderRoute: langRenderRoute, writePage: langWritePage, onPageGenerated, + }); + } + } + break; + + case 'category': + 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, }); } + break; + + case 'tag': + 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, + }); + } + break; + + case 'single': { + const langSinglePosts = selectRequestedPosts({ + publishedPosts: langPosts, requestedPostIds: langTargetedPlan.requestedPostIds, requestedPageSlugs: new Set(), + }).requestedSinglePosts; + if (langSinglePosts.length > 0) { + renderedUrlCount += await generateSinglePostPages({ + projectId: options.projectId, posts: langSinglePosts, + renderRoute: langRenderRoute, writePage: langWritePage, onPageGenerated, + }); + } + break; } - 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, + case 'date': { + const langArchiveMaps = buildRequestedArchiveMaps({ + requestedYears: langTargetedPlan.requestedYears, + requestedYearMonths: langTargetedPlan.requestedYearMonths, + requestedYearMonthDays: langTargetedPlan.requestedYearMonthDays, + years: langArchives.years, yearMonths: langArchives.yearMonths, yearMonthDays: langArchives.yearMonthDays, }); + if (langArchiveMaps.requestedYearsMap.size > 0 || langArchiveMaps.requestedYearMonthsMap.size > 0 || langArchiveMaps.requestedYearMonthDaysMap.size > 0) { + renderedUrlCount += await generateDateArchivePages({ + projectId: options.projectId, posts: langListPosts, + yearsMap: langArchiveMaps.requestedYearsMap, + yearMonthsMap: langArchiveMaps.requestedYearMonthsMap, + yearMonthDaysMap: langArchiveMaps.requestedYearMonthDaysMap, + maxPostsPerPage, renderRoute: langRenderRoute, writePage: langWritePage, onPageGenerated, + postsByYear: langPostIndex.postsByYear, postsByYearMonth: langPostIndex.postsByYearMonth, + postsByYearMonthDay: langPostIndex.postsByYearMonthDay, + }); + } + break; } } } - if (renderedUrlCount > 0 || deletedUrlCount > 0) { - onProgress(90, 'Regenerating calendar data...'); - await this.regenerateCalendar(options, (progress, message) => { - const mappedProgress = 90 + Math.floor((progress / 100) * 9); - onProgress(Math.min(99, mappedProgress), message || 'Regenerating calendar data...'); - }); - } - - onProgress(100, `Apply complete (${deletedUrlCount} deleted, ${renderedUrlCount} rendered)`); - - return { - renderedUrlCount, - deletedUrlCount, - removedEmptyDirCount, - }; + onProgress(100, `${section} rendering complete`); + return renderedUrlCount; } } - diff --git a/src/main/engine/ValidationApplyPlannerService.ts b/src/main/engine/ValidationApplyPlannerService.ts index 95a0727..f997e99 100644 --- a/src/main/engine/ValidationApplyPlannerService.ts +++ b/src/main/engine/ValidationApplyPlannerService.ts @@ -282,31 +282,18 @@ export function buildTargetedValidationPlan(params: BuildTargetedValidationPlanP } } - for (const year of Array.from(requestedYears.values())) { - for (const ym of availableYearMonths) { - if (ym.startsWith(`${year}/`)) { - requestedYearMonths.add(ym); - } - } - } - - for (const ym of Array.from(requestedYearMonths.values())) { - for (const ymd of availableYearMonthDays) { - if (ymd.startsWith(`${ym}/`)) { - requestedYearMonthDays.add(ymd); - } - } - - const [yearStr] = ym.split('/'); - requestedYears.add(Number(yearStr)); - } - + // Upward cascade only: day → month → year for (const ymd of Array.from(requestedYearMonthDays.values())) { const [yearStr, monthStr] = ymd.split('/'); requestedYears.add(Number(yearStr)); requestedYearMonths.add(`${yearStr}/${monthStr}`); } + for (const ym of Array.from(requestedYearMonths.values())) { + const [yearStr] = ym.split('/'); + requestedYears.add(Number(yearStr)); + } + return { requestedPostIds, requestedCategorySet: new Set( diff --git a/src/main/ipc/blogHandlers.ts b/src/main/ipc/blogHandlers.ts index 49415a4..7e3bd5a 100644 --- a/src/main/ipc/blogHandlers.ts +++ b/src/main/ipc/blogHandlers.ts @@ -5,6 +5,7 @@ import { type BlogGenerationSection, type BlogGenerationOptions, type SiteValidationReport, + type ApplyValidationPreparation, } from '../engine/BlogGenerationEngine'; import { resolvePageTitle } from '../engine/PageRenderer'; import type { EngineBundle } from '../engine/EngineBundle'; @@ -328,14 +329,71 @@ export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundl const baseOptions = await resolveBlogGenerationBaseOptions(); const taskTimestamp = Date.now(); - return bundle.taskManager.runTask({ - id: `site-validate-apply-${taskTimestamp}`, - name: 'Apply Site Validation', + const taskGroupId = `site-validate-apply-${taskTimestamp}`; + const taskGroupName = 'Apply Site Validation'; + + const sectionDisplayNames: Record = { + core: 'Render Site Core', + single: 'Render Single Posts', + category: 'Render Category Archives', + tag: 'Render Tag Archives', + date: 'Render Date Archives', + }; + + // Phase 1: Prepare — plan & delete extra URLs + const preparation = await bundle.taskManager.runTask({ + id: `site-validate-prepare-${taskTimestamp}`, + name: 'Prepare Validation Apply', + groupId: taskGroupId, + groupName: taskGroupName, execute: async (onProgress) => { - return blogGenerationEngine.applyValidation(baseOptions, report, (progress, message) => { - onProgress(progress, message || 'Applying site validation...'); + return blogGenerationEngine.prepareApplyValidation(baseOptions, report, (progress, message) => { + onProgress(progress, message || 'Preparing validation apply...'); }); }, }); + + // Phase 2: Render sections in parallel (grouped tasks) + let renderedUrlCount = 0; + + if (preparation.sectionsToRender.length > 0) { + const sectionResults = await Promise.all( + preparation.sectionsToRender.map((section) => + bundle.taskManager.runTask({ + id: `site-validate-render-${section}-${taskTimestamp}`, + name: sectionDisplayNames[section], + groupId: taskGroupId, + groupName: taskGroupName, + execute: async (onProgress) => { + return blogGenerationEngine.applyValidationForSection(baseOptions, preparation, section, (progress, message) => { + onProgress(progress, message || `Rendering ${section} routes...`); + }); + }, + }), + ), + ); + renderedUrlCount = sectionResults.reduce((sum, count) => sum + count, 0); + } + + // Phase 3: Regenerate calendar (if anything changed) + if (renderedUrlCount > 0 || preparation.deletedUrlCount > 0) { + await bundle.taskManager.runTask({ + id: `site-validate-calendar-${taskTimestamp}`, + name: 'Regenerate Calendar', + groupId: taskGroupId, + groupName: taskGroupName, + execute: async (onProgress) => { + return blogGenerationEngine.regenerateCalendar(baseOptions, (progress, message) => { + onProgress(progress, message || 'Regenerating calendar...'); + }); + }, + }); + } + + return { + renderedUrlCount, + deletedUrlCount: preparation.deletedUrlCount, + removedEmptyDirCount: preparation.removedEmptyDirCount, + }; }); } diff --git a/src/main/main.ts b/src/main/main.ts index 77336aa..e40c94a 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -449,6 +449,8 @@ async function processBlogmarkDeepLink(rawDeepLink: string): Promise { content: transformResult.post.content, tags: transformResult.post.tags, categories: transformResult.post.categories, + language: (metadata as { mainLanguage?: string } | null)?.mainLanguage || undefined, + author: (metadata as { defaultAuthor?: string } | null)?.defaultAuthor || undefined, }); const blogmarkCreatedPayload = { diff --git a/src/renderer/components/Editor/PostEditor.tsx b/src/renderer/components/Editor/PostEditor.tsx index cca44f6..13fee90 100644 --- a/src/renderer/components/Editor/PostEditor.tsx +++ b/src/renderer/components/Editor/PostEditor.tsx @@ -235,6 +235,17 @@ export const PostEditor: React.FC = ({ postId }) => { const canonicalLanguage = postLanguage || post?.language || projectLanguage; const fieldIdPrefix = `post-editor-${postId}`; + // Keep activeEditingLanguage in sync when canonicalLanguage changes + // (e.g. due to async projectLanguage loading after post init) + const prevCanonicalLanguageRef = useRef(canonicalLanguage); + useEffect(() => { + const prev = prevCanonicalLanguageRef.current; + prevCanonicalLanguageRef.current = canonicalLanguage; + if (prev !== canonicalLanguage && activeEditingLanguage === prev) { + setActiveEditingLanguage(canonicalLanguage); + } + }, [canonicalLanguage, activeEditingLanguage]); + const loadTranslations = useCallback(async () => { const result = await window.electronAPI?.posts.getTranslations?.(postId); const items = result || []; diff --git a/tests/engine/ApplyValidationWorkerService.test.ts b/tests/engine/ApplyValidationWorkerService.test.ts new file mode 100644 index 0000000..1e372b5 --- /dev/null +++ b/tests/engine/ApplyValidationWorkerService.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it } from 'vitest'; +import { + buildApplyValidationWorkerTasks, + type ApplyValidationWorkerParams, + type ApplyValidationLanguageParams, +} from '../../src/main/engine/ApplyValidationWorkerService'; +import type { PostData } from '../../src/main/engine/PostEngine'; +import { buildGenerationPostIndex } from '../../src/main/engine/GenerationPostIndexService'; +import type { TargetedValidationPlan } from '../../src/main/engine/ValidationApplyPlannerService'; +import type { SerializedBlogGenerationOptions } from '../../src/main/engine/GenerationWorkerData'; + +function makePost(overrides: Partial = {}): PostData { + const createdAt = overrides.createdAt ?? new Date('2025-01-15T10:00:00Z'); + return { + id: overrides.id ?? 'post-1', + projectId: 'test', + title: overrides.title ?? 'Test Post', + slug: overrides.slug ?? 'test-post', + content: overrides.content ?? '# Test', + status: 'published', + createdAt, + updatedAt: overrides.updatedAt ?? createdAt, + publishedAt: createdAt, + tags: overrides.tags ?? [], + categories: overrides.categories ?? [], + availableLanguages: overrides.availableLanguages ?? [], + }; +} + +function makeBaseParams(overrides?: Partial): ApplyValidationWorkerParams { + const options: SerializedBlogGenerationOptions = { + projectId: 'test', + projectName: 'Test Blog', + dataDir: '/data', + baseUrl: 'https://example.com', + }; + + return { + options, + maxPostsPerPage: 50, + htmlDir: '/data/html', + mediaItems: [], + backlinksRecord: {}, + hashMapEntries: [], + postFilePathEntries: [], + postMediaLinksEntries: [], + ...overrides, + }; +} + +function makeEmptyTargetedPlan(overrides?: Partial): TargetedValidationPlan { + return { + requestedPostIds: new Set(), + requestedCategorySet: new Set(), + requestedTagSet: new Set(), + requestedYears: new Set(), + requestedYearMonths: new Set(), + requestedYearMonthDays: new Set(), + requestedPageSlugs: new Set(), + requestRootRoutes: false, + ...overrides, + }; +} + +describe('buildApplyValidationWorkerTasks', () => { + it('returns empty tasks when plan has no requests', () => { + const posts = [makePost()]; + const postIndex = buildGenerationPostIndex(posts); + + const tasks = buildApplyValidationWorkerTasks( + makeBaseParams(), + { + targetedPlan: makeEmptyTargetedPlan(), + publishedRoutePosts: posts, + publishedListPosts: posts, + generationPostIndex: postIndex, + years: new Map(), + yearMonths: new Map(), + yearMonthDays: new Map(), + }, + ); + + expect(tasks).toHaveLength(0); + }); + + it('creates a single-post task containing only requested post IDs', () => { + const post1 = makePost({ id: 'p1', slug: 'post-one', tags: ['alpha'], categories: ['news'] }); + const post2 = makePost({ id: 'p2', slug: 'post-two', tags: ['beta'], categories: ['other'] }); + const posts = [post1, post2]; + const postIndex = buildGenerationPostIndex(posts); + + const tasks = buildApplyValidationWorkerTasks( + makeBaseParams(), + { + targetedPlan: makeEmptyTargetedPlan({ + requestedPostIds: new Set(['p1']), + }), + publishedRoutePosts: posts, + publishedListPosts: posts, + generationPostIndex: postIndex, + years: new Map(), + yearMonths: new Map(), + yearMonthDays: new Map(), + }, + ); + + const singleTasks = tasks.filter((t) => t.section === 'single'); + expect(singleTasks).toHaveLength(1); + expect(singleTasks[0].posts).toHaveLength(1); + expect(singleTasks[0].posts[0].id).toBe('p1'); + }); + + it('creates a category task containing only requested categories', () => { + const post1 = makePost({ id: 'p1', categories: ['news'], tags: [] }); + const post2 = makePost({ id: 'p2', categories: ['other'], tags: [] }); + const posts = [post1, post2]; + const postIndex = buildGenerationPostIndex(posts); + + const tasks = buildApplyValidationWorkerTasks( + makeBaseParams(), + { + targetedPlan: makeEmptyTargetedPlan({ + requestedCategorySet: new Set(['news']), + }), + publishedRoutePosts: posts, + publishedListPosts: posts, + generationPostIndex: postIndex, + years: new Map(), + yearMonths: new Map(), + yearMonthDays: new Map(), + }, + ); + + const catTasks = tasks.filter((t) => t.section === 'category'); + expect(catTasks).toHaveLength(1); + expect(catTasks[0].allCategories).toEqual(['news']); + expect(catTasks[0].postsByCategoryEntries).toHaveLength(1); + expect(catTasks[0].postsByCategoryEntries![0][0]).toBe('news'); + }); + + it('creates a tag task containing only requested tags', () => { + const post1 = makePost({ id: 'p1', tags: ['alpha'] }); + const post2 = makePost({ id: 'p2', tags: ['beta'] }); + const posts = [post1, post2]; + const postIndex = buildGenerationPostIndex(posts); + + const tasks = buildApplyValidationWorkerTasks( + makeBaseParams(), + { + targetedPlan: makeEmptyTargetedPlan({ + requestedTagSet: new Set(['alpha']), + }), + publishedRoutePosts: posts, + publishedListPosts: posts, + generationPostIndex: postIndex, + years: new Map(), + yearMonths: new Map(), + yearMonthDays: new Map(), + }, + ); + + const tagTasks = tasks.filter((t) => t.section === 'tag'); + expect(tagTasks).toHaveLength(1); + expect(tagTasks[0].allTags).toEqual(['alpha']); + expect(tagTasks[0].postsByTagEntries).toHaveLength(1); + expect(tagTasks[0].postsByTagEntries![0][0]).toBe('alpha'); + }); + + it('creates a date task containing only requested year/month/day archives', () => { + const post1 = makePost({ id: 'p1', createdAt: new Date('2025-01-15T10:00:00Z') }); + const post2 = makePost({ id: 'p2', createdAt: new Date('2024-06-20T10:00:00Z') }); + const posts = [post1, post2]; + const postIndex = buildGenerationPostIndex(posts); + + const years = new Map([[2025, new Date()], [2024, new Date()]]); + const yearMonths = new Map([['2025/01', new Date()], ['2024/06', new Date()]]); + const yearMonthDays = new Map([['2025/01/15', new Date()], ['2024/06/20', new Date()]]); + + const tasks = buildApplyValidationWorkerTasks( + makeBaseParams(), + { + targetedPlan: makeEmptyTargetedPlan({ + requestedYears: new Set([2025]), + requestedYearMonths: new Set(['2025/01']), + requestedYearMonthDays: new Set(['2025/01/15']), + }), + publishedRoutePosts: posts, + publishedListPosts: posts, + generationPostIndex: postIndex, + years, + yearMonths, + yearMonthDays, + }, + ); + + const dateTasks = tasks.filter((t) => t.section === 'date'); + expect(dateTasks).toHaveLength(1); + expect(dateTasks[0].yearsEntries).toHaveLength(1); + expect(dateTasks[0].yearsEntries![0][0]).toBe(2025); + expect(dateTasks[0].yearMonthsEntries).toHaveLength(1); + expect(dateTasks[0].yearMonthsEntries![0][0]).toBe('2025/01'); + expect(dateTasks[0].yearMonthDaysEntries).toHaveLength(1); + expect(dateTasks[0].yearMonthDaysEntries![0][0]).toBe('2025/01/15'); + }); + + it('creates a core task when requestRootRoutes is true', () => { + const posts = [makePost()]; + const postIndex = buildGenerationPostIndex(posts); + + const tasks = buildApplyValidationWorkerTasks( + makeBaseParams(), + { + targetedPlan: makeEmptyTargetedPlan({ + requestRootRoutes: true, + }), + publishedRoutePosts: posts, + publishedListPosts: posts, + generationPostIndex: postIndex, + years: new Map(), + yearMonths: new Map(), + yearMonthDays: new Map(), + }, + ); + + const coreTasks = tasks.filter((t) => t.section === 'core'); + expect(coreTasks).toHaveLength(1); + }); + + it('does not create core task when requestRootRoutes is false', () => { + const posts = [makePost()]; + const postIndex = buildGenerationPostIndex(posts); + + const tasks = buildApplyValidationWorkerTasks( + makeBaseParams(), + { + targetedPlan: makeEmptyTargetedPlan({ + requestRootRoutes: false, + requestedPostIds: new Set(['post-1']), + }), + publishedRoutePosts: posts, + publishedListPosts: posts, + generationPostIndex: postIndex, + years: new Map(), + yearMonths: new Map(), + yearMonthDays: new Map(), + }, + ); + + expect(tasks.filter((t) => t.section === 'core')).toHaveLength(0); + }); + + it('creates tasks across multiple sections for a comprehensive plan', () => { + const post1 = makePost({ + id: 'p1', slug: 'target-post', + categories: ['news'], tags: ['alpha'], + createdAt: new Date('2025-01-15T10:00:00Z'), + }); + const post2 = makePost({ + id: 'p2', slug: 'other-post', + categories: ['other'], tags: ['beta'], + createdAt: new Date('2024-06-20T10:00:00Z'), + }); + const posts = [post1, post2]; + const postIndex = buildGenerationPostIndex(posts); + + const years = new Map([[2025, new Date()]]); + const yearMonths = new Map([['2025/01', new Date()]]); + const yearMonthDays = new Map([['2025/01/15', new Date()]]); + + const tasks = buildApplyValidationWorkerTasks( + makeBaseParams(), + { + targetedPlan: makeEmptyTargetedPlan({ + requestRootRoutes: true, + requestedPostIds: new Set(['p1']), + requestedCategorySet: new Set(['news']), + requestedTagSet: new Set(['alpha']), + requestedYears: new Set([2025]), + requestedYearMonths: new Set(['2025/01']), + requestedYearMonthDays: new Set(['2025/01/15']), + }), + publishedRoutePosts: posts, + publishedListPosts: posts, + generationPostIndex: postIndex, + years, + yearMonths, + yearMonthDays, + }, + ); + + expect(tasks.filter((t) => t.section === 'core')).toHaveLength(1); + expect(tasks.filter((t) => t.section === 'single')).toHaveLength(1); + expect(tasks.filter((t) => t.section === 'category')).toHaveLength(1); + expect(tasks.filter((t) => t.section === 'tag')).toHaveLength(1); + expect(tasks.filter((t) => t.section === 'date')).toHaveLength(1); + expect(tasks).toHaveLength(5); + }); + + it('sets language prefix on tasks for language subtree rendering', () => { + const posts = [makePost({ id: 'p1', tags: ['alpha'] })]; + const postIndex = buildGenerationPostIndex(posts); + + const tasks = buildApplyValidationWorkerTasks( + makeBaseParams(), + { + targetedPlan: makeEmptyTargetedPlan({ + requestedTagSet: new Set(['alpha']), + }), + publishedRoutePosts: posts, + publishedListPosts: posts, + generationPostIndex: postIndex, + years: new Map(), + yearMonths: new Map(), + yearMonthDays: new Map(), + languagePrefix: '/fr', + mainLanguage: 'en', + }, + ); + + expect(tasks).toHaveLength(1); + expect(tasks[0].languagePrefix).toBe('/fr'); + expect(tasks[0].mainLanguage).toBe('en'); + expect(tasks[0].options.language).toBe('fr'); + }); + + it('uses all route posts as lookupPosts regardless of targeted plan', () => { + const post1 = makePost({ id: 'p1', slug: 'target', tags: ['alpha'] }); + const post2 = makePost({ id: 'p2', slug: 'other', tags: ['beta'] }); + const posts = [post1, post2]; + const postIndex = buildGenerationPostIndex(posts); + + const tasks = buildApplyValidationWorkerTasks( + makeBaseParams(), + { + targetedPlan: makeEmptyTargetedPlan({ + requestedPostIds: new Set(['p1']), + }), + publishedRoutePosts: posts, + publishedListPosts: posts, + generationPostIndex: postIndex, + years: new Map(), + yearMonths: new Map(), + yearMonthDays: new Map(), + }, + ); + + // Single task has only 1 post to render... + expect(tasks[0].posts).toHaveLength(1); + // ...but lookupPosts contains ALL posts for slug resolution + expect(tasks[0].lookupPosts).toHaveLength(2); + }); +}); diff --git a/tests/engine/BlogGenerationEngine.test.ts b/tests/engine/BlogGenerationEngine.test.ts index 395fbfd..6746cf5 100644 --- a/tests/engine/BlogGenerationEngine.test.ts +++ b/tests/engine/BlogGenerationEngine.test.ts @@ -1496,7 +1496,7 @@ describe('BlogGenerationEngine', () => { generateSpy.mockRestore(); }); - it('applies validation for a missing month by generating that month and its day archives only', async () => { + it('applies validation for a missing month by generating that month and parent year only', async () => { const posts = [ makePost({ id: '1', slug: 'jan-post', createdAt: new Date('2025-01-15T10:00:00Z') }), makePost({ id: '2', slug: 'feb-post', createdAt: new Date('2025-02-20T10:00:00Z') }), @@ -1520,13 +1520,17 @@ describe('BlogGenerationEngine', () => { existingHtmlUrlCount: 0, }, vi.fn()); + // The requested month and its parent year are rendered + expect(await fileExists(path.join(tempDir, 'html', '2025', 'index.html'))).toBe(true); expect(await fileExists(path.join(tempDir, 'html', '2025', '01', 'index.html'))).toBe(true); - expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'index.html'))).toBe(true); + // Child day archives are NOT cascaded into — only upward cascade + expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'index.html'))).toBe(false); + // Unrelated months are not rendered expect(await fileExists(path.join(tempDir, 'html', '2025', '02', 'index.html'))).toBe(false); expect(await fileExists(path.join(tempDir, 'html', '2025', '02', '20', 'index.html'))).toBe(false); }); - it('applies validation for a missing year by generating that year and nested month/day archives only', async () => { + it('applies validation for a missing year by generating only that year archive', async () => { const posts = [ makePost({ id: '1', slug: 'year-2025', createdAt: new Date('2025-01-15T10:00:00Z') }), makePost({ id: '2', slug: 'year-2024', createdAt: new Date('2024-02-20T10:00:00Z') }), @@ -1550,9 +1554,11 @@ describe('BlogGenerationEngine', () => { existingHtmlUrlCount: 0, }, vi.fn()); + // Only the requested year archive is rendered — no downward cascade expect(await fileExists(path.join(tempDir, 'html', '2025', 'index.html'))).toBe(true); - expect(await fileExists(path.join(tempDir, 'html', '2025', '01', 'index.html'))).toBe(true); - expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'index.html'))).toBe(true); + expect(await fileExists(path.join(tempDir, 'html', '2025', '01', 'index.html'))).toBe(false); + expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'index.html'))).toBe(false); + // Other years are not rendered expect(await fileExists(path.join(tempDir, 'html', '2024', 'index.html'))).toBe(false); expect(await fileExists(path.join(tempDir, 'html', '2024', '02', 'index.html'))).toBe(false); expect(await fileExists(path.join(tempDir, 'html', '2024', '02', '20', 'index.html'))).toBe(false); diff --git a/tests/engine/ValidationApplyPlannerService.test.ts b/tests/engine/ValidationApplyPlannerService.test.ts index e109290..4651f41 100644 --- a/tests/engine/ValidationApplyPlannerService.test.ts +++ b/tests/engine/ValidationApplyPlannerService.test.ts @@ -86,9 +86,11 @@ describe('ValidationApplyPlannerService', () => { expect(targeted.requestedTagSet.has('tag-1')).toBe(true); expect(targeted.requestedYears.has(2025)).toBe(true); expect(targeted.requestedYearMonths.has('2025/01')).toBe(true); - expect(targeted.requestedYearMonths.has('2025/02')).toBe(true); + // 2025/02 should NOT be included — only directly affected months are rerendered + expect(targeted.requestedYearMonths.has('2025/02')).toBe(false); expect(targeted.requestedYearMonthDays.has('2025/01/15')).toBe(true); - expect(targeted.requestedYearMonthDays.has('2025/02/20')).toBe(true); + // 2025/02/20 should NOT be included — only directly affected days are rerendered + expect(targeted.requestedYearMonthDays.has('2025/02/20')).toBe(false); expect(targeted.requestedPageSlugs.has('about')).toBe(true); expect(targeted.requestRootRoutes).toBe(true); }); diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index 94ab5df..c10d824 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -3236,7 +3236,7 @@ describe('IPC Handlers', () => { }); describe('blog:applyValidation', () => { - it('should run apply via taskManager.runTask', async () => { + it('should run grouped tasks via taskManager.runTask', async () => { const mockProject = createMockProject({ id: 'test-project', dataPath: '/mock/data' }); mockProjectEngine.getActiveProject.mockResolvedValue(mockProject); mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir'); @@ -3266,9 +3266,12 @@ describe('IPC Handlers', () => { existingHtmlUrlCount: 1, }); + // Should run preparation as a grouped task expect(mockTaskManager.runTask).toHaveBeenCalledWith( expect.objectContaining({ - name: 'Apply Site Validation', + name: 'Prepare Validation Apply', + groupId: expect.stringContaining('site-validate-apply-'), + groupName: 'Apply Site Validation', execute: expect.any(Function), }), ); diff --git a/tests/renderer/components/EditorCanonicalLanguageFocus.test.tsx b/tests/renderer/components/EditorCanonicalLanguageFocus.test.tsx new file mode 100644 index 0000000..29fb9a0 --- /dev/null +++ b/tests/renderer/components/EditorCanonicalLanguageFocus.test.tsx @@ -0,0 +1,293 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; + +let monacoOnChange: ((value: string | undefined) => void) | null = null; + +vi.mock('@monaco-editor/react', () => ({ + default: (props: { value?: string; defaultValue?: string; onChange?: (value: string | undefined) => void; key?: string | number }) => { + monacoOnChange = props.onChange ?? null; + return
; + }, +})); + +vi.mock('@milkdown/kit/core', () => { + const makeChain = () => { + const chain = { + config: (callback: (ctx: { set: () => void; get: () => { markdownUpdated: () => void } }) => void) => { + callback({ set: () => {}, get: () => ({ markdownUpdated: () => {} }) }); + return chain; + }, + use: () => chain, + }; + return chain; + }; + return { + Editor: { make: makeChain }, + defaultValueCtx: Symbol('defaultValueCtx'), + editorViewCtx: Symbol('editorViewCtx'), + rootCtx: Symbol('rootCtx'), + remarkStringifyOptionsCtx: Symbol('remarkStringifyOptionsCtx'), + remarkPluginsCtx: Symbol('remarkPluginsCtx'), + }; +}); + +vi.mock('@milkdown/kit/preset/commonmark', () => ({ + commonmark: {}, + toggleStrongCommand: { key: 'toggleStrong' }, + toggleEmphasisCommand: { key: 'toggleEmphasis' }, + wrapInBlockquoteCommand: { key: 'wrapInBlockquote' }, + wrapInBulletListCommand: { key: 'wrapInBulletList' }, + wrapInOrderedListCommand: { key: 'wrapInOrderedList' }, + insertHrCommand: { key: 'insertHr' }, + toggleInlineCodeCommand: { key: 'toggleInlineCode' }, + insertImageCommand: { key: 'insertImage' }, + toggleLinkCommand: { key: 'toggleLink' }, +})); + +vi.mock('@milkdown/kit/preset/gfm', () => ({ + gfm: {}, + toggleStrikethroughCommand: { key: 'toggleStrike' }, +})); + +vi.mock('@milkdown/kit/plugin/history', () => ({ + history: {}, + undoCommand: { key: 'undo' }, + redoCommand: { key: 'redo' }, +})); + +vi.mock('@milkdown/kit/plugin/listener', () => ({ + listener: {}, + listenerCtx: Symbol('listenerCtx'), +})); + +vi.mock('@milkdown/kit/plugin/clipboard', () => ({ clipboard: {} })); +vi.mock('@milkdown/kit/plugin/trailing', () => ({ trailing: {} })); +vi.mock('@milkdown/kit/plugin/indent', () => ({ indent: {} })); +vi.mock('@milkdown/kit/plugin/cursor', () => ({ cursor: {} })); + +vi.mock('@milkdown/kit/utils', () => ({ + $node: () => ({}), + $inputRule: () => ({}), + $remark: () => ({}), + $prose: () => ({}), + replaceAll: () => () => {}, + callCommand: () => () => {}, +})); + +vi.mock('@milkdown/react', () => ({ + Milkdown: () =>
, + MilkdownProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + useInstance: () => [false, () => ({ action: (action: unknown) => { + if (typeof action === 'function') { + action({ get: () => ({}) }); + } + } })] as const, + useEditor: (factory: (root: Node) => unknown) => { + factory(document.createElement('div')); + }, +})); + +vi.mock('../../../src/renderer/components/Lightbox', () => ({ + Lightbox: () => null, + useMarkdownImages: () => [], +})); +vi.mock('../../../src/renderer/components/PostLinks', () => ({ PostLinks: () => null })); +vi.mock('../../../src/renderer/components/LinkedMediaPanel', () => ({ LinkedMediaPanel: () => null })); +vi.mock('../../../src/renderer/components/ErrorModal', () => ({ ErrorModal: () => null })); +vi.mock('../../../src/renderer/components/ConfirmDeleteModal', () => ({ ConfirmDeleteModal: () => null })); +vi.mock('../../../src/renderer/components/SettingsView', () => ({ SettingsView: () => null })); +vi.mock('../../../src/renderer/components/TagsView', () => ({ TagsView: () => null })); +vi.mock('../../../src/renderer/components/TagInput', () => ({ TagInput: () => null })); +vi.mock('../../../src/renderer/components/ChatPanel', () => ({ ChatPanel: () => null })); +vi.mock('../../../src/renderer/components/ImportAnalysisView', () => ({ ImportAnalysisView: () => null })); +vi.mock('../../../src/renderer/components/MetadataDiffPanel', () => ({ MetadataDiffPanel: () => null })); +vi.mock('../../../src/renderer/components/GitDiffView/GitDiffView', () => ({ GitDiffView: () => null })); +vi.mock('../../../src/renderer/components/InsertModal', () => ({ InsertModal: () => null })); +vi.mock('../../../src/renderer/components/AISuggestionsModal/AISuggestionsModal', () => ({ + AISuggestionsModal: () => null, +})); +vi.mock('../../../src/renderer/components/Toast', () => ({ + showToast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, +})); + +import { PostEditor } from '../../../src/renderer/components/Editor/PostEditor'; +import { useAppStore } from '../../../src/renderer/store'; + +const createPost = (overrides: Record = {}) => ({ + id: 'post-1', + title: 'Bookmarklet Post', + content: '[Example](https://example.com)', + excerpt: '', + slug: 'bookmarklet-post', + status: 'draft' as const, + tags: [], + categories: ['article'], + featuredImage: null, + publishedAt: null, + createdAt: new Date('2026-03-13T12:00:00.000Z'), + updatedAt: new Date('2026-03-13T12:00:00.000Z'), + author: undefined, + metadata: {}, + seoTitle: undefined, + seoDescription: undefined, + canonicalUrl: undefined, + projectId: 'project-1', + filePath: '', + ...overrides, +}); + +describe('Editor keeps canonical language focus on post creation', () => { + let metadataResolve: (value: unknown) => void; + let eventHandlers: Map; + + beforeEach(() => { + monacoOnChange = null; + eventHandlers = new Map(); + vi.clearAllMocks(); + + const neverSettles = new Promise(() => {}); + + (window as any).addEventListener = vi.fn((event: string, handler: EventListener) => { + if (!eventHandlers.has(event)) eventHandlers.set(event, []); + eventHandlers.get(event)!.push(handler); + }); + (window as any).removeEventListener = vi.fn((event: string, handler: EventListener) => { + const handlers = eventHandlers.get(event); + if (handlers) { + const idx = handlers.indexOf(handler); + if (idx >= 0) handlers.splice(idx, 1); + } + }); + const metadataPromise = new Promise((resolve) => { + metadataResolve = resolve; + }); + + (window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({ language: null })); + (window as any).electronAPI.posts.hasPublishedVersion = vi.fn().mockReturnValue(neverSettles); + (window as any).electronAPI.posts.update = vi.fn().mockResolvedValue(null); + (window as any).electronAPI.posts.getPreviewUrl = vi.fn().mockResolvedValue('http://localhost:4123/preview'); + (window as any).electronAPI.posts.getTranslations = vi.fn().mockResolvedValue([]); + (window as any).electronAPI.posts.upsertTranslation = vi.fn().mockResolvedValue(null); + (window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles); + (window as any).electronAPI.meta.getProjectMetadata = vi.fn().mockReturnValue(metadataPromise); + (window as any).electronAPI.templates.getEnabledByKind = vi.fn().mockResolvedValue([]); + + useAppStore.setState({ + activeProject: { id: 'project-1', name: 'Test Blog', path: '/tmp/test' } as any, + preferredEditorMode: 'markdown', + posts: [], + media: [], + dirtyPosts: new Set(), + isLoading: false, + }); + }); + + afterEach(() => { + useAppStore.setState({ + dirtyPosts: new Set(), + }); + }); + + it('updates activeEditingLanguage when projectLanguage loads after initialization', async () => { + render(); + + // Wait for post data to load and initialization to complete + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // Before metadata resolves, the active flag should be 'en' (default projectLanguage) + let flagContainer = screen.getByLabelText(/translations/i); + let activeButton = flagContainer.querySelector('.active'); + expect(activeButton).toBeTruthy(); + expect(activeButton?.getAttribute('aria-label')).toContain('English'); + + // Now resolve projectMetadata with mainLanguage = 'de' + await act(async () => { + metadataResolve({ mainLanguage: 'de' }); + // Let React process the state updates + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // After metadata resolves, the active flag should be 'de' (the canonical language) + // Re-query DOM since buttons may have been re-rendered + flagContainer = screen.getByLabelText(/translations/i); + activeButton = flagContainer.querySelector('.active'); + expect(activeButton).toBeTruthy(); + expect(activeButton?.getAttribute('aria-label')).toContain('German'); + }); + + it('does not create translation draft when typing after projectLanguage loads', async () => { + render(); + + // Wait for post data to load and initialization to complete + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // Resolve projectMetadata with mainLanguage = 'de' + await act(async () => { + metadataResolve({ mainLanguage: 'de' }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // User types in the editor - this should go to canonical, not create a translation + await act(async () => { + monacoOnChange?.('[Example](https://example.com)\n\nNew paragraph'); + }); + + // No translation flag should appear for 'en' translation + const flagContainer = screen.getByLabelText(/translations/i); + const allButtons = flagContainer.querySelectorAll('button'); + // Should only have 1 button (canonical 'de'), no 'en' translation button + expect(allButtons).toHaveLength(1); + }); + + it('keeps canonical language focus when post has explicit language matching project', async () => { + // Post created via IPC handler with language set explicitly + (window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({ language: 'de' })); + + render(); + + // Wait for initialization + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // Resolve projectMetadata (even though post already has language) + await act(async () => { + metadataResolve({ mainLanguage: 'de' }); + await Promise.resolve(); + await Promise.resolve(); + }); + + // The active flag should be 'de' (canonical) + const flagContainer = screen.getByLabelText(/translations/i); + const activeButton = flagContainer.querySelector('.active'); + expect(activeButton).toBeTruthy(); + expect(activeButton?.getAttribute('aria-label')).toContain('German'); + + // Typing should not create translations + await act(async () => { + monacoOnChange?.('[Example](https://example.com)\n\nNew content'); + }); + + const allButtons = screen.getByLabelText(/translations/i).querySelectorAll('button'); + expect(allButtons).toHaveLength(1); + }); +});