diff --git a/app/modules/dev_tools/definition.py b/app/modules/dev_tools/definition.py index ba80eaa6..bb91f5f1 100644 --- a/app/modules/dev_tools/definition.py +++ b/app/modules/dev_tools/definition.py @@ -36,6 +36,7 @@ dev_tools_module = ModuleDefinition( "test_runner", # Test execution "violation_management", # Violation tracking and assignment "sql_query", # Ad-hoc SQL query tool + "translation_editor", # UI translation editor ], menu_items={ FrontendType.ADMIN: [ @@ -44,6 +45,7 @@ dev_tools_module = ModuleDefinition( "code-quality", # Code quality dashboard "tests", # Test runner dashboard "sql-query", # SQL query tool + "translation-editor", # Translation editor ], FrontendType.STORE: [], # No store menu items - internal module }, @@ -85,6 +87,13 @@ dev_tools_module = ModuleDefinition( route="/admin/platform-debug", order=40, ), + MenuItemDefinition( + id="translation-editor", + label_key="dev_tools.menu.translation_editor", + icon="globe", + route="/admin/translation-editor", + order=50, + ), ], ), ], diff --git a/app/modules/dev_tools/locales/de.json b/app/modules/dev_tools/locales/de.json index a6b131f8..467c9a6f 100644 --- a/app/modules/dev_tools/locales/de.json +++ b/app/modules/dev_tools/locales/de.json @@ -14,7 +14,24 @@ "components": "Komponenten", "icons": "Icons", "sql_query": "SQL Abfrage", - "platform_debug": "Plattform Debug" + "platform_debug": "Plattform Debug", + "translation_editor": "Übersetzungseditor" + }, + "translation_editor": { + "title": "Übersetzungseditor", + "all_modules": "Alle Module", + "search_placeholder": "Schlüssel und Werte suchen...", + "filter_all": "Alle", + "filter_missing": "Fehlende", + "filter_variables": "Variablen", + "module": "Modul", + "key_path": "Schlüsselpfad", + "missing": "fehlend", + "saving": "Speichern...", + "no_results": "Keine Übersetzungsschlüssel entsprechen Ihren Filtern.", + "loading": "Übersetzungen werden geladen...", + "previous": "Zurück", + "next": "Weiter" }, "sql_query": { "title": "SQL Abfrage-Werkzeug", diff --git a/app/modules/dev_tools/locales/en.json b/app/modules/dev_tools/locales/en.json index 5b57ddce..7facccfd 100644 --- a/app/modules/dev_tools/locales/en.json +++ b/app/modules/dev_tools/locales/en.json @@ -14,7 +14,24 @@ "components": "Components", "icons": "Icons", "sql_query": "SQL Query", - "platform_debug": "Platform Debug" + "platform_debug": "Platform Debug", + "translation_editor": "Translation Editor" + }, + "translation_editor": { + "title": "Translation Editor", + "all_modules": "All Modules", + "search_placeholder": "Search keys and values...", + "filter_all": "All", + "filter_missing": "Missing", + "filter_variables": "Variables", + "module": "Module", + "key_path": "Key Path", + "missing": "missing", + "saving": "Saving...", + "no_results": "No translation keys match your filters.", + "loading": "Loading translations...", + "previous": "Previous", + "next": "Next" }, "sql_query": { "title": "SQL Query Tool", diff --git a/app/modules/dev_tools/locales/fr.json b/app/modules/dev_tools/locales/fr.json index 83de7046..cbf59b6d 100644 --- a/app/modules/dev_tools/locales/fr.json +++ b/app/modules/dev_tools/locales/fr.json @@ -14,7 +14,24 @@ "components": "Composants", "icons": "Icônes", "sql_query": "Requête SQL", - "platform_debug": "Debug Plateforme" + "platform_debug": "Debug Plateforme", + "translation_editor": "Éditeur de traductions" + }, + "translation_editor": { + "title": "Éditeur de traductions", + "all_modules": "Tous les modules", + "search_placeholder": "Rechercher clés et valeurs...", + "filter_all": "Tous", + "filter_missing": "Manquantes", + "filter_variables": "Variables", + "module": "Module", + "key_path": "Chemin de clé", + "missing": "manquante", + "saving": "Enregistrement...", + "no_results": "Aucune clé de traduction ne correspond à vos filtres.", + "loading": "Chargement des traductions...", + "previous": "Précédent", + "next": "Suivant" }, "sql_query": { "title": "Outil de requête SQL", diff --git a/app/modules/dev_tools/locales/lb.json b/app/modules/dev_tools/locales/lb.json index 998bbfaf..54961774 100644 --- a/app/modules/dev_tools/locales/lb.json +++ b/app/modules/dev_tools/locales/lb.json @@ -14,7 +14,24 @@ "components": "Komponenten", "icons": "Icons", "sql_query": "SQL Ufro", - "platform_debug": "Plattform Debug" + "platform_debug": "Plattform Debug", + "translation_editor": "Iwwersetzungseditor" + }, + "translation_editor": { + "title": "Iwwersetzungseditor", + "all_modules": "All Moduler", + "search_placeholder": "Schlësselen a Wäerter sichen...", + "filter_all": "All", + "filter_missing": "Fehlend", + "filter_variables": "Variabelen", + "module": "Modul", + "key_path": "Schlësselpfad", + "missing": "fehlend", + "saving": "Späicheren...", + "no_results": "Keng Iwwersetzungsschlësselen entspriechen Ären Filteren.", + "loading": "Iwwersetzunge gi gelueden...", + "previous": "Zréck", + "next": "Weider" }, "sql_query": { "title": "SQL Ufro-Werkzeug", diff --git a/app/modules/dev_tools/routes/api/admin.py b/app/modules/dev_tools/routes/api/admin.py index da8f92fc..32504c89 100644 --- a/app/modules/dev_tools/routes/api/admin.py +++ b/app/modules/dev_tools/routes/api/admin.py @@ -11,7 +11,11 @@ from app.modules.dev_tools.routes.api.admin_platform_debug import ( router as platform_debug_router, ) from app.modules.dev_tools.routes.api.admin_sql_query import router as sql_query_router +from app.modules.dev_tools.routes.api.admin_translations import ( + router as translations_router, +) router = APIRouter() router.include_router(sql_query_router, tags=["sql-query"]) router.include_router(platform_debug_router, tags=["platform-debug"]) +router.include_router(translations_router, tags=["translations"]) diff --git a/app/modules/dev_tools/routes/api/admin_translations.py b/app/modules/dev_tools/routes/api/admin_translations.py new file mode 100644 index 00000000..abb5fe47 --- /dev/null +++ b/app/modules/dev_tools/routes/api/admin_translations.py @@ -0,0 +1,312 @@ +# app/modules/dev_tools/routes/api/admin_translations.py +""" +Translation Editor API endpoints. + +Browse, search, and edit UI translation JSON files across all modules. +All endpoints require super-admin authentication. +""" + +import json +import logging +import re +from pathlib import Path +from typing import Literal + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from app.api.deps import UserContext, get_current_super_admin_api +from app.utils.i18n import ( + SUPPORTED_LANGUAGES, + clear_translation_cache, + get_locales_path, + get_module_locale_dirs, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/translations", tags=["translations"]) + +# Variable placeholder pattern: {name} +VARIABLE_PATTERN = re.compile(r"\{(\w+)\}") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _flatten_json(data: dict, prefix: str = "") -> dict[str, str]: + """Flatten nested dict to dot-notation paths.""" + result = {} + for key, value in data.items(): + path = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + result.update(_flatten_json(value, path)) + else: + result[path] = value + return result + + +def _set_nested_value(data: dict, path: str, value: str) -> None: + """Set a value in a nested dict using dot-notation path.""" + keys = path.split(".") + current = data + for key in keys[:-1]: + if key not in current or not isinstance(current[key], dict): + current[key] = {} + current = current[key] + current[keys[-1]] = value + + +def _remove_nested_value(data: dict, path: str) -> None: + """Remove a value from a nested dict using dot-notation path.""" + keys = path.split(".") + current = data + for key in keys[:-1]: + if key not in current or not isinstance(current[key], dict): + return + current = current[key] + current.pop(keys[-1], None) + + +def _load_json(file_path: Path) -> dict: + """Load JSON file, return empty dict if missing.""" + if not file_path.exists(): + return {} + try: + with open(file_path, encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError) as e: + logger.error(f"Error loading {file_path}: {e}") + return {} + + +def _save_json(file_path: Path, data: dict) -> None: + """Write dict to JSON file with pretty formatting.""" + file_path.parent.mkdir(parents=True, exist_ok=True) + with open(file_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4, ensure_ascii=False) + f.write("\n") + + +def _get_module_file_path(module: str, language: str) -> Path | None: + """Get the JSON file path for a module + language, or None if not found.""" + if module == "shared": + return get_locales_path() / f"{language}.json" + for mod_code, locales_dir in get_module_locale_dirs(): + if mod_code == module: + return locales_dir / f"{language}.json" + return None + + +def _extract_variables(text: str) -> list[str]: + """Extract {variable} placeholders from a string.""" + return VARIABLE_PATTERN.findall(text) + + +def _load_all_keys() -> tuple[list[dict], list[dict], dict]: + """ + Load all translation keys across all modules and languages. + + Returns: + (modules_list, keys_list, stats_dict) + """ + modules_info = [] + all_keys: dict[tuple[str, str], dict[str, str | None]] = {} + + # Global translations (static/locales/) + shared_keys_per_lang: dict[str, dict[str, str]] = {} + for lang in SUPPORTED_LANGUAGES: + file_path = get_locales_path() / f"{lang}.json" + data = _load_json(file_path) + flat = _flatten_json(data) + shared_keys_per_lang[lang] = flat + for path in flat: + key_id = ("shared", path) + if key_id not in all_keys: + all_keys[key_id] = {la: None for la in SUPPORTED_LANGUAGES} + all_keys[key_id][lang] = flat[path] + + shared_all_paths = set() + for flat in shared_keys_per_lang.values(): + shared_all_paths.update(flat.keys()) + modules_info.append({ + "code": "shared", + "label": "Shared", + "key_count": len(shared_all_paths), + }) + + # Module translations + for mod_code, locales_dir in get_module_locale_dirs(): + mod_keys_per_lang: dict[str, dict[str, str]] = {} + for lang in SUPPORTED_LANGUAGES: + file_path = locales_dir / f"{lang}.json" + data = _load_json(file_path) + flat = _flatten_json(data) + mod_keys_per_lang[lang] = flat + for path in flat: + key_id = (mod_code, path) + if key_id not in all_keys: + all_keys[key_id] = {la: None for la in SUPPORTED_LANGUAGES} + all_keys[key_id][lang] = flat[path] + + mod_all_paths = set() + for flat in mod_keys_per_lang.values(): + mod_all_paths.update(flat.keys()) + if mod_all_paths: + modules_info.append({ + "code": mod_code, + "label": mod_code.replace("_", " ").replace("-", " ").title(), + "key_count": len(mod_all_paths), + }) + + # Build keys list and stats + keys_list = [] + missing_counts = {lang: 0 for lang in SUPPORTED_LANGUAGES} + + for (module, path), values in sorted(all_keys.items()): + # Detect variables from any non-null value + variables = [] + has_variables = False + for val in values.values(): + if val and isinstance(val, str): + found = _extract_variables(val) + if found: + has_variables = True + for v in found: + if v not in variables: + variables.append(v) + + entry = { + "module": module, + "path": path, + "values": values, + "has_variables": has_variables, + } + if has_variables: + entry["variables"] = variables + + keys_list.append(entry) + + for lang in SUPPORTED_LANGUAGES: + if values[lang] is None: + missing_counts[lang] += 1 + + stats = { + "total_keys": len(keys_list), + "missing": missing_counts, + } + + return modules_info, keys_list, stats + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + + +class UpdateKeyRequest(BaseModel): + module: str = Field(..., min_length=1) + path: str = Field(..., min_length=1) + language: Literal["en", "fr", "de", "lb"] + value: str = Field(...) + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get("/keys") +async def get_translation_keys( + ctx: UserContext = Depends(get_current_super_admin_api), +): + """Load all translation keys across all modules and languages.""" + modules, keys, stats = _load_all_keys() + return { + "modules": modules, + "languages": SUPPORTED_LANGUAGES, + "keys": keys, + "stats": stats, + } + + +@router.put("/key") +async def update_translation_key( + body: UpdateKeyRequest, + ctx: UserContext = Depends(get_current_super_admin_api), +): + """Update a single translation value.""" + file_path = _get_module_file_path(body.module, body.language) + if file_path is None: + return JSONResponse( + status_code=404, + content={"detail": f"Module '{body.module}' not found"}, + ) + data = _load_json(file_path) + + if body.value == "": + # Empty string means delete the key + _remove_nested_value(data, body.path) + else: + _set_nested_value(data, body.path, body.value) + + _save_json(file_path, data) + clear_translation_cache() + + # Return updated entry + values = {} + for lang in SUPPORTED_LANGUAGES: + lang_path = _get_module_file_path(body.module, lang) + if lang_path: + lang_data = _load_json(lang_path) + flat = _flatten_json(lang_data) + values[lang] = flat.get(body.path) + else: + values[lang] = None + + variables = [] + has_variables = False + for val in values.values(): + if val and isinstance(val, str): + found = _extract_variables(val) + if found: + has_variables = True + for v in found: + if v not in variables: + variables.append(v) + + entry = { + "module": body.module, + "path": body.path, + "values": values, + "has_variables": has_variables, + } + if has_variables: + entry["variables"] = variables + + return entry + + +@router.get("/missing") +async def get_missing_translations( + module: str | None = Query(None), + ctx: UserContext = Depends(get_current_super_admin_api), +): + """Return only keys with missing translations in at least one language.""" + _, keys, stats = _load_all_keys() + + missing_keys = [ + k for k in keys + if any(v is None for v in k["values"].values()) + ] + + if module: + missing_keys = [k for k in missing_keys if k["module"] == module] + + return { + "keys": missing_keys, + "stats": stats, + } diff --git a/app/modules/dev_tools/routes/pages/admin.py b/app/modules/dev_tools/routes/pages/admin.py index 3bb5d209..f2df67b2 100644 --- a/app/modules/dev_tools/routes/pages/admin.py +++ b/app/modules/dev_tools/routes/pages/admin.py @@ -149,6 +149,21 @@ async def admin_platform_debug_page( ) +@router.get("/translation-editor", response_class=HTMLResponse, include_in_schema=False) +async def admin_translation_editor_page( + request: Request, + current_user: User = Depends( + require_menu_access("translation-editor", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """Render translation editor page.""" + return templates.TemplateResponse( + "dev_tools/admin/translation-editor.html", + get_admin_context(request, db, current_user), + ) + + @router.get("/sql-query", response_class=HTMLResponse, include_in_schema=False) async def admin_sql_query_page( request: Request, diff --git a/app/modules/dev_tools/static/admin/js/translation-editor.js b/app/modules/dev_tools/static/admin/js/translation-editor.js new file mode 100644 index 00000000..fc3df487 --- /dev/null +++ b/app/modules/dev_tools/static/admin/js/translation-editor.js @@ -0,0 +1,292 @@ +// app/modules/dev_tools/static/admin/js/translation-editor.js + +const transLog = window.LogConfig.createLogger('TRANSLATION_EDITOR'); + +/** + * Translation Editor Alpine.js Component + * Browse, search, and edit UI string translations across all modules. + */ +function translationEditor() { + return { + // Inherit base layout functionality + ...data(), + + // Page identifier (for sidebar menu highlight) + currentPage: 'translation-editor', + + // Data + modules: [], + keys: [], + languages: [], + stats: {}, + + // Filters + selectedModule: 'all', + searchQuery: '', + filterMode: 'all', // 'all' | 'missing' | 'has_variables' + + // Editing + editingCell: null, // {module, path, language} + editValue: '', + saving: false, + + // Pagination (uses pagination macro contract) + pagination: { page: 1, total: 0, per_page: 50 }, + + // UI + loading: true, + error: null, + + // Computed-like getters + get filteredKeys() { + let result = this.keys; + + // Module filter + if (this.selectedModule !== 'all') { + result = result.filter(k => k.module === this.selectedModule); + } + + // Filter mode + if (this.filterMode === 'missing') { + result = result.filter(k => + this.languages.some(lang => k.values[lang] === null) + ); + } else if (this.filterMode === 'has_variables') { + result = result.filter(k => k.has_variables); + } + + // Search + if (this.searchQuery.trim()) { + const q = this.searchQuery.toLowerCase(); + result = result.filter(k => { + if (k.path.toLowerCase().includes(q)) return true; + if (k.module.toLowerCase().includes(q)) return true; + return this.languages.some(lang => { + const val = k.values[lang]; + return val && val.toLowerCase().includes(q); + }); + }); + } + + // Update pagination total + this.pagination.total = result.length; + + return result; + }, + + get totalPages() { + return Math.max(1, Math.ceil(this.pagination.total / this.pagination.per_page)); + }, + + get startIndex() { + if (this.pagination.total === 0) return 0; + return ((this.pagination.page - 1) * this.pagination.per_page) + 1; + }, + + get endIndex() { + return Math.min(this.pagination.page * this.pagination.per_page, this.pagination.total); + }, + + get pageNumbers() { + const total = this.totalPages; + const current = this.pagination.page; + const maxVisible = 5; + + if (total <= maxVisible) { + return Array.from({length: total}, (_, i) => i + 1); + } + + const half = Math.floor(maxVisible / 2); + let start = Math.max(1, current - half); + let end = Math.min(total, start + maxVisible - 1); + + if (end - start < maxVisible - 1) { + start = Math.max(1, end - maxVisible + 1); + } + + const pages = []; + if (start > 1) pages.push(1); + if (start > 2) pages.push('...'); + for (let i = start; i <= end; i++) pages.push(i); + if (end < total - 1) pages.push('...'); + if (end < total) pages.push(total); + + return pages; + }, + + get paginatedKeys() { + const start = (this.pagination.page - 1) * this.pagination.per_page; + return this.filteredKeys.slice(start, start + this.pagination.per_page); + }, + + previousPage() { + if (this.pagination.page > 1) this.pagination.page--; + }, + + nextPage() { + if (this.pagination.page < this.totalPages) this.pagination.page++; + }, + + goToPage(p) { + if (typeof p === 'number') this.pagination.page = p; + }, + + async init() { + if (window._translationEditorInitialized) return; + window._translationEditorInitialized = true; + + this.$nextTick(() => { + if (typeof this.initBase === 'function') this.initBase(); + }); + + if (window.PlatformSettings) { + this.pagination.per_page = await window.PlatformSettings.getRowsPerPage(); + } + + await this.loadKeys(); + }, + + async loadKeys() { + this.loading = true; + this.error = null; + + try { + const resp = await apiClient.get('/admin/translations/keys'); + this.modules = resp.modules; + this.keys = resp.keys; + this.languages = resp.languages; + this.stats = resp.stats; + this.pagination.total = resp.keys.length; + } catch (e) { + this.error = e.message; + transLog.error('Failed to load translations:', e); + } finally { + this.loading = false; + } + }, + + isEditing(entry, lang) { + return this.editingCell && + this.editingCell.module === entry.module && + this.editingCell.path === entry.path && + this.editingCell.language === lang; + }, + + startEdit(entry, lang) { + // Save any current edit first + if (this.editingCell) { + this._doSave(); + } + + this.editingCell = { + module: entry.module, + path: entry.path, + language: lang, + }; + this.editValue = entry.values[lang] || ''; + + // Focus the textarea + this.$nextTick(() => { + const ta = this.$el.querySelector('textarea[x-ref="editInput"]'); + if (ta) ta.focus(); + }); + }, + + cancelEdit() { + this.editingCell = null; + this.editValue = ''; + }, + + async saveEdit() { + await this._doSave(); + }, + + async saveAndNext(entry, lang) { + await this._doSave(); + + // Move to next language column, or next row + const langIdx = this.languages.indexOf(lang); + if (langIdx < this.languages.length - 1) { + this.startEdit(entry, this.languages[langIdx + 1]); + } else { + // Move to next row, first language + const entries = this.paginatedKeys; + const rowIdx = entries.findIndex( + e => e.module === entry.module && e.path === entry.path + ); + if (rowIdx < entries.length - 1) { + this.startEdit(entries[rowIdx + 1], this.languages[0]); + } + } + }, + + async _doSave() { + if (!this.editingCell) return; + + const { module, path, language } = this.editingCell; + + // Find the current entry + const entry = this.keys.find( + k => k.module === module && k.path === path + ); + if (!entry) { + this.editingCell = null; + return; + } + + const oldValue = entry.values[language]; + const newValue = this.editValue; + + // Skip if unchanged + if ((oldValue || '') === newValue) { + this.editingCell = null; + this.editValue = ''; + return; + } + + this.saving = true; + this.editingCell = null; + + try { + const updated = await apiClient.put('/admin/translations/key', { + module, + path, + language, + value: newValue, + }); + + // Update the local entry + entry.values = updated.values; + entry.has_variables = updated.has_variables; + if (updated.variables) { + entry.variables = updated.variables; + } + + // Recalculate missing stats + this._recalcStats(); + } catch (e) { + this.error = e.message; + transLog.error('Failed to save translation:', e); + } finally { + this.saving = false; + this.editValue = ''; + } + }, + + _recalcStats() { + const missing = {}; + for (const lang of this.languages) { + missing[lang] = this.keys.filter(k => k.values[lang] === null).length; + } + this.stats = { + ...this.stats, + missing, + }; + }, + + getMissingVars(entry, value) { + if (!entry.variables || !value) return []; + return entry.variables.filter(v => !value.includes(`{${v}}`)); + }, + }; +} diff --git a/app/modules/dev_tools/templates/dev_tools/admin/translation-editor.html b/app/modules/dev_tools/templates/dev_tools/admin/translation-editor.html new file mode 100644 index 00000000..4952d5bb --- /dev/null +++ b/app/modules/dev_tools/templates/dev_tools/admin/translation-editor.html @@ -0,0 +1,163 @@ +{# app/modules/dev_tools/templates/dev_tools/admin/translation-editor.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_wrapper, table_header, table_empty_state %} +{% from 'shared/macros/pagination.html' import pagination %} + +{% block title %}Translation Editor{% endblock %} + +{% block alpine_data %}translationEditor(){% endblock %} + +{% block content %} +{{ page_header('Translation Editor', back_url='/admin/dashboard', back_label='Back to Dashboard') }} + +{{ loading_state('Loading translations...') }} +{{ error_state('Error loading translations') }} + +
+ +
+
+ +
+ + +
+ + +
+
+ + + + +
+
+ + +
+ + + +
+ + +
+ +
+
+
+ + +
+ + Showing + of keys + + + Total: keys across + modules + +
+ + + {% call table_wrapper() %} + + + Module + Key Path + + + + + {{ table_empty_state(6, title='No translation keys found', icon='globe', show_condition='filteredKeys.length === 0') }} + + + {% endcall %} + {{ pagination(show_condition='filteredKeys.length > 0') }} + + +
+ + Saving... +
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %}