From 2ded5b6aaf67d3b1303da760f1caaa87331db3be Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Fri, 22 May 2026 16:48:47 +0200 Subject: [PATCH] added app translations for better maintainability --- api/app_questionnaires.php | 38 +----- common.php | 208 ++++++++++++++++++++++++++++- data/app_ui_strings.json | 146 ++++++++++++++++++++ docs/android-questionnaire-api.md | 30 +++-- handlers/app_questionnaires.php | 35 +---- handlers/translations.php | 26 ++-- scripts/build_app_ui_strings.py | 140 +++++++++++++++++++ website/css/style.css | 1 + website/js/pages/editor.js | 35 +++-- website/js/pages/translations.js | 10 +- website/js/translations-helpers.js | 47 +++++-- 11 files changed, 594 insertions(+), 122 deletions(-) create mode 100644 data/app_ui_strings.json create mode 100644 scripts/build_app_ui_strings.py diff --git a/api/app_questionnaires.php b/api/app_questionnaires.php index 55bfdc1..8af1c6f 100644 --- a/api/app_questionnaires.php +++ b/api/app_questionnaires.php @@ -4,7 +4,7 @@ * * GET (no params) -> ordered questionnaire list with conditions * GET ?id= -> 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; } diff --git a/common.php b/common.php index 6782c1e..eea7cc5 100644 --- a/common.php +++ b/common.php @@ -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 + */ +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 + */ +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> + */ +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> + */ +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"); diff --git a/data/app_ui_strings.json b/data/app_ui_strings.json new file mode 100644 index 0000000..073e185 --- /dev/null +++ b/data/app_ui_strings.json @@ -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" + } +} diff --git a/docs/android-questionnaire-api.md b/docs/android-questionnaire-api.md index 67f3200..2501876 100644 --- a/docs/android-questionnaire-api.md +++ b/docs/android-questionnaire-api.md @@ -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=` 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=`. -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=` (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. diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index 9dbc26b..c764f30 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -3,7 +3,7 @@ * Android app API endpoint. * GET (no params) -> ordered questionnaire list with conditions * GET ?id= -> 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 diff --git a/handlers/translations.php b/handlers/translations.php index 928a79a..4e396f5 100644 --- a/handlers/translations.php +++ b/handlers/translations.php @@ -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) { diff --git a/scripts/build_app_ui_strings.py b/scripts/build_app_ui_strings.py new file mode 100644 index 0000000..374cd05 --- /dev/null +++ b/scripts/build_app_ui_strings.py @@ -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() diff --git a/website/css/style.css b/website/css/style.css index 21d4e0c..6b8d135 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -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; } diff --git a/website/js/pages/editor.js b/website/js/pages/editor.js index 58c5c3a..7418dec 100644 --- a/website/js/pages/editor.js +++ b/website/js/pages/editor.js @@ -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) : ''}

No translatable content yet. Add questions with German text first.

@@ -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 = ` +
+

${escHtml(qnName)}

+ ${renderGroupedTranslationsHTML(qnGroups, editorTransLang, sourceLabel, editable, { showColumnHeader: false })} +
`; return `
@@ -1027,11 +1034,9 @@ function renderEditorTransList(entries, editable) {
${translationListHeaderHTML(langLabel, sourceLabel)} -
- ${renderGroupedTranslationsHTML(groups, editorTransLang, sourceLabel, editable, { showColumnHeader: false })} -
+
${bodyHtml}
- ${missingCount ? `${missingCount} missing · ` : ''}${entries.length} keys + ${missingCount ? `${missingCount} missing · ` : ''}${qnEntries.length} keys ${pct}% in ${escHtml(editorTransLang.toUpperCase())} @@ -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 diff --git a/website/js/pages/translations.js b/website/js/pages/translations.js index 09e3f86..e72258c 100644 --- a/website/js/pages/translations.js +++ b/website/js/pages/translations.js @@ -57,7 +57,8 @@ function renderShell() { @@ -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 `
diff --git a/website/js/translations-helpers.js b/website/js/translations-helpers.js index d9cecd0..33b9544 100644 --- a/website/js/translations-helpers.js +++ b/website/js/translations-helpers.js @@ -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 `
${qnHeader}${header}${body}
`; } -/** 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 `${map[type] || type}`; } @@ -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 ? `` : `${escHtml(german)}`;