Merge pull request #2436 from SillyTavern/translation-improvements Translation improvements
Signed| @@ -209,7 +209,7 @@ import { | ||
| 209 | 209 | instruct_presets, |
| 210 | 210 | selectContextPreset, |
| 211 | 211 | } from './scripts/instruct-mode.js'; |
| 212 | 212 | import { initLocales, t, translate } from './scripts/i18n.js'; |
| 213 | 213 | import { getFriendlyTokenizerName, getTokenCount, getTokenCountAsync, getTokenizerModel, initTokenizers, saveTokenCache } from './scripts/tokenizers.js'; |
| 214 | 214 | import { |
| 215 | 215 | user_avatar, |
| @@ -7826,6 +7826,8 @@ window['SillyTavern'].getContext = function () { | ||
| 7826 | 7826 | messageFormatting: messageFormatting, |
| 7827 | 7827 | shouldSendOnEnter: shouldSendOnEnter, |
| 7828 | 7828 | isMobile: isMobile, |
| 7829 | + t: t, | |
| 7830 | + translate: translate, | |
| 7829 | 7831 | tags: tags, |
| 7830 | 7832 | tagMap: tag_map, |
| 7831 | 7833 | menuType: menu_type, |
| @@ -10,6 +10,71 @@ const langs = await fetch('/locales/lang.json').then(response => response.json() | ||
| 10 | 10 | var localeData = await getLocaleData(localeFile); |
| 11 | 11 | |
| 12 | 12 | /** |
| 13 | + * An observer that will check if any new i18n elements are added to the document | |
| 14 | + * @type {MutationObserver} | |
| 15 | + */ | |
| 16 | +const observer = new MutationObserver(mutations => { | |
| 17 | + mutations.forEach(mutation => { | |
| 18 | + mutation.addedNodes.forEach(node => { | |
| 19 | + if (node.nodeType === Node.ELEMENT_NODE && node instanceof Element) { | |
| 20 | + if (node.hasAttribute('data-i18n')) { | |
| 21 | + translateElement(node); | |
| 22 | + } | |
| 23 | + node.querySelectorAll('[data-i18n]').forEach(element => { | |
| 24 | + translateElement(element); | |
| 25 | + }); | |
| 26 | + } | |
| 27 | + }); | |
| 28 | + if (mutation.attributeName === 'data-i18n' && mutation.target instanceof Element) { | |
| 29 | + translateElement(mutation.target); | |
| 30 | + } | |
| 31 | + }); | |
| 32 | +}); | |
| 33 | + | |
| 34 | +/** | |
| 35 | + * Translates a template string with named arguments | |
| 36 | + * | |
| 37 | + * Uses the template literal with all values replaced by index placeholder for translation key. | |
| 38 | + * | |
| 39 | + * @example | |
| 40 | + * ```js | |
| 41 | + * toastr.warn(t`Tag ${tagName} not found.`); | |
| 42 | + * ``` | |
| 43 | + * Should be translated in the translation files as: | |
| 44 | + * ``` | |
| 45 | + * Tag ${0} not found. -> Tag ${0} nicht gefunden. | |
| 46 | + * ``` | |
| 47 | + * | |
| 48 | + * @param {TemplateStringsArray} strings - Template strings array | |
| 49 | + * @param {...any} values - Values for placeholders in the template string | |
| 50 | + * @returns {string} Translated and formatted string | |
| 51 | + */ | |
| 52 | +export function t(strings, ...values) { | |
| 53 | + let str = strings.reduce((result, string, i) => result + string + (values[i] !== undefined ? `\${${i}}` : ''), ''); | |
| 54 | + let translatedStr = translate(str); | |
| 55 | + | |
| 56 | + // Replace indexed placeholders with actual values | |
| 57 | + return translatedStr.replace(/\$\{(\d+)\}/g, (match, index) => values[index]); | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** | |
| 61 | + * Translates a given key or text | |
| 62 | + * | |
| 63 | + * If the translation is based on a key, that one is used to find a possible translation in the translation file. | |
| 64 | + * The original text still has to be provided, as that is the default value being returned if no translation is found. | |
| 65 | + * | |
| 66 | + * For in-code text translation on a format string, using the template literal `t` is preferred. | |
| 67 | + * | |
| 68 | + * @param {string} text - The text to translate | |
| 69 | + * @param {string?} key - The key to use for translation. If not provided, text is used as the key. | |
| 70 | + * @returns {string} - The translated text | |
| 71 | + */ | |
| 72 | +export function translate(text, key = null) { | |
| 73 | + const translationKey = key || text; | |
| 74 | + return localeData?.[translationKey] || text; | |
| 75 | +} | |
| 76 | + | |
| 77 | +/** | |
| 13 | 78 | * Fetches the locale data for the given language. |
| 14 | 79 | * @param {string} language Language code |
| 15 | 80 | * @returns {Promise<Record<string, string>>} Locale data |
| @@ -40,6 +105,29 @@ function findLang(language) { | ||
| 40 | 105 | return supportedLang; |
| 41 | 106 | } |
| 42 | 107 | |
| 108 | +/** | |
| 109 | + * Translates a given element based on its data-i18n attribute. | |
| 110 | + * @param {Element} element The element to translate | |
| 111 | + */ | |
| 112 | +function translateElement(element) { | |
| 113 | + const keys = element.getAttribute('data-i18n').split(';'); // Multi-key entries are ; delimited | |
| 114 | + for (const key of keys) { | |
| 115 | + const attributeMatch = key.match(/\[(\S+)\](.+)/); // [attribute]key | |
| 116 | + if (attributeMatch) { // attribute-tagged key | |
| 117 | + const localizedValue = localeData?.[attributeMatch[2]]; | |
| 118 | + if (localizedValue || localizedValue === '') { | |
| 119 | + element.setAttribute(attributeMatch[1], localizedValue); | |
| 120 | + } | |
| 121 | + } else { // No attribute tag, treat as 'text' | |
| 122 | + const localizedValue = localeData?.[key]; | |
| 123 | + if (localizedValue || localizedValue === '') { | |
| 124 | + element.textContent = localizedValue; | |
| 125 | + } | |
| 126 | + } | |
| 127 | + } | |
| 128 | +} | |
| 129 | + | |
| 130 | + | |
| 43 | 131 | async function getMissingTranslations() { |
| 44 | 132 | const missingData = []; |
| 45 | 133 | |
| @@ -103,22 +191,7 @@ export function applyLocale(root = document) { | ||
| 103 | 191 | |
| 104 | 192 | //find all the elements with `data-i18n` attribute |
| 105 | 193 | $root.find('[data-i18n]').each(function () { |
| 106 | - //read the translation from the language data | |
| 194 | + translateElement(this); | |
| 107 | - const keys = $(this).data('i18n').split(';'); // Multi-key entries are ; delimited | |
| 108 | - for (const key of keys) { | |
| 109 | - const attributeMatch = key.match(/\[(\S+)\](.+)/); // [attribute]key | |
| 110 | - if (attributeMatch) { // attribute-tagged key | |
| 111 | - const localizedValue = localeData?.[attributeMatch[2]]; | |
| 112 | - if (localizedValue || localizedValue == '') { | |
| 113 | - $(this).attr(attributeMatch[1], localizedValue); | |
| 114 | - } | |
| 115 | - } else { // No attribute tag, treat as 'text' | |
| 116 | - const localizedValue = localeData?.[key]; | |
| 117 | - if (localizedValue || localizedValue == '') { | |
| 118 | - $(this).text(localizedValue); | |
| 119 | - } | |
| 120 | - } | |
| 121 | - } | |
| 122 | 195 | }); |
| 123 | 196 | |
| 124 | 197 | if (root !== document) { |
| @@ -126,7 +199,6 @@ export function applyLocale(root = document) { | ||
| 126 | 199 | } |
| 127 | 200 | } |
| 128 | 201 | |
| 129 | - | |
| 130 | 202 | function addLanguagesToDropdown() { |
| 131 | 203 | const uiLanguageSelects = $('#ui_language_select, #onboarding_ui_language_select'); |
| 132 | 204 | for (const langObj of langs) { // Set the value to the language code |
| @@ -159,6 +231,13 @@ export function initLocales() { | ||
| 159 | 231 | location.reload(); |
| 160 | 232 | }); |
| 161 | 233 | |
| 234 | + observer.observe(document, { | |
| 235 | + childList: true, | |
| 236 | + subtree: true, | |
| 237 | + attributes: true, | |
| 238 | + attributeFilter: ['data-i18n'], | |
| 239 | + }); | |
| 240 | + | |
| 162 | 241 | 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); |
| 163 | 242 | registerDebugFunction('applyLocale', 'Apply locale', 'Reapplies the currently selected locale to the page.', applyLocale); |
| 164 | 243 | } |