diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..583bad1 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["biomejs.biome"] +} diff --git a/README.md b/README.md index abb32b2..983655b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -[中文文档](README_ZH.md) / [日本語](README_JA.md) -

sd-webui-bilingual-localization

# sd-webui-bilingual-localization @@ -7,6 +5,17 @@ ![Snipaste_2023-03-30_01-05-45](https://user-images.githubusercontent.com/16256221/228617304-3107244b-ce13-4b96-b665-1d13090d24a7.png) +## Development + +```bash +# format & lint +bunx @biomejs/biome check --write --unsafe --config-path=./biome.json + +# build +bun build ./src/main.ts --outfile ./javascript/bilingual_localization.js +``` + + ## Features - Bilingual translation, no need to worry about how to find the original button. - Compatible with language pack extensions, no need to re-import. diff --git a/README_JA.md b/README_JA.md deleted file mode 100644 index 12b5038..0000000 --- a/README_JA.md +++ /dev/null @@ -1,81 +0,0 @@ -[English Version](README.md) - -

sd-webui-bilingual-localization

- -# sd-webui-bilingual-localization -[Stable Diffusion web UI](https://github.com/AUTOMATIC1111/stable-diffusion-webui)のバイリンガル対応拡張機能 - -![Snipaste_2023-03-30_01-05-45](https://user-images.githubusercontent.com/16256221/228617304-3107244b-ce13-4b96-b665-1d13090d24a7.png) - -## 特徴 -- バイリンガル対応により、元のボタンを探す必要がありません。 -- 日本語化拡張機能と互換性があり、ファイルを取り込み直す必要はありません。 -- ツールチップの動的翻訳をサポートします。 -- スコープと正規表現パターンによる柔軟な翻訳が可能です。 - -## インストール - -以下の方法から選択します。 -拡張機能に対応したWebUI(2023年以降のバージョン)が必要です。 - -#### 方法1 - -WebUIの`Install from URL`でインストールを行います。 - -Extensions - Install from URLを順にクリックします。 - -1個目のテキストボックスに`https://github.com/journey-ad/sd-webui-bilingual-localization`を入力し、Installボタンをクリックします。 - -![Snipaste_2023-02-28_00-27-48](https://user-images.githubusercontent.com/16256221/221625310-a6ef0b4c-a1e0-46bb-be9c-6d88cd0ad684.png) - -その後、Installedパネルに切り替え、Apply and restart UIボタンをクリックします。 - -![Snipaste_2023-02-28_00-29-14](https://user-images.githubusercontent.com/16256221/221625345-9e656f25-89dd-4361-8ee5-f4ab39d18ca4.png) - - -#### 方法2 - -拡張機能のディレクトリに手動でcloneします。 - -```bash -git clone https://github.com/journey-ad/sd-webui-bilingual-localization extensions/sd-webui-bilingual-localization -``` - -## 使用方法 - -> **⚠️重要⚠️** -> Settings - User interface - Localizationが`None`に設定されていることを確認してください。 - -Settings - Bilingual Localizationパネルで、有効にしたい言語ファイル名を選択し、Apply settingsボタンとReload UIボタンを順にクリックします。 - -![Snipaste_2023-02-28_00-04-21](https://user-images.githubusercontent.com/16256221/221625729-73519629-8c1f-4eb5-99db-a1d3f4b58a87.png) - -## スコープ - -ローカリゼーションは、グローバルな影響を防ぐためにスコープを限定したサポートを提供します。構文規則は以下の通りです: -- `####` スコープが指定された要素の祖先のIDと一致する場合にのみ、スコープ付きのテキストが適用されます。 -- `##@##` スコープが指定されたCSSセレクタと一致する場合にのみ、スコープ付きのテキストが適用されます。 - -```json - ... - "##tab_ti##Normal": "正常", // id="tab_ti"の要素の下にある`Normal`のみが`正常`に変換されます - "##tab_threedopenpose##Normal": "法線マップ", // id="tab_threedopenpose"の要素の下にある`Normal`のみが `法線マップ`に変換されます - "##@.extra-networks .tab-nav button##Lora": "Loraモデル", // class=".extra-networks .tab-nav button"の要素の下にある`Lora`のみが`Loraモデル`に変換されます - ... -``` - -## 正規表現パターン - -正規表現を使った日本語化が可能です。構文ルールは`@@`、キャプチャグループは`$n`です。ドキュメント:[String.prototype.replace()](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/replace)。 -```json -{ - ... - "@@/^(\\d+) images in this directory, divided into (\\d+) pages$/": "このディレクトリには$1枚の画像、$2ページ", - "@@/^Favorites path from settings: (.*)$/": "お気に入りのディレクトリパス:$1", - ... -} -``` - -## 日本語化ファイルの取得 - -内蔵の日本語化ファイルは提供されなくなりました。サードパーティーの日本語化拡張機能をインストールし、当ページの[使用方法](#使用方法)に記載されている方法でセットアップしてください。 \ No newline at end of file diff --git a/README_ZH.md b/README_ZH.md deleted file mode 100644 index 87935d9..0000000 --- a/README_ZH.md +++ /dev/null @@ -1,81 +0,0 @@ -[English Version](README.md) - -

sd-webui-bilingual-localization

