added app translations for better maintainability

This commit is contained in:
2026-05-22 16:48:47 +02:00
parent 0291b8be7a
commit 2ded5b6aaf
11 changed files with 594 additions and 122 deletions

View File

@ -4,7 +4,7 @@
*
* GET (no params) -> ordered questionnaire list with conditions
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format
* GET ?translations=1 -> all translations merged across all tables
* GET ?translations=1 -> global app UI strings only (not questionnaire content)
*
* POST (coach interview upload) is handled by api/index.php -> handlers/app_questionnaires.php
*/
@ -27,37 +27,8 @@ $qnID = $_GET['id'] ?? '';
$translations = $_GET['translations'] ?? '';
if ($translations) {
// Return all translations merged from all three tables, keyed by language
$result = [];
// question translations: map questionID -> defaultText (the key), then lang -> text
$rows = $pdo->query("
SELECT q.defaultText AS stringKey, qt.languageCode, qt.text
FROM question_translation qt
JOIN question q ON q.questionID = qt.questionID
")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
}
// answer option translations: map answerOptionID -> defaultText (the key)
$rows = $pdo->query("
SELECT ao.defaultText AS stringKey, aot.languageCode, aot.text
FROM answer_option_translation aot
JOIN answer_option ao ON ao.answerOptionID = aot.answerOptionID
")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
}
// string translations (symptoms, hints, textKeys, etc.)
$rows = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
}
qdb_discard($tmpDb, $lockFp);
echo json_encode(["success" => true, "translations" => $result]);
echo json_encode(['success' => true, 'translations' => qdb_build_app_translations_map($pdo)]);
exit;
}
@ -148,8 +119,9 @@ if ($qnID) {
qdb_discard($tmpDb, $lockFp);
echo json_encode([
"meta" => ["id" => $qnID],
"questions" => $questions,
'meta' => ['id' => $qnID],
'questions' => $questions,
'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID),
]);
exit;
}

View File

