From 2452b47fb6d3940d9497718c4e3227d8a1cb222f Mon Sep 17 00:00:00 2001 From: Duradundi Date: Thu, 24 Sep 2026 10:41:27 +0000 Subject: [PATCH] Initial commit of translation tool --- README.md | 38 +++ pyproject.toml | 14 + translation_tool/__init__.py | 1 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 204 bytes .../__pycache__/hooks.cpython-310.pyc | Bin 0 -> 554 bytes translation_tool/api/__init__.py | 0 .../api/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 187 bytes .../__pycache__/translations.cpython-310.pyc | Bin 0 -> 3070 bytes translation_tool/api/translations.py | 111 ++++++ translation_tool/events/__init__.py | 0 .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 190 bytes .../__pycache__/translation.cpython-310.pyc | Bin 0 -> 596 bytes translation_tool/events/translation.py | 12 + translation_tool/hooks.py | 13 + translation_tool/modules.txt | 1 + translation_tool/patches.txt | 6 + .../public/js/label_translator.js | 316 ++++++++++++++++++ translation_tool/translation_tool/__init__.py | 0 .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 200 bytes 19 files changed, 512 insertions(+) create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 translation_tool/__init__.py create mode 100644 translation_tool/__pycache__/__init__.cpython-310.pyc create mode 100644 translation_tool/__pycache__/hooks.cpython-310.pyc create mode 100644 translation_tool/api/__init__.py create mode 100644 translation_tool/api/__pycache__/__init__.cpython-310.pyc create mode 100644 translation_tool/api/__pycache__/translations.cpython-310.pyc create mode 100644 translation_tool/api/translations.py create mode 100644 translation_tool/events/__init__.py create mode 100644 translation_tool/events/__pycache__/__init__.cpython-310.pyc create mode 100644 translation_tool/events/__pycache__/translation.cpython-310.pyc create mode 100644 translation_tool/events/translation.py create mode 100644 translation_tool/hooks.py create mode 100644 translation_tool/modules.txt create mode 100644 translation_tool/patches.txt create mode 100644 translation_tool/public/js/label_translator.js create mode 100644 translation_tool/translation_tool/__init__.py create mode 100644 translation_tool/translation_tool/__pycache__/__init__.cpython-310.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..381618b --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Translation Tool + +Runtime label translation for ASM UI (`asm_ui_app`) using Frappe **Translation** doctype records. + +## Features + +- Bulk API: flat `{ source_text: translated_text }` map for a language +- Client-side `label_translator.js` replaces hardcoded English labels/sections when Arabic is active +- Cache invalidation when Translation records change + +## Install + +```bash +cd ~/frappe-bench +bench get-app translation_tool # if not already in apps/ +bench --site install-app translation_tool +bench build --app translation_tool +bench --site clear-cache +``` + +Ensure `translation_tool` is listed in `sites//apps.txt` after install. + +## Translation records + +Create rows in **Translation** doctype: + +| language | source_text | translated_text | +|----------|-------------|-----------------| +| ar | Asset Information | معلومات الأصل | +| ar | Workflow Actions | إجراءات سير العمل | + +`source_text` must match the visible English UI string exactly. + +## API + +- `translation_tool.api.translations.get_translations?language=ar` +- `translation_tool.api.translations.get_translation?source_text=...&language=ar` +- `translation_tool.api.translations.get_available_languages` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..11ee300 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "translation_tool" +authors = [ + { name = "support@seeraarabia.com", email = "support@seeraarabia.com" } +] +description = "Runtime label translation for ASM UI using Frappe Translation doctype" +requires-python = ">=3.10" +readme = "README.md" +dynamic = ["version"] +dependencies = [] + +[build-system] +requires = ["flit_core >=3.4,<4"] +build-backend = "flit_core.buildapi" diff --git a/translation_tool/__init__.py b/translation_tool/__init__.py new file mode 100644 index 0000000..f102a9c --- /dev/null +++ b/translation_tool/__init__.py @@ -0,0 +1 @@ +__version__ = "0.0.1" diff --git a/translation_tool/__pycache__/__init__.cpython-310.pyc b/translation_tool/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8885bbf37ae039f3470a1a859d360c1a13921175 GIT binary patch literal 204 zcmd1j<>g`kf=BnaWQhUk#~=1X8Urs^l=WaXEp=@t~_r|6~@73h{0rxxj_6(trF zr0OQ6<|Sw71DVD8B}IvO#W{&3AahFc^K&o-;^Q;(GE3s)^$IF)aoFVMr7h0RTWuI7|Ql literal 0 HcmV?d00001 diff --git a/translation_tool/__pycache__/hooks.cpython-310.pyc b/translation_tool/__pycache__/hooks.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1257ae70ed9b0277e85e36d63959b080e58321cb GIT binary patch literal 554 zcmZWmy-ve05O#jrrmZTNVD(z0CL;?%WdMl*23mxaC352{adBeHc97a<;RVFpdT)ZZgf5u6qmP`SC`HsuJ?f`E4bs5f zAq~?G?a;2%-jmO~v`eEI8u#tKQKZtG7$&OB2*Ee|^S=z9HlTy%{!>Rjyqz{}4QRw}u MANZc{gg`kf@k-)WP#|%AOaaM0yz#qT+9L_QW%06G#UL?G8BP?5yY=J{fzwFRQ<%9 zto+h6-GZY06y4OK0^QQ$)FS<~qQruNRNbW1yyOghAhTG%q$n}3I47|rGe0lBBtJg~ nQy{S*Q$IdFGcU6wK3=b&@)n0pZhlH>PO2Tq-eM*o!NLFlo#Qf7 literal 0 HcmV?d00001 diff --git a/translation_tool/api/__pycache__/translations.cpython-310.pyc b/translation_tool/api/__pycache__/translations.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d10ebd2728e3014b77b9649a49718e99a1a46aeb GIT binary patch literal 3070 zcmZ`*TTdLx6|Snj%!Qi`EZD@#_C~99CM#fbQ<9Yz#paTA;*298vP`rZH(doZZf1t+ z9%Cj=vXXg-6!DOE9^ldMdCE_zr$~9qPuN{+mG4x~3M9dsH8Vf)iR zCSh~86Yi=LQ|>p7uT|F3&4`nE@AC1cXBkiW0wQDkTyiOL76TXQ?h3-<%st^TI}tfw zVPD)kadPJZo?r6zU0J$tF4>7|H7+#Y>uJ2))$_SWPcCxz0Xw{%3wU3yn~}{c$F;zw zW+ZKObfg-YdYzex{&y*96`CBbIi|D|Cz;Zz@podC$kg0U;)6JAwDFydL^XG_J;Q1) zhIQC(D^uE4StCpm+Qw8 z)`40Kll@j_XTGgla$f27d?!_UvDkHfTQ#G-ML3Wy_U-%e`ENzoj)#@$Li^ZEG`2f& zA{+f+Qh3sP5H1(o#|`<;EtUBs?lNxir7QFdCR~fGOBQ%+WPgoD&QdpK8#cl@vj2>q z@JvwjF5H}7azs~1*5P`V;*@zgd&5th-V8tIxpOHv%N@i})ZcQ8W36N=%MX`7Uv2zm z^*MlV>4PxSBiPM&Q5fwhQ=uTd3X_hqE}BZ(@7LTS6cszM@lqWd-!`Sbg#|3&g?NwF zo1%q^@c}ei40(1_&kEAl28=$(f$W*aZgwB6IfL0+dV6 zK@JusPa?@t-rkvmw4XTPIoBUuvp}hw0XohOXUV4K*Yzw`)&%@!fsim|8K+s?jF5xv zq!YbF4iZ9o4#JE|m8eYhAfgY_cS(T2fOCP6E#U@I^Cl*u;Yut>WE?P&8-Hu8#BSL)*DF>5?N^R<7XmqD@)&|X8FqAsWxJ?a9 z5*aAr4RDdpx%&(VxGZyyf5AZ&oOSPJ?!G78%)Rh08Bl~y`dKL#uwb$jGg&_Oo*^jD znXH_MKOyak_Yps0wQ6^1-4a$nl@JUuL@>V;^uM{s5do?7t?(C51aO)zYN5K3t$ zid32wXnIkr)6C3he+5)$`t{uA6(t3m;L_mX?;Z!MTDSCDVZ1EP5)jG(76I9;6^!KR z``EyEie}lh_4lFBHHdnxtjDQCX=ogxts716t#6lA{ zQ2!N%Q{vuHfbNPa*r&>cQxLWPPX8Q35uU;2pf#wizo657pN`ypL5Hq%&cq4*PR zeuW5_14#ag>$#j?II<-DOR6GN9coAPD$YG#?p53!tmVlHIEpH+YAbG}s5t5GayfeL z^y+P_S8w)h>utO{{{%!esGl^*_E2|w;VTskSBF+nOUrbj`{!z zd|XGPUwt&Tw*K_D!HX>19x{JH1J6ERU0)qc4IXa<^{1Ob{r69v1P|*gR|6k>7Tmv2 z22a;lR@Z|^&j&-KBpxe;X+y?Qc8$L%gcQpSJoe<=1)5(}iN7zv?Uy*aa=y?1CJvFh zmavgHlnJ)vYQl(xPxNi---v$oK>~!L)I2loa8sFiyhl1ivD^f3I}m zLDGuCBwa$IZ)b#R7W7f%s;La7>mSgTA49C6`Ui=uKZAz%|cZi62V6XVBC{B>lS z%3lAF#E(e)7y{O0W5)VRH)wnP6B@tqMYI3M^*>;)g-$q@IDG%B@k*JG+EaFtf0tVl zc|@D>dJV@SwML3_%^qUphjA%1_T0BTXipzgIogXeWj!JivL&Kvr6J#}m-g5%KR9T~ gPNJ6RC(u?}nYm)lnVY;bUaijX>dYLkPEGUw03*ofjQ{`u literal 0 HcmV?d00001 diff --git a/translation_tool/api/translations.py b/translation_tool/api/translations.py new file mode 100644 index 0000000..60a5cd3 --- /dev/null +++ b/translation_tool/api/translations.py @@ -0,0 +1,111 @@ +import frappe + +CACHE_KEY = "translation_tool:flat_map:{language}" + + +def _build_flat_map(language: str) -> dict[str, str]: + rows = frappe.get_all( + "Translation", + filters={"language": language}, + fields=["source_text", "translated_text"], + limit_page_length=0, + ) + + translation_map: dict[str, str] = {} + for row in rows: + source = (row.get("source_text") or "").strip() + translated = (row.get("translated_text") or "").strip() + if source and translated: + translation_map[source] = translated + + return translation_map + + +def get_cached_flat_map(language: str) -> dict[str, str]: + if not language: + return {} + + cache_key = CACHE_KEY.format(language=language) + cached = frappe.cache().get_value(cache_key) + if cached is not None: + return cached + + translation_map = _build_flat_map(language) + frappe.cache().set_value(cache_key, translation_map) + return translation_map + + +def clear_translation_cache(language: str | None = None) -> None: + if language: + frappe.cache().delete_value(CACHE_KEY.format(language=language)) + return + + for lang_row in frappe.get_all("Translation", distinct=True, pluck="language"): + if lang_row: + frappe.cache().delete_value(CACHE_KEY.format(language=lang_row)) + + +@frappe.whitelist(allow_guest=True) +def get_translations(language: str = "ar") -> dict: + """Return flat source_text -> translated_text map for a language.""" + try: + language = (language or "ar").strip() + translation_map = get_cached_flat_map(language) + return { + "success": True, + "language": language, + "count": len(translation_map), + "translations": translation_map, + } + except Exception as exc: + frappe.log_error(title="Translation Tool API Error", message=frappe.get_traceback()) + return { + "success": False, + "error": str(exc), + "language": language, + "count": 0, + "translations": {}, + } + + +@frappe.whitelist(allow_guest=False) +def get_translation(source_text: str, language: str = "ar") -> dict: + """Return a single translation by exact source_text match.""" + source_text = (source_text or "").strip() + language = (language or "ar").strip() + + if not source_text: + return {"success": False, "error": "source_text is required"} + + translation_map = get_cached_flat_map(language) + translated = translation_map.get(source_text) + + return { + "success": True, + "source_text": source_text, + "translated_text": translated or source_text, + "found": bool(translated), + } + + +@frappe.whitelist(allow_guest=False) +def get_available_languages() -> dict: + """List languages that have Translation records.""" + try: + languages = frappe.db.sql( + """ + SELECT DISTINCT language + FROM `tabTranslation` + WHERE language IS NOT NULL AND language != '' + ORDER BY language + """, + as_dict=True, + ) + language_list = [row.language for row in languages if row.get("language")] + if "en" not in language_list: + language_list.insert(0, "en") + + return {"success": True, "languages": language_list} + except Exception as exc: + frappe.log_error(title="Translation Tool API Error", message=frappe.get_traceback()) + return {"success": False, "error": str(exc), "languages": ["en"]} diff --git a/translation_tool/events/__init__.py b/translation_tool/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/translation_tool/events/__pycache__/__init__.cpython-310.pyc b/translation_tool/events/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..563e77602b49ccf6a1ed529ccc35f585fd42ffd1 GIT binary patch literal 190 zcmd1j<>g`kf|vKVWP#|%AOaaM0yz#qT+9L_QW%06G#UL?G8BP?5yY=V{fzwFRQ<%9 zto+h6-GZY06y4OK0^QQ$)FS<~qQruNRNbW1yyOghAhTG%q$n}3I47|rGe0lBBtJg~ qQy{f0HLs*tKR!M)FS8^*Uaz3?7Kcr4eoARhsvXGcVkRKL!TW(GDqP#Ps#V=kEfV6dr1oRh_pJT{n*ad^ z9;4w#-XOw&T6`4lVt_9~;~lz}9LYQQEC%6T4PrBLuLC5X&>AiuXdxFdH>zFtYEAJm z)95>`0u^JIn8ZhG3MvE_pEk-@J7s#R>W1;8<2lzVZR_faR@PDF)s9@^T*xRnAG0lm z#_E_I0AIm9;p$IaHg-#;G8@~kW}UNoMy{Lno?N+ds_STWMQz=b9QyKjMZsG0FhM(N z!#}`z_bHwqHoV_z#rzV}jK}*an=^bSay-E)=D%~{CW$mIic!oC1mea2W9O>7o*yOs gen0!0`&S*AQ|=b?H(RTFLvQ)tfKP*SI2BX*2exXVy#N3J literal 0 HcmV?d00001 diff --git a/translation_tool/events/translation.py b/translation_tool/events/translation.py new file mode 100644 index 0000000..b9c8d69 --- /dev/null +++ b/translation_tool/events/translation.py @@ -0,0 +1,12 @@ +import frappe + +from translation_tool.api.translations import clear_translation_cache + + +def clear_cache_for_doc(doc, method=None): + clear_translation_cache(getattr(doc, "language", None)) + + if method == "on_update" and doc.has_value_changed("language"): + before = doc.get_doc_before_save() + if before and before.language: + clear_translation_cache(before.language) diff --git a/translation_tool/hooks.py b/translation_tool/hooks.py new file mode 100644 index 0000000..47b79a8 --- /dev/null +++ b/translation_tool/hooks.py @@ -0,0 +1,13 @@ +app_name = "translation_tool" +app_title = "Translation Tool" +app_publisher = "support@seeraarabia.com" +app_description = "Runtime label translation for ASM UI using Frappe Translation doctype" +app_email = "support@seeraarabia.com" +app_license = "mit" + +doc_events = { + "Translation": { + "on_update": "translation_tool.events.translation.clear_cache_for_doc", + "on_trash": "translation_tool.events.translation.clear_cache_for_doc", + }, +} diff --git a/translation_tool/modules.txt b/translation_tool/modules.txt new file mode 100644 index 0000000..f1099a9 --- /dev/null +++ b/translation_tool/modules.txt @@ -0,0 +1 @@ +Translation Tool diff --git a/translation_tool/patches.txt b/translation_tool/patches.txt new file mode 100644 index 0000000..73efa6f --- /dev/null +++ b/translation_tool/patches.txt @@ -0,0 +1,6 @@ +[pre_model_sync] +# Patches added in this section will be executed before doctypes are migrated +# Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations + +[post_model_sync] +# Patches added in this section will be executed after doctypes are migrated diff --git a/translation_tool/public/js/label_translator.js b/translation_tool/public/js/label_translator.js new file mode 100644 index 0000000..8d4915d --- /dev/null +++ b/translation_tool/public/js/label_translator.js @@ -0,0 +1,316 @@ +/** + * 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(); +})(); diff --git a/translation_tool/translation_tool/__init__.py b/translation_tool/translation_tool/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/translation_tool/translation_tool/__pycache__/__init__.cpython-310.pyc b/translation_tool/translation_tool/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5738c48010ea924385cd4bf8e82138d3509b3f27 GIT binary patch literal 200 zcmd1j<>g`kg5(EVvOx4>5P=LBfgA@QE@lA|DGb33nv8xc8Hzx{2;x_^enx(7s(xZl zR(@%kZb4Cgif(FAfo^GWYLR|gQDQ+ss%}zhUUG&$kXfu>Qk0lioRe6RnV%P5lAoVL jgg|_JW?p7Ve7s&k