| | import { registerDebugFunction } from './power-user.js'; |
| | import { updateSecretDisplay } from './secrets.js'; |
| |
|
| | const storageKey = 'language'; |
| | const overrideLanguage = localStorage.getItem(storageKey); |
| | const localeFile = String(overrideLanguage || navigator.language || navigator.userLanguage || 'en').toLowerCase(); |
| | var langs; |
| | |
| | |
| | var localeData; |
| |
|
| | |
| | let trackMissingDynamicTranslate = null; |
| |
|
| | export const getCurrentLocale = () => localeFile; |
| |
|
| | |
| | |
| | |
| | |
| | |
| | export function addLocaleData(localeId, data) { |
| | if (!localeData) { |
| | console.warn('Localization data not loaded yet. Additional data will not be added.'); |
| | return; |
| | } |
| |
|
| | if (localeId !== localeFile) { |
| | console.debug('Ignoring addLocaleData call for different locale', localeId); |
| | return; |
| | } |
| |
|
| | for (const [key, value] of Object.entries(data)) { |
| | |
| | if (!Object.hasOwn(localeData, key)) { |
| | localeData[key] = value; |
| | } |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | const observer = new MutationObserver(mutations => { |
| | mutations.forEach(mutation => { |
| | mutation.addedNodes.forEach(node => { |
| | if (node.nodeType === Node.ELEMENT_NODE && node instanceof Element) { |
| | if (node.hasAttribute('data-i18n')) { |
| | translateElement(node); |
| | } |
| | node.querySelectorAll('[data-i18n]').forEach(element => { |
| | translateElement(element); |
| | }); |
| | } |
| | }); |
| | if (mutation.attributeName === 'data-i18n' && mutation.target instanceof Element) { |
| | translateElement(mutation.target); |
| | } |
| | }); |
| | }); |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | export function t(strings, ...values) { |
| | let str = strings.reduce((result, string, i) => result + string + (values[i] !== undefined ? `\${${i}}` : ''), ''); |
| | let translatedStr = translate(str); |
| |
|
| | |
| | return translatedStr.replace(/\$\{(\d+)\}/g, (match, index) => values[index]); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | export function translate(text, key = null) { |
| | const translationKey = key || text; |
| | if (translationKey === null || translationKey === undefined) { |
| | console.trace('WARN: No translation key provided'); |
| | return ''; |
| | } |
| | if (trackMissingDynamicTranslate && localeData && !Object.hasOwn(localeData, translationKey)) { |
| | trackMissingDynamicTranslate.add(translationKey); |
| | } |
| | return localeData?.[translationKey] || text; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | async function getLocaleData(language) { |
| | let supportedLang = findLang(language); |
| | if (!supportedLang) { |
| | return {}; |
| | } |
| |
|
| | const data = await fetch(`./locales/${language}.json`).then(response => { |
| | console.log(`Loading locale data from ./locales/${language}.json`); |
| | if (!response.ok) { |
| | return {}; |
| | } |
| | return response.json(); |
| | }); |
| |
|
| | return data; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | function findLang(language) { |
| | const supportedLang = langs.find(x => x.lang === language); |
| |
|
| | if (!supportedLang && language !== 'en') { |
| | console.warn(`Unsupported language: ${language}`); |
| | } |
| | return supportedLang; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | function translateElement(element) { |
| | const keys = element.getAttribute('data-i18n').split(';'); |
| | for (const key of keys) { |
| | const attributeMatch = key.match(/\[(\S+)\](.+)/); |
| | if (attributeMatch) { |
| | const localizedValue = localeData?.[attributeMatch[2]]; |
| | if (localizedValue || localizedValue === '') { |
| | element.setAttribute(attributeMatch[1], localizedValue); |
| | } |
| | } else { |
| | const localizedValue = localeData?.[key]; |
| | if (localizedValue || localizedValue === '') { |
| | element.textContent = localizedValue; |
| | } |
| | } |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | function isSupportedNonEnglish(locale = null) { |
| | const lang = locale || localeFile; |
| | return lang && lang != 'en' && findLang(lang); |
| | } |
| |
|
| | async function getMissingTranslations() { |
| | |
| | const missingData = []; |
| |
|
| | if (trackMissingDynamicTranslate) { |
| | missingData.push(...Array.from(trackMissingDynamicTranslate).map(key => ({ key, language: localeFile, value: key }))); |
| | } |
| |
|
| | |
| | const langsToProcess = isSupportedNonEnglish() ? [findLang(localeFile)] : langs; |
| |
|
| | for (const language of langsToProcess) { |
| | const localeData = await getLocaleData(language.lang); |
| | $(document).find('[data-i18n]').each(function () { |
| | const keys = $(this).data('i18n').split(';'); |
| | for (const key of keys) { |
| | const attributeMatch = key.match(/\[(\S+)\](.+)/); |
| | if (attributeMatch) { |
| | const localizedValue = localeData?.[attributeMatch[2]]; |
| | if (!localizedValue) { |
| | missingData.push({ key, language: language.lang, value: String($(this).attr(attributeMatch[1])) }); |
| | } |
| | } else { |
| | const localizedValue = localeData?.[key]; |
| | if (!localizedValue) { |
| | missingData.push({ key, language: language.lang, value: $(this).text().trim() }); |
| | } |
| | } |
| | } |
| | }); |
| | } |
| |
|
| | |
| | const uniqueMissingData = []; |
| | for (const { key, language, value } of missingData) { |
| | if (!uniqueMissingData.some(x => x.key === key && x.language === language && x.value === value)) { |
| | uniqueMissingData.push({ key, language, value }); |
| | } |
| | } |
| |
|
| | |
| | uniqueMissingData.sort((a, b) => a.language.localeCompare(b.language) || a.key.localeCompare(b.key)); |
| |
|
| | |
| | const missingDataMap = Object.fromEntries(uniqueMissingData.map(({ key, value }) => [key, value])); |
| |
|
| | console.log(`Missing Translations (${uniqueMissingData.length}):`); |
| | console.table(uniqueMissingData); |
| | console.log(`Full map of missing data (${Object.keys(missingDataMap).length}):`); |
| | console.log(missingDataMap); |
| |
|
| | if (trackMissingDynamicTranslate) { |
| | const trackMissingDynamicTranslateMap = Object.fromEntries(Array.from(trackMissingDynamicTranslate).map(key => [key, key])); |
| | console.log(`Dynamic translations missing (${Object.keys(trackMissingDynamicTranslateMap).length}):`); |
| | console.log(trackMissingDynamicTranslateMap); |
| | } |
| |
|
| | toastr.success(`Found ${uniqueMissingData.length} missing translations. See browser console for details.`); |
| | } |
| |
|
| | export function applyLocale(root = document) { |
| | if (!localeData || Object.keys(localeData).length === 0) { |
| | return root; |
| | } |
| |
|
| | const $root = root instanceof Document ? $(root) : $(new DOMParser().parseFromString(root, 'text/html')); |
| |
|
| | |
| | $root.find('[data-i18n]').each(function () { |
| | translateElement(this); |
| | }); |
| |
|
| | if (root !== document) { |
| | return $root.get(0).body.innerHTML; |
| | } |
| | } |
| |
|
| | function addLanguagesToDropdown() { |
| | const uiLanguageSelects = $('#ui_language_select, #onboarding_ui_language_select'); |
| | for (const langObj of langs) { |
| | const option = document.createElement('option'); |
| | option.value = langObj['lang']; |
| | option.innerText = langObj['display']; |
| | uiLanguageSelects.append(option); |
| | } |
| |
|
| | const selectedLanguage = localStorage.getItem(storageKey); |
| | if (selectedLanguage) { |
| | uiLanguageSelects.val(selectedLanguage); |
| | } |
| | } |
| |
|
| | export async function initLocales() { |
| | langs = await fetch('/locales/lang.json').then(response => response.json()); |
| | localeData = await getLocaleData(localeFile); |
| | applyLocale(); |
| | addLanguagesToDropdown(); |
| | updateSecretDisplay(); |
| |
|
| | $('#ui_language_select, #onboarding_ui_language_select').on('change', async function () { |
| | const language = String($(this).val()); |
| |
|
| | if (language) { |
| | localStorage.setItem(storageKey, language); |
| | } else { |
| | localStorage.removeItem(storageKey); |
| | } |
| |
|
| | location.reload(); |
| | }); |
| |
|
| | observer.observe(document, { |
| | childList: true, |
| | subtree: true, |
| | attributes: true, |
| | attributeFilter: ['data-i18n'], |
| | }); |
| |
|
| | if (localStorage.getItem('trackDynamicTranslate') === 'true' && isSupportedNonEnglish()) { |
| | trackMissingDynamicTranslate = new Set(); |
| | } |
| |
|
| | registerDebugFunction('getMissingTranslations', 'Get missing translations', |
| | 'Detects missing localization data in the current locale and dumps the data into the browser console. ' + |
| | 'If the current locale is English, searches all other locales.', |
| | getMissingTranslations); |
| | registerDebugFunction('trackDynamicTranslate', 'Track dynamic translation', |
| | 'Toggles tracking of dynamic translations, which will be dumped into the missing translations translations too. ' + |
| | 'This includes things translated via the t`...` function and translate(). It will only track strings translated <b>after</b> this is toggled on, ' |
| | + '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.', |
| | () => { |
| | const isTracking = localStorage.getItem('trackDynamicTranslate') !== 'true'; |
| | localStorage.setItem('trackDynamicTranslate', isTracking ? 'true' : 'false'); |
| | if (isTracking && isSupportedNonEnglish()) { |
| | trackMissingDynamicTranslate = new Set(); |
| | toastr.success('Dynamic translation tracking enabled.'); |
| | } else if (isTracking) { |
| | trackMissingDynamicTranslate = null; |
| | toastr.warning('Dynamic translation tracking enabled, but will not be tracked with locale English.'); |
| | } else { |
| | trackMissingDynamicTranslate = null; |
| | toastr.info('Dynamic translation tracking disabled.'); |
| | } |
| | }); |
| | registerDebugFunction('applyLocale', 'Apply locale', 'Reapplies the currently selected locale to the page.', applyLocale); |
| | } |
| |
|