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