Initial commit of translation tool

This commit is contained in:
Duradundi 2026-09-24 10:41:27 +00:00
commit 2452b47fb6
19 changed files with 512 additions and 0 deletions

38
README.md Normal file
View File

@ -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 <site> install-app translation_tool
bench build --app translation_tool
bench --site <site> clear-cache
```
Ensure `translation_tool` is listed in `sites/<site>/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`

14
pyproject.toml Normal file
View File

@ -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"

View File

@ -0,0 +1 @@
__version__ = "0.0.1"

Binary file not shown.

Binary file not shown.

View File

View File

@ -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"]}

View File

View File

@ -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)

13
translation_tool/hooks.py Normal file
View File

@ -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",
},
}

View File

@ -0,0 +1 @@
Translation Tool

View File

@ -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

View File

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