317 lines
8.4 KiB
JavaScript
317 lines
8.4 KiB
JavaScript
/**
|
|
* Runtime label translator for asm_ui_app.
|
|
* Replaces hardcoded English labels/sections using Frappe Translation doctype.
|
|
*/
|
|
(function () {
|
|
"use strict";
|
|
|
|
var API_METHOD =
|
|
"/api/method/translation_tool.api.translations.get_translations";
|
|
var STORAGE_KEY = "i18nextLng";
|
|
var VERSION = "1.0.2";
|
|
|
|
var translations = {};
|
|
var isArabic = false;
|
|
var isApplying = false;
|
|
var debounceTimer = null;
|
|
var observer = null;
|
|
var reapplyInterval = null;
|
|
|
|
/** Text nodes cannot use .dataset — store originals in WeakMap */
|
|
var textOriginals = new WeakMap();
|
|
var trackedTextNodes = [];
|
|
|
|
var ELEMENT_SELECTORS =
|
|
"label, h1, h2, h3, h4, h5, h6, th, legend, nav span, aside span, [role='tab']";
|
|
|
|
var SKIP_ANCESTOR_SELECTORS =
|
|
"input, textarea, select, option, script, style, noscript, code, pre";
|
|
|
|
var SKIP_VALUE_SELECTORS =
|
|
"td, [data-value], .recharts-wrapper, .toast, [class*='toast']";
|
|
|
|
function log() {
|
|
if (window.__TRANSLATION_TOOL_DEBUG__) {
|
|
console.log.apply(
|
|
console,
|
|
["[translation_tool]"].concat([].slice.call(arguments))
|
|
);
|
|
}
|
|
}
|
|
|
|
function getActiveLanguage() {
|
|
try {
|
|
var stored = localStorage.getItem(STORAGE_KEY);
|
|
if (stored) return stored;
|
|
} catch (e) {
|
|
/* ignore */
|
|
}
|
|
return document.documentElement.getAttribute("lang") || "en";
|
|
}
|
|
|
|
function shouldTranslate() {
|
|
var lang = getActiveLanguage();
|
|
var dir = document.documentElement.getAttribute("dir");
|
|
return lang === "ar" || dir === "rtl";
|
|
}
|
|
|
|
function parentElement(node) {
|
|
return node && node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
|
|
}
|
|
|
|
function shouldSkipNode(node) {
|
|
var el = parentElement(node);
|
|
if (!el) return true;
|
|
if (el.closest(SKIP_ANCESTOR_SELECTORS)) return true;
|
|
if (el.closest(SKIP_VALUE_SELECTORS)) return true;
|
|
if (el.closest("[data-tt-skip]")) return true;
|
|
if (el.isContentEditable) return true;
|
|
return false;
|
|
}
|
|
|
|
function translateExactText(text) {
|
|
var trimmed = (text || "").trim();
|
|
if (!trimmed) return null;
|
|
return translations[trimmed] || null;
|
|
}
|
|
|
|
function restoreOriginals(root) {
|
|
trackedTextNodes.forEach(function (textNode) {
|
|
if (textOriginals.has(textNode) && textNode.parentNode) {
|
|
textNode.textContent = textOriginals.get(textNode);
|
|
}
|
|
});
|
|
trackedTextNodes = [];
|
|
textOriginals = new WeakMap();
|
|
|
|
root.querySelectorAll("[data-tt-el-original]").forEach(function (el) {
|
|
el.textContent = el.dataset.ttElOriginal;
|
|
delete el.dataset.ttElOriginal;
|
|
});
|
|
|
|
root.querySelectorAll("[data-tt-attr-original]").forEach(function (el) {
|
|
var attr = el.dataset.ttAttrOriginalName;
|
|
if (attr) {
|
|
el.setAttribute(attr, el.dataset.ttAttrOriginal);
|
|
}
|
|
delete el.dataset.ttAttrOriginal;
|
|
delete el.dataset.ttAttrOriginalName;
|
|
});
|
|
}
|
|
|
|
function rememberTextNode(textNode, original) {
|
|
if (!textOriginals.has(textNode)) {
|
|
textOriginals.set(textNode, original);
|
|
trackedTextNodes.push(textNode);
|
|
}
|
|
}
|
|
|
|
function translateTextNode(textNode) {
|
|
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) return;
|
|
if (shouldSkipNode(textNode)) return;
|
|
|
|
var original = textNode.textContent;
|
|
var trimmed = original.trim();
|
|
if (!trimmed) return;
|
|
|
|
var translated = translateExactText(trimmed);
|
|
if (!translated) return;
|
|
|
|
rememberTextNode(textNode, original);
|
|
|
|
var leading = (original.match(/^\s*/) || [""])[0];
|
|
var trailing = (original.match(/\s*$/) || [""])[0];
|
|
textNode.textContent = leading + translated + trailing;
|
|
}
|
|
|
|
function translateLeafElement(el) {
|
|
if (!el || el.nodeType !== 1) return;
|
|
if (shouldSkipNode(el)) return;
|
|
if (el.childElementCount > 0) return;
|
|
|
|
var original = el.textContent;
|
|
var trimmed = original.trim();
|
|
if (!trimmed) return;
|
|
|
|
var translated = translateExactText(trimmed);
|
|
if (!translated) return;
|
|
|
|
if (!el.dataset.ttElOriginal) {
|
|
el.dataset.ttElOriginal = original;
|
|
}
|
|
el.textContent = translated;
|
|
}
|
|
|
|
function translateAttributes(root) {
|
|
root.querySelectorAll("[placeholder], [title], [aria-label]").forEach(function (el) {
|
|
if (shouldSkipNode(el)) return;
|
|
|
|
["placeholder", "title", "aria-label"].forEach(function (attr) {
|
|
var value = el.getAttribute(attr);
|
|
if (!value) return;
|
|
|
|
var trimmed = value.trim();
|
|
var translated = translateExactText(trimmed);
|
|
if (!translated) return;
|
|
|
|
if (!el.dataset.ttAttrOriginal) {
|
|
el.dataset.ttAttrOriginal = value;
|
|
el.dataset.ttAttrOriginalName = attr;
|
|
}
|
|
el.setAttribute(attr, translated);
|
|
});
|
|
});
|
|
}
|
|
|
|
function walkAndTranslateText(root) {
|
|
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
|
var batch = [];
|
|
var node;
|
|
while ((node = walker.nextNode())) {
|
|
batch.push(node);
|
|
}
|
|
batch.forEach(translateTextNode);
|
|
}
|
|
|
|
function applyTranslations(root) {
|
|
if (!isArabic || !Object.keys(translations).length) return;
|
|
|
|
isApplying = true;
|
|
try {
|
|
translateAttributes(root);
|
|
|
|
root.querySelectorAll(ELEMENT_SELECTORS).forEach(translateLeafElement);
|
|
|
|
walkAndTranslateText(root);
|
|
} catch (err) {
|
|
console.error("[translation_tool] applyTranslations error:", err);
|
|
} finally {
|
|
isApplying = false;
|
|
}
|
|
}
|
|
|
|
function scheduleApply() {
|
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(function () {
|
|
var root = document.getElementById("root");
|
|
if (!root || !isArabic) return;
|
|
applyTranslations(root);
|
|
}, 80);
|
|
}
|
|
|
|
function setupObserver() {
|
|
var root = document.getElementById("root");
|
|
if (!root || observer) return;
|
|
|
|
observer = new MutationObserver(function () {
|
|
if (isApplying || !isArabic) return;
|
|
scheduleApply();
|
|
});
|
|
|
|
observer.observe(root, {
|
|
childList: true,
|
|
subtree: true,
|
|
characterData: true,
|
|
});
|
|
}
|
|
|
|
function setupReapplyInterval() {
|
|
if (reapplyInterval) return;
|
|
reapplyInterval = setInterval(function () {
|
|
if (!isArabic) return;
|
|
var root = document.getElementById("root");
|
|
if (root) applyTranslations(root);
|
|
}, 2000);
|
|
}
|
|
|
|
function clearReapplyInterval() {
|
|
if (reapplyInterval) {
|
|
clearInterval(reapplyInterval);
|
|
reapplyInterval = null;
|
|
}
|
|
}
|
|
|
|
function fetchTranslations(language) {
|
|
var url = API_METHOD + "?language=" + encodeURIComponent(language);
|
|
return fetch(url, {
|
|
method: "GET",
|
|
credentials: "include",
|
|
headers: { Accept: "application/json" },
|
|
})
|
|
.then(function (res) {
|
|
return res.json();
|
|
})
|
|
.then(function (payload) {
|
|
var message = payload.message || payload;
|
|
if (message && message.success && message.translations) {
|
|
return message.translations;
|
|
}
|
|
return {};
|
|
})
|
|
.catch(function (err) {
|
|
console.warn("[translation_tool] Failed to load translations:", err);
|
|
return {};
|
|
});
|
|
}
|
|
|
|
function activateArabic() {
|
|
isArabic = true;
|
|
return fetchTranslations("ar").then(function (map) {
|
|
translations = map || {};
|
|
log("Loaded", Object.keys(translations).length, "translations");
|
|
var root = document.getElementById("root");
|
|
if (root) applyTranslations(root);
|
|
setupObserver();
|
|
setupReapplyInterval();
|
|
});
|
|
}
|
|
|
|
function deactivateTranslation() {
|
|
isArabic = false;
|
|
translations = {};
|
|
clearReapplyInterval();
|
|
var root = document.getElementById("root");
|
|
if (root) restoreOriginals(root);
|
|
}
|
|
|
|
function syncLanguage() {
|
|
if (shouldTranslate()) {
|
|
activateArabic();
|
|
} else {
|
|
deactivateTranslation();
|
|
}
|
|
}
|
|
|
|
function watchLanguageChanges() {
|
|
var htmlObserver = new MutationObserver(function () {
|
|
syncLanguage();
|
|
});
|
|
htmlObserver.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ["lang", "dir"],
|
|
});
|
|
|
|
window.addEventListener("storage", function (event) {
|
|
if (event.key === STORAGE_KEY) syncLanguage();
|
|
});
|
|
|
|
setInterval(function () {
|
|
var active = shouldTranslate();
|
|
if (active !== isArabic) syncLanguage();
|
|
}, 1000);
|
|
}
|
|
|
|
function init() {
|
|
if (!document.getElementById("root")) {
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
return;
|
|
}
|
|
|
|
watchLanguageChanges();
|
|
syncLanguage();
|
|
log("label_translator initialized v" + VERSION);
|
|
}
|
|
|
|
init();
|
|
})();
|