From accc76d8a2a78e555ca22adfc44830bdba71646a Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 15 Jul 2025 10:31:14 +0200 Subject: [PATCH] Add dynamic .json translations for content script (#1006) --- .../src/entrypoints/background/ContextMenu.ts | 8 +- .../src/entrypoints/content.ts | 5 +- .../src/entrypoints/contentScript/Popup.ts | 77 +++---- .../src/entrypoints/popup/pages/Unlock.tsx | 2 +- .../src/locales/en/auth.json | 1 + .../src/locales/en/common.json | 16 +- .../src/locales/en/content.json | 39 ++++ .../src/locales/nl/common.json | 16 +- .../src/locales/nl/content.json | 31 +++ .../src/utils/contentTranslations.ts | 168 --------------- .../src/utils/i18n/StandaloneI18n.ts | 196 ++++++++++++++++++ 11 files changed, 344 insertions(+), 215 deletions(-) create mode 100644 apps/browser-extension/src/locales/en/content.json create mode 100644 apps/browser-extension/src/locales/nl/content.json delete mode 100644 apps/browser-extension/src/utils/contentTranslations.ts create mode 100644 apps/browser-extension/src/utils/i18n/StandaloneI18n.ts diff --git a/apps/browser-extension/src/entrypoints/background/ContextMenu.ts b/apps/browser-extension/src/entrypoints/background/ContextMenu.ts index a7ee1ac7a..10f64a086 100644 --- a/apps/browser-extension/src/entrypoints/background/ContextMenu.ts +++ b/apps/browser-extension/src/entrypoints/background/ContextMenu.ts @@ -1,8 +1,8 @@ import { type Browser } from '@wxt-dev/browser'; import { sendMessage } from 'webext-bridge/background'; -import { t } from '@/utils/contentTranslations'; import { PasswordGenerator } from '@/utils/dist/shared/password-generator'; +import { tc } from '@/utils/i18n/StandaloneI18n'; import { browser } from "#imports"; @@ -21,7 +21,7 @@ export async function setupContextMenus() : Promise { browser.contextMenus.create({ id: "aliasvault-activate-form", parentId: "aliasvault-root", - title: await t('autofillWithAliasVault'), + title: await tc('autofillWithAliasVault'), contexts: ["editable"], }); @@ -37,7 +37,7 @@ export async function setupContextMenus() : Promise { browser.contextMenus.create({ id: "aliasvault-generate-password", parentId: "aliasvault-root", - title: await t('generateRandomPassword'), + title: await tc('generateRandomPassword'), contexts: ["all"] }); @@ -83,7 +83,7 @@ export function handleContextMenuClick(info: Browser.contextMenus.OnClickData, t */ async function copyPasswordToClipboard(generatedPassword: string) : Promise { navigator.clipboard.writeText(generatedPassword).then(async () => { - showToast(await t('passwordCopiedToClipboard')); + showToast(await tc('passwordCopiedToClipboard')); }); /** diff --git a/apps/browser-extension/src/entrypoints/content.ts b/apps/browser-extension/src/entrypoints/content.ts index 328245b0f..82172924e 100644 --- a/apps/browser-extension/src/entrypoints/content.ts +++ b/apps/browser-extension/src/entrypoints/content.ts @@ -9,6 +9,7 @@ import { BoolResponse as messageBoolResponse } from '@/utils/types/messaging/Boo import { defineContentScript } from '#imports'; import { createShadowRootUi } from '#imports'; +import { tc } from '@/utils/i18n/StandaloneI18n'; export default defineContentScript({ matches: [''], @@ -159,13 +160,13 @@ export default defineContentScript({ if (authStatus.hasPendingMigrations) { // Show upgrade required popup - createUpgradeRequiredPopup(inputElement, container, 'Vault upgrade required.'); + await createUpgradeRequiredPopup(inputElement, container, await tc('vaultUpgradeRequired')); return; } if (authStatus.error) { // Show upgrade required popup for version-related errors - createUpgradeRequiredPopup(inputElement, container, authStatus.error); + await createUpgradeRequiredPopup(inputElement, container, authStatus.error); return; } diff --git a/apps/browser-extension/src/entrypoints/contentScript/Popup.ts b/apps/browser-extension/src/entrypoints/contentScript/Popup.ts index a4409075e..4518f3343 100644 --- a/apps/browser-extension/src/entrypoints/contentScript/Popup.ts +++ b/apps/browser-extension/src/entrypoints/contentScript/Popup.ts @@ -4,11 +4,11 @@ import { filterCredentials } from '@/entrypoints/contentScript/Filter'; import { fillCredential } from '@/entrypoints/contentScript/Form'; import { DISABLED_SITES_KEY, TEMPORARY_DISABLED_SITES_KEY, GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, VAULT_LOCKED_DISMISS_UNTIL_KEY, LAST_CUSTOM_EMAIL_KEY, LAST_CUSTOM_USERNAME_KEY } from '@/utils/Constants'; -import { t } from '@/utils/contentTranslations'; import { CreateIdentityGenerator } from '@/utils/dist/shared/identity-generator'; import type { Credential } from '@/utils/dist/shared/models/vault'; import { CreatePasswordGenerator, PasswordGenerator } from '@/utils/dist/shared/password-generator'; import { FormDetector } from '@/utils/formDetector/FormDetector'; +import { tc } from '@/utils/i18n/StandaloneI18n'; import { SqliteClient } from '@/utils/SqliteClient'; import { CredentialsResponse } from '@/utils/types/messaging/CredentialsResponse'; import { IdentitySettingsResponse } from '@/utils/types/messaging/IdentitySettingsResponse'; @@ -47,8 +47,7 @@ export function openAutofillPopup(input: HTMLInputElement, container: HTMLElemen if (response.success) { await createAutofillPopup(input, response.credentials, container); } else { - const vaultLockedText = await t('vaultLocked'); - createVaultLockedPopup(input, container, vaultLockedText); + await createVaultLockedPopup(input, container); } })(); } @@ -158,13 +157,13 @@ export function removeExistingPopup(container: HTMLElement) : void { */ export async function createAutofillPopup(input: HTMLInputElement, credentials: Credential[] | undefined, rootContainer: HTMLElement) : Promise { // Get all translations first - const newText = await t('new'); - const searchPlaceholder = await t('searchVault'); - const hideFor1HourText = await t('hideFor1Hour'); - const hidePermanentlyText = await t('hidePermanently'); - const noMatchesText = await t('noMatchesFound'); - const creatingText = await t('creatingNewAlias'); - const failedText = await t('failedToCreateIdentity'); + const newText = await tc('new'); + const searchPlaceholder = await tc('searchVault'); + const hideFor1HourText = await tc('hideFor1Hour'); + const hidePermanentlyText = await tc('hidePermanently'); + const noMatchesText = await tc('noMatchesFound'); + const creatingText = await tc('creatingNewAlias'); + const failedText = await tc('failedToCreateIdentity'); // Disable browser's native autocomplete to avoid conflicts with AliasVault's autocomplete. input.setAttribute('autocomplete', 'false'); @@ -449,7 +448,7 @@ export async function createAutofillPopup(input: HTMLInputElement, credentials: /** * Create vault locked popup. */ -export function createVaultLockedPopup(input: HTMLInputElement, rootContainer: HTMLElement, vaultLockedText?: string): void { +export async function createVaultLockedPopup(input: HTMLInputElement, rootContainer: HTMLElement): Promise { /** * Handle unlock click. */ @@ -472,7 +471,7 @@ export function createVaultLockedPopup(input: HTMLInputElement, rootContainer: H // Add message const messageElement = document.createElement('div'); messageElement.className = 'av-vault-locked-message'; - messageElement.textContent = vaultLockedText || 'AliasVault is locked.'; + messageElement.textContent = await tc('vaultLocked'); container.appendChild(messageElement); // Add unlock button with SVG icon @@ -772,10 +771,10 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon `; - const randomIdentitySubtext = await t('randomIdentityDescription'); - const randomIdentityTitle = await t('createRandomAlias'); - const randomIdentityTitleDropdown = await t('randomAlias'); - const randomIdentitySubtextDropdown = 'Random identity with random email'; + const randomIdentitySubtext = await tc('randomIdentityDescription'); + const randomIdentityTitle = await tc('createRandomAlias'); + const randomIdentityTitleDropdown = await tc('randomAlias'); + const randomIdentitySubtextDropdown = await tc('randomIdentityDescriptionDropdown'); const manualUsernamePasswordIcon = ` @@ -783,22 +782,24 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon `; - const manualUsernamePasswordSubtext = await t('manualCredentialDescription'); - const manualUsernamePasswordTitle = await t('createUsernamePassword'); - const manualUsernamePasswordTitleDropdown = await t('usernamePassword'); - const manualUsernamePasswordSubtextDropdown = 'Manual username and password'; + const manualUsernamePasswordSubtext = await tc('manualCredentialDescription'); + const manualUsernamePasswordTitle = await tc('createUsernamePassword'); + const manualUsernamePasswordTitleDropdown = await tc('usernamePassword'); + const manualUsernamePasswordSubtextDropdown = await tc('manualCredentialDescriptionDropdown'); // Get all translated strings first - const serviceNameText = await t('serviceName'); - const enterServiceNameText = await t('enterServiceName'); - const cancelText = await t('cancel'); - const createAndSaveAliasText = await t('createAndSaveAlias'); - const emailText = await t('email'); - const enterEmailAddressText = await t('enterEmailAddress'); - const usernameText = await t('username'); - const enterUsernameText = await t('enterUsername'); - const generatedPasswordText = await t('generatedPassword'); - const createAndSaveCredentialText = await t('createAndSaveCredential'); + const serviceNameText = await tc('serviceName'); + const enterServiceNameText = await tc('enterServiceName'); + const cancelText = await tc('cancel'); + const createAndSaveAliasText = await tc('createAndSaveAlias'); + const emailText = await tc('email'); + const enterEmailAddressText = await tc('enterEmailAddress'); + const usernameText = await tc('username'); + const enterUsernameText = await tc('enterUsername'); + const generatedPasswordText = await tc('generatedPassword'); + const generateNewPasswordText = await tc('generateNewPassword'); + const togglePasswordVisibilityText = await tc('togglePasswordVisibility'); + const createAndSaveCredentialText = await tc('createAndSaveCredential'); // Create the main content popup.innerHTML = ` @@ -893,13 +894,13 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon class="av-create-popup-input" data-is-generated="true" > - -
- Switch accounts? + {t('switchAccounts')}
diff --git a/apps/browser-extension/src/locales/en/auth.json b/apps/browser-extension/src/locales/en/auth.json index 0d1ed0144..66cef650c 100644 --- a/apps/browser-extension/src/locales/en/auth.json +++ b/apps/browser-extension/src/locales/en/auth.json @@ -23,6 +23,7 @@ "sessionExpired": "Your session has expired. Please log in again.", "unlockSuccess": "Vault unlocked successfully!", "connectingTo": "Connecting to", + "switchAccounts": "Switch accounts?", "errors": { "invalidCode": "Please enter a valid 6-digit authentication code.", "serverError": "Could not reach AliasVault server. Please try again later or contact support if the problem persists.", diff --git a/apps/browser-extension/src/locales/en/common.json b/apps/browser-extension/src/locales/en/common.json index 26729bc2e..e65511847 100644 --- a/apps/browser-extension/src/locales/en/common.json +++ b/apps/browser-extension/src/locales/en/common.json @@ -42,7 +42,21 @@ "NoVaultFound": "Your account does not have a vault yet. Please complete the tutorial in the AliasVault web client before using the browser extension.", "serverNotAvailable": "The AliasVault server is not available. Please try again later or contact support if the problem persists.", "clientVersionNotSupported": "This version of the AliasVault browser extension is not supported by the server anymore. Please update your browser extension to the latest version.", - "serverVersionNotSupported": "The AliasVault server needs to be updated to a newer version in order to use this browser extension. Please contact support if you need help." + "serverVersionNotSupported": "The AliasVault server needs to be updated to a newer version in order to use this browser extension. Please contact support if you need help.", + "unknownError": "An unknown error occurred", + "failedToStoreVault": "Failed to store vault", + "vaultNotAvailable": "Vault not available", + "failedToGetVault": "Failed to get vault", + "vaultIsLocked": "Vault is locked", + "failedToGetCredentials": "Failed to get credentials", + "failedToCreateIdentity": "Failed to create identity", + "failedToGetDefaultEmailDomain": "Failed to get default email domain", + "failedToGetDefaultIdentitySettings": "Failed to get default identity settings", + "failedToGetPasswordSettings": "Failed to get password settings", + "failedToUploadVault": "Failed to upload vault", + "noDerivedKeyAvailable": "No derived key available for encryption", + "failedToUploadVaultToServer": "Failed to upload new vault to server", + "noVaultOrDerivedKeyFound": "No vault or derived key found" }, "apiErrors": { "UNKNOWN_ERROR": "An unknown error occurred. Please try again.", diff --git a/apps/browser-extension/src/locales/en/content.json b/apps/browser-extension/src/locales/en/content.json new file mode 100644 index 000000000..5c4cd235b --- /dev/null +++ b/apps/browser-extension/src/locales/en/content.json @@ -0,0 +1,39 @@ +{ + "new": "New", + "cancel": "Cancel", + "search": "Search", + "vaultLocked": "AliasVault is locked.", + "creatingNewAlias": "Creating new alias...", + "noMatchesFound": "No matches found", + "searchVault": "Search vault...", + "serviceName": "Service name", + "email": "Email", + "username": "Username", + "generatedPassword": "Generated Password", + "enterServiceName": "Enter service name", + "enterEmailAddress": "Enter email address", + "enterUsername": "Enter username", + "hideFor1Hour": "Hide for 1 hour (current site)", + "hidePermanently": "Hide permanently (current site)", + "createRandomAlias": "Create random alias", + "createUsernamePassword": "Create username/password", + "randomAlias": "Random alias", + "usernamePassword": "Username/password", + "createAndSaveAlias": "Create and save alias", + "createAndSaveCredential": "Create and save credential", + "randomIdentityDescription": "Generate a random identity with a random email address accessible in AliasVault.", + "randomIdentityDescriptionDropdown": "Random identity with random email", + "manualCredentialDescription": "Specify your own email address and username.", + "manualCredentialDescriptionDropdown": "Manual username and password", + "failedToCreateIdentity": "Failed to create identity. Please try again.", + "enterEmailAndOrUsername": "Enter email and/or username", + "autofillWithAliasVault": "Autofill with AliasVault", + "generateRandomPassword": "Generate random password (copy to clipboard)", + "generateNewPassword": "Generate new password", + "togglePasswordVisibility": "Toggle password visibility", + "passwordCopiedToClipboard": "Password copied to clipboard", + "enterEmailAndOrUsernameError": "Enter email and/or username", + "openAliasVaultToUpgrade": "Open AliasVault to upgrade", + "vaultUpgradeRequired": "Vault upgrade required.", + "dismissPopup": "Dismiss popup" +} \ No newline at end of file diff --git a/apps/browser-extension/src/locales/nl/common.json b/apps/browser-extension/src/locales/nl/common.json index be8534df4..aa877d304 100644 --- a/apps/browser-extension/src/locales/nl/common.json +++ b/apps/browser-extension/src/locales/nl/common.json @@ -42,7 +42,21 @@ "NoVaultFound": "Uw account heeft nog geen vault. Voltooi eerst de tutorial in de AliasVault webclient voordat u de browserextensie gebruikt.", "serverNotAvailable": "De AliasVault server is niet beschikbaar. Probeer het later opnieuw of neem contact op met de ondersteuning als het probleem aanhoudt.", "clientVersionNotSupported": "Deze versie van de AliasVault browserextensie wordt niet meer ondersteund door de server. Update uw browserextensie naar de nieuwste versie.", - "serverVersionNotSupported": "De AliasVault server moet worden bijgewerkt naar een nieuwere versie om deze browserextensie te kunnen gebruiken. Neem contact op met de ondersteuning als u hulp nodig hebt." + "serverVersionNotSupported": "De AliasVault server moet worden bijgewerkt naar een nieuwere versie om deze browserextensie te kunnen gebruiken. Neem contact op met de ondersteuning als u hulp nodig hebt.", + "unknownError": "Er is een onbekende fout opgetreden", + "failedToStoreVault": "Vault opslaan mislukt", + "vaultNotAvailable": "Vault niet beschikbaar", + "failedToGetVault": "Vault ophalen mislukt", + "vaultIsLocked": "Vault is vergrendeld", + "failedToGetCredentials": "Inloggegevens ophalen mislukt", + "failedToCreateIdentity": "Identiteit aanmaken mislukt", + "failedToGetDefaultEmailDomain": "Standaard e-maildomein ophalen mislukt", + "failedToGetDefaultIdentitySettings": "Standaard identiteitsinstellingen ophalen mislukt", + "failedToGetPasswordSettings": "Wachtwoordinstellingen ophalen mislukt", + "failedToUploadVault": "Vault uploaden mislukt", + "noDerivedKeyAvailable": "Geen afgeleide sleutel beschikbaar voor versleuteling", + "failedToUploadVaultToServer": "Nieuwe vault uploaden naar server mislukt", + "noVaultOrDerivedKeyFound": "Geen vault of afgeleide sleutel gevonden" }, "apiErrors": { "UNKNOWN_ERROR": "Er is een onbekende fout opgetreden. Probeer het opnieuw.", diff --git a/apps/browser-extension/src/locales/nl/content.json b/apps/browser-extension/src/locales/nl/content.json new file mode 100644 index 000000000..a49bd15e5 --- /dev/null +++ b/apps/browser-extension/src/locales/nl/content.json @@ -0,0 +1,31 @@ +{ + "new": "Nieuw", + "cancel": "Annuleren", + "search": "Zoeken", + "vaultLocked": "AliasVault is vergrendeld.", + "creatingNewAlias": "Nieuwe alias aanmaken...", + "noMatchesFound": "Geen resultaten gevonden", + "searchVault": "Kluis doorzoeken...", + "serviceName": "Servicenaam", + "email": "E-mail", + "username": "Gebruikersnaam", + "generatedPassword": "Gegenereerd wachtwoord", + "enterServiceName": "Voer servicenaam in", + "enterEmailAddress": "Voer e-mailadres in", + "enterUsername": "Voer gebruikersnaam in", + "hideFor1Hour": "Verberg voor 1 uur (huidige site)", + "hidePermanently": "Permanent verbergen (huidige site)", + "createRandomAlias": "Willekeurige alias aanmaken", + "createUsernamePassword": "Gebruikersnaam/wachtwoord aanmaken", + "randomAlias": "Willekeurige alias", + "usernamePassword": "Gebruikersnaam/wachtwoord", + "createAndSaveAlias": "Alias aanmaken en opslaan", + "createAndSaveCredential": "Inloggegevens aanmaken en opslaan", + "randomIdentityDescription": "Genereer een willekeurige identiteit met een willekeurig e-mailadres toegankelijk in AliasVault.", + "manualCredentialDescription": "Specificeer je eigen e-mailadres en gebruikersnaam.", + "failedToCreateIdentity": "Identiteit aanmaken mislukt. Probeer opnieuw.", + "enterEmailAndOrUsername": "Voer e-mail en/of gebruikersnaam in", + "autofillWithAliasVault": "Autofill met AliasVault", + "generateRandomPassword": "Willekeurig wachtwoord genereren (kopiƫren naar klembord)", + "passwordCopiedToClipboard": "Wachtwoord gekopieerd naar klembord" +} diff --git a/apps/browser-extension/src/utils/contentTranslations.ts b/apps/browser-extension/src/utils/contentTranslations.ts deleted file mode 100644 index 7169e785b..000000000 --- a/apps/browser-extension/src/utils/contentTranslations.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { storage } from '#imports'; - -/** - * Translation keys for content scripts - */ -export interface IContentTranslations { - // Common - new: string; - cancel: string; - search: string; - - // Status messages - vaultLocked: string; - creatingNewAlias: string; - noMatchesFound: string; - - // Form labels and placeholders - searchVault: string; - serviceName: string; - email: string; - username: string; - generatedPassword: string; - enterServiceName: string; - enterEmailAddress: string; - enterUsername: string; - - // Context menu - hideFor1Hour: string; - hidePermanently: string; - - // Create alias popup - createRandomAlias: string; - createUsernamePassword: string; - randomAlias: string; - usernamePassword: string; - createAndSaveAlias: string; - createAndSaveCredential: string; - randomIdentityDescription: string; - manualCredentialDescription: string; - - // Error messages - failedToCreateIdentity: string; - enterEmailAndOrUsername: string; - - // Context menu - autofillWithAliasVault: string; - generateRandomPassword: string; - passwordCopiedToClipboard: string; -} - -/** - * English translations - */ -const enTranslations: IContentTranslations = { - new: 'New', - cancel: 'Cancel', - search: 'Search', - vaultLocked: 'AliasVault is locked.', - creatingNewAlias: 'Creating new alias...', - noMatchesFound: 'No matches found', - searchVault: 'Search vault...', - serviceName: 'Service name', - email: 'Email', - username: 'Username', - generatedPassword: 'Generated Password', - enterServiceName: 'Enter service name', - enterEmailAddress: 'Enter email address', - enterUsername: 'Enter username', - hideFor1Hour: 'Hide for 1 hour (current site)', - hidePermanently: 'Hide permanently (current site)', - createRandomAlias: 'Create random alias', - createUsernamePassword: 'Create username/password', - randomAlias: 'Random alias', - usernamePassword: 'Username/password', - createAndSaveAlias: 'Create and save alias', - createAndSaveCredential: 'Create and save credential', - randomIdentityDescription: 'Generate a random identity with a random email address accessible in AliasVault.', - manualCredentialDescription: 'Specify your own email address and username.', - failedToCreateIdentity: 'Failed to create identity. Please try again.', - enterEmailAndOrUsername: 'Enter email and/or username', - autofillWithAliasVault: 'Autofill with AliasVault', - generateRandomPassword: 'Generate random password (copy to clipboard)', - passwordCopiedToClipboard: 'Password copied to clipboard' -}; - -/** - * Dutch translations - */ -const nlTranslations: IContentTranslations = { - new: 'Nieuw', - cancel: 'Annuleren', - search: 'Zoeken', - vaultLocked: 'AliasVault is vergrendeld.', - creatingNewAlias: 'Nieuwe alias aanmaken...', - noMatchesFound: 'Geen resultaten gevonden', - searchVault: 'Kluis doorzoeken...', - serviceName: 'Servicenaam', - email: 'E-mail', - username: 'Gebruikersnaam', - generatedPassword: 'Gegenereerd wachtwoord', - enterServiceName: 'Voer servicenaam in', - enterEmailAddress: 'Voer e-mailadres in', - enterUsername: 'Voer gebruikersnaam in', - hideFor1Hour: 'Verberg voor 1 uur (huidige site)', - hidePermanently: 'Permanent verbergen (huidige site)', - createRandomAlias: 'Willekeurige alias aanmaken', - createUsernamePassword: 'Gebruikersnaam/wachtwoord aanmaken', - randomAlias: 'Willekeurige alias', - usernamePassword: 'Gebruikersnaam/wachtwoord', - createAndSaveAlias: 'Alias aanmaken en opslaan', - createAndSaveCredential: 'Inloggegevens aanmaken en opslaan', - randomIdentityDescription: 'Genereer een willekeurige identiteit met een willekeurig e-mailadres toegankelijk in AliasVault.', - manualCredentialDescription: 'Specificeer je eigen e-mailadres en gebruikersnaam.', - failedToCreateIdentity: 'Identiteit aanmaken mislukt. Probeer opnieuw.', - enterEmailAndOrUsername: 'Voer e-mail en/of gebruikersnaam in', - autofillWithAliasVault: 'Autofill met AliasVault', - generateRandomPassword: 'Willekeurig wachtwoord genereren (kopiƫren naar klembord)', - passwordCopiedToClipboard: 'Wachtwoord gekopieerd naar klembord' -}; - -/** - * All available translations - */ -const translations = { - en: enTranslations, - nl: nlTranslations -}; - -/** - * Get current language from storage - */ -export async function getCurrentLanguage(): Promise { - try { - // Use extension storage API exclusively (reliable across all contexts) - const langFromStorage = await storage.getItem('local:language') as string; - if (langFromStorage && ['en', 'nl'].includes(langFromStorage)) { - return langFromStorage; - } - - // If no language is set in storage, detect browser language and save it - const browserLang = navigator.language.split('-')[0]; - const detectedLanguage = ['en', 'nl'].includes(browserLang) ? browserLang : 'en'; - - // Save the detected language to storage for future use - await storage.setItem('local:language', detectedLanguage); - - return detectedLanguage; - } catch (error) { - console.error('Failed to get current language:', error); - return 'en'; - } -} - -/** - * Get translation for a key - */ -export async function t(key: keyof IContentTranslations): Promise { - const lang = await getCurrentLanguage(); - return translations[lang as keyof typeof translations]?.[key] || translations.en[key]; -} - -/** - * Get all translations for current language - */ -export async function getTranslations(): Promise { - const lang = await getCurrentLanguage(); - return translations[lang as keyof typeof translations] || translations.en; -} diff --git a/apps/browser-extension/src/utils/i18n/StandaloneI18n.ts b/apps/browser-extension/src/utils/i18n/StandaloneI18n.ts new file mode 100644 index 000000000..0aba3b53c --- /dev/null +++ b/apps/browser-extension/src/utils/i18n/StandaloneI18n.ts @@ -0,0 +1,196 @@ +import { storage } from '#imports'; + +/** + * Type for content translations + */ +export type ContentTranslations = { + [key: string]: string; +}; + +/** + * Cache for loaded translations to avoid repeated file reads + */ +const translationCache = new Map(); + +/** + * Get current language from storage + */ +export async function getCurrentLanguage(): Promise { + try { + // Use extension storage API exclusively (reliable across all contexts) + const langFromStorage = await storage.getItem('local:language') as string; + if (langFromStorage && ['en', 'nl', 'de', 'es', 'fr', 'uk'].includes(langFromStorage)) { + return langFromStorage; + } + + // If no language is set in storage, detect browser language and save it + const browserLang = navigator.language.split('-')[0]; + const detectedLanguage = ['en', 'nl', 'de', 'es', 'fr', 'uk'].includes(browserLang) ? browserLang : 'en'; + + // Save the detected language to storage for future use + await storage.setItem('local:language', detectedLanguage); + + return detectedLanguage; + } catch (error) { + console.error('Failed to get current language:', error); + return 'en'; + } +} + +/** + * Load translations for a specific namespace and language + */ +async function loadTranslations(namespace: string, language: string): Promise { + const cacheKey = `${namespace}:${language}`; + + // Check cache first + if (translationCache.has(cacheKey)) { + return translationCache.get(cacheKey)!; + } + + try { + // Dynamically import the translation file + const translations = await import(`../../locales/${language}/${namespace}.json`); + const translationData = translations.default || translations; + + // Cache the translations + translationCache.set(cacheKey, translationData); + + return translationData; + } catch (error) { + console.warn(`Failed to load translations for ${namespace}:${language}`, error); + + // Fallback to English if available + if (language !== 'en') { + try { + const fallbackTranslations = await import(`../../locales/en/${namespace}.json`); + const fallbackData = fallbackTranslations.default || fallbackTranslations; + translationCache.set(cacheKey, fallbackData); + return fallbackData; + } catch (fallbackError) { + console.error('Failed to load English fallback translations', fallbackError); + } + } + + // Return empty object as last resort + return {}; + } +} + +/** + * Translation function for non-React contexts + * + * @param key - Translation key (supports nested keys like 'errors.networkError') + * @param namespace - Translation namespace (default: 'content') + * @param fallback - Fallback text if translation is not found + * @returns Promise - Translated text + */ +export async function t( + key: string, + namespace: string = 'content', + fallback?: string +): Promise { + try { + const language = await getCurrentLanguage(); + const translations = await loadTranslations(namespace, language); + + // Support nested keys like 'errors.networkError' + const value = getNestedValue(translations, key); + + if (value && typeof value === 'string') { + return value; + } + + // If translation not found and we're not using English, try English fallback + if (language !== 'en') { + const englishTranslations = await loadTranslations(namespace, 'en'); + const englishValue = getNestedValue(englishTranslations, key); + + if (englishValue && typeof englishValue === 'string') { + return englishValue; + } + } + + // Return fallback or key if no translation found + return fallback || key; + } catch (error) { + console.error('Translation error:', error); + return fallback || key; + } +} + +/** + * Get nested value from object using dot notation + */ +function getNestedValue(obj: Record, path: string): unknown { + return path.split('.').reduce((current, key) => { + return current && current[key] !== undefined ? current[key] : undefined; + }, obj); +} + +/** + * Translation function specifically for content scripts + * This is a convenience wrapper around the main t() function + */ +export async function tc(key: string, fallback?: string): Promise { + return t(key, 'content', fallback); +} + +/** + * Translation function for errors namespace + */ +export async function te(key: string, fallback?: string): Promise { + return t(key, 'errors', fallback); +} + +/** + * Translation function for common namespace + */ +export async function tcommon(key: string, fallback?: string): Promise { + return t(key, 'common', fallback); +} + +/** + * Clear translation cache (useful for language changes) + */ +export function clearTranslationCache(): void { + translationCache.clear(); +} + +/** + * Pre-load common translations for use in synchronous contexts + */ +export async function preloadTranslationsForSync( + namespaces: string[] = ['common', 'errors'] +): Promise> { + const languages = ['en', 'nl']; + const translations: Record = {}; + + for (const lang of languages) { + const langTranslations: ContentTranslations = {}; + + for (const namespace of namespaces) { + try { + const nsTranslations = await loadTranslations(namespace, lang); + // Flatten the namespace structure for easier access + Object.keys(nsTranslations).forEach(key => { + if (namespace === 'common' && key === 'errors') { + // Handle nested errors object + const errors = nsTranslations[key] as Record; + Object.keys(errors).forEach(errorKey => { + langTranslations[`errors.${errorKey}`] = errors[errorKey]; + }); + } else { + langTranslations[key] = nsTranslations[key]; + } + }); + } catch (error) { + console.warn(`Failed to preload ${namespace} translations for ${lang}:`, error); + } + } + + translations[lang] = langTranslations; + } + + return translations; +}