Merge pull request #2436 from SillyTavern/translation-improvements Translation improvements
Signed| @@ -209,7 +209,7 @@ import { | |||
| 209 | instruct_presets, | 209 | instruct_presets, |
| 210 | selectContextPreset, | 210 | selectContextPreset, |
| 211 | } from './scripts/instruct-mode.js'; | 211 | } from './scripts/instruct-mode.js'; |
| 212 | import { initLocales } from './scripts/i18n.js'; | 212 | import { initLocales, t, translate } from './scripts/i18n.js'; |
| 213 | import { getFriendlyTokenizerName, getTokenCount, getTokenCountAsync, getTokenizerModel, initTokenizers, saveTokenCache } from './scripts/tokenizers.js'; | 213 | import { getFriendlyTokenizerName, getTokenCount, getTokenCountAsync, getTokenizerModel, initTokenizers, saveTokenCache } from './scripts/tokenizers.js'; |
| 214 | import { | 214 | import { |
| 215 | user_avatar, | 215 | user_avatar, |
| @@ -7826,6 +7826,8 @@ window['SillyTavern'].getContext = function () { | |||
| 7826 | messageFormatting: messageFormatting, | 7826 | messageFormatting: messageFormatting, |
| 7827 | shouldSendOnEnter: shouldSendOnEnter, | 7827 | shouldSendOnEnter: shouldSendOnEnter, |
| 7828 | isMobile: isMobile, | 7828 | isMobile: isMobile, |
| 7829 | t: t, | ||
| 7830 | translate: translate, | ||
| 7829 | tags: tags, | 7831 | tags: tags, |
| 7830 | tagMap: tag_map, | 7832 | tagMap: tag_map, |
| 7831 | menuType: menu_type, | 7833 | menuType: menu_type, |
| @@ -10,6 +10,71 @@ const langs = await fetch('/locales/lang.json').then(response => response.json() | |||
| 10 | var localeData = await getLocaleData(localeFile); | 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 | * Fetches the locale data for the given language. | 78 | * Fetches the locale data for the given language. |
| 14 | * @param {string} language Language code | 79 | * @param {string} language Language code |
| 15 | * @returns {Promise<Record<string, string>>} Locale data | 80 | * @returns {Promise<Record<string, string>>} Locale data |
| @@ -40,6 +105,29 @@ function findLang(language) { | |||
| 40 | return supportedLang; | 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 | async function getMissingTranslations() { | 131 | async function getMissingTranslations() { |
| 44 | const missingData = []; | 132 | const missingData = []; |
| 45 | 133 | ||
| @@ -103,22 +191,7 @@ export function applyLocale(root = document) { | |||
| 103 | 191 | ||
| 104 | //find all the elements with `data-i18n` attribute | 192 | //find all the elements with `data-i18n` attribute |
| 105 | $root.find('[data-i18n]').each(function () { | 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 | if (root !== document) { | 197 | if (root !== document) { |
| @@ -126,7 +199,6 @@ export function applyLocale(root = document) { | |||
| 126 | } | 199 | } |
| 127 | } | 200 | } |
| 128 | 201 | ||
| 129 | |||
| 130 | function addLanguagesToDropdown() { | 202 | function addLanguagesToDropdown() { |
| 131 | const uiLanguageSelects = $('#ui_language_select, #onboarding_ui_language_select'); | 203 | const uiLanguageSelects = $('#ui_language_select, #onboarding_ui_language_select'); |
| 132 | for (const langObj of langs) { // Set the value to the language code | 204 | for (const langObj of langs) { // Set the value to the language code |
| @@ -159,6 +231,13 @@ export function initLocales() { | |||
| 159 | location.reload(); | 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 | 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); | 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 | registerDebugFunction('applyLocale', 'Apply locale', 'Reapplies the currently selected locale to the page.', applyLocale); | 242 | registerDebugFunction('applyLocale', 'Apply locale', 'Reapplies the currently selected locale to the page.', applyLocale); |
| 164 | } | 243 | } |