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:
@@ -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<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>) => {
|
||||
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'));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -686,6 +686,7 @@ export interface ElectronAPI {
|
||||
rebuildLinks: () => Promise<void>;
|
||||
isSlugAvailable: (slug: string, excludePostId?: string) => Promise<boolean>;
|
||||
generateUniqueSlug: (title: string, excludePostId?: string) => Promise<string>;
|
||||
requestAutoTranslation: (id: string) => Promise<void>;
|
||||
};
|
||||
media: {
|
||||
import: (sourcePath: string, metadata?: Partial<MediaData>) => Promise<MediaData>;
|
||||
|
||||
@@ -734,6 +734,9 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
||||
updatePost(postId, { status: 'draft' } as Partial<PostData>);
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user