Blame Raw
Cohee · 51ad27fb · · 332 lines (12.9 KB)
2 contributors
1import { registerDebugFunction } from './power-user.js';
2import { updateSecretDisplay } from './secrets.js';
3
4const storageKey = 'language';
5const overrideLanguage = localStorage.getItem(storageKey);
6const localeFile = String(overrideLanguage || navigator.language || navigator.userLanguage || 'en').toLowerCase();
7var langs;
8// Don't change to let/const! It will break module loading.
9// eslint-disable-next-line prefer-const
10var localeData;
11
12/** @type {Set<string>|null} Array of translations keys if they should be tracked - if not tracked then null */
13let trackMissingDynamicTranslate = null;
14
15export 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 */
22export 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 */
45const 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 */
81export 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 */
101export 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 */
118async 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 */
139function 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 */
153function 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 */
176function isSupportedNonEnglish(locale = null) {
177 const lang = locale || localeFile;
178 return lang && lang != 'en' && findLang(lang);
179}
180
181async 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 */
246export 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
263function 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
278export 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}