@ -212,19 +212,167 @@ function qdb_ensure_source_language(PDO $pdo): void {
}
function qdb_upsert_source_translation(PDO $pdo, string $type, string $id, string $text): void {
qdb_ensure_source_language($pdo);
$lang = QDB_SOURCE_LANGUAGE;
qdb_put_translation($pdo, $type, $id, QDB_SOURCE_LANGUAGE, $text);
}
/** Catalog of global app UI string keys (LanguageManager). */
function qdb_app_string_catalog(): array {
static $catalog = null;
if ($catalog !== null) {
return $catalog;
}
$path = __DIR__ . '/data/app_ui_strings.json';
if (!is_readable($path)) {
$catalog = ['keys' => [], 'germanDefaults' => []];
return $catalog;
}
$data = json_decode(file_get_contents($path), true);
$catalog = is_array($data) ? $data : ['keys' => [], 'germanDefaults' => []];
return $catalog;
}
function qdb_app_string_key_set(): array {
$keys = qdb_app_string_catalog()['keys'] ?? [];
return array_flip($keys);
}
/** Keys for dev-only UI (database export, etc.) — excluded from website App UI and app sync catalog. */
function qdb_dev_only_app_string_key_set(): array {
static $set = null;
if ($set !== null) {
return $set;
}
$set = array_flip([
'database',
'database_clients_title',
'download_header',
'export_success_downloads',
'export_failed',
'headers',
'view_missing',
'no_header_template_found',
'saved_pdf_csv',
'no_pdf_viewer',
'save_error',
'no_clients_available',
'questionnaire_id',
'questionnaires',
'status',
'data',
'data_final_warning',
'open_client_via_load',
'ask_before_upload',
'You have completed questionnaires that have not been uploaded yet.',
'You have completed questionnaires that have not been uploaded yet. Tap to open the app and upload.',
]);
return $set;
}
/**
* Global app UI strings (screens, toasts, auth, etc.) for the mobile app.
* @return list<array{key: string, type: string, entityId: string, sortOrder: int}>
*/
function qdb_app_ui_string_entries(): array {
$catalog = qdb_app_string_catalog();
$defaults = $catalog['germanDefaults'] ?? [];
$entries = [];
$order = 0;
foreach ($catalog['keys'] ?? [] as $key) {
$entries[] = [
'key' => $key,
'type' => 'app_string',
'entityId' => $key,
'sortOrder' => $order++,
'germanDefault' => $defaults[$key] ?? $key,
];
}
return $entries;
}
/**
* All string keys referenced by questionnaire content (questions, options, config).
* @return array<string, true>
*/
function qdb_all_questionnaire_translation_key_set(PDO $pdo): array {
static $cache = null;
if ($cache !== null) {
return $cache;
}
$keys = [];
$rows = $pdo->query("SELECT defaultText, configJson FROM question")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $dbQ) {
if ($dbQ['defaultText'] !== '') {
$keys[$dbQ['defaultText']] = true;
}
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2'] as $field) {
if (!empty($config[$field])) {
$keys[$config[$field]] = true;
}
}
if (!empty($config['symptoms']) && is_array($config['symptoms'])) {
foreach ($config['symptoms'] as $s) {
if ($s !== '') {
$keys[$s] = true;
}
}
}
if (!empty($config['options']) && is_array($config['options'])) {
foreach ($config['options'] as $opt) {
if (is_string($opt) && $opt !== '') {
$keys[$opt] = true;
} elseif (is_array($opt) && !empty($opt['key'])) {
$keys[$opt['key']] = true;
}
}
}
}
foreach ($pdo->query("SELECT defaultText FROM answer_option")->fetchAll(PDO::FETCH_COLUMN) as $text) {
if ($text !== '') {
$keys[$text] = true;
}
}
$cache = $keys;
return $cache;
}
/** App UI catalog entries for coaches (excludes dev-only and questionnaire-scoped keys). */
function qdb_app_ui_string_entries_global_only(PDO $pdo): array {
$used = qdb_all_questionnaire_translation_key_set($pdo);
$dev = qdb_dev_only_app_string_key_set();
$entries = [];
foreach (qdb_app_ui_string_entries() as $e) {
if (!isset($used[$e['key']]) && !isset($dev[$e['key']])) {
$entries[] = $e;
}
}
return $entries;
}
/** Upsert one translation row; sync German text into defaultText for questions/options. */
function qdb_put_translation(PDO $pdo, string $type, string $id, string $lang, string $text): void {
if ($type === 'question') {
$pdo->prepare("INSERT INTO question_translation (questionID, languageCode, text)
VALUES (:id, :lang, :t)
ON CONFLICT(questionID, languageCode) DO UPDATE SET text = excluded.text")
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
if ($lang === QDB_SOURCE_LANGUAGE) {
$pdo->prepare("UPDATE question SET defaultText = :t WHERE questionID = :id")
->execute([':t' => $text, ':id' => $id]);
}
} elseif ($type === 'answer_option') {
$pdo->prepare("INSERT INTO answer_option_translation (answerOptionID, languageCode, text)
VALUES (:id, :lang, :t)
ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text")
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
} elseif ($type === 'string') {
if ($lang === QDB_SOURCE_LANGUAGE) {
$pdo->prepare("UPDATE answer_option SET defaultText = :t WHERE answerOptionID = :id")
->execute([':t' => $text, ':id' => $id]);
}
} elseif ($type === 'string' || $type === 'app_string') {
$pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text)
VALUES (:id, :lang, :t)
ON CONFLICT(stringKey, languageCode) DO UPDATE SET text = excluded.text")
@ -311,7 +459,14 @@ function qdb_attach_translation_texts(array &$entries, array $translations): voi
$tr = $translations[$e['entityId']] ?? [];
$e['translations'] = (object)$tr;
$de = trim($tr[QDB_SOURCE_LANGUAGE] ?? '');
$e['germanText'] = $de !== '' ? $de : $e['key'];
if ($de !== '') {
$e['germanText'] = $de;
} elseif (!empty($e['germanDefault'])) {
$e['germanText'] = $e['germanDefault'];
} else {
$e['germanText'] = $e['key'];
}
unset($e['germanDefault']);
}
unset($e);
}
@ -347,7 +502,7 @@ function qdb_load_translations_for_entries(PDO $pdo, array $entries): array {
}
$sKeys = array_values(array_filter(array_map(
fn($e) => $e['type'] === 'string' ? $e['entityId'] : null,
fn($e) => ($e['type'] === 'string' || $e['type'] === 'app_string') ? $e['entityId'] : null,
$entries
)));
if ($sKeys) {
@ -362,6 +517,49 @@ function qdb_load_translations_for_entries(PDO $pdo, array $entries): array {
return $translations;
}
/**
* Flat language -> stringKey -> text map for global app UI strings only.
* @return array<string, array<string, string>>
*/
function qdb_build_app_translations_map(PDO $pdo): array {
$entries = qdb_app_ui_string_entries();
$translations = qdb_load_translations_for_entries($pdo, $entries);
$defaults = qdb_app_string_catalog()['germanDefaults'] ?? [];
$result = [];
foreach ($entries as $e) {
$key = $e['key'];
foreach ($translations[$e['entityId']] ?? [] as $lang => $text) {
$result[$lang][$key] = $text;
}
if (!isset($result[QDB_SOURCE_LANGUAGE][$key]) && !empty($defaults[$key])) {
$result[QDB_SOURCE_LANGUAGE][$key] = $defaults[$key];
}
}
return $result;
}
/**
* Flat language -> stringKey -> text map for one questionnaire (questions, options, config strings).
* Excludes keys from the global app UI catalog.
* @return array<string, array<string, string>>
*/
function qdb_build_questionnaire_translations_map(PDO $pdo, string $qnID): array {
$lists = qdb_translation_entry_lists($pdo, $qnID);
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
if (!$entries) {
return [];
}
$translations = qdb_load_translations_for_entries($pdo, $entries);
$result = [];
foreach ($entries as $e) {
$key = $e['key'];
foreach ($translations[$e['entityId']] ?? [] as $lang => $text) {
$result[$lang][$key] = $text;
}
}
return $result;
}
// --- AES-256-CBC: IV(16) + CIPHERTEXT ---
function aes256_cbc_encrypt_bytes(string $plain, string $key): string {
$key = str_pad(substr($key, 0, 32), 32, "\0");

146
data/app_ui_strings.json Normal file
View File

@ -0,0 +1,146 @@
{
"version": 2,
"description": "Coach-visible app UI strings only (excludes dev settings / database tools).",
"keys": [
"all_clients",
"april",
"august",
"auth_footer_accounts",
"auth_subtitle_change_password",
"auth_subtitle_login",
"auth_title",
"cancel",
"choose_answer",
"choose_more_elements",
"client_code_exists",
"coach_code",
"confirm_password_hint",
"date_after",
"date_before",
"day",
"december",
"done",
"download",
"download_failed_use_offline",
"enter_coach_code",
"error_invalid_data",
"error_not_found",
"example_text",
"exit_btn",
"february",
"fill_all_fields",
"fill_both_fields",
"hours_short",
"january",
"july",
"june",
"lay",
"locked",
"login_btn",
"login_failed_with_reason",
"login_required",
"march",
"may",
"minutes_short",
"month",
"new_password_hint",
"no_clients_assigned",
"no_profile",
"none",
"not_done",
"november",
"october",
"offline",
"ok",
"online",
"password_hint",
"password_too_short",
"passwords_dont_match",
"please_username_password",
"points",
"questions_filled",
"refresh",
"save_password_btn",
"select_one_answer",
"select_one_answer_per_row",
"september",
"session_dash",
"session_label",
"start",
"start_upload",
"upload",
"username_hint",
"year"
],
"germanDefaults": {
"all_clients": "all_clients",
"april": "April",
"august": "August",
"auth_footer_accounts": "Konten werden von Ihrer Organisation vergeben.",
"auth_subtitle_change_password": "Bitte legen Sie jetzt ein eigenes Passwort fest.",
"auth_subtitle_login": "Melden Sie sich mit Ihrem Coach-Konto an.",
"auth_title": "BW Schützt",
"cancel": "Cancel",
"choose_answer": "Antwort wählen",
"choose_more_elements": "Es müssen mehr Elemenete ausgewählt werden.",
"client_code_exists": "Dieser Client-Code existiert bereits.",
"coach_code": "Code Coach",
"confirm_password_hint": "Passwort bestätigen",
"date_after": "Das Datum muss nach",
"date_before": "Das Datum muss vor",
"day": "Tag",
"december": "Dezember",
"done": "Erledigt",
"download": "Herunterladen",
"download_failed_use_offline": "Download fehlgeschlagen arbeite offline mit vorhandener Datenbank",
"enter_coach_code": "Bitte geben Sie Ihren Coach Code ein, um fortzufahren!",
"error_invalid_data": "error_invalid_data",
"error_not_found": "error_not_found",
"example_text": "Beispieltext",
"exit_btn": "Beenden",
"february": "Februar",
"fill_all_fields": "fill_all_fields",
"fill_both_fields": "Bitte beide Felder ausfüllen!",
"hours_short": "h",
"january": "Januar",
"july": "Juli",
"june": "Juni",
"lay": "liegen.",
"locked": "Locked",
"login_btn": "Login",
"login_failed_with_reason": "Login fehlgeschlagen: {reason}",
"login_required": "Bitte zuerst einloggen",
"march": "März",
"may": "Mai",
"minutes_short": "m",
"month": "Monat",
"new_password_hint": "Neues Passwort",
"no_clients_assigned": "Ihr Supervisor hat Ihnen noch keine Klienten zugewiesen.",
"no_profile": "Dieser Klient ist noch nicht Teil der Datenbank",
"none": "Keine",
"not_done": "Nicht erledigt",
"november": "November",
"october": "Oktober",
"offline": "Offline",
"ok": "OK",
"online": "Online",
"password_hint": "Passwort",
"password_too_short": "Mindestens 6 Zeichen",
"passwords_dont_match": "Passwörter stimmen nicht überein",
"please_username_password": "Bitte Benutzername und Passwort eingeben.",
"points": "Punkte",
"questions_filled": "questions_filled",
"refresh": "Aktualisieren",
"save_password_btn": "Passwort speichern",
"select_one_answer": "Bitte wählen Sie eine Antwort aus!",
"select_one_answer_per_row": "Bitte wählen Sie eine Antwort pro Reihe aus!",
"september": "September",
"session_dash": "Sitzung: —",
"session_label": "Sitzung",
"start": "Start",
"start_upload": "Upload starten?",
"upload": "Hochladen",
"username_hint": "Benutzername",
"year": "Jahr"
}
}

View File

@ -287,28 +287,32 @@ Success:
}
```
The translation map is grouped by language code. Each language contains a flat key-value map.
The translation map is grouped by language code. Each language contains a flat key-value map of **app UI strings only** (screens, toasts, auth labels, etc.). Questionnaire question/option text is **not** included here.
Translation keys can come from:
- question default text, exposed as `question`
- answer option default text, exposed as `options[].key`
- general string keys from `string_translation`, such as hints, labels, symptoms, and text keys
Android lookup process:
Android lookup for app UI:
1. Load `translations[activeLanguage]`.
2. For each questionnaire string, look up the string value as a key.
3. If a translation is missing, fall back to the raw key from the questionnaire JSON.
2. Look up the string key (e.g. `save`, `client_code`).
3. If missing, fall back to embedded defaults in the app.
## Questionnaire detail translations
`GET /api/app_questionnaires?id=<questionnaireID>` includes a sibling `translations` object with the same shape (language code → key → text), scoped to that questionnaire only:
- question default text (`question` field values)
- answer option default text (`options[].key`)
- questionnaire config string keys (`textKey`, hints, symptoms, etc.), excluding global app UI catalog keys
Android should apply this map while a questionnaire is open (overlay on top of app UI translations), then clear it when leaving the questionnaire.
## Recommended Android Sync Flow
1. Login with `/api/auth/login` and store the returned token securely.
2. Fetch the assigned client list with `GET /api/app_questionnaires?clients=1` (coach role only).
3. Fetch translations with `GET /api/app_questionnaires?translations=1`.
3. Fetch app UI translations with `GET /api/app_questionnaires?translations=1`.
4. Fetch the active questionnaire list with `GET /api/app_questionnaires`.
5. For each list item, fetch details with `GET /api/app_questionnaires?id=<id>`.
6. Cache the client list, translations, the questionnaire list, and questionnaire details locally for offline use.
5. For each list item, fetch details with `GET /api/app_questionnaires?id=<id>` (includes per-questionnaire `translations`).
6. Cache the client list, app translations, the questionnaire list, and questionnaire details (with embedded translations) locally for offline use.
7. When a questionnaire is completed, upload answers with `POST /api/app_questionnaires`.
There is currently no version or `updatedAt` field in the app fetch response, so the safest refresh strategy is to re-fetch translations, list, and details at login or app start when network is available.

View File

@ -3,7 +3,7 @@
* Android app API endpoint.
* GET (no params) -> ordered questionnaire list with conditions
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format
* GET ?translations=1 -> all translations merged across all tables
* GET ?translations=1 -> global app UI strings only (not questionnaire content)
* GET ?clients=1 -> list of clients assigned to the authenticated coach
* POST -> submit interview answers for a client (coach / supervisor / admin)
*/
@ -207,33 +207,8 @@ if ($fetchClients) {
}
if ($translations) {
$result = [];
$rows = $pdo->query("
SELECT q.defaultText AS stringKey, qt.languageCode, qt.text
FROM question_translation qt
JOIN question q ON q.questionID = qt.questionID
")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
}
$rows = $pdo->query("
SELECT ao.defaultText AS stringKey, aot.languageCode, aot.text
FROM answer_option_translation aot
JOIN answer_option ao ON ao.answerOptionID = aot.answerOptionID
")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
}
$rows = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$result[$r['languageCode']][$r['stringKey']] = $r['text'];
}
qdb_discard($tmpDb, $lockFp);
json_success(["translations" => $result]);
json_success(['translations' => qdb_build_app_translations_map($pdo)]);
}
if ($qnID) {
@ -311,7 +286,11 @@ if ($qnID) {
}
qdb_discard($tmpDb, $lockFp);
json_success(["meta" => ["id" => $qnID], "questions" => $questions]);
json_success([
'meta' => ['id' => $qnID],
'questions' => $questions,
'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID),
]);
}
// Default: ordered questionnaire list

View File

@ -2,7 +2,7 @@
$tokenRec = require_valid_token();
$validTypes = ['question', 'answer_option', 'string', 'language'];
$validTypes = ['question', 'answer_option', 'string', 'app_string', 'language'];
switch ($method) {
@ -26,8 +26,12 @@ case 'GET':
SELECT questionnaireID, name, orderIndex FROM questionnaire ORDER BY orderIndex
")->fetchAll(PDO::FETCH_ASSOC);
$appEntries = qdb_app_ui_string_entries_global_only($pdo);
$appTranslations = qdb_load_translations_for_entries($pdo, $appEntries);
qdb_attach_translation_texts($appEntries, $appTranslations);
$questionnaires = [];
$allEntries = [];
$allEntries = $appEntries;
foreach ($qnRows as $qn) {
$lists = qdb_translation_entry_lists($pdo, $qn['questionnaireID']);
@ -51,6 +55,7 @@ case 'GET':
qdb_discard($tmpDb, $lockFp);
json_success([
'languages' => $languages,
'appStrings' => $appEntries,
'questionnaires' => $questionnaires,
'entries' => $allEntries,
'sourceLanguage' => QDB_SOURCE_LANGUAGE,
@ -149,22 +154,7 @@ case 'PUT':
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
if ($type === 'question') {
$pdo->prepare("INSERT INTO question_translation (questionID, languageCode, text)
VALUES (:id, :lang, :t)
ON CONFLICT(questionID, languageCode) DO UPDATE SET text = excluded.text")
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
} elseif ($type === 'answer_option') {
$pdo->prepare("INSERT INTO answer_option_translation (answerOptionID, languageCode, text)
VALUES (:id, :lang, :t)
ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text")
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
} else {
$pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text)
VALUES (:id, :lang, :t)
ON CONFLICT(stringKey, languageCode) DO UPDATE SET text = excluded.text")
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
}
qdb_put_translation($pdo, $type, $id, $lang, $text);
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {

View File

@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Rebuild data/app_ui_strings.json from coach-visible Android code only."""
from __future__ import annotations
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
APP_JAVA = ROOT / "Questionnaire-App" / "app" / "src" / "main"
LM_FILE = APP_JAVA / "java" / "com" / "dano" / "test1" / "utils" / "LanguageManager.kt"
OUT = Path(__file__).resolve().parents[1] / "data" / "app_ui_strings.json"
DEV_KT = {
"DevSettingsActivity.kt",
"DatabaseButtonHandler.kt",
"SaveButtonHandler.kt",
"HeaderOrderRepository.kt",
"ExcelExportService.kt",
}
DEV_ONLY_KEYS = {
"database",
"database_clients_title",
"download_header",
"export_success_downloads",
"export_failed",
"headers",
"view_missing",
"no_header_template_found",
"saved_pdf_csv",
"no_pdf_viewer",
"save_error",
"no_clients_available",
"questionnaire_id",
"questionnaires",
"status",
"data",
"data_final_warning",
"open_client_via_load",
"ask_before_upload",
"You have completed questionnaires that have not been uploaded yet.",
"You have completed questionnaires that have not been uploaded yet. Tap to open the app and upload.",
}
EXTRA_COACH_KEYS = {
"save",
"please_client_code",
"choose_more_elements",
"previous",
"client",
"question",
"answer",
"no_questionnaires",
"no_questions_available",
"error",
"client_code",
"cancel",
"ok",
"not_done",
"none",
}
def parse_german_defaults(text: str) -> dict[str, str]:
out: dict[str, str] = {}
in_german = False
for line in text.splitlines():
if '"GERMAN" to mapOf(' in line:
in_german = True
continue
if not in_german:
continue
m = re.match(r'\s*"([^"]+)"\s+to\s+"(.*)",?\s*$', line)
if m:
key = m.group(1)
val = m.group(2).replace("\\n", "\n").replace('\\"', '"')
out[key] = val
elif line.strip().startswith("),") or ('"ENGLISH"' in line and "mapOf" in line):
break
return out
def extract_keys(text: str) -> set[str]:
keys: set[str] = set()
patterns = [
r'LanguageManager\.getText(?:Formatted)?\(\s*[^,]+,\s*"([^"]+)"',
r'LanguageManager\.getText(?:Formatted)?\(\s*"([^"]+)"\s*,\s*"([^"]+)"',
r'\bt\(\s*"([^"]+)"\s*\)',
r'\bt\(\s*lang\s*,\s*"([^"]+)"',
]
for pat in patterns:
for m in re.finditer(pat, text):
keys.add(m.group(m.lastindex))
return keys
def main() -> None:
de_defaults = parse_german_defaults(LM_FILE.read_text(encoding="utf-8"))
old = json.loads(OUT.read_text(encoding="utf-8")) if OUT.exists() else {}
old_def = old.get("germanDefaults", {})
coach: set[str] = set(EXTRA_COACH_KEYS)
dev: set[str] = set()
for kt in (APP_JAVA / "java").rglob("*.kt"):
keys = extract_keys(kt.read_text(encoding="utf-8", errors="ignore"))
if kt.name in DEV_KT:
dev |= keys
else:
coach |= keys
for xml in (APP_JAVA / "res" / "layout").rglob("*.xml"):
for m in re.finditer(r'android:tag="([^"]+)"', xml.read_text(encoding="utf-8", errors="ignore")):
coach.add(m.group(1))
for month in (
"january", "february", "march", "april", "may", "june",
"july", "august", "september", "october", "november", "december",
):
coach.add(month)
keys = sorted(coach - dev - DEV_ONLY_KEYS)
german_defaults = {
k: de_defaults.get(k) or old_def.get(k) or k for k in keys
}
payload = {
"version": 2,
"description": "Coach-visible app UI strings only (excludes dev settings / database tools).",
"keys": keys,
"germanDefaults": german_defaults,
}
OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"Wrote {len(keys)} keys to {OUT}")
if __name__ == "__main__":
main()

View File

@ -1085,6 +1085,7 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; }
}
.badge-q { background: #dbeafe; color: #1e40af; }
.badge-opt { background: #e2e8f0; color: #475569; }
.badge-app { background: #d1fae5; color: #065f46; }
.badge-str { background: #ede9fe; color: #5b21b6; }
.cell-trans { position: relative; }

View File

@ -961,10 +961,10 @@ function renderTranslationsTab() {
if (!container || !transData) return;
const editable = canEdit();
const languages = transData.languages || [];
const entries = transData.entries || [];
const qnEntries = transData.entries || [];
const targets = targetLanguages(languages);
if (!entries.length) {
if (!qnEntries.length) {
container.innerHTML = `
${editable ? languageManagerHTML(languages) : ''}
<p style="color:var(--text-secondary);margin-top:12px">No translatable content yet. Add questions with German text first.</p>
@ -992,7 +992,7 @@ function renderTranslationsTab() {
container.innerHTML = `
${editable ? languageManagerHTML(languages) : ''}
${langSelectHtml}
${targets.length && editorTransLang ? renderEditorTransList(entries, editable) : ''}
${targets.length && editorTransLang ? renderEditorTransList(qnEntries, editable) : ''}
`;
if (editable) bindLanguageManager(languages);
@ -1003,18 +1003,25 @@ function renderTranslationsTab() {
});
if (targets.length && editorTransLang) {
bindEditorTransEditing(entries, editable);
bindEditorTransFiltering(entries);
bindEditorTransEditing(qnEntries, editable);
bindEditorTransFiltering(qnEntries);
}
}
function renderEditorTransList(entries, editable) {
function renderEditorTransList(qnEntries, editable) {
const targets = targetLanguages(transData.languages || []);
const langLabel = targets.find(l => l.languageCode === editorTransLang)?.name || editorTransLang.toUpperCase();
const sourceLabel = sourceLanguageLabel(transData.languages || []);
const groups = buildTranslationGroups(entries, editorTransLang, 0);
const missingCount = entries.filter(e => !translationFor(e, editorTransLang).trim()).length;
const pct = entries.length ? Math.round(((entries.length - missingCount) / entries.length) * 100) : 0;
const missingCount = qnEntries.filter(e => !translationFor(e, editorTransLang).trim()).length;
const pct = qnEntries.length ? Math.round(((qnEntries.length - missingCount) / qnEntries.length) * 100) : 0;
const qnGroups = buildTranslationGroups(qnEntries, editorTransLang, 0);
const qnName = transData.questionnaire?.name || questionnaire?.name || 'Questionnaire';
const bodyHtml = `
<div class="trans-qn-block" data-qn="${escHtml(questionnaire.questionnaireID)}">
<h2 class="trans-qn-title">${escHtml(qnName)}</h2>
${renderGroupedTranslationsHTML(qnGroups, editorTransLang, sourceLabel, editable, { showColumnHeader: false })}
</div>`;
return `
<div class="trans-toolbar">
@ -1027,11 +1034,9 @@ function renderEditorTransList(entries, editable) {
</select>
</div>
${translationListHeaderHTML(langLabel, sourceLabel)}
<div id="transGroupedRoot">
${renderGroupedTranslationsHTML(groups, editorTransLang, sourceLabel, editable, { showColumnHeader: false })}
</div>
<div id="transGroupedRoot">${bodyHtml}</div>
<div class="trans-stats">
<span id="transStats">${missingCount ? `${missingCount} missing · ` : ''}${entries.length} keys</span>
<span id="transStats">${missingCount ? `${missingCount} missing · ` : ''}${qnEntries.length} keys</span>
<span class="trans-stats-progress">
<span class="trans-progress-bar"><span class="trans-progress-fill" style="width:${pct}%"></span></span>
<span class="trans-progress-label">${pct}% in ${escHtml(editorTransLang.toUpperCase())}</span>
@ -1114,6 +1119,10 @@ function bindEditorTransFiltering(entries) {
const anyVisible = [...group.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
group.classList.toggle('trans-group-hidden', !anyVisible);
});
root.querySelectorAll('.trans-qn-block').forEach(block => {
const anyVisible = [...block.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
block.classList.toggle('trans-qn-hidden', !anyVisible);
});
const stats = document.getElementById('transStats');
if (stats) {
stats.textContent = text || type

View File

@ -57,7 +57,8 @@ function renderShell() {
<input type="search" id="transFilter" class="trans-search-input" placeholder="Search…" disabled>
<select id="transTypeFilter" class="trans-select trans-type-select" disabled>
<option value="">All types</option>
<option value="string">UI strings</option>
<option value="app_string">App strings</option>
<option value="string">Questionnaire UI strings</option>
<option value="question">Questions</option>
<option value="answer_option">Answer options</option>
</select>
@ -99,7 +100,7 @@ async function loadTranslations() {
const languages = transData.languages || [];
const questionnaires = transData.questionnaires || [];
const flat = flattenAllQuestionnaires(questionnaires);
const flat = flattenAllQuestionnaires(questionnaires, data.appStrings || []);
allEntries = flat.allEntries;
transData.sections = flat.sections;
@ -234,7 +235,10 @@ function renderEntryList() {
const headerOnce = translationListHeaderHTML(langLabel, sourceLabel);
const sectionsHtml = sections.map(sec => {
const entries = allEntries.slice(sec.startIdx, sec.startIdx + sec.entryCount);
let entries = allEntries.slice(sec.startIdx, sec.startIdx + sec.entryCount);
if (sec.isApp) {
entries = entries.filter(e => e.type === 'app_string');
}
const groups = buildTranslationGroups(entries, selectedLang, sec.startIdx);
return `
<div class="trans-qn-block" data-qn="${esc(sec.questionnaireID)}">

View File

@ -57,15 +57,25 @@ export function sortGroupItems(items, targetLang) {
*/
export function buildTranslationGroups(entries, targetLang, indexOffset = 0) {
const items = entries.map((entry, i) => ({ entry, idx: indexOffset + i }));
const strings = items.filter(({ entry }) => entry.type === 'string');
const content = items.filter(({ entry }) => entry.type !== 'string');
const appStrings = items.filter(({ entry }) => entry.type === 'app_string');
const qStrings = items.filter(({ entry }) => entry.type === 'string');
const content = items.filter(({ entry }) =>
entry.type !== 'string' && entry.type !== 'app_string'
);
const groups = [];
if (strings.length) {
if (appStrings.length) {
groups.push({
id: 'app_strings',
title: 'App strings',
items: sortGroupItems(appStrings, targetLang),
});
}
if (qStrings.length) {
groups.push({
id: 'strings',
title: 'UI strings',
items: sortGroupItems(strings, targetLang),
items: sortGroupItems(qStrings, targetLang),
});
}
if (content.length) {
@ -107,11 +117,29 @@ export function renderGroupedTranslationsHTML(groups, targetLang, sourceLabel, e
return `<div class="${wrapClass}">${qnHeader}${header}${body}</div>`;
}
/** Flatten questionnaires from ?all=1 into one entries array + section metadata */
export function flattenAllQuestionnaires(questionnaires) {
/** Only global app UI rows (never questionnaire questions/options). */
export function filterGlobalAppEntries(entries) {
return (entries || []).filter(e => e && e.type === 'app_string');
}
/** Flatten app strings + questionnaires from ?all=1 */
export function flattenAllQuestionnaires(questionnaires, appStrings = []) {
const allEntries = [];
const sections = [];
const appNorm = filterGlobalAppEntries((appStrings || []).map(normalizeEntry));
if (appNorm.length) {
const startIdx = 0;
appNorm.forEach(e => allEntries.push(e));
sections.push({
questionnaireID: '__app__',
name: 'App UI',
startIdx,
entryCount: appNorm.length,
isApp: true,
});
}
for (const qn of questionnaires) {
const normalized = (qn.entries || []).map(normalizeEntry);
if (!normalized.length) continue;
@ -122,6 +150,7 @@ export function flattenAllQuestionnaires(questionnaires) {
name: qn.name || 'Questionnaire',
startIdx,
entryCount: normalized.length,
isApp: false,
});
}
@ -129,8 +158,8 @@ export function flattenAllQuestionnaires(questionnaires) {
}
export function typeBadge(type) {
const map = { question: 'Q', answer_option: 'Opt', string: 'Str' };
const cls = { question: 'badge-q', answer_option: 'badge-opt', string: 'badge-str' };
const map = { question: 'Q', answer_option: 'Opt', string: 'Str', app_string: 'App' };
const cls = { question: 'badge-q', answer_option: 'badge-opt', string: 'badge-str', app_string: 'badge-app' };
return `<span class="trans-type-badge ${cls[type] || ''}">${map[type] || type}</span>`;
}
@ -165,7 +194,7 @@ export function translationRowHTML(entry, idx, { targetLang, editable = true })
const missing = !val.trim();
const disabled = editable ? '' : 'disabled';
const sourceCell = entry.type === 'string' && editable
const sourceCell = editable
? `<input type="text" class="trans-cell-input" data-idx="${idx}" data-field="german"
value="${escHtml(german)}" placeholder="German text…" autocomplete="off">`
: `<span class="trans-source-text" title="${escHtml(german)}">${escHtml(german)}</span>`;