feat(dev_tools): add translation editor for browsing and editing UI translations
Some checks failed
Some checks failed
New admin page to browse, search, compare, and inline-edit translation keys across all modules and languages from the browser. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,7 @@ dev_tools_module = ModuleDefinition(
|
|||||||
"test_runner", # Test execution
|
"test_runner", # Test execution
|
||||||
"violation_management", # Violation tracking and assignment
|
"violation_management", # Violation tracking and assignment
|
||||||
"sql_query", # Ad-hoc SQL query tool
|
"sql_query", # Ad-hoc SQL query tool
|
||||||
|
"translation_editor", # UI translation editor
|
||||||
],
|
],
|
||||||
menu_items={
|
menu_items={
|
||||||
FrontendType.ADMIN: [
|
FrontendType.ADMIN: [
|
||||||
@@ -44,6 +45,7 @@ dev_tools_module = ModuleDefinition(
|
|||||||
"code-quality", # Code quality dashboard
|
"code-quality", # Code quality dashboard
|
||||||
"tests", # Test runner dashboard
|
"tests", # Test runner dashboard
|
||||||
"sql-query", # SQL query tool
|
"sql-query", # SQL query tool
|
||||||
|
"translation-editor", # Translation editor
|
||||||
],
|
],
|
||||||
FrontendType.STORE: [], # No store menu items - internal module
|
FrontendType.STORE: [], # No store menu items - internal module
|
||||||
},
|
},
|
||||||
@@ -85,6 +87,13 @@ dev_tools_module = ModuleDefinition(
|
|||||||
route="/admin/platform-debug",
|
route="/admin/platform-debug",
|
||||||
order=40,
|
order=40,
|
||||||
),
|
),
|
||||||
|
MenuItemDefinition(
|
||||||
|
id="translation-editor",
|
||||||
|
label_key="dev_tools.menu.translation_editor",
|
||||||
|
icon="globe",
|
||||||
|
route="/admin/translation-editor",
|
||||||
|
order=50,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -14,7 +14,24 @@
|
|||||||
"components": "Komponenten",
|
"components": "Komponenten",
|
||||||
"icons": "Icons",
|
"icons": "Icons",
|
||||||
"sql_query": "SQL Abfrage",
|
"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": {
|
"sql_query": {
|
||||||
"title": "SQL Abfrage-Werkzeug",
|
"title": "SQL Abfrage-Werkzeug",
|
||||||
|
|||||||
@@ -14,7 +14,24 @@
|
|||||||
"components": "Components",
|
"components": "Components",
|
||||||
"icons": "Icons",
|
"icons": "Icons",
|
||||||
"sql_query": "SQL Query",
|
"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": {
|
"sql_query": {
|
||||||
"title": "SQL Query Tool",
|
"title": "SQL Query Tool",
|
||||||
|
|||||||
@@ -14,7 +14,24 @@
|
|||||||
"components": "Composants",
|
"components": "Composants",
|
||||||
"icons": "Icônes",
|
"icons": "Icônes",
|
||||||
"sql_query": "Requête SQL",
|
"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": {
|
"sql_query": {
|
||||||
"title": "Outil de requête SQL",
|
"title": "Outil de requête SQL",
|
||||||
|
|||||||
@@ -14,7 +14,24 @@
|
|||||||
"components": "Komponenten",
|
"components": "Komponenten",
|
||||||
"icons": "Icons",
|
"icons": "Icons",
|
||||||
"sql_query": "SQL Ufro",
|
"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": {
|
"sql_query": {
|
||||||
"title": "SQL Ufro-Werkzeug",
|
"title": "SQL Ufro-Werkzeug",
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ from app.modules.dev_tools.routes.api.admin_platform_debug import (
|
|||||||
router as platform_debug_router,
|
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_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 = APIRouter()
|
||||||
router.include_router(sql_query_router, tags=["sql-query"])
|
router.include_router(sql_query_router, tags=["sql-query"])
|
||||||
router.include_router(platform_debug_router, tags=["platform-debug"])
|
router.include_router(platform_debug_router, tags=["platform-debug"])
|
||||||
|
router.include_router(translations_router, tags=["translations"])
|
||||||
|
|||||||
312
app/modules/dev_tools/routes/api/admin_translations.py
Normal file
312
app/modules/dev_tools/routes/api/admin_translations.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
@router.get("/sql-query", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def admin_sql_query_page(
|
async def admin_sql_query_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
292
app/modules/dev_tools/static/admin/js/translation-editor.js
Normal file
292
app/modules/dev_tools/static/admin/js/translation-editor.js
Normal file
@@ -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}}`));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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') }}
|
||||||
|
|
||||||
|
<div x-show="!loading && !error" class="space-y-4">
|
||||||
|
<!-- Top filter bar -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<!-- Module filter -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">Module</label>
|
||||||
|
<select x-model="selectedModule" @change="pagination.page = 1"
|
||||||
|
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
|
||||||
|
<option value="all">All Modules</option>
|
||||||
|
<template x-for="mod in modules" :key="mod.code">
|
||||||
|
<option :value="mod.code" :selected="selectedModule === mod.code"
|
||||||
|
x-text="`${mod.label} (${mod.key_count})`"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<div class="relative">
|
||||||
|
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
|
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
|
||||||
|
</span>
|
||||||
|
<input type="text" x-model="searchQuery" @input.debounce.200ms="pagination.page = 1"
|
||||||
|
placeholder="Search keys and values..."
|
||||||
|
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter mode -->
|
||||||
|
<div class="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden">
|
||||||
|
<button @click="filterMode = 'all'; pagination.page = 1"
|
||||||
|
:class="filterMode === 'all' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'"
|
||||||
|
class="px-3 py-1.5 text-sm font-medium transition-colors">All</button>
|
||||||
|
<button @click="filterMode = 'missing'; pagination.page = 1"
|
||||||
|
:class="filterMode === 'missing' ? 'bg-amber-600 text-white' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'"
|
||||||
|
class="px-3 py-1.5 text-sm font-medium border-l border-gray-300 dark:border-gray-600 transition-colors">Missing</button>
|
||||||
|
<button @click="filterMode = 'has_variables'; pagination.page = 1"
|
||||||
|
:class="filterMode === 'has_variables' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'"
|
||||||
|
class="px-3 py-1.5 text-sm font-medium border-l border-gray-300 dark:border-gray-600 transition-colors">Variables</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats badges -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<template x-for="lang in languages" :key="lang">
|
||||||
|
<span x-show="stats.missing && stats.missing[lang] > 0"
|
||||||
|
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||||
|
:class="stats.missing[lang] > 50 ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' : 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'">
|
||||||
|
<span x-text="lang.toUpperCase()"></span>:
|
||||||
|
<span x-text="stats.missing[lang]" class="ml-0.5"></span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results info -->
|
||||||
|
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<span>
|
||||||
|
Showing <span x-text="filteredKeys.length" class="font-medium"></span>
|
||||||
|
of <span x-text="keys.length" class="font-medium"></span> keys
|
||||||
|
</span>
|
||||||
|
<span x-show="stats.total_keys">
|
||||||
|
Total: <span x-text="stats.total_keys" class="font-medium"></span> keys across
|
||||||
|
<span x-text="modules.length" class="font-medium"></span> modules
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Translation table -->
|
||||||
|
{% call table_wrapper() %}
|
||||||
|
<thead>
|
||||||
|
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||||
|
<th class="px-4 py-3 w-28">Module</th>
|
||||||
|
<th class="px-4 py-3 w-64">Key Path</th>
|
||||||
|
<template x-for="lang in languages" :key="lang">
|
||||||
|
<th class="px-4 py-3" x-text="lang.toUpperCase()"></th>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||||
|
{{ table_empty_state(6, title='No translation keys found', icon='globe', show_condition='filteredKeys.length === 0') }}
|
||||||
|
<template x-for="entry in paginatedKeys" :key="entry.module + '.' + entry.path">
|
||||||
|
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<td class="px-4 py-3 text-xs font-mono text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
|
<span x-text="entry.module"></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm font-mono text-gray-900 dark:text-gray-100 whitespace-nowrap">
|
||||||
|
<span x-text="entry.path"></span>
|
||||||
|
<span x-show="entry.has_variables"
|
||||||
|
class="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300"
|
||||||
|
:title="'Variables: ' + (entry.variables || []).join(', ')">
|
||||||
|
{ }
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<template x-for="lang in languages" :key="lang">
|
||||||
|
<td class="px-4 py-2 text-sm max-w-xs relative group"
|
||||||
|
@click="startEdit(entry, lang)"
|
||||||
|
:class="entry.values[lang] === null ? 'bg-red-50/50 dark:bg-red-900/10' : ''">
|
||||||
|
|
||||||
|
<!-- Display mode -->
|
||||||
|
<div x-show="!isEditing(entry, lang)"
|
||||||
|
class="cursor-pointer min-h-[28px] flex items-center rounded px-1 -mx-1 group-hover:bg-gray-100 dark:group-hover:bg-gray-700 transition-colors">
|
||||||
|
<span x-show="entry.values[lang] !== null"
|
||||||
|
class="text-gray-900 dark:text-gray-100 truncate"
|
||||||
|
:title="entry.values[lang]"
|
||||||
|
x-text="entry.values[lang]"></span>
|
||||||
|
<span x-show="entry.values[lang] === null"
|
||||||
|
class="text-red-400 dark:text-red-500 italic text-xs">missing</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit mode -->
|
||||||
|
<div x-show="isEditing(entry, lang)" @click.stop>
|
||||||
|
<textarea x-ref="editInput"
|
||||||
|
x-model="editValue"
|
||||||
|
@keydown.enter.prevent="saveEdit()"
|
||||||
|
@keydown.tab.prevent="saveAndNext(entry, lang)"
|
||||||
|
@keydown.escape="cancelEdit()"
|
||||||
|
@blur="saveEdit()"
|
||||||
|
rows="2"
|
||||||
|
class="w-full text-sm rounded border-purple-400 dark:border-purple-500 dark:bg-gray-700 dark:text-gray-100 focus:ring-purple-500 focus:border-purple-500 resize-y p-1"></textarea>
|
||||||
|
<!-- Variable warning -->
|
||||||
|
<div x-show="entry.has_variables && editValue && getMissingVars(entry, editValue).length > 0"
|
||||||
|
class="mt-0.5 text-[10px] text-amber-600 dark:text-amber-400">
|
||||||
|
Missing: <span x-text="getMissingVars(entry, editValue).map(v => '{' + v + '}').join(', ')"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
{% endcall %}
|
||||||
|
{{ pagination(show_condition='filteredKeys.length > 0') }}
|
||||||
|
|
||||||
|
<!-- Save indicator -->
|
||||||
|
<div x-show="saving" x-transition
|
||||||
|
class="fixed bottom-6 right-6 bg-purple-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm flex items-center gap-2">
|
||||||
|
<span x-html="$icon('spinner', 'w-4 h-4')"></span>
|
||||||
|
Saving...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script defer src="{{ url_for('dev_tools_static', path='admin/js/translation-editor.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user