From ab8f4c20a8fcc8ed7f3c76f48cd0454d1c3bb3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrien=20Boull=C3=A9?= <14262895+adrienboulle@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:24:44 +0200 Subject: [PATCH 1/2] fix(merge-pdf): route result notifications through i18n Fixes #825 --- public/locales/en/tools.json | 16 +++++++- public/locales/fr/tools.json | 16 +++++++- src/js/logic/merge-pdf-page.ts | 31 +++++++------- src/tests/merge-pdf-i18n.test.ts | 70 ++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 17 deletions(-) create mode 100644 src/tests/merge-pdf-i18n.test.ts diff --git a/public/locales/en/tools.json b/public/locales/en/tools.json index 4976b13d..a3aa07e9 100644 --- a/public/locales/en/tools.json +++ b/public/locales/en/tools.json @@ -20,7 +20,21 @@ "subtitle": "Combine multiple PDFs into one file. Preserves Bookmarks.", "retainPageLabels": "Keep original page labels from each file", "retainPageLabelsHint": "Off (default): the merged PDF uses natural page numbering 1–N. On: each file's original page labels are preserved (may produce duplicate labels).", - "removeDuplicateFonts": "Remove duplicate fonts" + "removeDuplicateFonts": "Remove duplicate fonts", + "alert": { + "thumbnailError": "Failed to render page thumbnails", + "noSelection": "No files or pages selected to merge.", + "mergeSuccess": "PDFs merged successfully!", + "mergeFailed": "Failed to merge PDFs.", + "mergeError": "Failed to merge PDFs. Please check that all files are valid and not password-protected.", + "workerError": "An unexpected error occurred in the merge worker.", + "loadError": "Failed to load one or more PDF files" + }, + "loader": { + "renderingPreviews": "Rendering page previews...", + "merging": "Merging PDFs...", + "loadingDocuments": "Loading PDF documents..." + } }, "splitPdf": { "name": "Split PDF", diff --git a/public/locales/fr/tools.json b/public/locales/fr/tools.json index 86813f7b..ebbcbe65 100644 --- a/public/locales/fr/tools.json +++ b/public/locales/fr/tools.json @@ -19,7 +19,21 @@ "name": "Fusionner des PDF", "subtitle": "Assembler plusieurs PDF en un seul fichier, tout en conservant les signets.", "retainPageLabels": "Conserver les étiquettes de page d'origine de chaque fichier", - "retainPageLabelsHint": "Désactivé (par défaut) : le PDF fusionné utilise une numérotation naturelle 1–N. Activé : les étiquettes de page d'origine de chaque fichier sont conservées (peut produire des étiquettes en double)." + "retainPageLabelsHint": "Désactivé (par défaut) : le PDF fusionné utilise une numérotation naturelle 1–N. Activé : les étiquettes de page d'origine de chaque fichier sont conservées (peut produire des étiquettes en double).", + "alert": { + "thumbnailError": "Échec du rendu des aperçus de pages", + "noSelection": "Aucun fichier ni page sélectionné pour la fusion.", + "mergeSuccess": "PDF fusionnés avec succès !", + "mergeFailed": "Échec de la fusion des PDF.", + "mergeError": "Échec de la fusion des PDF. Veuillez vérifier que tous les fichiers sont valides et non protégés par mot de passe.", + "workerError": "Une erreur inattendue s'est produite dans le worker de fusion.", + "loadError": "Impossible de charger un ou plusieurs fichiers PDF" + }, + "loader": { + "renderingPreviews": "Génération des aperçus de pages…", + "merging": "Fusion des PDF…", + "loadingDocuments": "Chargement des documents PDF…" + } }, "splitPdf": { "name": "Scinder un PDF", diff --git a/src/js/logic/merge-pdf-page.ts b/src/js/logic/merge-pdf-page.ts index 18a4303d..56f228df 100644 --- a/src/js/logic/merge-pdf-page.ts +++ b/src/js/logic/merge-pdf-page.ts @@ -12,6 +12,7 @@ import { showWasmRequiredDialog, WasmProvider, } from '../utils/wasm-provider.js'; +import { t } from '../i18n/i18n'; import { createIcons, icons } from 'lucide'; import * as pdfjsLib from 'pdfjs-dist'; @@ -199,7 +200,7 @@ async function renderPageMergeThumbnails() { lazyLoadMargin: '300px', onProgress: () => { currentPageNumber++; - showLoader(`Rendering page previews...`); + showLoader(t('tools:mergePdf.loader.renderingPreviews')); }, onBatchComplete: () => { createIcons({ icons }); @@ -216,7 +217,7 @@ async function renderPageMergeThumbnails() { initializePageThumbnailsSortable(); } catch (error) { console.error('Error rendering page thumbnails:', error); - showAlert('Error', 'Failed to render page thumbnails'); + showAlert(t('common.error'), t('tools:mergePdf.alert.thumbnailError')); } finally { hideLoader(); mergeState.isRendering = false; @@ -282,7 +283,7 @@ export async function merge() { return; } - showLoader('Merging PDFs...'); + showLoader(t('tools:mergePdf.loader.merging')); try { const jobs: MergeJob[] = []; const filesToMerge: MergeFile[] = []; @@ -369,7 +370,7 @@ export async function merge() { } if (jobs.length === 0) { - showAlert('Error', 'No files or pages selected to merge.'); + showAlert(t('common.error'), t('tools:mergePdf.alert.noSelection')); hideLoader(); return; } @@ -410,8 +411,8 @@ export async function merge() { downloadFile(blob, 'merged.pdf'); mergeState.mergeSuccess = true; showAlert( - 'Success', - 'PDFs merged successfully!', + t('common.success'), + t('tools:mergePdf.alert.mergeSuccess'), 'success', async () => { await resetState(); @@ -419,21 +420,21 @@ export async function merge() { ); } else { console.error('Worker merge error:', e.data.message); - showAlert('Error', e.data.message || 'Failed to merge PDFs.'); + showAlert( + t('common.error'), + e.data.message || t('tools:mergePdf.alert.mergeFailed') + ); } }; mergeWorker.onerror = (e) => { hideLoader(); console.error('Worker error:', e); - showAlert('Error', 'An unexpected error occurred in the merge worker.'); + showAlert(t('common.error'), t('tools:mergePdf.alert.workerError')); }; } catch (e) { console.error('Merge error:', e); - showAlert( - 'Error', - 'Failed to merge PDFs. Please check that all files are valid and not password-protected.' - ); + showAlert(t('common.error'), t('tools:mergePdf.alert.mergeError')); hideLoader(); } } @@ -447,14 +448,14 @@ export async function refreshMergeUI() { const wasInPageMode = mergeState.activeMode === 'page'; - showLoader('Loading PDF documents...'); + showLoader(t('tools:mergePdf.loader.loadingDocuments')); try { mergeState.pdfDocs = {}; mergeState.pdfBytes = {}; hideLoader(); state.files = await batchDecryptIfNeeded(state.files); - showLoader('Loading PDF documents...'); + showLoader(t('tools:mergePdf.loader.loadingDocuments')); for (let i = 0; i < state.files.length; i++) { const file = state.files[i]; @@ -472,7 +473,7 @@ export async function refreshMergeUI() { } } catch (error) { console.error('Error loading PDFs:', error); - showAlert('Error', 'Failed to load one or more PDF files'); + showAlert(t('common.error'), t('tools:mergePdf.alert.loadError')); return; } finally { hideLoader(); diff --git a/src/tests/merge-pdf-i18n.test.ts b/src/tests/merge-pdf-i18n.test.ts new file mode 100644 index 00000000..d1a41de8 --- /dev/null +++ b/src/tests/merge-pdf-i18n.test.ts @@ -0,0 +1,70 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const ROOT = process.cwd(); +const LOGIC_FILE = join(ROOT, 'src/js/logic/merge-pdf-page.ts'); +const CHECKED_LANGUAGES = ['en', 'fr'] as const; + +const source = readFileSync(LOGIC_FILE, 'utf8'); + +const loadLocale = (lang: string, namespace: 'common' | 'tools') => + JSON.parse( + readFileSync( + join(ROOT, 'public/locales', lang, `${namespace}.json`), + 'utf8' + ) + ) as Record; + +const resolveKey = (bundle: Record, key: string): unknown => + key + .split('.') + .reduce( + (node, part) => + node && typeof node === 'object' + ? (node as Record)[part] + : undefined, + bundle + ); + +const usedKeys = [...source.matchAll(/\bt\('([^']+)'/g)].map( + (match) => match[1] +); + +describe('merge PDF notifications are localized', () => { + it('uses translation keys for every alert and loader message', () => { + const literalCalls = [ + ...source.matchAll(/\bshow(?:Alert|Loader)\(\s*['"`]/g), + ]; + expect(literalCalls).toHaveLength(0); + }); + + it('references at least the alert and loader keys added for the merge tool', () => { + expect(usedKeys).toContain('tools:mergePdf.alert.mergeSuccess'); + expect(usedKeys).toContain('tools:mergePdf.loader.merging'); + expect(usedKeys).toContain('common.success'); + expect(usedKeys).toContain('common.error'); + }); + + for (const lang of CHECKED_LANGUAGES) { + it(`resolves every key used by the merge tool in "${lang}"`, () => { + const bundles = { + common: loadLocale(lang, 'common'), + tools: loadLocale(lang, 'tools'), + }; + + const missing = usedKeys.filter((key) => { + const [namespace, plainKey] = key.startsWith('tools:') + ? ['tools', key.slice('tools:'.length)] + : ['common', key]; + const value = resolveKey( + bundles[namespace as 'common' | 'tools'], + plainKey + ); + return typeof value !== 'string' || value.length === 0; + }); + + expect(missing).toEqual([]); + }); + } +}); From 448035b77b0d43f5c8ef09af722df443b39c40b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrien=20Boull=C3=A9?= <14262895+adrienboulle@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:59:13 +0200 Subject: [PATCH 2/2] fix(merge-pdf): keep worker errors out of the alert, add missing fr key --- public/locales/fr/tools.json | 1 + src/js/logic/merge-pdf-page.ts | 7 +++---- src/tests/merge-pdf-i18n.test.ts | 7 +++++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/public/locales/fr/tools.json b/public/locales/fr/tools.json index ebbcbe65..f8f1fbe1 100644 --- a/public/locales/fr/tools.json +++ b/public/locales/fr/tools.json @@ -20,6 +20,7 @@ "subtitle": "Assembler plusieurs PDF en un seul fichier, tout en conservant les signets.", "retainPageLabels": "Conserver les étiquettes de page d'origine de chaque fichier", "retainPageLabelsHint": "Désactivé (par défaut) : le PDF fusionné utilise une numérotation naturelle 1–N. Activé : les étiquettes de page d'origine de chaque fichier sont conservées (peut produire des étiquettes en double).", + "removeDuplicateFonts": "Supprimer les polices en double", "alert": { "thumbnailError": "Échec du rendu des aperçus de pages", "noSelection": "Aucun fichier ni page sélectionné pour la fusion.", diff --git a/src/js/logic/merge-pdf-page.ts b/src/js/logic/merge-pdf-page.ts index 56f228df..94082978 100644 --- a/src/js/logic/merge-pdf-page.ts +++ b/src/js/logic/merge-pdf-page.ts @@ -419,11 +419,10 @@ export async function merge() { } ); } else { + // The worker has no access to i18next: its message is technical and + // English-only, so it goes to the console, not to the user. console.error('Worker merge error:', e.data.message); - showAlert( - t('common.error'), - e.data.message || t('tools:mergePdf.alert.mergeFailed') - ); + showAlert(t('common.error'), t('tools:mergePdf.alert.mergeFailed')); } }; diff --git a/src/tests/merge-pdf-i18n.test.ts b/src/tests/merge-pdf-i18n.test.ts index d1a41de8..2be2416d 100644 --- a/src/tests/merge-pdf-i18n.test.ts +++ b/src/tests/merge-pdf-i18n.test.ts @@ -39,6 +39,13 @@ describe('merge PDF notifications are localized', () => { expect(literalCalls).toHaveLength(0); }); + it('never surfaces the raw worker message to the user', () => { + const rawWorkerMessageInAlert = [ + ...source.matchAll(/\bshowAlert\([^;]*?\be\.data\.message/gs), + ]; + expect(rawWorkerMessageInAlert).toHaveLength(0); + }); + it('references at least the alert and loader keys added for the merge tool', () => { expect(usedKeys).toContain('tools:mergePdf.alert.mergeSuccess'); expect(usedKeys).toContain('tools:mergePdf.loader.merging');