269 lines
8.6 KiB
JavaScript
269 lines
8.6 KiB
JavaScript
import { app } from "../../../../scripts/app.js";
|
|
import { logger } from "./logger.js";
|
|
import { APIService } from "../services/api.js";
|
|
|
|
export const UI_LANGUAGE_SETTING_ID = "PromptAssistant.Settings.InterfaceLanguage";
|
|
export const UI_LANGUAGE_STORAGE_KEY = "PromptAssistant.Settings.InterfaceLanguage";
|
|
|
|
const SUPPORTED_LANGUAGES = new Set(["zh", "en", "zh-TW", "ja", "ko", "ar", "es", "fa", "fr", "pt-BR", "ru", "tr"]);
|
|
|
|
export const LANGUAGE_OPTIONS = [
|
|
{ text: "简体中文", value: "zh" },
|
|
{ text: "English", value: "en" },
|
|
{ text: "繁體中文", value: "zh-TW" },
|
|
{ text: "日本語", value: "ja" },
|
|
{ text: "한국어", value: "ko" },
|
|
{ text: "Русский", value: "ru" },
|
|
{ text: "Türkçe", value: "tr" },
|
|
{ text: "Español", value: "es" },
|
|
{ text: "Français", value: "fr" },
|
|
{ text: "Português", value: "pt-BR" },
|
|
{ text: "العربية", value: "ar" },
|
|
{ text: "فارسی", value: "fa" }
|
|
];
|
|
const LOCALE_CACHE = new Map();
|
|
const DOM_SKIP_SELECTOR = [
|
|
"input",
|
|
"textarea",
|
|
"select",
|
|
"option",
|
|
"[contenteditable='true']",
|
|
"[data-pa-no-i18n]",
|
|
".prompt-list-row",
|
|
".tab-title",
|
|
".tab-subtitle",
|
|
".service-tab-title",
|
|
".service-tab-subtitle",
|
|
".model-tag-name"
|
|
].join(",");
|
|
|
|
let currentLanguage = "zh";
|
|
let currentMessages = {};
|
|
let isToastPatched = false;
|
|
let stopGlobalLocalizationObserver = null;
|
|
|
|
export function normalizeUiLanguage(value) {
|
|
return SUPPORTED_LANGUAGES.has(value) ? value : "zh";
|
|
}
|
|
|
|
export function getStoredUiLanguage() {
|
|
try {
|
|
return normalizeUiLanguage(localStorage.getItem(UI_LANGUAGE_STORAGE_KEY) || "zh");
|
|
} catch {
|
|
return "zh";
|
|
}
|
|
}
|
|
|
|
export function getCurrentUiLanguage() {
|
|
const settingValue = app?.ui?.settings?.getSettingValue?.(UI_LANGUAGE_SETTING_ID);
|
|
if (SUPPORTED_LANGUAGES.has(settingValue)) {
|
|
return settingValue;
|
|
}
|
|
return getStoredUiLanguage();
|
|
}
|
|
|
|
export function persistUiLanguage(value) {
|
|
const normalized = normalizeUiLanguage(value);
|
|
try {
|
|
localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, normalized);
|
|
} catch { }
|
|
return normalized;
|
|
}
|
|
|
|
async function fetchUiLocale(language) {
|
|
const apiUrl = APIService.getApiUrl(`/config/ui_locale/${language}`);
|
|
try {
|
|
const apiResponse = await fetch(apiUrl, { cache: "no-cache" });
|
|
if (apiResponse.ok) {
|
|
const payload = await apiResponse.json();
|
|
if (payload && payload.success && payload.messages && typeof payload.messages === "object") {
|
|
return payload.messages;
|
|
}
|
|
if (payload && typeof payload === "object" && !payload.success) {
|
|
throw new Error(payload.error || `Failed to load locale from API: ${language}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.warn(`[uiI18n] API locale fetch failed: ${error.message}`);
|
|
}
|
|
|
|
const localeUrl = new URL(`../../locales/${language}/ui.json`, import.meta.url);
|
|
const response = await fetch(localeUrl.href, { cache: "no-cache" });
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load locale: ${language}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
export async function ensureUiLocaleLoaded(force = false) {
|
|
const language = getCurrentUiLanguage();
|
|
if (!force && currentLanguage === language && currentMessages && Object.keys(currentMessages).length > 0) {
|
|
return currentMessages;
|
|
}
|
|
|
|
if (!LOCALE_CACHE.has(language) || force) {
|
|
try {
|
|
LOCALE_CACHE.set(language, await fetchUiLocale(language));
|
|
} catch (error) {
|
|
logger.warn(`[uiI18n] ${error.message}`);
|
|
if (language !== "zh") {
|
|
try {
|
|
if (!LOCALE_CACHE.has("zh") || force) {
|
|
LOCALE_CACHE.set("zh", await fetchUiLocale("zh"));
|
|
}
|
|
} catch (fallbackError) {
|
|
logger.warn(`[uiI18n] ${fallbackError.message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
currentLanguage = language;
|
|
currentMessages = LOCALE_CACHE.get(language) || LOCALE_CACHE.get("zh") || {};
|
|
return currentMessages;
|
|
}
|
|
|
|
function getTranslatedValue(key) {
|
|
if (typeof key !== "string" || !key) return null;
|
|
const translated = currentMessages?.[key];
|
|
if (typeof translated === "string") {
|
|
return translated;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function tUI(key, fallback = undefined) {
|
|
const translated = getTranslatedValue(key);
|
|
if (translated !== null) {
|
|
return translated;
|
|
}
|
|
return fallback ?? key;
|
|
}
|
|
|
|
function shouldSkipElement(element) {
|
|
if (!element || element.nodeType !== Node.ELEMENT_NODE) return false;
|
|
return !!element.closest(DOM_SKIP_SELECTOR);
|
|
}
|
|
|
|
function translateTextNode(node) {
|
|
if (!node || node.nodeType !== Node.TEXT_NODE) return;
|
|
const parent = node.parentElement;
|
|
if (!parent || shouldSkipElement(parent)) return;
|
|
|
|
const rawText = node.nodeValue;
|
|
if (!rawText || !rawText.trim()) return;
|
|
|
|
const trimmed = rawText.trim();
|
|
const translated = getTranslatedValue(trimmed);
|
|
if (!translated || translated === trimmed) return;
|
|
|
|
const leading = rawText.match(/^\s*/)?.[0] ?? "";
|
|
const trailing = rawText.match(/\s*$/)?.[0] ?? "";
|
|
node.nodeValue = `${leading}${translated}${trailing}`;
|
|
}
|
|
|
|
function translateAttributes(element) {
|
|
if (!element || element.nodeType !== Node.ELEMENT_NODE || shouldSkipElement(element)) return;
|
|
["placeholder", "title", "aria-label", "alt"].forEach((attr) => {
|
|
const value = element.getAttribute(attr);
|
|
if (!value) return;
|
|
const translated = getTranslatedValue(value.trim());
|
|
if (translated && translated !== value) {
|
|
element.setAttribute(attr, translated);
|
|
}
|
|
});
|
|
}
|
|
|
|
export function localizeElement(root) {
|
|
const lang = getCurrentUiLanguage();
|
|
if (!root || lang === "zh" || !SUPPORTED_LANGUAGES.has(lang)) return;
|
|
|
|
if (root.nodeType === Node.TEXT_NODE) {
|
|
translateTextNode(root);
|
|
return;
|
|
}
|
|
|
|
if (root.nodeType !== Node.ELEMENT_NODE) return;
|
|
translateAttributes(root);
|
|
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
|
|
while (walker.nextNode()) {
|
|
const node = walker.currentNode;
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
translateTextNode(node);
|
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
translateAttributes(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function observeLocalizedMutations(root) {
|
|
const lang = getCurrentUiLanguage();
|
|
if (!root || root.nodeType !== Node.ELEMENT_NODE || lang === "zh" || !SUPPORTED_LANGUAGES.has(lang)) {
|
|
return () => { };
|
|
}
|
|
|
|
const observer = new MutationObserver((mutations) => {
|
|
mutations.forEach((mutation) => {
|
|
if (mutation.type === "childList") {
|
|
mutation.addedNodes.forEach((node) => {
|
|
localizeElement(node);
|
|
});
|
|
} else if (mutation.type === "attributes") {
|
|
localizeElement(mutation.target);
|
|
} else if (mutation.type === "characterData") {
|
|
localizeElement(mutation.target);
|
|
}
|
|
});
|
|
});
|
|
|
|
observer.observe(root, {
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: true,
|
|
characterData: true,
|
|
attributeFilter: ["placeholder", "title", "aria-label", "alt"]
|
|
});
|
|
|
|
return () => observer.disconnect();
|
|
}
|
|
|
|
export function syncGlobalUiLocalization() {
|
|
if (typeof document === "undefined" || !document.body) return;
|
|
|
|
if (typeof stopGlobalLocalizationObserver === "function") {
|
|
stopGlobalLocalizationObserver();
|
|
stopGlobalLocalizationObserver = null;
|
|
}
|
|
|
|
const lang = getCurrentUiLanguage();
|
|
if (lang === "zh" || !SUPPORTED_LANGUAGES.has(lang)) return;
|
|
|
|
localizeElement(document.body);
|
|
stopGlobalLocalizationObserver = observeLocalizedMutations(document.body);
|
|
}
|
|
|
|
export function localizeToastPayload(payload) {
|
|
if (!payload || typeof payload !== "object") {
|
|
return payload;
|
|
}
|
|
|
|
const localized = { ...payload };
|
|
["summary", "detail", "message"].forEach((field) => {
|
|
if (typeof localized[field] === "string") {
|
|
localized[field] = tUI(localized[field], localized[field]);
|
|
}
|
|
});
|
|
return localized;
|
|
}
|
|
|
|
export function patchToastLocalization() {
|
|
if (isToastPatched) return;
|
|
const toast = app?.extensionManager?.toast;
|
|
if (!toast || typeof toast.add !== "function") return;
|
|
|
|
const originalAdd = toast.add.bind(toast);
|
|
toast.add = (payload) => originalAdd(localizeToastPayload(payload));
|
|
isToastPatched = true;
|
|
}
|