2023-12-02 13:04:51 -05:00
|
|
|
import { registerDebugFunction } from './power-user.js';
|
2024-04-26 22:57:42 +03:00
|
|
|
import { updateSecretDisplay } from './secrets.js';
|
2023-08-22 10:37:18 +03:00
|
|
|
|
2023-12-02 13:04:51 -05:00
|
|
|
const storageKey = 'language';
|
2024-03-10 20:49:11 +02:00
|
|
|
const overrideLanguage = localStorage.getItem(storageKey);
|
|
|
|
const localeFile = String(overrideLanguage || navigator.language || navigator.userLanguage || 'en').toLowerCase();
|
2024-07-10 17:56:01 +02:00
|
|
|
var langs;
|
2024-05-13 15:56:37 +03:00
|
|
|
// Don't change to let/const! It will break module loading.
|
|
|
|
// eslint-disable-next-line prefer-const
|
2024-07-10 17:56:01 +02:00
|
|
|
var localeData;
|
2024-03-10 20:49:11 +02:00
|
|
|
|
2025-02-27 21:26:45 +01:00
|
|
|
/** @type {Set<string>|null} Array of translations keys if they should be tracked - if not tracked then null */
|
2025-02-27 20:45:10 +01:00
|
|
|
let trackMissingDynamicTranslate = null;
|
|
|
|
|
2024-10-28 11:01:48 +02:00
|
|
|
export const getCurrentLocale = () => localeFile;
|
|
|
|
|
2025-02-27 16:18:10 +00:00
|
|
|
/**
|
|
|
|
* Adds additional localization data to the current locale file.
|
|
|
|
* @param {string} localeId Locale ID (e.g. 'fr-fr' or 'zh-cn')
|
|
|
|
* @param {Record<string, string>} data Localization data to add
|
|
|
|
*/
|
|
|
|
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)) {
|
|
|
|
// Overrides for default locale data are not allowed
|
|
|
|
if (!Object.hasOwn(localeData, key)) {
|
|
|
|
localeData[key] = value;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-06-28 23:01:54 +02:00
|
|
|
/**
|
|
|
|
* An observer that will check if any new i18n elements are added to the document
|
|
|
|
* @type {MutationObserver}
|
|
|
|
*/
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
2024-06-28 23:53:25 +02:00
|
|
|
/**
|
|
|
|
* Translates a template string with named arguments
|
|
|
|
*
|
|
|
|
* Uses the template literal with all values replaced by index placeholder for translation key.
|
|
|
|
*
|
|
|
|
* @example
|
|
|
|
* ```js
|
|
|
|
* toastr.warn(t`Tag ${tagName} not found.`);
|
|
|
|
* ```
|
|
|
|
* Should be translated in the translation files as:
|
|
|
|
* ```
|
|
|
|
* Tag ${0} not found. -> Tag ${0} nicht gefunden.
|
|
|
|
* ```
|
|
|
|
*
|
|
|
|
* @param {TemplateStringsArray} strings - Template strings array
|
|
|
|
* @param {...any} values - Values for placeholders in the template string
|
|
|
|
* @returns {string} Translated and formatted string
|
|
|
|
*/
|
|
|
|
export function t(strings, ...values) {
|
|
|
|
let str = strings.reduce((result, string, i) => result + string + (values[i] !== undefined ? `\${${i}}` : ''), '');
|
|
|
|
let translatedStr = translate(str);
|
|
|
|
|
|
|
|
// Replace indexed placeholders with actual values
|
|
|
|
return translatedStr.replace(/\$\{(\d+)\}/g, (match, index) => values[index]);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Translates a given key or text
|
|
|
|
*
|
|
|
|
* If the translation is based on a key, that one is used to find a possible translation in the translation file.
|
|
|
|
* The original text still has to be provided, as that is the default value being returned if no translation is found.
|
|
|
|
*
|
|
|
|
* For in-code text translation on a format string, using the template literal `t` is preferred.
|
|
|
|
*
|
|
|
|
* @param {string} text - The text to translate
|
|
|
|
* @param {string?} key - The key to use for translation. If not provided, text is used as the key.
|
|
|
|
* @returns {string} - The translated text
|
|
|
|
*/
|
|
|
|
export function translate(text, key = null) {
|
|
|
|
const translationKey = key || text;
|
2025-02-27 20:48:16 +01:00
|
|
|
if (translationKey === null || translationKey === undefined) {
|
|
|
|
console.trace('WARN: No translation key provided');
|
|
|
|
return '';
|
|
|
|
}
|
2025-02-27 21:26:45 +01:00
|
|
|
if (trackMissingDynamicTranslate && localeData && !Object.hasOwn(localeData, translationKey)) {
|
|
|
|
trackMissingDynamicTranslate.add(translationKey);
|
2025-02-27 20:45:10 +01:00
|
|
|
}
|
2024-06-28 23:53:25 +02:00
|
|
|
return localeData?.[translationKey] || text;
|
|
|
|
}
|
|
|
|
|
2024-03-10 20:49:11 +02:00
|
|
|
/**
|
|
|
|
* Fetches the locale data for the given language.
|
|
|
|
* @param {string} language Language code
|
|
|
|
* @returns {Promise<Record<string, string>>} Locale data
|
|
|
|
*/
|
|
|
|
async function getLocaleData(language) {
|
2024-04-24 21:12:40 +07:00
|
|
|
let supportedLang = findLang(language);
|
2024-04-26 22:57:42 +03:00
|
|
|
if (!supportedLang) {
|
|
|
|
return {};
|
|
|
|
}
|
2023-08-22 10:37:18 +03:00
|
|
|
|
2024-03-10 20:49:11 +02:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2024-04-24 21:12:40 +07:00
|
|
|
function findLang(language) {
|
2024-04-26 22:57:42 +03:00
|
|
|
var supportedLang = langs.find(x => x.lang === language);
|
2024-04-24 21:12:40 +07:00
|
|
|
|
|
|
|
if (!supportedLang) {
|
|
|
|
console.warn(`Unsupported language: ${language}`);
|
|
|
|
}
|
2024-04-26 22:57:42 +03:00
|
|
|
return supportedLang;
|
2024-04-24 21:12:40 +07:00
|
|
|
}
|
|
|
|
|
2024-06-28 23:01:54 +02:00
|
|
|
/**
|
|
|
|
* Translates a given element based on its data-i18n attribute.
|
|
|
|
* @param {Element} element The element to translate
|
|
|
|
*/
|
|
|
|
function translateElement(element) {
|
|
|
|
const keys = element.getAttribute('data-i18n').split(';'); // Multi-key entries are ; delimited
|
|
|
|
for (const key of keys) {
|
|
|
|
const attributeMatch = key.match(/\[(\S+)\](.+)/); // [attribute]key
|
|
|
|
if (attributeMatch) { // attribute-tagged key
|
|
|
|
const localizedValue = localeData?.[attributeMatch[2]];
|
|
|
|
if (localizedValue || localizedValue === '') {
|
|
|
|
element.setAttribute(attributeMatch[1], localizedValue);
|
|
|
|
}
|
|
|
|
} else { // No attribute tag, treat as 'text'
|
|
|
|
const localizedValue = localeData?.[key];
|
|
|
|
if (localizedValue || localizedValue === '') {
|
|
|
|
element.textContent = localizedValue;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-02-27 20:45:10 +01:00
|
|
|
/**
|
|
|
|
* Checks if the given locale is supported and not English.
|
|
|
|
* @param {string} [locale=null] The locale to check (defaults to the current locale)
|
|
|
|
* @returns {boolean} True if the locale is not English and supported
|
|
|
|
*/
|
|
|
|
function isSupportedNonEnglish(locale = null) {
|
|
|
|
const lang = locale || localeFile;
|
|
|
|
return lang && lang != 'en' && findLang(lang);
|
|
|
|
}
|
2024-06-28 23:01:54 +02:00
|
|
|
|
2024-03-10 20:49:11 +02:00
|
|
|
async function getMissingTranslations() {
|
2025-02-27 20:45:10 +01:00
|
|
|
/** @type {Array<{key: string, language: string, value: string}>} */
|
2023-08-24 15:13:04 +03:00
|
|
|
const missingData = [];
|
2024-04-26 22:57:42 +03:00
|
|
|
|
2025-02-27 20:45:10 +01:00
|
|
|
if (trackMissingDynamicTranslate) {
|
2025-02-27 21:26:45 +01:00
|
|
|
missingData.push(...Array.from(trackMissingDynamicTranslate).map(key => ({ key, language: localeFile, value: key })));
|
2025-02-27 20:45:10 +01:00
|
|
|
}
|
|
|
|
|
2024-04-26 22:57:42 +03:00
|
|
|
// Determine locales to search for untranslated strings
|
2025-02-27 20:45:10 +01:00
|
|
|
const langsToProcess = isSupportedNonEnglish() ? [findLang(localeFile)] : langs;
|
2024-04-26 22:57:42 +03:00
|
|
|
|
2024-04-24 21:12:40 +07:00
|
|
|
for (const language of langsToProcess) {
|
|
|
|
const localeData = await getLocaleData(language.lang);
|
2023-12-02 13:04:51 -05:00
|
|
|
$(document).find('[data-i18n]').each(function () {
|
|
|
|
const keys = $(this).data('i18n').split(';'); // Multi-key entries are ; delimited
|
2023-08-24 15:13:04 +03:00
|
|
|
for (const key of keys) {
|
|
|
|
const attributeMatch = key.match(/\[(\S+)\](.+)/); // [attribute]key
|
|
|
|
if (attributeMatch) { // attribute-tagged key
|
2024-03-09 23:03:51 +00:00
|
|
|
const localizedValue = localeData?.[attributeMatch[2]];
|
2023-08-24 15:13:04 +03:00
|
|
|
if (!localizedValue) {
|
2025-02-27 20:45:10 +01:00
|
|
|
missingData.push({ key, language: language.lang, value: String($(this).attr(attributeMatch[1])) });
|
2023-08-24 15:13:04 +03:00
|
|
|
}
|
|
|
|
} else { // No attribute tag, treat as 'text'
|
2024-03-09 23:03:51 +00:00
|
|
|
const localizedValue = localeData?.[key];
|
2023-08-24 15:13:04 +03:00
|
|
|
if (!localizedValue) {
|
2024-04-24 21:12:40 +07:00
|
|
|
missingData.push({ key, language: language.lang, value: $(this).text().trim() });
|
2023-08-24 15:13:04 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// Remove duplicates
|
|
|
|
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 });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Sort by language, then key
|
|
|
|
uniqueMissingData.sort((a, b) => a.language.localeCompare(b.language) || a.key.localeCompare(b.key));
|
|
|
|
|
|
|
|
// Map to { language: { key: value } }
|
2025-02-27 20:59:38 +01:00
|
|
|
const missingDataMap = Object.fromEntries(uniqueMissingData.map(({ key, value }) => [key, value]));
|
2023-08-24 15:13:04 +03:00
|
|
|
|
2025-02-27 20:59:38 +01:00
|
|
|
console.log(`Missing Translations (${uniqueMissingData.length}):`);
|
2023-08-24 15:13:04 +03:00
|
|
|
console.table(uniqueMissingData);
|
2025-02-27 20:59:38 +01:00
|
|
|
console.log(`Full map of missing data (${Object.keys(missingDataMap).length}):`);
|
2023-08-24 15:13:04 +03:00
|
|
|
console.log(missingDataMap);
|
|
|
|
|
2025-02-27 20:45:10 +01:00
|
|
|
if (trackMissingDynamicTranslate) {
|
2025-02-27 21:26:45 +01:00
|
|
|
const trackMissingDynamicTranslateMap = Object.fromEntries(Array.from(trackMissingDynamicTranslate).map(key => [key, key]));
|
2025-02-27 20:59:38 +01:00
|
|
|
console.log(`Dynamic translations missing (${Object.keys(trackMissingDynamicTranslateMap).length}):`);
|
|
|
|
console.log(trackMissingDynamicTranslateMap);
|
2025-02-27 20:45:10 +01:00
|
|
|
}
|
|
|
|
|
2023-08-27 23:20:43 +03:00
|
|
|
toastr.success(`Found ${uniqueMissingData.length} missing translations. See browser console for details.`);
|
|
|
|
}
|
2023-08-24 15:13:04 +03:00
|
|
|
|
2023-08-22 10:37:18 +03:00
|
|
|
export function applyLocale(root = document) {
|
2024-03-12 20:24:45 +02:00
|
|
|
if (!localeData || Object.keys(localeData).length === 0) {
|
|
|
|
return root;
|
|
|
|
}
|
|
|
|
|
2023-12-02 13:04:51 -05:00
|
|
|
const $root = root instanceof Document ? $(root) : $(new DOMParser().parseFromString(root, 'text/html'));
|
2023-08-22 10:37:18 +03:00
|
|
|
|
|
|
|
//find all the elements with `data-i18n` attribute
|
2023-12-02 13:04:51 -05:00
|
|
|
$root.find('[data-i18n]').each(function () {
|
2024-06-28 23:01:54 +02:00
|
|
|
translateElement(this);
|
2023-08-22 10:37:18 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
if (root !== document) {
|
|
|
|
return $root.get(0).body.innerHTML;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-03-10 20:49:11 +02:00
|
|
|
function addLanguagesToDropdown() {
|
2024-05-13 19:20:28 +07:00
|
|
|
const uiLanguageSelects = $('#ui_language_select, #onboarding_ui_language_select');
|
2024-03-12 19:03:12 +01:00
|
|
|
for (const langObj of langs) { // Set the value to the language code
|
2023-08-22 10:37:18 +03:00
|
|
|
const option = document.createElement('option');
|
2024-03-12 20:24:45 +02:00
|
|
|
option.value = langObj['lang']; // Set the value to the language code
|
|
|
|
option.innerText = langObj['display']; // Set the display text to the language name
|
2024-05-13 19:20:28 +07:00
|
|
|
uiLanguageSelects.append(option);
|
2023-08-22 10:37:18 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
const selectedLanguage = localStorage.getItem(storageKey);
|
|
|
|
if (selectedLanguage) {
|
2024-05-13 19:20:28 +07:00
|
|
|
uiLanguageSelects.val(selectedLanguage);
|
2023-08-22 10:37:18 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-07-10 17:56:01 +02:00
|
|
|
export async function initLocales() {
|
|
|
|
langs = await fetch('/locales/lang.json').then(response => response.json());
|
|
|
|
localeData = await getLocaleData(localeFile);
|
2023-08-22 10:37:18 +03:00
|
|
|
applyLocale();
|
|
|
|
addLanguagesToDropdown();
|
2024-04-26 22:57:42 +03:00
|
|
|
updateSecretDisplay();
|
2023-08-22 10:37:18 +03:00
|
|
|
|
2024-05-13 19:20:28 +07:00
|
|
|
$('#ui_language_select, #onboarding_ui_language_select').on('change', async function () {
|
2023-08-22 14:30:49 +03:00
|
|
|
const language = String($(this).val());
|
2023-08-22 10:37:18 +03:00
|
|
|
|
|
|
|
if (language) {
|
|
|
|
localStorage.setItem(storageKey, language);
|
|
|
|
} else {
|
|
|
|
localStorage.removeItem(storageKey);
|
|
|
|
}
|
|
|
|
|
|
|
|
location.reload();
|
|
|
|
});
|
2023-08-27 23:20:43 +03:00
|
|
|
|
2024-06-28 23:01:54 +02:00
|
|
|
observer.observe(document, {
|
|
|
|
childList: true,
|
|
|
|
subtree: true,
|
|
|
|
attributes: true,
|
|
|
|
attributeFilter: ['data-i18n'],
|
|
|
|
});
|
|
|
|
|
2025-02-27 20:45:10 +01:00
|
|
|
if (localStorage.getItem('trackDynamicTranslate') === 'true' && isSupportedNonEnglish()) {
|
2025-02-27 21:26:45 +01:00
|
|
|
trackMissingDynamicTranslate = new Set();
|
2025-02-27 20:45:10 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
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()) {
|
2025-02-27 21:26:45 +01:00
|
|
|
trackMissingDynamicTranslate = new Set();
|
2025-02-27 20:45:10 +01:00
|
|
|
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.');
|
|
|
|
}
|
|
|
|
});
|
2023-08-27 23:20:43 +03:00
|
|
|
registerDebugFunction('applyLocale', 'Apply locale', 'Reapplies the currently selected locale to the page.', applyLocale);
|
2023-12-02 10:15:03 -05:00
|
|
|
}
|