Initial commit of translation tool
This commit is contained in:
commit
2452b47fb6
38
README.md
Normal file
38
README.md
Normal 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
14
pyproject.toml
Normal 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"
|
||||
1
translation_tool/__init__.py
Normal file
1
translation_tool/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
__version__ = "0.0.1"
|
||||
BIN
translation_tool/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
translation_tool/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
translation_tool/__pycache__/hooks.cpython-310.pyc
Normal file
BIN
translation_tool/__pycache__/hooks.cpython-310.pyc
Normal file
Binary file not shown.
0
translation_tool/api/__init__.py
Normal file
0
translation_tool/api/__init__.py
Normal file
BIN
translation_tool/api/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
translation_tool/api/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
translation_tool/api/__pycache__/translations.cpython-310.pyc
Normal file
BIN
translation_tool/api/__pycache__/translations.cpython-310.pyc
Normal file
Binary file not shown.
111
translation_tool/api/translations.py
Normal file
111
translation_tool/api/translations.py
Normal 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"]}
|
||||
0
translation_tool/events/__init__.py
Normal file
0
translation_tool/events/__init__.py
Normal file
BIN
translation_tool/events/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
translation_tool/events/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
translation_tool/events/__pycache__/translation.cpython-310.pyc
Normal file
BIN
translation_tool/events/__pycache__/translation.cpython-310.pyc
Normal file
Binary file not shown.
12
translation_tool/events/translation.py
Normal file
12
translation_tool/events/translation.py
Normal 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
13
translation_tool/hooks.py
Normal 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",
|
||||
},
|
||||
}
|
||||
1
translation_tool/modules.txt
Normal file
1
translation_tool/modules.txt
Normal file
@ -0,0 +1 @@
|
||||
Translation Tool
|
||||
6
translation_tool/patches.txt
Normal file
6
translation_tool/patches.txt
Normal 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
|
||||
316
translation_tool/public/js/label_translator.js
Normal file
316
translation_tool/public/js/label_translator.js
Normal 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();
|
||||
})();
|
||||
0
translation_tool/translation_tool/__init__.py
Normal file
0
translation_tool/translation_tool/__init__.py
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user