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:
@@ -572,6 +572,54 @@ 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'));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user