Merge pull request #2436 from SillyTavern/translation-improvements Translation improvements

175a91f979c4433125ccc424ecffd143524c3163

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
2 files changed, +99 -18Showing whitespace changes
public/script.js+3 -1
@@ -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';
212import { initLocales } from './scripts/i18n.js';212import { initLocales, t, translate } from './scripts/i18n.js';
213import { getFriendlyTokenizerName, getTokenCount, getTokenCountAsync, getTokenizerModel, initTokenizers, saveTokenCache } from './scripts/tokenizers.js';213import { getFriendlyTokenizerName, getTokenCount, getTokenCountAsync, getTokenizerModel, initTokenizers, saveTokenCache } from './scripts/tokenizers.js';
214import {214import {
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,
public/scripts/i18n.js+96 -17
@@ -10,6 +10,71 @@ const langs = await fetch('/locales/lang.json').then(response => response.json()
10var localeData = await getLocaleData(localeFile);10var localeData = await getLocaleData(localeFile);
1111
12/**12/**
13 * An observer that will check if any new i18n elements are added to the document
14 * @type {MutationObserver}
15 */
16const 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 */
52export 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 */
72export 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 code79 * @param {string} language Language code
15 * @returns {Promise<Record<string, string>>} Locale data80 * @returns {Promise<Record<string, string>>} Locale data
@@ -40,6 +105,29 @@ function findLang(language) {
40 return supportedLang;105 return supportedLang;
41}106}
42107
108/**
109 * Translates a given element based on its data-i18n attribute.
110 * @param {Element} element The element to translate
111 */
112function 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
43async function getMissingTranslations() {131async function getMissingTranslations() {
44 const missingData = [];132 const missingData = [];
45133
@@ -103,22 +191,7 @@ export function applyLocale(root = document) {
103191
104 //find all the elements with `data-i18n` attribute192 //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 data194 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 });
123196
124 if (root !== document) {197 if (root !== document) {
@@ -126,7 +199,6 @@ export function applyLocale(root = document) {
126 }199 }
127}200}
128201
129
130function addLanguagesToDropdown() {202function 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 code204 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 });
161233
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}