fix: don't auto-translate on auto-save, only on manual save (#49)

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-12 21:37:45 +01:00
committed by GitHub
parent ab91ec1848
commit 6a8d38d5a2
5 changed files with 209 additions and 54 deletions

View File

@@ -571,7 +571,55 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
}); });
// ============ Post Handlers ============ // ============ 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<void> => {
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<PostData>) => { safeHandle('posts:create', async (_, data: Partial<PostData>) => {
const engine = bundle.postEngine; const engine = bundle.postEngine;
@@ -611,6 +659,13 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
return engine.updatePost(id, data); 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) => { safeHandle('posts:delete', async (_, id: string) => {
const engine = bundle.postEngine; const engine = bundle.postEngine;
return engine.deletePost(id); return engine.deletePost(id);
@@ -683,7 +738,11 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
safeHandle('posts:publish', async (_, id: string) => { safeHandle('posts:publish', async (_, id: string) => {
const engine = bundle.postEngine; 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) => { safeHandle('posts:discard', async (_, id: string) => {
@@ -1962,58 +2021,6 @@ export function registerEventForwarding(bundle: EngineBundle): void {
postEngine.on('rebuildStarted', forwardEvent('posts:rebuildStarted')); postEngine.on('rebuildStarted', forwardEvent('posts:rebuildStarted'));
postEngine.on('databaseRebuilt', forwardEvent('posts:databaseRebuilt')); 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('mediaImported', forwardEvent('media:imported'));
mediaEngine.on('mediaUpdated', forwardEvent('media:updated')); mediaEngine.on('mediaUpdated', forwardEvent('media:updated'));
mediaEngine.on('mediaDeleted', forwardEvent('media:deleted')); mediaEngine.on('mediaDeleted', forwardEvent('media:deleted'));

View File

@@ -81,6 +81,7 @@ export const electronAPI: ElectronAPI = {
rebuildLinks: () => ipcRenderer.invoke('posts:rebuildLinks'), rebuildLinks: () => ipcRenderer.invoke('posts:rebuildLinks'),
isSlugAvailable: (slug: string, excludePostId?: string) => ipcRenderer.invoke('posts:isSlugAvailable', slug, excludePostId), isSlugAvailable: (slug: string, excludePostId?: string) => ipcRenderer.invoke('posts:isSlugAvailable', slug, excludePostId),
generateUniqueSlug: (title: string, excludePostId?: string) => ipcRenderer.invoke('posts:generateUniqueSlug', title, excludePostId), generateUniqueSlug: (title: string, excludePostId?: string) => ipcRenderer.invoke('posts:generateUniqueSlug', title, excludePostId),
requestAutoTranslation: (id: string) => ipcRenderer.invoke('posts:requestAutoTranslation', id),
}, },
// Media // Media

View File

@@ -686,6 +686,7 @@ export interface ElectronAPI {
rebuildLinks: () => Promise<void>; rebuildLinks: () => Promise<void>;
isSlugAvailable: (slug: string, excludePostId?: string) => Promise<boolean>; isSlugAvailable: (slug: string, excludePostId?: string) => Promise<boolean>;
generateUniqueSlug: (title: string, excludePostId?: string) => Promise<string>; generateUniqueSlug: (title: string, excludePostId?: string) => Promise<string>;
requestAutoTranslation: (id: string) => Promise<void>;
}; };
media: { media: {
import: (sourcePath: string, metadata?: Partial<MediaData>) => Promise<MediaData>; import: (sourcePath: string, metadata?: Partial<MediaData>) => Promise<MediaData>;

View File

@@ -734,6 +734,9 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
updatePost(postId, { status: 'draft' } as Partial<PostData>); updatePost(postId, { status: 'draft' } as Partial<PostData>);
} }
markClean(postId); markClean(postId);
// Trigger auto-translation for missing languages on manual save
window.electronAPI?.posts.requestAutoTranslation(postId).catch(() => {});
} catch (error) { } catch (error) {
console.error('Failed to save post:', error); console.error('Failed to save post:', error);
const err = error as Error; const err = error as Error;

View File

@@ -3572,4 +3572,147 @@ describe('IPC Handlers', () => {
await expect(invokeHandler('projects:getAll')).rejects.toThrow('Something went wrong'); 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();
});
});
}); });