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 {
209209 instruct_presets,
210210 selectContextPreset,
211211} from './scripts/instruct-mode.js';
212212import { initLocales, t, translate } from './scripts/i18n.js';
213213import { getFriendlyTokenizerName, getTokenCount, getTokenCountAsync, getTokenizerModel, initTokenizers, saveTokenCache } from './scripts/tokenizers.js';
214214import {
215215 user_avatar,
@@ -7826,6 +7826,8 @@ window['SillyTavern'].getContext = function () {
78267826 messageFormatting: messageFormatting,
78277827 shouldSendOnEnter: shouldSendOnEnter,
78287828 isMobile: isMobile,
7829+ t: t,
7830+ translate: translate,
78297831 tags: tags,
78307832 tagMap: tag_map,
78317833 menuType: menu_type,
public/scripts/i18n.js+96 -17
@@ -10,6 +10,71 @@ const langs = await fetch('/locales/lang.json').then(response => response.json()
1010var localeData = await getLocaleData(localeFile);
1111
1212/**
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+/**
1378 * Fetches the locale data for the given language.
1479 * @param {string} language Language code
1580 * @returns {Promise<Record<string, string>>} Locale data
@@ -40,6 +105,29 @@ function findLang(language) {
40105 return supportedLang;
41106}
42107
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+
43131async function getMissingTranslations() {
44132 const missingData = [];
45133
@@ -103,22 +191,7 @@ export function applyLocale(root = document) {
103191
104192 //find all the elements with `data-i18n` attribute
105193 $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- }
122195 });
123196
124197 if (root !== document) {
@@ -126,7 +199,6 @@ export function applyLocale(root = document) {
126199 }
127200}
128201
129-
130202function addLanguagesToDropdown() {
131203 const uiLanguageSelects = $('#ui_language_select, #onboarding_ui_language_select');
132204 for (const langObj of langs) { // Set the value to the language code
@@ -159,6 +231,13 @@ export function initLocales() {
159231 location.reload();
160232 });
161233
234+ observer.observe(document, {
235+ childList: true,
236+ subtree: true,
237+ attributes: true,
238+ attributeFilter: ['data-i18n'],
239+ });
240+
162241 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);
163242 registerDebugFunction('applyLocale', 'Apply locale', 'Reapplies the currently selected locale to the page.', applyLocale);
164243}