| 1 | import { registerDebugFunction } from './power-user.js'; |
| 2 | import { updateSecretDisplay } from './secrets.js'; |
| 3 | |
| 4 | const storageKey = 'language'; |
| 5 | const overrideLanguage = localStorage.getItem(storageKey); |
| 6 | const localeFile = String(overrideLanguage || navigator.language || navigator.userLanguage || 'en').toLowerCase(); |
| 7 | var langs; |
| 8 | // Don't change to let/const! It will break module loading. |
| 9 | // eslint-disable-next-line prefer-const |
| 10 | var localeData; |
| 11 | |
| 12 | /** @type {Set<string>|null} Array of translations keys if they should be tracked - if not tracked then null */ |
| 13 | let trackMissingDynamicTranslate = null; |
| 14 | |
| 15 | export const getCurrentLocale = () => localeFile; |
| 16 | |
| 17 | /** |
| 18 | * Adds additional localization data to the current locale file. |
| 19 | * @param {string} localeId Locale ID (e.g. 'fr-fr' or 'zh-cn') |
| 20 | * @param {Record<string, string>} data Localization data to add |
| 21 | */ |
| 22 | export function addLocaleData(localeId, data) { |
| 23 | if (!localeData) { |
| 24 | console.warn('Localization data not loaded yet. Additional data will not be added.'); |
| 25 | return; |
| 26 | } |
| 27 | |
| 28 | if (localeId !== localeFile) { |
| 29 | console.debug('Ignoring addLocaleData call for different locale', localeId); |
| 30 | return; |
| 31 | } |
| 32 | |
| 33 | for (const [key, value] of Object.entries(data)) { |
| 34 | // Overrides for default locale data are not allowed |
| 35 | if (!Object.hasOwn(localeData, key)) { |
| 36 | localeData[key] = value; |
| 37 | } |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | /** |
| 42 | * An observer that will check if any new i18n elements are added to the document |
| 43 | * @type {MutationObserver} |
| 44 | */ |
| 45 | const observer = new MutationObserver(mutations => { |
| 46 | mutations.forEach(mutation => { |
| 47 | mutation.addedNodes.forEach(node => { |
| 48 | if (node.nodeType === Node.ELEMENT_NODE && node instanceof Element) { |
| 49 | if (node.hasAttribute('data-i18n')) { |
| 50 | translateElement(node); |
| 51 | } |
| 52 | node.querySelectorAll('[data-i18n]').forEach(element => { |
| 53 | translateElement(element); |
| 54 | }); |
| 55 | } |
| 56 | }); |
| 57 | if (mutation.attributeName === 'data-i18n' && mutation.target instanceof Element) { |
| 58 | translateElement(mutation.target); |
| 59 | } |
| 60 | }); |
| 61 | }); |
| 62 | |
| 63 | /** |
| 64 | * Translates a template string with named arguments |
| 65 | * |
| 66 | * Uses the template literal with all values replaced by index placeholder for translation key. |
| 67 | * |
| 68 | * @example |
| 69 | * ```js |
| 70 | * toastr.warning(t`Tag ${tagName} not found.`); |
| 71 | * ``` |
| 72 | * Should be translated in the translation files as: |
| 73 | * ``` |
| 74 | * Tag ${0} not found. -> Tag ${0} nicht gefunden. |
| 75 | * ``` |
| 76 | * |
| 77 | * @param {TemplateStringsArray} strings - Template strings array |
| 78 | * @param {...any} values - Values for placeholders in the template string |
| 79 | * @returns {string} Translated and formatted string |
| 80 | */ |
| 81 | export function t(strings, ...values) { |
| 82 | let str = strings.reduce((result, string, i) => result + string + (values[i] !== undefined ? `\${${i}}` : ''), ''); |
| 83 | let translatedStr = translate(str); |
| 84 | |
| 85 | // Replace indexed placeholders with actual values |
| 86 | return translatedStr.replace(/\$\{(\d+)\}/g, (match, index) => values[index]); |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Translates a given key or text |
| 91 | * |
| 92 | * If the translation is based on a key, that one is used to find a possible translation in the translation file. |
| 93 | * The original text still has to be provided, as that is the default value being returned if no translation is found. |
| 94 | * |
| 95 | * For in-code text translation on a format string, using the template literal `t` is preferred. |
| 96 | * |
| 97 | * @param {string} text - The text to translate |
| 98 | * @param {string?} key - The key to use for translation. If not provided, text is used as the key. |
| 99 | * @returns {string} - The translated text |
| 100 | */ |
| 101 | export function translate(text, key = null) { |
| 102 | const translationKey = key || text; |
| 103 | if (translationKey === null || translationKey === undefined) { |
| 104 | console.trace('WARN: No translation key provided'); |
| 105 | return ''; |
| 106 | } |
| 107 | if (trackMissingDynamicTranslate && localeData && !Object.hasOwn(localeData, translationKey)) { |
| 108 | trackMissingDynamicTranslate.add(translationKey); |
| 109 | } |
| 110 | return localeData?.[translationKey] || text; |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * Fetches the locale data for the given language. |
| 115 | * @param {string} language Language code |
| 116 | * @returns {Promise<Record<string, string>>} Locale data |
| 117 | */ |
| 118 | async function getLocaleData(language) { |
| 119 | let supportedLang = findLang(language); |
| 120 | if (!supportedLang) { |
| 121 | return {}; |
| 122 | } |
| 123 | |
| 124 | const data = await fetch(`./locales/${language}.json`).then(response => { |
| 125 | console.log(`Loading locale data from ./locales/${language}.json`); |
| 126 | if (!response.ok) { |
| 127 | return {}; |
| 128 | } |
| 129 | return response.json(); |
| 130 | }); |
| 131 | |
| 132 | return data; |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Gets a language object for the given language code. |
| 137 | * @param {string} language Language code |
| 138 | */ |
| 139 | function findLang(language) { |
| 140 | const supportedLang = langs.find(x => x.lang === language); |
| 141 | |
| 142 | const isEn = language.startsWith('en'); // includes 'en', and more specific locales like 'en-us', 'en-au', etc |
| 143 | if (!supportedLang && !isEn) { |
| 144 | console.warn(`Unsupported language: ${language}`); |
| 145 | } |
| 146 | return supportedLang; |
| 147 | } |
| 148 | |
| 149 | /** |
| 150 | * Translates a given element based on its data-i18n attribute. |
| 151 | * @param {Element} element The element to translate |
| 152 | */ |
| 153 | function translateElement(element) { |
| 154 | const keys = element.getAttribute('data-i18n').split(';'); // Multi-key entries are ; delimited |
| 155 | for (const key of keys) { |
| 156 | const attributeMatch = key.match(/\[(\S+)\](.+)/); // [attribute]key |
| 157 | if (attributeMatch) { // attribute-tagged key |
| 158 | const localizedValue = localeData?.[attributeMatch[2]]; |
| 159 | if (localizedValue || localizedValue === '') { |
| 160 | element.setAttribute(attributeMatch[1], localizedValue); |
| 161 | } |
| 162 | } else { // No attribute tag, treat as 'text' |
| 163 | const localizedValue = localeData?.[key]; |
| 164 | if (localizedValue || localizedValue === '') { |
| 165 | element.textContent = localizedValue; |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | /** |
| 172 | * Checks if the given locale is supported and not English. |
| 173 | * @param {string} [locale=null] The locale to check (defaults to the current locale) |
| 174 | * @returns {boolean} True if the locale is not English and supported |
| 175 | */ |
| 176 | function isSupportedNonEnglish(locale = null) { |
| 177 | const lang = locale || localeFile; |
| 178 | return lang && lang != 'en' && findLang(lang); |
| 179 | } |
| 180 | |
| 181 | async function getMissingTranslations() { |
| 182 | /** @type {Array<{key: string, language: string, value: string}>} */ |
| 183 | const missingData = []; |
| 184 | |
| 185 | if (trackMissingDynamicTranslate) { |
| 186 | missingData.push(...Array.from(trackMissingDynamicTranslate).map(key => ({ key, language: localeFile, value: key }))); |
| 187 | } |
| 188 | |
| 189 | // Determine locales to search for untranslated strings |
| 190 | const langsToProcess = isSupportedNonEnglish() ? [findLang(localeFile)] : langs; |
| 191 | |
| 192 | for (const language of langsToProcess) { |
| 193 | const localeData = await getLocaleData(language.lang); |
| 194 | $(document).find('[data-i18n]').each(function () { |
| 195 | const keys = $(this).data('i18n').split(';'); // Multi-key entries are ; delimited |
| 196 | for (const key of keys) { |
| 197 | const attributeMatch = key.match(/\[(\S+)\](.+)/); // [attribute]key |
| 198 | if (attributeMatch) { // attribute-tagged key |
| 199 | const localizedValue = localeData?.[attributeMatch[2]]; |
| 200 | if (!localizedValue) { |
| 201 | missingData.push({ key, language: language.lang, value: String($(this).attr(attributeMatch[1])) }); |
| 202 | } |
| 203 | } else { // No attribute tag, treat as 'text' |
| 204 | const localizedValue = localeData?.[key]; |
| 205 | if (!localizedValue) { |
| 206 | missingData.push({ key, language: language.lang, value: $(this).text().trim() }); |
| 207 | } |
| 208 | } |
| 209 | } |
| 210 | }); |
| 211 | } |
| 212 | |
| 213 | // Remove duplicates |
| 214 | const uniqueMissingData = []; |
| 215 | for (const { key, language, value } of missingData) { |
| 216 | if (!uniqueMissingData.some(x => x.key === key && x.language === language && x.value === value)) { |
| 217 | uniqueMissingData.push({ key, language, value }); |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | // Sort by language, then key |
| 222 | uniqueMissingData.sort((a, b) => a.language.localeCompare(b.language) || a.key.localeCompare(b.key)); |
| 223 | |
| 224 | // Map to { language: { key: value } } |
| 225 | const missingDataMap = Object.fromEntries(uniqueMissingData.map(({ key, value }) => [key, value])); |
| 226 | |
| 227 | console.log(`Missing Translations (${uniqueMissingData.length}):`); |
| 228 | console.table(uniqueMissingData); |
| 229 | console.log(`Full map of missing data (${Object.keys(missingDataMap).length}):`); |
| 230 | console.log(missingDataMap); |
| 231 | |
| 232 | if (trackMissingDynamicTranslate) { |
| 233 | const trackMissingDynamicTranslateMap = Object.fromEntries(Array.from(trackMissingDynamicTranslate).map(key => [key, key])); |
| 234 | console.log(`Dynamic translations missing (${Object.keys(trackMissingDynamicTranslateMap).length}):`); |
| 235 | console.log(trackMissingDynamicTranslateMap); |
| 236 | } |
| 237 | |
| 238 | toastr.success(`Found ${uniqueMissingData.length} missing translations. See browser console for details.`); |
| 239 | } |
| 240 | |
| 241 | /** |
| 242 | * Applies localization to a given root element or HTML string. |
| 243 | * @param {Document|string} root The root element or HTML string to apply localization to |
| 244 | * @returns {Document|string} Translated root, in the same format as the input (Document or HTML string) |
| 245 | */ |
| 246 | export function applyLocale(root = document) { |
| 247 | if (!localeData || Object.keys(localeData).length === 0) { |
| 248 | return root; |
| 249 | } |
| 250 | |
| 251 | const $root = root instanceof Document ? $(root) : $(new DOMParser().parseFromString(root, 'text/html')); |
| 252 | |
| 253 | //find all the elements with `data-i18n` attribute |
| 254 | $root.find('[data-i18n]').each(function () { |
| 255 | translateElement(this); |
| 256 | }); |
| 257 | |
| 258 | if (root !== document) { |
| 259 | return $root.get(0).body.innerHTML; |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | function addLanguagesToDropdown() { |
| 264 | const uiLanguageSelects = $('#ui_language_select, #onboarding_ui_language_select'); |
| 265 | for (const langObj of langs) { // Set the value to the language code |
| 266 | const option = document.createElement('option'); |
| 267 | option.value = langObj.lang; // Set the value to the language code |
| 268 | option.innerText = langObj.display; // Set the display text to the language name |
| 269 | uiLanguageSelects.append(option); |
| 270 | } |
| 271 | |
| 272 | const selectedLanguage = localStorage.getItem(storageKey); |
| 273 | if (selectedLanguage) { |
| 274 | uiLanguageSelects.val(selectedLanguage); |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | export async function initLocales() { |
| 279 | langs = await fetch('/locales/lang.json').then(response => response.json()); |
| 280 | localeData = await getLocaleData(localeFile); |
| 281 | document.documentElement.lang = localeFile; |
| 282 | applyLocale(); |
| 283 | addLanguagesToDropdown(); |
| 284 | updateSecretDisplay(); |
| 285 | |
| 286 | $('#ui_language_select, #onboarding_ui_language_select').on('change', async function () { |
| 287 | const language = String($(this).val()); |
| 288 | |
| 289 | if (language) { |
| 290 | localStorage.setItem(storageKey, language); |
| 291 | } else { |
| 292 | localStorage.removeItem(storageKey); |
| 293 | } |
| 294 | |
| 295 | location.reload(); |
| 296 | }); |
| 297 | |
| 298 | observer.observe(document, { |
| 299 | childList: true, |
| 300 | subtree: true, |
| 301 | attributes: true, |
| 302 | attributeFilter: ['data-i18n'], |
| 303 | }); |
| 304 | |
| 305 | if (localStorage.getItem('trackDynamicTranslate') === 'true' && isSupportedNonEnglish()) { |
| 306 | trackMissingDynamicTranslate = new Set(); |
| 307 | } |
| 308 | |
| 309 | registerDebugFunction('getMissingTranslations', 'Get missing translations', |
| 310 | 'Detects missing localization data in the current locale and dumps the data into the browser console. ' + |
| 311 | 'If the current locale is English, searches all other locales.', |
| 312 | getMissingTranslations); |
| 313 | registerDebugFunction('trackDynamicTranslate', 'Track dynamic translation', |
| 314 | 'Toggles tracking of dynamic translations, which will be dumped into the missing translations translations too. ' + |
| 315 | 'This includes things translated via the t`...` function and translate(). It will only track strings translated <b>after</b> this is toggled on, ' |
| 316 | + 'and when they actually pop up, so refreshing the page and opening popups, etc, is needed. Will only track if the current locale is not English.', |
| 317 | () => { |
| 318 | const isTracking = localStorage.getItem('trackDynamicTranslate') !== 'true'; |
| 319 | localStorage.setItem('trackDynamicTranslate', isTracking ? 'true' : 'false'); |
| 320 | if (isTracking && isSupportedNonEnglish()) { |
| 321 | trackMissingDynamicTranslate = new Set(); |
| 322 | toastr.success('Dynamic translation tracking enabled.'); |
| 323 | } else if (isTracking) { |
| 324 | trackMissingDynamicTranslate = null; |
| 325 | toastr.warning('Dynamic translation tracking enabled, but will not be tracked with locale English.'); |
| 326 | } else { |
| 327 | trackMissingDynamicTranslate = null; |
| 328 | toastr.info('Dynamic translation tracking disabled.'); |
| 329 | } |
| 330 | }); |
| 331 | registerDebugFunction('applyLocale', 'Apply locale', 'Reapplies the currently selected locale to the page.', applyLocale); |
| 332 | } |