Files

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;
}