- -# sd-webui-bilingual-localization -[Stable Diffusion web UI](https://github.com/AUTOMATIC1111/stable-diffusion-webui) 双语对照翻译插件 - -![Snipaste_2023-03-30_01-05-45](https://user-images.githubusercontent.com/16256221/228617304-3107244b-ce13-4b96-b665-1d13090d24a7.png) - -## 功能 -- 全新实现的双语对照翻译功能,不必再担心切换翻译后找不到原始功能 -- 兼容原生语言包扩展,无需重新导入多语言语料 -- 支持动态title提示的翻译 -- 额外支持作用域和正则表达式替换,翻译更加灵活 - -## 安装 - -以下方式选择其一,需要使用支持扩展功能的 webui (2023年之后的版本) - -#### 方式1 - -使用 webui 提供的`Install from URL`功能安装 - -按下图所示,依次点击Extensions - Install from URL - -然后在第一个文本框内填入`https://github.com/journey-ad/sd-webui-bilingual-localization`,点击Install按钮 -![Snipaste_2023-02-28_00-27-48](https://user-images.githubusercontent.com/16256221/221625310-a6ef0b4c-a1e0-46bb-be9c-6d88cd0ad684.png) - -之后切换到Installed面板,点击Apply and restart UI按钮 -![Snipaste_2023-02-28_00-29-14](https://user-images.githubusercontent.com/16256221/221625345-9e656f25-89dd-4361-8ee5-f4ab39d18ca4.png) - - -#### 方式2 - -手动克隆到你的扩展目录里 - -```bash -git clone https://github.com/journey-ad/sd-webui-bilingual-localization extensions/sd-webui-bilingual-localization -``` - -## 使用 - -> **⚠️重要⚠️** -> 确保Settings - User interface - Localization 已设置为了 `None` - -在Settings - Bilingual Localization中选择要启用的本地化文件,依次点击Apply settingsReload UI按钮 -![Snipaste_2023-02-28_00-04-21](https://user-images.githubusercontent.com/16256221/221625729-73519629-8c1f-4eb5-99db-a1d3f4b58a87.png) - -## 作用域支持 - -本地化语料支持限定作用域,防止影响全局翻译,语法规则: -- `####` 仅当节点祖先元素ID匹配指定的作用域时才会生效 -- `##@##` 仅当节点祖先元素匹配指定的CSS选择器时才会生效 - -```json -{ - ... - "##tab_ti##Normal": "正态", // 仅id="tab_ti"元素下的`Normal`会被翻译为`正态` - "##tab_threedopenpose##Normal": "法线图", // 仅id="tab_threedopenpose"元素下的`Normal`会被翻译为`法线图` - "##@.extra-networks .tab-nav button##Lora": "Lora模型", // 仅class=".extra-networks .tab-nav button"元素下的`Lora`会被翻译为`Lora模型` - ... -} -``` - -## 正则表达式支持 - -本地化语料支持正则表达式替换,语法规则`@@`,括号匹配变量`$n`,参考[String.prototype.replace()](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/replace) -```json -{ - ... - "@@/^(\\d+) images in this directory, divided into (\\d+) pages$/": "目录中有$1张图片,共$2页", - "@@/^Favorites path from settings: (.*)$/": "设置的收藏夹目录:$1", - ... -} -``` - -## 获取本地化文件 - -本地化文件不再随插件提供,请安装第三方语言包并按照本文[使用](#使用)部分的方式设置使用 - -*预览图片中的语言包可以在这里找到 https://gist.github.com/journey-ad/d98ed173321658be6e51f752d6e6163c* diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..c70281a --- /dev/null +++ b/biome.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "organizeImports": { + "enabled": true + }, + "formatter": { + "indentStyle": "space", + "lineEnding": "crlf", + "ignore": ["javascript", "scripts"] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + }, + "ignore": ["javascript", "scripts"] + } +} diff --git a/javascript/bilingual_localization.js b/javascript/bilingual_localization.js index f8c2f12..f9699c3 100644 --- a/javascript/bilingual_localization.js +++ b/javascript/bilingual_localization.js @@ -1,15 +1,482 @@ -(function () { - const customCSS = ` +// src/config/opts.ts +var opts = { + bilingual_localization_enabled: true, + bilingual_localization_logger: false, + bilingual_localization_file: "None", + bilingual_localization_dirs: "{}", + bilingual_localization_order: "Translation First" +}; + +// src/lib/create-logger.ts +function createLogger() { + const loggerTimerMap = new Map; + const loggerConf = { badge: true, label: "Logger", enable: false }; + return new Proxy(console, { + get: (target, propKey) => { + if (propKey === "init") { + return (label) => { + loggerConf.label = label; + loggerConf.enable = true; + }; + } + if (!(propKey in target)) + return; + return (...args) => { + if (!loggerConf.enable) + return; + let color = ["#39cfe1", "#006cab"]; + let label; + let start; + switch (propKey) { + case "error": + color = ["#f70000", "#a70000"]; + break; + case "warn": + color = ["#f7b500", "#b58400"]; + break; + case "time": + label = args[0]; + if (loggerTimerMap.has(label)) { + console.warn(`Timer '${label}' already exists`); + } else { + loggerTimerMap.set(label, performance.now()); + } + return; + case "timeEnd": + label = args[0]; + start = loggerTimerMap.get(label); + if (start === undefined) { + console.warn(`Timer '${label}' does not exist`); + } else { + loggerTimerMap.delete(label); + console.log(`${label}: ${performance.now() - start} ms`); + } + return; + case "groupEnd": + loggerConf.badge = true; + break; + } + const badge = loggerConf.badge ? [ + `%c${loggerConf.label}`, + `color: #fff; background: linear-gradient(180deg, ${color[0]}, ${color[1]}); text-shadow: 0px 0px 1px #0003; padding: 3px 5px; border-radius: 4px;` + ] : []; + target[propKey](...badge, ...args); + if (propKey === "group" || propKey === "groupCollapsed") { + loggerConf.badge = false; + } + }; + } + }); +} + +// src/lib/get-regax.ts +function getRegex(regexString) { + try { + const trimmedRegexString = regexString.trim(); + if (!trimmedRegexString.startsWith("/") || trimmedRegexString.split("/").length < 3) { + const escapedRegexString = trimmedRegexString.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(escapedRegexString); + } + const lastSlashIndex = trimmedRegexString.lastIndexOf("/"); + const regexPattern = trimmedRegexString.slice(1, lastSlashIndex); + const regexFlags = trimmedRegexString.slice(lastSlashIndex + 1); + return new RegExp(regexPattern, regexFlags); + } catch (e) { + return null; + } +} + +// src/lib/delegate-event.ts +function delegateEvent(parent, eventType, selector, handler) { + parent.addEventListener(eventType, (event) => { + let target = event.target; + while (target !== parent) { + if (target.matches(selector)) { + handler.call(target, event); + } + target = target.parentNode; + } + }); +} + +// src/lib/gradio-app.ts +function gradioApp() { + const elems = document.getElementsByTagName("gradio-app"); + const elem = elems.length === 0 ? document : elems[0]; + if (elem !== document) { + elem.getElementById = (id) => document.getElementById(id); + } + return elem.shadowRoot ? elem.shadowRoot : elem; +} +function querySelectorAll(...args) { + const nodeList = gradioApp()?.querySelectorAll(...args); + return nodeList || new NodeList; +} + +// src/lib/handle-dropdown.ts +function handleDropdown() { + delegateEvent(gradioApp(), "mousedown", "ul.options .item", (event) => { + const { target } = event; + if (!target.classList.contains("item")) { + target.closest(".item").dispatchEvent(new Event("mousedown", { bubbles: true })); + return; + } + const source = target.dataset.value; + const $labelEl = target?.closest(".wrap")?.querySelector(".wrap-inner .single-select"); + if (source && $labelEl) { + $labelEl.title = titles?.[source] || ""; + $labelEl.textContent = "__biligual__will_be_replaced__"; + doTranslate($labelEl, source, "element"); + } + }); +} + +// src/lib/read-files.ts +function readFile(filePath) { + const request = new XMLHttpRequest; + request.open("GET", `file=${filePath}`, false); + request.send(null); + return request.responseText; +} + +// src/lib/translate-el.ts +function translateEl(el, { deep = false, rich = false } = {}) { + if (!getI18n) + return; + if (el.matches?.(ignore_selector)) + return; + if (el.title) { + doTranslate(el, el.title, "title"); + } + if (el.placeholder && getConfig()?.enableTransPlaceHolder === true) { + doTranslate(el, el.placeholder, "placeholder"); + } + if (el.tagName === "OPTION") { + doTranslate(el, el.textContent, "option"); + } + if (deep || rich) { + Array.from(el.childNodes).forEach((node) => { + if (node.nodeName === "#text") { + if (rich) { + doTranslate(node, node.textContent, "text"); + return; + } + if (deep) { + doTranslate(node, node.textContent, "element"); + } + } else if (node.childNodes.length > 0) { + translateEl(node, { deep, rich }); + } + }); + } else { + doTranslate(el, el.textContent, "element"); + } +} +var ignore_selector = [ + ".bilingual__trans_wrapper", + ".resultsFlexContainer", + "#setting_sd_model_checkpoint select", + "#setting_sd_vae select", + "#txt2img_styles, #img2txt_styles", + ".extra-network-cards .card .actions .name", + "script, style, svg, g, path", + "svg *, canvas, canvas *", + "#txt2img_prompt_container, #img2img_prompt_container, .physton-prompt", + "#txt2img_prompt_container *, #img2img_prompt_container *, .physton-prompt *", + ".progressDiv, .progress, .progress-text", + ".progressDiv *, .progress *, .progress-text *" +]; + +// src/lib/tranlate-page.ts +function translatePage() { + if (!getI18n()) + return; + const logger = createLogger(); + logger.time("Full Page"); + const majorSelectors = [ + "label span, fieldset span, button", + "textarea[placeholder], select, option", + ".transition > div > span:not([class])", + ".label-wrap > span", + ".gradio-image>div.float", + ".gradio-file>div.float", + ".gradio-code>div.float", + "#modelmerger_interp_description .output-html", + "#modelmerger_interp_description .gradio-html", + "#lightboxModal span" + ]; + const minorSelectors = [ + 'div[data-testid="image"] > div > div', + "#extras_image_batch > div", + ".output-html:not(#footer), .gradio-html:not(#footer), .output-markdown, .gradio-markdown", + "#dynamic-prompting" + ]; + majorSelectors.forEach((selector) => { + querySelectorAll(selector).forEach((el) => translateEl(el, { deep: true })); + }); + minorSelectors.forEach((selector) => { + querySelectorAll(selector).forEach((el) => translateEl(el, { rich: true })); + }); + logger.timeEnd("Full Page"); +} + +// src/setup.ts +function setup3() { + config = { + enabled: opts.bilingual_localization_enabled, + file: opts.bilingual_localization_file, + dirs: opts.bilingual_localization_dirs, + order: opts.bilingual_localization_order, + enableLogger: opts.bilingual_localization_logger + }; + const { enabled, file, dirs, enableLogger } = config; + if (!enabled || file === "None" || dirs === "None") + return; + const dirsParsed = JSON.parse(dirs); + const logger = createLogger(); + if (enableLogger) { + logger.init("Bilingual"); + } + logger.log("Bilingual Localization initialized."); + const regex_scope = /^##(?.+)##(?.+)$/; + i18n = JSON.parse(readFile(dirsParsed[file]), (key, value) => { + if (key.startsWith("@@")) { + const regex = getRegex(key.slice(2)); + if (regex instanceof RegExp) { + i18nRegex.set(regex, value); + } + } else { + const match = key.match(regex_scope); + if (match?.groups) { + let { scope, skey } = match.groups; + if (scope.startsWith("@")) { + scope = scope.slice(1); + } else { + scope = `#${scope}`; + } + if (!scope.length) { + return value; + } + i18nScope[scope] ||= {}; + i18nScope[scope][skey] = value; + scopedSource[skey] ||= []; + scopedSource[skey].push(scope); + } else { + return value; + } + } + }); + translatePage(); + handleDropdown(); +} +function getI18n() { + return i18n; +} +function getI18nRegex() { + return i18nRegex; +} +function getI18nScope() { + return i18nScope; +} +function getScopedSource() { + return scopedSource; +} +function getConfig() { + return config; +} +var i18n = null; +var i18nRegex = new Map; +var i18nScope = {}; +var scopedSource = {}; +var config = null; + +// src/lib/check-regax.ts +function checkRegex(source) { + const i18nRegex2 = getI18nRegex(); + for (const [regex, value] of i18nRegex2.entries()) { + if (regex instanceof RegExp) { + if (regex.test(source)) { + const logger = createLogger(); + logger.log("regex", regex, source, value); + return source.replace(regex, value); + } + } else { + console.warn("Expected regex to be an instance of RegExp, but it was a string."); + } + } + return source; +} + +// src/lib/html-encode.ts +function htmlEncode(htmlStr) { + return htmlStr.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} + +// src/lib/parse-html-string-to-element.ts +function parseHtmlStringToElement(htmlStr) { + const template = document.createElement("template"); + template.insertAdjacentHTML("afterbegin", htmlStr); + return template.firstElementChild; +} + +// src/lib/do-translate.ts +function doTranslate(el, source, type) { + if (!getI18n) + return; + let trimmedSource = source.trim(); + if (!trimmedSource) + return; + if (re_num.test(trimmedSource)) + return; + if (re_emoji.test(trimmedSource)) + return; + let translation = getI18n[trimmedSource] || checkRegex(trimmedSource); + const scopes = getScopedSource[trimmedSource]; + if (scopes) { + console.log("scope", el, trimmedSource, scopes); + for (const scope of scopes) { + if (el.parentElement.closest(scope)) { + translation = getI18nScope[scope][trimmedSource]; + break; + } + } + } + if (!translation || trimmedSource === translation) { + if (el.textContent === "__biligual__will_be_replaced__") + el.textContent = trimmedSource; + if (el.nextSibling?.className === "bilingual__trans_wrapper") + el.nextSibling.remove(); + return; + } + const config2 = getConfig(); + if (config2?.order === "Original First") { + [trimmedSource, translation] = [translation, trimmedSource]; + } + const isTranslationIncludeSource = translation.startsWith(source); + switch (type) { + case "text": + el.textContent = translation; + break; + case "element": { + if (isTranslationIncludeSource) { + if (el.nodeType === 3) { + el.nodeValue = translation; + } else if (htmlEncode(el.textContent) === el.innerHTML) { + el.innerHTML = htmlEncode(translation); + } + break; + } + const htmlEl = parseHtmlStringToElement(`
${htmlEncode(translation)}${htmlEncode(source)}
`); + if (el.hasChildNodes()) { + const textNode = Array.from(el.childNodes).find((node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim() === trimmedSource || node.textContent?.trim() === "__bilingual__will_be_replaced__"); + if (textNode) { + textNode.textContent = ""; + if (textNode.nextSibling?.nodeType === Node.ELEMENT_NODE && textNode.nextSibling.className === "bilingual__trans_wrapper") { + textNode.nextSibling.remove(); + } + if (textNode.parentNode && htmlEl) { + textNode.parentNode.insertBefore(htmlEl, textNode.nextSibling); + } + } + } else { + el.textContent = ""; + if (el.nextSibling?.nodeType === Node.ELEMENT_NODE && el.nextSibling.className === "bilingual__trans_wrapper") { + el.nextSibling.remove(); + } + if (el.parentNode && htmlEl) { + el.parentNode.insertBefore(htmlEl, el.nextSibling); + } + } + break; + } + case "option": + el.textContent = isTranslationIncludeSource ? translation : `${translation} (${trimmedSource})`; + break; + case "title": + el.title = isTranslationIncludeSource ? translation : `${translation}\n${trimmedSource}`; + break; + case "placeholder": + el.placeholder = isTranslationIncludeSource ? translation : `${translation}\n\n${trimmedSource}`; + break; + default: + return translation; + } +} +var re_num = /^[\.\d]+$/; +var re_emoji = /[\p{Extended_Pictographic}\u{1F3FB}-\u{1F3FF}\u{1F9B0}-\u{1F9B3}]/u; + +// src/init.ts +function init() { + const styleEl = document.createElement("style"); + if (styleEl.textContent) { + styleEl.textContent = customCSS; + } else { + styleEl.appendChild(document.createTextNode(customCSS)); + } + gradioApp().appendChild(styleEl); + let loaded = false; + let _count = 0; + const observer = new MutationObserver((mutations) => { + if (window.localization && Object.keys(window.localization).length) + return; + if (Object.keys(opts).length === 0) + return; + let _nodesCount = 0; + const _now = performance.now(); + for (const mutation of mutations) { + if (mutation.type === "characterData") { + if (mutation.target?.parentElement?.parentElement?.tagName === "LABEL") { + translateEl(mutation.target); + } + } else if (mutation.type === "attributes") { + _nodesCount++; + translateEl(mutation.target); + } else { + mutation.addedNodes.forEach((node) => { + if (node instanceof Element && node.className === "bilingual__trans_wrapper") + return; + _nodesCount++; + if (node.nodeType === 1 && node instanceof Element && /(output|gradio)-(html|markdown)/.test(node.className)) { + translateEl(node, { rich: true }); + } else if (node.nodeType === 3) { + doTranslate(node, node.textContent, "text"); + } else { + translateEl(node, { deep: true }); + } + }); + } + } + if (_nodesCount > 0) { + const logger = createLogger(); + logger.info(`UI Update #${_count++}: ${performance.now() - _now} ms, ${_nodesCount} nodes`, mutations); + } + if (loaded) + return; + const i18n2 = getI18n(); + if (i18n2) + return; + loaded = true; + setup3(); + }); + observer.observe(gradioApp(), { + characterData: true, + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["title", "placeholder"] + }); +} +var customCSS = ` .bilingual__trans_wrapper { - display: inline-flex; - flex-direction: column; - align-items: center; - font-size: 13px; - line-height: 1; + display: inline-flex; + flex-direction: column; + align-items: center; + font-size: var(--section-header-text-size); + line-height: 1; } - + .bilingual__trans_wrapper em { - font-style: normal; + font-style: normal; } #txtimg_hr_finalres .bilingual__trans_wrapper em, @@ -20,9 +487,9 @@ #available_extensions .date_added .bilingual__trans_wrapper em, #available_extensions+p>.bilingual__trans_wrapper em, .gradio-image div[data-testid="image"] .bilingual__trans_wrapper em { - display: none; + display: none; } - + #settings .bilingual__trans_wrapper:not(#settings .tabitem .bilingual__trans_wrapper), label>span>.bilingual__trans_wrapper, fieldset>span>.bilingual__trans_wrapper, @@ -40,497 +507,58 @@ .posex_setting_cont .bilingual__trans_wrapper:not(.posex_bg .bilingual__trans_wrapper), /* Posex extension */ #dynamic-prompting .bilingual__trans_wrapper { - font-size: 12px; - align-items: flex-start; + align-items: flex-start; } #extensions label .bilingual__trans_wrapper, #available_extensions td .bilingual__trans_wrapper, .label-wrap>span>.bilingual__trans_wrapper { - font-size: inherit; - line-height: inherit; + font-size: inherit; + line-height: inherit; } .label-wrap>span:first-of-type { - font-size: 13px; - line-height: 1; + font-size: 13px; + line-height: 1; } #txt2img_script_container > div { - margin-top: var(--layout-gap, 12px); + margin-top: var(--layout-gap, 12px); } - + textarea::placeholder { - line-height: 1; - padding: 4px 0; + line-height: 1; + padding: 4px 0; } - + label>span { - line-height: 1; + line-height: 1; } - + div[data-testid="image"] .start-prompt { - background-color: rgba(255, 255, 255, .6); - color: #222; - transition: opacity .2s ease-in-out; + background-color: rgba(255, 255, 255, .6); + color: #222; + transition: opacity .2s ease-in-out; } div[data-testid="image"]:hover .start-prompt { - opacity: 0; + opacity: 0; } .label-wrap > span.icon { - width: 1em; - height: 1em; - transform-origin: center center; + width: 1em; + height: 1em; + transform-origin: center center; } .gradio-dropdown ul.options li.item { - padding: 0.3em 0.4em !important; + padding: 0.3em 0.4em !important; } - + /* Posex extension */ .posex_bg { - white-space: nowrap; - } - ` - - let i18n = null, i18nRegex = new Map(), i18nScope = {}, scopedSource = {}, config = null; - - // First load - function setup() { - config = { - enabled: opts["bilingual_localization_enabled"], - file: opts["bilingual_localization_file"], - dirs: opts["bilingual_localization_dirs"], - order: opts["bilingual_localization_order"], - enableLogger: opts["bilingual_localization_logger"] - } - - let { enabled, file, dirs, enableLogger } = config - - if (!enabled || file === "None" || dirs === "None") return - - dirs = JSON.parse(dirs) - - enableLogger && logger.init('Bilingual') - logger.log('Bilingual Localization initialized.') - - // Load localization file - const regex_scope = /^##(?.+)##(?.+)$/ // ##scope##.skey - i18n = JSON.parse(readFile(dirs[file]), (key, value) => { - // parse regex translations - if (key.startsWith('@@')) { - const regex = getRegex(key.slice(2)) - if (regex instanceof RegExp) { - i18nRegex.set(regex, value) - } - } else if (regex_scope.test(key)) { - // parse scope translations - let { scope, skey } = key.match(regex_scope).groups - - if (scope.startsWith('@')) { - scope = scope.slice(1) - } else { - scope = '#' + scope - } - - if (!scope.length) { - return value - } - - i18nScope[scope] ||= {} - i18nScope[scope][skey] = value - - scopedSource[skey] ||= [] - scopedSource[skey].push(scope) - } else { - return value - } - }) - - logger.group('Localization file loaded.') - logger.log('i18n', i18n) - logger.log('i18nRegex', i18nRegex) - logger.log('i18nScope', i18nScope) - logger.groupEnd() - - translatePage() - handleDropdown() - } - - function handleDropdown() { - // process gradio dropdown menu - delegateEvent(gradioApp(), 'mousedown', 'ul.options .item', function (event) { - const { target } = event - - if (!target.classList.contains('item')) { - // simulate click menu item - target.closest('.item').dispatchEvent(new Event('mousedown', { bubbles: true })) - return - } - - const source = target.dataset.value - const $labelEl = target?.closest('.wrap')?.querySelector('.wrap-inner .single-select') // the label element - - if (source && $labelEl) { - $labelEl.title = titles?.[source] || '' // set title from hints.js - $labelEl.textContent = "__biligual__will_be_replaced__" // marked as will be replaced - doTranslate($labelEl, source, 'element') // translate the label element - } - }); - } - - // Translate page - function translatePage() { - if (!i18n) return - - logger.time('Full Page') - querySelectorAll([ - "label span, fieldset span, button", // major label and button description text - "textarea[placeholder], select, option", // text box placeholder and select element - ".transition > div > span:not([class])", ".label-wrap > span", // collapse panel added by extension - ".gradio-image>div.float", // image upload description - ".gradio-file>div.float", // file upload description - ".gradio-code>div.float", // code editor description - "#modelmerger_interp_description .output-html", // model merger description - "#modelmerger_interp_description .gradio-html", // model merger description - "#lightboxModal span" // image preview lightbox - ]) - .forEach(el => translateEl(el, { deep: true })) - - querySelectorAll([ - 'div[data-testid="image"] > div > div', // description of image upload panel - '#extras_image_batch > div', // description of extras image batch file upload panel - ".output-html:not(#footer), .gradio-html:not(#footer), .output-markdown, .gradio-markdown", // output html exclude footer - '#dynamic-prompting' // dynamic-prompting extension - ]) - .forEach(el => translateEl(el, { rich: true })) - - logger.timeEnd('Full Page') - } - - const ignore_selector = [ - '.bilingual__trans_wrapper', // self - '.resultsFlexContainer', // tag-autocomplete - '#setting_sd_model_checkpoint select', // model checkpoint - '#setting_sd_vae select', // vae model - '#txt2img_styles, #img2txt_styles', // styles select - '.extra-network-cards .card .actions .name', // extra network cards name - 'script, style, svg, g, path', // script / style / svg elements - ] - // Translate element - function translateEl(el, { deep = false, rich = false } = {}) { - if (!i18n) return // translation not ready. - if (el.matches?.(ignore_selector)) return // ignore some elements. - - if (el.title) { - doTranslate(el, el.title, 'title') - } - - if (el.placeholder) { - doTranslate(el, el.placeholder, 'placeholder') - } - - if (el.tagName === 'OPTION') { - doTranslate(el, el.textContent, 'option') - } - - if (deep || rich) { - Array.from(el.childNodes).forEach(node => { - if (node.nodeName === '#text') { - if (rich) { - doTranslate(node, node.textContent, 'text') - return - } - - if (deep) { - doTranslate(node, node.textContent, 'element') - } - } else if (node.childNodes.length > 0) { - translateEl(node, { deep, rich }) - } - }) - } else { - doTranslate(el, el.textContent, 'element') + white-space: nowrap; } - } - - function checkRegex(source) { - for (const [regex, value] of i18nRegex.entries()) { - if (regex.test(source)) { - logger.log('regex', regex, source, value) - return source.replace(regex, value) - } - } - } - - const re_num = /^[\.\d]+$/, - re_emoji = /[\p{Extended_Pictographic}\u{1F3FB}-\u{1F3FF}\u{1F9B0}-\u{1F9B3}]/u - - function doTranslate(el, source, type) { - if (!i18n) return // translation not ready. - source = source.trim() - if (!source) return - if (re_num.test(source)) return - // if (re_emoji.test(source)) return - - let translation = i18n[source] || checkRegex(source), - scopes = scopedSource[source] - - if (scopes) { - console.log('scope', el, source, scopes); - for (let scope of scopes) { - if (el.parentElement.closest(scope)) { - translation = i18nScope[scope][source] - break - } - } - } - - if (!translation || source === translation) { - if (el.textContent === '__biligual__will_be_replaced__') el.textContent = source // restore original text if translation not exist - if (el.nextSibling?.className === 'bilingual__trans_wrapper') el.nextSibling.remove() // remove exist translation if translation not exist - return - } - - if (config.order === "Original First") { - [source, translation] = [translation, source] - } - - switch (type) { - case 'text': - el.textContent = translation - break; - - case 'element': - const htmlStr = `
${htmlEncode(translation)}${htmlEncode(source)}
` - const htmlEl = parseHtmlStringToElement(htmlStr) - if (el.hasChildNodes()) { - const textNode = Array.from(el.childNodes).find(node => - node.nodeName === '#text' && - (node.textContent.trim() === source || node.textContent.trim() === '__biligual__will_be_replaced__') - ) - - if (textNode) { - textNode.textContent = '' - if (textNode.nextSibling?.className === 'bilingual__trans_wrapper') textNode.nextSibling.remove() - textNode.parentNode.insertBefore(htmlEl, textNode.nextSibling) - } - } else { - el.textContent = '' - if (el.nextSibling?.className === 'bilingual__trans_wrapper') el.nextSibling.remove() - el.parentNode.insertBefore(htmlEl, el.nextSibling) - } - break; - - case 'option': - el.textContent = `${translation} (${source})` - break; - - case 'title': - el.title = `${translation}\n${source}` - break; - - case 'placeholder': - el.placeholder = `${translation}\n\n${source}` - break; - - default: - return translation - } - } - - function gradioApp() { - const elems = document.getElementsByTagName('gradio-app') - const elem = elems.length == 0 ? document : elems[0] - - if (elem !== document) elem.getElementById = function (id) { return document.getElementById(id) } - return elem.shadowRoot ? elem.shadowRoot : elem - } - - function querySelector(...args) { - return gradioApp()?.querySelector(...args) - } - - function querySelectorAll(...args) { - return gradioApp()?.querySelectorAll(...args) - } - - function delegateEvent(parent, eventType, selector, handler) { - parent.addEventListener(eventType, function (event) { - var target = event.target; - while (target !== parent) { - if (target.matches(selector)) { - handler.call(target, event); - } - target = target.parentNode; - } - }); - } - - function parseHtmlStringToElement(htmlStr) { - const template = document.createElement('template') - template.insertAdjacentHTML('afterbegin', htmlStr) - return template.firstElementChild - } - - function htmlEncode(htmlStr) { - return htmlStr.replace(/&/g, '&').replace(//g, '>') - .replace(/"/g, '"').replace(/'/g, ''') - } - - // get regex object from string - function getRegex(regex) { - try { - regex = regex.trim(); - let parts = regex.split('/'); - if (regex[0] !== '/' || parts.length < 3) { - regex = regex.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); //escap common string - return new RegExp(regex); - } - - const option = parts[parts.length - 1]; - const lastIndex = regex.lastIndexOf('/'); - regex = regex.substring(1, lastIndex); - return new RegExp(regex, option); - } catch (e) { - return null - } - } - - // Load file - function readFile(filePath) { - let request = new XMLHttpRequest(); - request.open("GET", `file=${filePath}`, false); - request.send(null); - return request.responseText; - } - - const logger = (function () { - const loggerTimerMap = new Map() - const loggerConf = { badge: true, label: 'Logger', enable: false } - return new Proxy(console, { - get: (target, propKey) => { - if (propKey === 'init') { - return (label) => { - loggerConf.label = label - loggerConf.enable = true - } - } - - if (!(propKey in target)) return undefined - - return (...args) => { - if (!loggerConf.enable) return - - let color = ['#39cfe1', '#006cab'] - - let label, start - switch (propKey) { - case 'error': - color = ['#f70000', '#a70000'] - break; - case 'warn': - color = ['#f7b500', '#b58400'] - break; - case 'time': - label = args[0] - if (loggerTimerMap.has(label)) { - logger.warn(`Timer '${label}' already exisits`) - } else { - loggerTimerMap.set(label, performance.now()) - } - return - case 'timeEnd': - label = args[0], start = loggerTimerMap.get(label) - if (start === undefined) { - logger.warn(`Timer '${label}' does not exist`) - } else { - loggerTimerMap.delete(label) - logger.log(`${label}: ${performance.now() - start} ms`) - } - return - case 'groupEnd': - loggerConf.badge = true - break - } - - const badge = loggerConf.badge ? [`%c${loggerConf.label}`, `color: #fff; background: linear-gradient(180deg, ${color[0]}, ${color[1]}); text-shadow: 0px 0px 1px #0003; padding: 3px 5px; border-radius: 4px;`] : [] - - target[propKey](...badge, ...args) - - if (propKey === 'group' || propKey === 'groupCollapsed') { - loggerConf.badge = false - } - } - } - }) - }()) - - function init() { - // Add style to dom - let $styleEL = document.createElement('style'); - - if ($styleEL.styleSheet) { - $styleEL.styleSheet.cssText = customCSS; - } else { - $styleEL.appendChild(document.createTextNode(customCSS)); - } - gradioApp().appendChild($styleEL); - - let loaded = false - let _count = 0 - - const observer = new MutationObserver(mutations => { - if (window.localization && Object.keys(window.localization).length) return; // disabled if original translation enabled - if (Object.keys(opts).length === 0) return; // does nothing if opts is not loaded - - let _nodesCount = 0, _now = performance.now() - - for (const mutation of mutations) { - if (mutation.type === 'characterData') { - if (mutation.target?.parentElement?.parentElement?.tagName === 'LABEL') { - translateEl(mutation.target) - } - } else if (mutation.type === 'attributes') { - _nodesCount++ - translateEl(mutation.target) - } else { - mutation.addedNodes.forEach(node => { - if (node.className === 'bilingual__trans_wrapper') return - - _nodesCount++ - if (node.nodeType === 1 && /(output|gradio)-(html|markdown)/.test(node.className)) { - translateEl(node, { rich: true }) - } else if (node.nodeType === 3) { - doTranslate(node, node.textContent, 'text') - } else { - translateEl(node, { deep: true }) - } - }) - } - } - - if (_nodesCount > 0) { - logger.info(`UI Update #${_count++}: ${performance.now() - _now} ms, ${_nodesCount} nodes`, mutations) - } - - if (loaded) return; - if (i18n) return; - - loaded = true - setup() - }) - - observer.observe(gradioApp(), { - characterData: true, - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['title', 'placeholder'] - }) - } + `; - // Init after page loaded - document.addEventListener('DOMContentLoaded', init) -})(); +// src/main.ts +document.addEventListener("DOMContentLoaded", init); diff --git a/javascript/original_bilingual_localization.bak b/javascript/original_bilingual_localization.bak new file mode 100644 index 0000000..f8c2f12 --- /dev/null +++ b/javascript/original_bilingual_localization.bak @@ -0,0 +1,536 @@ +(function () { + const customCSS = ` + .bilingual__trans_wrapper { + display: inline-flex; + flex-direction: column; + align-items: center; + font-size: 13px; + line-height: 1; + } + + .bilingual__trans_wrapper em { + font-style: normal; + } + + #txtimg_hr_finalres .bilingual__trans_wrapper em, + #tab_ti .output-html .bilingual__trans_wrapper em, + #tab_ti .gradio-html .bilingual__trans_wrapper em, + #sddp-dynamic-prompting .gradio-html .bilingual__trans_wrapper em, + #available_extensions .extension-tag .bilingual__trans_wrapper em, + #available_extensions .date_added .bilingual__trans_wrapper em, + #available_extensions+p>.bilingual__trans_wrapper em, + .gradio-image div[data-testid="image"] .bilingual__trans_wrapper em { + display: none; + } + + #settings .bilingual__trans_wrapper:not(#settings .tabitem .bilingual__trans_wrapper), + label>span>.bilingual__trans_wrapper, + fieldset>span>.bilingual__trans_wrapper, + .label-wrap>span>.bilingual__trans_wrapper, + .w-full>span>.bilingual__trans_wrapper, + .context-menu-items .bilingual__trans_wrapper, + .single-select .bilingual__trans_wrapper, ul.options .inner-item + .bilingual__trans_wrapper, + .output-html .bilingual__trans_wrapper:not(th .bilingual__trans_wrapper), + .gradio-html .bilingual__trans_wrapper:not(th .bilingual__trans_wrapper, .posex_cont .bilingual__trans_wrapper), + .output-markdown .bilingual__trans_wrapper, + .gradio-markdown .bilingual__trans_wrapper, + .gradio-image>div.float .bilingual__trans_wrapper, + .gradio-file>div.float .bilingual__trans_wrapper, + .gradio-code>div.float .bilingual__trans_wrapper, + .posex_setting_cont .bilingual__trans_wrapper:not(.posex_bg .bilingual__trans_wrapper), /* Posex extension */ + #dynamic-prompting .bilingual__trans_wrapper + { + font-size: 12px; + align-items: flex-start; + } + + #extensions label .bilingual__trans_wrapper, + #available_extensions td .bilingual__trans_wrapper, + .label-wrap>span>.bilingual__trans_wrapper { + font-size: inherit; + line-height: inherit; + } + + .label-wrap>span:first-of-type { + font-size: 13px; + line-height: 1; + } + + #txt2img_script_container > div { + margin-top: var(--layout-gap, 12px); + } + + textarea::placeholder { + line-height: 1; + padding: 4px 0; + } + + label>span { + line-height: 1; + } + + div[data-testid="image"] .start-prompt { + background-color: rgba(255, 255, 255, .6); + color: #222; + transition: opacity .2s ease-in-out; + } + div[data-testid="image"]:hover .start-prompt { + opacity: 0; + } + + .label-wrap > span.icon { + width: 1em; + height: 1em; + transform-origin: center center; + } + + .gradio-dropdown ul.options li.item { + padding: 0.3em 0.4em !important; + } + + /* Posex extension */ + .posex_bg { + white-space: nowrap; + } + ` + + let i18n = null, i18nRegex = new Map(), i18nScope = {}, scopedSource = {}, config = null; + + // First load + function setup() { + config = { + enabled: opts["bilingual_localization_enabled"], + file: opts["bilingual_localization_file"], + dirs: opts["bilingual_localization_dirs"], + order: opts["bilingual_localization_order"], + enableLogger: opts["bilingual_localization_logger"] + } + + let { enabled, file, dirs, enableLogger } = config + + if (!enabled || file === "None" || dirs === "None") return + + dirs = JSON.parse(dirs) + + enableLogger && logger.init('Bilingual') + logger.log('Bilingual Localization initialized.') + + // Load localization file + const regex_scope = /^##(?.+)##(?.+)$/ // ##scope##.skey + i18n = JSON.parse(readFile(dirs[file]), (key, value) => { + // parse regex translations + if (key.startsWith('@@')) { + const regex = getRegex(key.slice(2)) + if (regex instanceof RegExp) { + i18nRegex.set(regex, value) + } + } else if (regex_scope.test(key)) { + // parse scope translations + let { scope, skey } = key.match(regex_scope).groups + + if (scope.startsWith('@')) { + scope = scope.slice(1) + } else { + scope = '#' + scope + } + + if (!scope.length) { + return value + } + + i18nScope[scope] ||= {} + i18nScope[scope][skey] = value + + scopedSource[skey] ||= [] + scopedSource[skey].push(scope) + } else { + return value + } + }) + + logger.group('Localization file loaded.') + logger.log('i18n', i18n) + logger.log('i18nRegex', i18nRegex) + logger.log('i18nScope', i18nScope) + logger.groupEnd() + + translatePage() + handleDropdown() + } + + function handleDropdown() { + // process gradio dropdown menu + delegateEvent(gradioApp(), 'mousedown', 'ul.options .item', function (event) { + const { target } = event + + if (!target.classList.contains('item')) { + // simulate click menu item + target.closest('.item').dispatchEvent(new Event('mousedown', { bubbles: true })) + return + } + + const source = target.dataset.value + const $labelEl = target?.closest('.wrap')?.querySelector('.wrap-inner .single-select') // the label element + + if (source && $labelEl) { + $labelEl.title = titles?.[source] || '' // set title from hints.js + $labelEl.textContent = "__biligual__will_be_replaced__" // marked as will be replaced + doTranslate($labelEl, source, 'element') // translate the label element + } + }); + } + + // Translate page + function translatePage() { + if (!i18n) return + + logger.time('Full Page') + querySelectorAll([ + "label span, fieldset span, button", // major label and button description text + "textarea[placeholder], select, option", // text box placeholder and select element + ".transition > div > span:not([class])", ".label-wrap > span", // collapse panel added by extension + ".gradio-image>div.float", // image upload description + ".gradio-file>div.float", // file upload description + ".gradio-code>div.float", // code editor description + "#modelmerger_interp_description .output-html", // model merger description + "#modelmerger_interp_description .gradio-html", // model merger description + "#lightboxModal span" // image preview lightbox + ]) + .forEach(el => translateEl(el, { deep: true })) + + querySelectorAll([ + 'div[data-testid="image"] > div > div', // description of image upload panel + '#extras_image_batch > div', // description of extras image batch file upload panel + ".output-html:not(#footer), .gradio-html:not(#footer), .output-markdown, .gradio-markdown", // output html exclude footer + '#dynamic-prompting' // dynamic-prompting extension + ]) + .forEach(el => translateEl(el, { rich: true })) + + logger.timeEnd('Full Page') + } + + const ignore_selector = [ + '.bilingual__trans_wrapper', // self + '.resultsFlexContainer', // tag-autocomplete + '#setting_sd_model_checkpoint select', // model checkpoint + '#setting_sd_vae select', // vae model + '#txt2img_styles, #img2txt_styles', // styles select + '.extra-network-cards .card .actions .name', // extra network cards name + 'script, style, svg, g, path', // script / style / svg elements + ] + // Translate element + function translateEl(el, { deep = false, rich = false } = {}) { + if (!i18n) return // translation not ready. + if (el.matches?.(ignore_selector)) return // ignore some elements. + + if (el.title) { + doTranslate(el, el.title, 'title') + } + + if (el.placeholder) { + doTranslate(el, el.placeholder, 'placeholder') + } + + if (el.tagName === 'OPTION') { + doTranslate(el, el.textContent, 'option') + } + + if (deep || rich) { + Array.from(el.childNodes).forEach(node => { + if (node.nodeName === '#text') { + if (rich) { + doTranslate(node, node.textContent, 'text') + return + } + + if (deep) { + doTranslate(node, node.textContent, 'element') + } + } else if (node.childNodes.length > 0) { + translateEl(node, { deep, rich }) + } + }) + } else { + doTranslate(el, el.textContent, 'element') + } + } + + function checkRegex(source) { + for (const [regex, value] of i18nRegex.entries()) { + if (regex.test(source)) { + logger.log('regex', regex, source, value) + return source.replace(regex, value) + } + } + } + + const re_num = /^[\.\d]+$/, + re_emoji = /[\p{Extended_Pictographic}\u{1F3FB}-\u{1F3FF}\u{1F9B0}-\u{1F9B3}]/u + + function doTranslate(el, source, type) { + if (!i18n) return // translation not ready. + source = source.trim() + if (!source) return + if (re_num.test(source)) return + // if (re_emoji.test(source)) return + + let translation = i18n[source] || checkRegex(source), + scopes = scopedSource[source] + + if (scopes) { + console.log('scope', el, source, scopes); + for (let scope of scopes) { + if (el.parentElement.closest(scope)) { + translation = i18nScope[scope][source] + break + } + } + } + + if (!translation || source === translation) { + if (el.textContent === '__biligual__will_be_replaced__') el.textContent = source // restore original text if translation not exist + if (el.nextSibling?.className === 'bilingual__trans_wrapper') el.nextSibling.remove() // remove exist translation if translation not exist + return + } + + if (config.order === "Original First") { + [source, translation] = [translation, source] + } + + switch (type) { + case 'text': + el.textContent = translation + break; + + case 'element': + const htmlStr = `
${htmlEncode(translation)}${htmlEncode(source)}
` + const htmlEl = parseHtmlStringToElement(htmlStr) + if (el.hasChildNodes()) { + const textNode = Array.from(el.childNodes).find(node => + node.nodeName === '#text' && + (node.textContent.trim() === source || node.textContent.trim() === '__biligual__will_be_replaced__') + ) + + if (textNode) { + textNode.textContent = '' + if (textNode.nextSibling?.className === 'bilingual__trans_wrapper') textNode.nextSibling.remove() + textNode.parentNode.insertBefore(htmlEl, textNode.nextSibling) + } + } else { + el.textContent = '' + if (el.nextSibling?.className === 'bilingual__trans_wrapper') el.nextSibling.remove() + el.parentNode.insertBefore(htmlEl, el.nextSibling) + } + break; + + case 'option': + el.textContent = `${translation} (${source})` + break; + + case 'title': + el.title = `${translation}\n${source}` + break; + + case 'placeholder': + el.placeholder = `${translation}\n\n${source}` + break; + + default: + return translation + } + } + + function gradioApp() { + const elems = document.getElementsByTagName('gradio-app') + const elem = elems.length == 0 ? document : elems[0] + + if (elem !== document) elem.getElementById = function (id) { return document.getElementById(id) } + return elem.shadowRoot ? elem.shadowRoot : elem + } + + function querySelector(...args) { + return gradioApp()?.querySelector(...args) + } + + function querySelectorAll(...args) { + return gradioApp()?.querySelectorAll(...args) + } + + function delegateEvent(parent, eventType, selector, handler) { + parent.addEventListener(eventType, function (event) { + var target = event.target; + while (target !== parent) { + if (target.matches(selector)) { + handler.call(target, event); + } + target = target.parentNode; + } + }); + } + + function parseHtmlStringToElement(htmlStr) { + const template = document.createElement('template') + template.insertAdjacentHTML('afterbegin', htmlStr) + return template.firstElementChild + } + + function htmlEncode(htmlStr) { + return htmlStr.replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, ''') + } + + // get regex object from string + function getRegex(regex) { + try { + regex = regex.trim(); + let parts = regex.split('/'); + if (regex[0] !== '/' || parts.length < 3) { + regex = regex.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); //escap common string + return new RegExp(regex); + } + + const option = parts[parts.length - 1]; + const lastIndex = regex.lastIndexOf('/'); + regex = regex.substring(1, lastIndex); + return new RegExp(regex, option); + } catch (e) { + return null + } + } + + // Load file + function readFile(filePath) { + let request = new XMLHttpRequest(); + request.open("GET", `file=${filePath}`, false); + request.send(null); + return request.responseText; + } + + const logger = (function () { + const loggerTimerMap = new Map() + const loggerConf = { badge: true, label: 'Logger', enable: false } + return new Proxy(console, { + get: (target, propKey) => { + if (propKey === 'init') { + return (label) => { + loggerConf.label = label + loggerConf.enable = true + } + } + + if (!(propKey in target)) return undefined + + return (...args) => { + if (!loggerConf.enable) return + + let color = ['#39cfe1', '#006cab'] + + let label, start + switch (propKey) { + case 'error': + color = ['#f70000', '#a70000'] + break; + case 'warn': + color = ['#f7b500', '#b58400'] + break; + case 'time': + label = args[0] + if (loggerTimerMap.has(label)) { + logger.warn(`Timer '${label}' already exisits`) + } else { + loggerTimerMap.set(label, performance.now()) + } + return + case 'timeEnd': + label = args[0], start = loggerTimerMap.get(label) + if (start === undefined) { + logger.warn(`Timer '${label}' does not exist`) + } else { + loggerTimerMap.delete(label) + logger.log(`${label}: ${performance.now() - start} ms`) + } + return + case 'groupEnd': + loggerConf.badge = true + break + } + + const badge = loggerConf.badge ? [`%c${loggerConf.label}`, `color: #fff; background: linear-gradient(180deg, ${color[0]}, ${color[1]}); text-shadow: 0px 0px 1px #0003; padding: 3px 5px; border-radius: 4px;`] : [] + + target[propKey](...badge, ...args) + + if (propKey === 'group' || propKey === 'groupCollapsed') { + loggerConf.badge = false + } + } + } + }) + }()) + + function init() { + // Add style to dom + let $styleEL = document.createElement('style'); + + if ($styleEL.styleSheet) { + $styleEL.styleSheet.cssText = customCSS; + } else { + $styleEL.appendChild(document.createTextNode(customCSS)); + } + gradioApp().appendChild($styleEL); + + let loaded = false + let _count = 0 + + const observer = new MutationObserver(mutations => { + if (window.localization && Object.keys(window.localization).length) return; // disabled if original translation enabled + if (Object.keys(opts).length === 0) return; // does nothing if opts is not loaded + + let _nodesCount = 0, _now = performance.now() + + for (const mutation of mutations) { + if (mutation.type === 'characterData') { + if (mutation.target?.parentElement?.parentElement?.tagName === 'LABEL') { + translateEl(mutation.target) + } + } else if (mutation.type === 'attributes') { + _nodesCount++ + translateEl(mutation.target) + } else { + mutation.addedNodes.forEach(node => { + if (node.className === 'bilingual__trans_wrapper') return + + _nodesCount++ + if (node.nodeType === 1 && /(output|gradio)-(html|markdown)/.test(node.className)) { + translateEl(node, { rich: true }) + } else if (node.nodeType === 3) { + doTranslate(node, node.textContent, 'text') + } else { + translateEl(node, { deep: true }) + } + }) + } + } + + if (_nodesCount > 0) { + logger.info(`UI Update #${_count++}: ${performance.now() - _now} ms, ${_nodesCount} nodes`, mutations) + } + + if (loaded) return; + if (i18n) return; + + loaded = true + setup() + }) + + observer.observe(gradioApp(), { + characterData: true, + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['title', 'placeholder'] + }) + } + + // Init after page loaded + document.addEventListener('DOMContentLoaded', init) +})(); diff --git a/scripts/bilingual_localization_helper.py b/scripts/bilingual_localization_helper.py index 3a7c570..b9051a6 100644 --- a/scripts/bilingual_localization_helper.py +++ b/scripts/bilingual_localization_helper.py @@ -53,7 +53,11 @@ def on_ui_settings(): # translation order shared.opts.add_option("bilingual_localization_order", shared.OptionInfo("Translation First", "Translation display order", gr.Radio, {"choices": ["Translation First", "Original First"]}, section=BL_SECTION)) + # translate the inputbox's placeholder + shared.opts.add_option("bilingual_translate_placeholder", shared.OptionInfo(False, "Enable translate the inputbox's placeholder", section=BL_SECTION)) + # all localization files path in hidden option shared.opts.add_option("bilingual_localization_dirs", shared.OptionInfo(json.dumps(I18N_DIRS), "Localization dirs", section=BL_SECTION, component_args={"visible": False})) + shared.opts.data["bilingual_localization_dirs"] = json.dumps(I18N_DIRS) script_callbacks.on_ui_settings(on_ui_settings) diff --git a/src/config/opts.ts b/src/config/opts.ts new file mode 100644 index 0000000..b3502fe --- /dev/null +++ b/src/config/opts.ts @@ -0,0 +1,9 @@ +import type { Opts } from "../types/opts"; + +export const opts: Opts = { + bilingual_localization_enabled: true, + bilingual_localization_logger: false, + bilingual_localization_file: "None", + bilingual_localization_dirs: "{}", + bilingual_localization_order: "Translation First", +}; diff --git a/src/init.ts b/src/init.ts new file mode 100644 index 0000000..954b70e --- /dev/null +++ b/src/init.ts @@ -0,0 +1,183 @@ +import { opts } from "./config/opts"; +import { createLogger } from "./lib/create-logger"; +import { doTranslate } from "./lib/do-translate"; +import { gradioApp } from "./lib/gradio-app"; +import { translateEl } from "./lib/translate-el"; +import { getI18n, setup } from "./setup"; + +const customCSS = ` + .bilingual__trans_wrapper { + display: inline-flex; + flex-direction: column; + align-items: center; + font-size: var(--section-header-text-size); + line-height: 1; + } + + .bilingual__trans_wrapper em { + font-style: normal; + } + + #txtimg_hr_finalres .bilingual__trans_wrapper em, + #tab_ti .output-html .bilingual__trans_wrapper em, + #tab_ti .gradio-html .bilingual__trans_wrapper em, + #sddp-dynamic-prompting .gradio-html .bilingual__trans_wrapper em, + #available_extensions .extension-tag .bilingual__trans_wrapper em, + #available_extensions .date_added .bilingual__trans_wrapper em, + #available_extensions+p>.bilingual__trans_wrapper em, + .gradio-image div[data-testid="image"] .bilingual__trans_wrapper em { + display: none; + } + + #settings .bilingual__trans_wrapper:not(#settings .tabitem .bilingual__trans_wrapper), + label>span>.bilingual__trans_wrapper, + fieldset>span>.bilingual__trans_wrapper, + .label-wrap>span>.bilingual__trans_wrapper, + .w-full>span>.bilingual__trans_wrapper, + .context-menu-items .bilingual__trans_wrapper, + .single-select .bilingual__trans_wrapper, ul.options .inner-item + .bilingual__trans_wrapper, + .output-html .bilingual__trans_wrapper:not(th .bilingual__trans_wrapper), + .gradio-html .bilingual__trans_wrapper:not(th .bilingual__trans_wrapper, .posex_cont .bilingual__trans_wrapper), + .output-markdown .bilingual__trans_wrapper, + .gradio-markdown .bilingual__trans_wrapper, + .gradio-image>div.float .bilingual__trans_wrapper, + .gradio-file>div.float .bilingual__trans_wrapper, + .gradio-code>div.float .bilingual__trans_wrapper, + .posex_setting_cont .bilingual__trans_wrapper:not(.posex_bg .bilingual__trans_wrapper), /* Posex extension */ + #dynamic-prompting .bilingual__trans_wrapper + { + align-items: flex-start; + } + + #extensions label .bilingual__trans_wrapper, + #available_extensions td .bilingual__trans_wrapper, + .label-wrap>span>.bilingual__trans_wrapper { + font-size: inherit; + line-height: inherit; + } + + .label-wrap>span:first-of-type { + font-size: 13px; + line-height: 1; + } + + #txt2img_script_container > div { + margin-top: var(--layout-gap, 12px); + } + + textarea::placeholder { + line-height: 1; + padding: 4px 0; + } + + label>span { + line-height: 1; + } + + div[data-testid="image"] .start-prompt { + background-color: rgba(255, 255, 255, .6); + color: #222; + transition: opacity .2s ease-in-out; + } + div[data-testid="image"]:hover .start-prompt { + opacity: 0; + } + + .label-wrap > span.icon { + width: 1em; + height: 1em; + transform-origin: center center; + } + + .gradio-dropdown ul.options li.item { + padding: 0.3em 0.4em !important; + } + + /* Posex extension */ + .posex_bg { + white-space: nowrap; + } + `; + +export function init() { + // Add style to dom + const styleEl = document.createElement("style"); + + if (styleEl.textContent) { + styleEl.textContent = customCSS; + } else { + styleEl.appendChild(document.createTextNode(customCSS)); + } + gradioApp().appendChild(styleEl); + + let loaded = false; + let _count = 0; + + const observer = new MutationObserver((mutations) => { + // @ts-ignore + if (window.localization && Object.keys(window.localization).length) return; // disabled if original translation enabled + if (Object.keys(opts).length === 0) return; // does nothing if opts is not loaded + + let _nodesCount = 0; + const _now = performance.now(); + + for (const mutation of mutations) { + if (mutation.type === "characterData") { + if ( + mutation.target?.parentElement?.parentElement?.tagName === "LABEL" + ) { + translateEl(mutation.target); + } + } else if (mutation.type === "attributes") { + _nodesCount++; + translateEl(mutation.target); + } else { + // biome-ignore lint/complexity/noForEach: + mutation.addedNodes.forEach((node) => { + if ( + node instanceof Element && + node.className === "bilingual__trans_wrapper" + ) + return; // NodeがElement型であることを確認 + + _nodesCount++; + if ( + node.nodeType === 1 && + node instanceof Element && + /(output|gradio)-(html|markdown)/.test(node.className) + ) { + // nodeがElement型であることを確認 + translateEl(node, { rich: true }); + } else if (node.nodeType === 3) { + doTranslate(node, node.textContent, "text"); + } else { + translateEl(node, { deep: true }); + } + }); + } + } + + if (_nodesCount > 0) { + const logger = createLogger(); + logger.info( + `UI Update #${_count++}: ${performance.now() - _now} ms, ${_nodesCount} nodes`, + mutations, + ); + } + + if (loaded) return; + const i18n = getI18n(); + if (i18n) return; + + loaded = true; + setup(); + }); + + observer.observe(gradioApp(), { + characterData: true, + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["title", "placeholder"], + }); +} diff --git a/src/lib/check-regax.ts b/src/lib/check-regax.ts new file mode 100644 index 0000000..e58dd0e --- /dev/null +++ b/src/lib/check-regax.ts @@ -0,0 +1,20 @@ +import { getI18nRegex } from "../setup"; +import { createLogger } from "./create-logger"; + +export function checkRegex(source: string) { + const i18nRegex = getI18nRegex(); + for (const [regex, value] of i18nRegex.entries()) { + if (regex instanceof RegExp) { + if (regex.test(source)) { + const logger = createLogger(); + logger.log("regex", regex, source, value); + return source.replace(regex, value); + } + } else { + console.warn( + "Expected regex to be an instance of RegExp, but it was a string.", + ); + } + } + return source; +} diff --git a/src/lib/create-logger.ts b/src/lib/create-logger.ts new file mode 100644 index 0000000..74e9d7c --- /dev/null +++ b/src/lib/create-logger.ts @@ -0,0 +1,73 @@ +interface CustomConsole extends Console { + init: (label: string) => void; +} + +export function createLogger(): CustomConsole { + const loggerTimerMap = new Map(); + const loggerConf = { badge: true, label: "Logger", enable: false }; + + return new Proxy(console, { + get: (target, propKey) => { + if (propKey === "init") { + return (label: string) => { + loggerConf.label = label; + loggerConf.enable = true; + }; + } + + if (!(propKey in target)) return undefined; + + // biome-ignore lint/suspicious/noExplicitAny: + return (...args: any[]) => { + if (!loggerConf.enable) return; + + let color = ["#39cfe1", "#006cab"]; + + let label: string; + let start: number | undefined; + switch (propKey) { + case "error": + color = ["#f70000", "#a70000"]; + break; + case "warn": + color = ["#f7b500", "#b58400"]; + break; + case "time": + label = args[0]; + if (loggerTimerMap.has(label)) { + console.warn(`Timer '${label}' already exists`); + } else { + loggerTimerMap.set(label, performance.now()); + } + return; + case "timeEnd": + label = args[0]; + start = loggerTimerMap.get(label); + if (start === undefined) { + console.warn(`Timer '${label}' does not exist`); + } else { + loggerTimerMap.delete(label); + console.log(`${label}: ${performance.now() - start} ms`); + } + return; + case "groupEnd": + loggerConf.badge = true; + break; + } + + const badge = loggerConf.badge + ? [ + `%c${loggerConf.label}`, + `color: #fff; background: linear-gradient(180deg, ${color[0]}, ${color[1]}); text-shadow: 0px 0px 1px #0003; padding: 3px 5px; border-radius: 4px;`, + ] + : []; + + target[propKey](...badge, ...args); + + if (propKey === "group" || propKey === "groupCollapsed") { + loggerConf.badge = false; + } + }; + }, + }) as CustomConsole; +} diff --git a/src/lib/delegate-event.ts b/src/lib/delegate-event.ts new file mode 100644 index 0000000..803676c --- /dev/null +++ b/src/lib/delegate-event.ts @@ -0,0 +1,11 @@ +export function delegateEvent(parent, eventType, selector, handler) { + parent.addEventListener(eventType, (event) => { + let target = event.target; + while (target !== parent) { + if (target.matches(selector)) { + handler.call(target, event); + } + target = target.parentNode; + } + }); +} diff --git a/src/lib/do-translate.ts b/src/lib/do-translate.ts new file mode 100644 index 0000000..098bd56 --- /dev/null +++ b/src/lib/do-translate.ts @@ -0,0 +1,127 @@ +import { getConfig, getI18n, getI18nScope, getScopedSource } from "../setup"; +import { checkRegex } from "./check-regax"; +import { htmlEncode } from "./html-encode"; +import { parseHtmlStringToElement } from "./parse-html-string-to-element"; + +const re_num = /^[\.\d]+$/; +const re_emoji = + // biome-ignore lint/suspicious/noMisleadingCharacterClass: + /[\p{Extended_Pictographic}\u{1F3FB}-\u{1F3FF}\u{1F9B0}-\u{1F9B3}]/u; + +export function doTranslate(el, source, type) { + if (!getI18n) return; // translation not ready. + let trimmedSource = source.trim(); + if (!trimmedSource) return; + if (re_num.test(trimmedSource)) return; + if (re_emoji.test(trimmedSource)) return; + + let translation = getI18n[trimmedSource] || checkRegex(trimmedSource); + const scopes = getScopedSource[trimmedSource]; + + if (scopes) { + console.log("scope", el, trimmedSource, scopes); + for (const scope of scopes) { + if (el.parentElement.closest(scope)) { + translation = getI18nScope[scope][trimmedSource]; + break; + } + } + } + + if (!translation || trimmedSource === translation) { + if (el.textContent === "__biligual__will_be_replaced__") + el.textContent = trimmedSource; // restore original text if translation not exist + if (el.nextSibling?.className === "bilingual__trans_wrapper") + el.nextSibling.remove(); // remove exist translation if translation not exist + return; + } + + const config = getConfig(); + + if (config?.order === "Original First") { + [trimmedSource, translation] = [translation, trimmedSource]; + } + + const isTranslationIncludeSource = translation.startsWith(source); + + switch (type) { + case "text": + el.textContent = translation; + break; + + case "element": { + if (isTranslationIncludeSource) { + if (el.nodeType === 3) { + el.nodeValue = translation; + } else if (htmlEncode(el.textContent) === el.innerHTML) { + el.innerHTML = htmlEncode(translation); + } + break; + } + + const htmlEl = parseHtmlStringToElement( + `
${htmlEncode(translation)}${htmlEncode(source)}
`, + ); + + if (el.hasChildNodes()) { + const textNode = Array.from(el.childNodes).find( + (node) => + ((node as Text).nodeType === Node.TEXT_NODE && // Ensure it's a text node + (node as Text).textContent?.trim() === trimmedSource) || + (node as Text).textContent?.trim() === + "__bilingual__will_be_replaced__", + ) as Text | undefined; + + if (textNode) { + textNode.textContent = ""; + if ( + textNode.nextSibling?.nodeType === Node.ELEMENT_NODE && + (textNode.nextSibling as HTMLElement).className === + "bilingual__trans_wrapper" + ) { + textNode.nextSibling.remove(); + } + if (textNode.parentNode && htmlEl) { + // Ensure htmlEl is not null + textNode.parentNode.insertBefore(htmlEl, textNode.nextSibling); + } + } + } else { + el.textContent = ""; + if ( + el.nextSibling?.nodeType === Node.ELEMENT_NODE && + (el.nextSibling as HTMLElement).className === + "bilingual__trans_wrapper" + ) { + el.nextSibling.remove(); + } + if (el.parentNode && htmlEl) { + // Ensure htmlEl is not null + el.parentNode.insertBefore(htmlEl, el.nextSibling); + } + } + break; + } + + case "option": + el.textContent = isTranslationIncludeSource + ? translation + : `${translation} (${trimmedSource})`; + break; + + case "title": + el.title = isTranslationIncludeSource + ? translation + : `${translation}\n${trimmedSource}`; + break; + + case "placeholder": + el.placeholder = isTranslationIncludeSource + ? translation + : `${translation}\n\n${trimmedSource}`; + break; + + default: + return translation; + } +} diff --git a/src/lib/get-regax.ts b/src/lib/get-regax.ts new file mode 100644 index 0000000..673b0de --- /dev/null +++ b/src/lib/get-regax.ts @@ -0,0 +1,24 @@ +// get regex object from string +export function getRegex(regexString: string): RegExp | null { + try { + const trimmedRegexString = regexString.trim(); + + if ( + !trimmedRegexString.startsWith("/") || + trimmedRegexString.split("/").length < 3 + ) { + const escapedRegexString = trimmedRegexString.replace( + /[.*+\-?^${}()|[\]\\]/g, + "\\$&", + ); + return new RegExp(escapedRegexString); + } + + const lastSlashIndex = trimmedRegexString.lastIndexOf("/"); + const regexPattern = trimmedRegexString.slice(1, lastSlashIndex); + const regexFlags = trimmedRegexString.slice(lastSlashIndex + 1); + return new RegExp(regexPattern, regexFlags); + } catch (e) { + return null; + } +} diff --git a/src/lib/gradio-app.ts b/src/lib/gradio-app.ts new file mode 100644 index 0000000..c5c8fac --- /dev/null +++ b/src/lib/gradio-app.ts @@ -0,0 +1,27 @@ +export function gradioApp(): Document | ShadowRoot { + const elems = document.getElementsByTagName("gradio-app"); + // @ts-ignore + const elem: Document | HTMLElement = elems.length === 0 ? document : elems[0]; + + if (elem !== document) { + // biome-ignore lint/suspicious/noExplicitAny: + (elem as any).getElementById = (id: string): HTMLElement | null => + document.getElementById(id); + } + + // biome-ignore lint/suspicious/noExplicitAny: + return (elem as any).shadowRoot ? (elem as any).shadowRoot : elem; +} + +export function querySelector( + ...args: Parameters +): ReturnType | null { + return gradioApp()?.querySelector(...args) ?? null; +} + +export function querySelectorAll( + ...args: Parameters +): NodeListOf { + const nodeList = gradioApp()?.querySelectorAll(...args); + return nodeList || new NodeList(); +} diff --git a/src/lib/handle-dropdown.ts b/src/lib/handle-dropdown.ts new file mode 100644 index 0000000..629b9a3 --- /dev/null +++ b/src/lib/handle-dropdown.ts @@ -0,0 +1,30 @@ +import { delegateEvent } from "./delegate-event"; +import { doTranslate } from "./do-translate"; +import { gradioApp } from "./gradio-app"; + +export function handleDropdown() { + // process gradio dropdown menu + delegateEvent(gradioApp(), "mousedown", "ul.options .item", (event) => { + const { target } = event; + + if (!target.classList.contains("item")) { + // simulate click menu item + target + .closest(".item") + .dispatchEvent(new Event("mousedown", { bubbles: true })); + return; + } + + const source = target.dataset.value; + const $labelEl = target + ?.closest(".wrap") + ?.querySelector(".wrap-inner .single-select"); // the label element + + if (source && $labelEl) { + // @ts-ignore + $labelEl.title = titles?.[source] || ""; // set title from hints.js + $labelEl.textContent = "__biligual__will_be_replaced__"; // marked as will be replaced + doTranslate($labelEl, source, "element"); // translate the label element + } + }); +} diff --git a/src/lib/html-encode.ts b/src/lib/html-encode.ts new file mode 100644 index 0000000..9aa02ae --- /dev/null +++ b/src/lib/html-encode.ts @@ -0,0 +1,8 @@ +export function htmlEncode(htmlStr) { + return htmlStr + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/src/lib/parse-html-string-to-element.ts b/src/lib/parse-html-string-to-element.ts new file mode 100644 index 0000000..73412ef --- /dev/null +++ b/src/lib/parse-html-string-to-element.ts @@ -0,0 +1,5 @@ +export function parseHtmlStringToElement(htmlStr) { + const template = document.createElement("template"); + template.insertAdjacentHTML("afterbegin", htmlStr); + return template.firstElementChild; +} diff --git a/src/lib/read-files.ts b/src/lib/read-files.ts new file mode 100644 index 0000000..a2b5787 --- /dev/null +++ b/src/lib/read-files.ts @@ -0,0 +1,7 @@ +// Load file +export function readFile(filePath) { + const request = new XMLHttpRequest(); + request.open("GET", `file=${filePath}`, false); + request.send(null); + return request.responseText; +} diff --git a/src/lib/tranlate-page.ts b/src/lib/tranlate-page.ts new file mode 100644 index 0000000..b8e10cc --- /dev/null +++ b/src/lib/tranlate-page.ts @@ -0,0 +1,48 @@ +import { getI18n } from "../setup"; +import { createLogger } from "./create-logger"; +import { querySelectorAll } from "./gradio-app"; +import { translateEl } from "./translate-el"; + +export function translatePage() { + if (!getI18n()) return; + + const logger = createLogger(); + logger.time("Full Page"); + + // Define arrays of selectors + const majorSelectors = [ + "label span, fieldset span, button", // major label and button description text + "textarea[placeholder], select, option", // text box placeholder and select element + ".transition > div > span:not([class])", + ".label-wrap > span", // collapse panel added by extension + ".gradio-image>div.float", // image upload description + ".gradio-file>div.float", // file upload description + ".gradio-code>div.float", // code editor description + "#modelmerger_interp_description .output-html", // model merger description + "#modelmerger_interp_description .gradio-html", // model merger description + "#lightboxModal span", // image preview lightbox + ]; + + const minorSelectors = [ + 'div[data-testid="image"] > div > div', // description of image upload panel + "#extras_image_batch > div", // description of extras image batch file upload panel + ".output-html:not(#footer), .gradio-html:not(#footer), .output-markdown, .gradio-markdown", // output html exclude footer + "#dynamic-prompting", // dynamic-prompting extension + ]; + + // Process major selectors + // biome-ignore lint/complexity/noForEach: + majorSelectors.forEach((selector) => { + // biome-ignore lint/complexity/noForEach: + querySelectorAll(selector).forEach((el) => translateEl(el, { deep: true })); + }); + + // Process minor selectors + // biome-ignore lint/complexity/noForEach: + minorSelectors.forEach((selector) => { + // biome-ignore lint/complexity/noForEach: + querySelectorAll(selector).forEach((el) => translateEl(el, { rich: true })); + }); + + logger.timeEnd("Full Page"); +} diff --git a/src/lib/translate-el.ts b/src/lib/translate-el.ts new file mode 100644 index 0000000..569f589 --- /dev/null +++ b/src/lib/translate-el.ts @@ -0,0 +1,54 @@ +import { getConfig, getI18n } from "../setup"; +import { doTranslate } from "./do-translate"; + +const ignore_selector = [ + ".bilingual__trans_wrapper", // self + ".resultsFlexContainer", // tag-autocomplete + "#setting_sd_model_checkpoint select", // model checkpoint + "#setting_sd_vae select", // vae model + "#txt2img_styles, #img2txt_styles", // styles select + ".extra-network-cards .card .actions .name", // extra network cards name + "script, style, svg, g, path", // script / style / svg elements + "svg *, canvas, canvas *", + "#txt2img_prompt_container, #img2img_prompt_container, .physton-prompt", + "#txt2img_prompt_container *, #img2img_prompt_container *, .physton-prompt *", + ".progressDiv, .progress, .progress-text", + ".progressDiv *, .progress *, .progress-text *", +]; + +export function translateEl(el, { deep = false, rich = false } = {}) { + if (!getI18n) return; // translation not ready. + if (el.matches?.(ignore_selector)) return; // ignore some elements. + + if (el.title) { + doTranslate(el, el.title, "title"); + } + + if (el.placeholder && getConfig()?.enableTransPlaceHolder === true) { + doTranslate(el, el.placeholder, "placeholder"); + } + + if (el.tagName === "OPTION") { + doTranslate(el, el.textContent, "option"); + } + + if (deep || rich) { + // biome-ignore lint/complexity/noForEach: + Array.from(el.childNodes).forEach((node) => { + if ((node as Text).nodeName === "#text") { + if (rich) { + doTranslate(node, (node as Text).textContent, "text"); + return; + } + + if (deep) { + doTranslate(node, (node as Text).textContent, "element"); + } + } else if ((node as Text).childNodes.length > 0) { + translateEl(node, { deep, rich }); + } + }); + } else { + doTranslate(el, el.textContent, "element"); + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..4d8afbd --- /dev/null +++ b/src/main.ts @@ -0,0 +1,3 @@ +import { init } from "./init"; + +document.addEventListener("DOMContentLoaded", init); diff --git a/src/setup.ts b/src/setup.ts new file mode 100644 index 0000000..3aa01d2 --- /dev/null +++ b/src/setup.ts @@ -0,0 +1,100 @@ +import { opts } from "./config/opts"; +import { createLogger } from "./lib/create-logger"; +import { getRegex } from "./lib/get-regax"; +import { handleDropdown } from "./lib/handle-dropdown"; +import { readFile } from "./lib/read-files"; +import { translatePage } from "./lib/tranlate-page"; + +interface Config { + enabled: boolean; + file: string; + dirs: string; + order: string; + enableLogger: boolean; +} + +let i18n = null; +// biome-ignore lint/suspicious/noExplicitAny: +const i18nRegex = new Map(); +const i18nScope = {}; +const scopedSource = {}; +let config: Config | null = null; + +export function setup() { + config = { + enabled: opts.bilingual_localization_enabled, + file: opts.bilingual_localization_file, + dirs: opts.bilingual_localization_dirs, + order: opts.bilingual_localization_order, + enableLogger: opts.bilingual_localization_logger, + }; + + const { enabled, file, dirs, enableLogger } = config; + + if (!enabled || file === "None" || dirs === "None") return; + const dirsParsed = JSON.parse(dirs); + + const logger = createLogger(); + if (enableLogger) { + logger.init("Bilingual"); + } + logger.log("Bilingual Localization initialized."); + + // Load localization file + const regex_scope = /^##(?.+)##(?.+)$/; // ##scope##.skey + i18n = JSON.parse(readFile(dirsParsed[file]), (key, value) => { + // parse regex translations + if (key.startsWith("@@")) { + const regex = getRegex(key.slice(2)); + if (regex instanceof RegExp) { + i18nRegex.set(regex, value); + } + } else { + const match = key.match(regex_scope); + if (match?.groups) { + // parse scope translations + let { scope, skey } = match.groups; + + if (scope.startsWith("@")) { + scope = scope.slice(1); + } else { + scope = `#${scope}`; + } + + if (!scope.length) { + return value; + } + + i18nScope[scope] ||= {}; + i18nScope[scope][skey] = value; + + scopedSource[skey] ||= []; + scopedSource[skey].push(scope); + } else { + return value; + } + } + }); + translatePage(); + handleDropdown(); +} + +export function getI18n() { + return i18n; +} + +export function getI18nRegex() { + return i18nRegex; +} + +export function getI18nScope() { + return i18nScope; +} + +export function getScopedSource() { + return scopedSource; +} + +export function getConfig() { + return config; +} diff --git a/src/types/opts.d.ts b/src/types/opts.d.ts new file mode 100644 index 0000000..b9ad0e1 --- /dev/null +++ b/src/types/opts.d.ts @@ -0,0 +1,8 @@ +export interface Opts { + bilingual_translate_placeholder: boolean; + bilingual_localization_enabled: boolean; + bilingual_localization_logger: boolean; + bilingual_localization_file: string; + bilingual_localization_dirs: string; + bilingual_localization_order: string; +}