diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 528167f..ae61ee3 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -571,7 +571,55 @@ export function registerIpcHandlers(bundle: EngineBundle): void { }); // ============ Post Handlers ============ - + + // Auto-translate: enqueue translation tasks for each blog language that does + // not yet have a translation. Only triggered on manual save or publish. + const enqueueAutoTranslations = async (post: PostData): Promise => { + if (post.doNotTranslate) return; + const metadata = await bundle.metaEngine.getProjectMetadata(); + 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 bundle.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); + }); + } + }; + safeHandle('posts:create', async (_, data: Partial) => { const engine = bundle.postEngine; @@ -611,6 +659,13 @@ export function registerIpcHandlers(bundle: EngineBundle): void { return engine.updatePost(id, data); }); + safeHandle('posts:requestAutoTranslation', async (_, id: string) => { + const post = await bundle.postEngine.getPost(id); + if (post) { + await enqueueAutoTranslations(post); + } + }); + safeHandle('posts:delete', async (_, id: string) => { const engine = bundle.postEngine; return engine.deletePost(id); @@ -683,7 +738,11 @@ export function registerIpcHandlers(bundle: EngineBundle): void { safeHandle('posts:publish', async (_, id: string) => { const engine = bundle.postEngine; - return engine.publishPost(id); + const published = await engine.publishPost(id); + if (published) { + enqueueAutoTranslations(published); + } + return published; }); safeHandle('posts:discard', async (_, id: string) => { @@ -1962,58 +2021,6 @@ 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')); diff --git a/src/main/preload.ts b/src/main/preload.ts index 3fae8eb..2d7471a 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -81,6 +81,7 @@ export const electronAPI: ElectronAPI = { rebuildLinks: () => ipcRenderer.invoke('posts:rebuildLinks'), isSlugAvailable: (slug: string, excludePostId?: string) => ipcRenderer.invoke('posts:isSlugAvailable', slug, excludePostId), generateUniqueSlug: (title: string, excludePostId?: string) => ipcRenderer.invoke('posts:generateUniqueSlug', title, excludePostId), + requestAutoTranslation: (id: string) => ipcRenderer.invoke('posts:requestAutoTranslation', id), }, // Media diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index b774099..292f2ab 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -686,6 +686,7 @@ export interface ElectronAPI { rebuildLinks: () => Promise; isSlugAvailable: (slug: string, excludePostId?: string) => Promise; generateUniqueSlug: (title: string, excludePostId?: string) => Promise; + requestAutoTranslation: (id: string) => Promise; }; media: { import: (sourcePath: string, metadata?: Partial) => Promise; diff --git a/src/renderer/components/Editor/PostEditor.tsx b/src/renderer/components/Editor/PostEditor.tsx index 73fe353..bc58038 100644 --- a/src/renderer/components/Editor/PostEditor.tsx +++ b/src/renderer/components/Editor/PostEditor.tsx @@ -734,6 +734,9 @@ export const PostEditor: React.FC = ({ postId }) => { updatePost(postId, { status: 'draft' } as Partial); } markClean(postId); + + // Trigger auto-translation for missing languages on manual save + window.electronAPI?.posts.requestAutoTranslation(postId).catch(() => {}); } catch (error) { console.error('Failed to save post:', error); const err = error as Error; diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index 9e8f0b7..94ab5df 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -3572,4 +3572,147 @@ describe('IPC Handlers', () => { await expect(invokeHandler('projects:getAll')).rejects.toThrow('Something went wrong'); }); }); + + // ============ Auto-Translation Trigger Tests ============ + describe('posts:requestAutoTranslation', () => { + it('should enqueue translation tasks for missing languages', async () => { + const post = createMockPost({ id: 'post-1', title: 'Test Post', language: 'en' }); + mockPostEngine.getPost.mockResolvedValue(post); + mockMetaEngine.getProjectMetadata.mockResolvedValue({ + mainLanguage: 'en', + blogLanguages: ['en', 'de'], + }); + mockPostEngine.getPostTranslations.mockResolvedValue([]); + mockPostMediaEngine.getLinkedMediaForPost.mockResolvedValue([]); + mockTaskManager.runTask.mockResolvedValue(undefined); + + await invokeHandler('posts:requestAutoTranslation', 'post-1'); + + expect(mockPostEngine.getPost).toHaveBeenCalledWith('post-1'); + expect(mockTaskManager.runTask).toHaveBeenCalledTimes(1); + expect(mockTaskManager.runTask).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringContaining('de'), + }), + ); + }); + + it('should skip translation when post has doNotTranslate flag', async () => { + const post = createMockPost({ id: 'post-1', title: 'Test Post', language: 'en', doNotTranslate: true }); + mockPostEngine.getPost.mockResolvedValue(post); + mockMetaEngine.getProjectMetadata.mockResolvedValue({ + mainLanguage: 'en', + blogLanguages: ['en', 'de'], + }); + + await invokeHandler('posts:requestAutoTranslation', 'post-1'); + + expect(mockTaskManager.runTask).not.toHaveBeenCalled(); + }); + + it('should skip translation when post does not exist', async () => { + mockPostEngine.getPost.mockResolvedValue(null); + + await invokeHandler('posts:requestAutoTranslation', 'nonexistent'); + + expect(mockTaskManager.runTask).not.toHaveBeenCalled(); + }); + + it('should skip when all translations already exist', async () => { + const post = createMockPost({ id: 'post-1', title: 'Test Post', language: 'en' }); + mockPostEngine.getPost.mockResolvedValue(post); + mockMetaEngine.getProjectMetadata.mockResolvedValue({ + mainLanguage: 'en', + blogLanguages: ['en', 'de'], + }); + mockPostEngine.getPostTranslations.mockResolvedValue([{ language: 'de' }]); + + await invokeHandler('posts:requestAutoTranslation', 'post-1'); + + expect(mockTaskManager.runTask).not.toHaveBeenCalled(); + }); + }); + + describe('posts:publish auto-translation', () => { + it('should enqueue missing translations after publishing a post', async () => { + const publishedPost = createMockPost({ id: 'post-1', title: 'Published Post', status: 'published', language: 'en' }); + mockPostEngine.publishPost.mockResolvedValue(publishedPost); + mockMetaEngine.getProjectMetadata.mockResolvedValue({ + mainLanguage: 'en', + blogLanguages: ['en', 'de', 'fr'], + }); + mockPostEngine.getPostTranslations.mockResolvedValue([{ language: 'de' }]); + mockPostMediaEngine.getLinkedMediaForPost.mockResolvedValue([]); + mockTaskManager.runTask.mockResolvedValue(undefined); + + await invokeHandler('posts:publish', 'post-1'); + + // Should only enqueue for fr (de already exists) + expect(mockTaskManager.runTask).toHaveBeenCalledTimes(1); + expect(mockTaskManager.runTask).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringContaining('fr'), + }), + ); + }); + + it('should not enqueue translations when published post has doNotTranslate', async () => { + const publishedPost = createMockPost({ id: 'post-1', title: 'Published Post', status: 'published', doNotTranslate: true }); + mockPostEngine.publishPost.mockResolvedValue(publishedPost); + + await invokeHandler('posts:publish', 'post-1'); + + expect(mockTaskManager.runTask).not.toHaveBeenCalled(); + }); + }); + + describe('auto-translation should not trigger on postCreated/postUpdated events', () => { + it('should not trigger auto-translation when postCreated event handlers are called', async () => { + mockMetaEngine.getProjectMetadata.mockResolvedValue({ + mainLanguage: 'en', + blogLanguages: ['en', 'de'], + }); + mockPostEngine.getPostTranslations.mockResolvedValue([]); + mockPostMediaEngine.getLinkedMediaForPost.mockResolvedValue([]); + + // Simulate calling all postCreated handlers that were registered + const postCreatedCalls = mockPostEngine.on.mock.calls.filter( + ([event]: [string]) => event === 'postCreated' + ); + const post = createMockPost({ id: 'event-post-1', title: 'Event Post', language: 'en' }); + for (const [, handler] of postCreatedCalls) { + await handler(post); + } + + // Wait for any async work (enqueueAutoTranslations uses .then()) + await new Promise(r => setTimeout(r, 50)); + + // No auto-translation tasks should have been enqueued via taskManager + expect(mockTaskManager.runTask).not.toHaveBeenCalled(); + }); + + it('should not trigger auto-translation when postUpdated event handlers are called', async () => { + mockMetaEngine.getProjectMetadata.mockResolvedValue({ + mainLanguage: 'en', + blogLanguages: ['en', 'de'], + }); + mockPostEngine.getPostTranslations.mockResolvedValue([]); + mockPostMediaEngine.getLinkedMediaForPost.mockResolvedValue([]); + + // Simulate calling all postUpdated handlers that were registered + const postUpdatedCalls = mockPostEngine.on.mock.calls.filter( + ([event]: [string]) => event === 'postUpdated' + ); + const post = createMockPost({ id: 'event-post-2', title: 'Event Post 2', language: 'en' }); + for (const [, handler] of postUpdatedCalls) { + await handler(post); + } + + // Wait for any async work (enqueueAutoTranslations uses .then()) + await new Promise(r => setTimeout(r, 50)); + + // No auto-translation tasks should have been enqueued via taskManager + expect(mockTaskManager.runTask).not.toHaveBeenCalled(); + }); + }); });