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 (no params) -> ordered questionnaire list with conditions
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format * 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 * POST (coach interview upload) is handled by api/index.php -> handlers/app_questionnaires.php
*/ */
@ -27,37 +27,8 @@ $qnID = $_GET['id'] ?? '';
$translations = $_GET['translations'] ?? ''; $translations = $_GET['translations'] ?? '';
if ($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); qdb_discard($tmpDb, $lockFp);
echo json_encode(["success" => true, "translations" => $result]); echo json_encode(['success' => true, 'translations' => qdb_build_app_translations_map($pdo)]);
exit; exit;
} }
@ -148,8 +119,9 @@ if ($qnID) {
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
echo json_encode([ echo json_encode([
"meta" => ["id" => $qnID], 'meta' => ['id' => $qnID],
"questions" => $questions, 'questions' => $questions,
'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID),
]); ]);
exit; 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 { function qdb_upsert_source_translation(PDO $pdo, string $type, string $id, string $text): void {
qdb_ensure_source_language($pdo); qdb_put_translation($pdo, $type, $id, QDB_SOURCE_LANGUAGE, $text);
$lang = QDB_SOURCE_LANGUAGE; }
/** 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') { if ($type === 'question') {
$pdo->prepare("INSERT INTO question_translation (questionID, languageCode, text) $pdo->prepare("INSERT INTO question_translation (questionID, languageCode, text)
VALUES (:id, :lang, :t) VALUES (:id, :lang, :t)
ON CONFLICT(questionID, languageCode) DO UPDATE SET text = excluded.text") ON CONFLICT(questionID, languageCode) DO UPDATE SET text = excluded.text")
->execute([':id' => $id, ':lang' => $lang, ':t' => $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') { } elseif ($type === 'answer_option') {
$pdo->prepare("INSERT INTO answer_option_translation (answerOptionID, languageCode, text) $pdo->prepare("INSERT INTO answer_option_translation (answerOptionID, languageCode, text)
VALUES (:id, :lang, :t) VALUES (:id, :lang, :t)
ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text") ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text")
->execute([':id' => $id, ':lang' => $lang, ':t' => $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) $pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text)
VALUES (:id, :lang, :t) VALUES (:id, :lang, :t)
ON CONFLICT(stringKey, languageCode) DO UPDATE SET text = excluded.text") 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']] ?? []; $tr = $translations[$e['entityId']] ?? [];
$e['translations'] = (object)$tr; $e['translations'] = (object)$tr;
$de = trim($tr[QDB_SOURCE_LANGUAGE] ?? ''); $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); unset($e);
} }
@ -347,7 +502,7 @@ function qdb_load_translations_for_entries(PDO $pdo, array $entries): array {
} }
$sKeys = array_values(array_filter(array_map( $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 $entries
))); )));
if ($sKeys) { if ($sKeys) {
@ -362,6 +517,49 @@ function qdb_load_translations_for_entries(PDO $pdo, array $entries): array {
return $translations; 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 --- // --- AES-256-CBC: IV(16) + CIPHERTEXT ---
function aes256_cbc_encrypt_bytes(string $plain, string $key): string { function aes256_cbc_encrypt_bytes(string $plain, string $key): string {
$key = str_pad(substr($key, 0, 32), 32, "\0"); $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: Android lookup for app UI:
- 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:
1. Load `translations[activeLanguage]`. 1. Load `translations[activeLanguage]`.
2. For each questionnaire string, look up the string value as a key. 2. Look up the string key (e.g. `save`, `client_code`).
3. If a translation is missing, fall back to the raw key from the questionnaire JSON. 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 ## Recommended Android Sync Flow
1. Login with `/api/auth/login` and store the returned token securely. 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). 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`. 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>`. 5. For each list item, fetch details with `GET /api/app_questionnaires?id=<id>` (includes per-questionnaire `translations`).
6. Cache the client list, translations, the questionnaire list, and questionnaire details locally for offline use. 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`. 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. 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. * Android app API endpoint.
* GET (no params) -> ordered questionnaire list with conditions * GET (no params) -> ordered questionnaire list with conditions
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format * 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 * GET ?clients=1 -> list of clients assigned to the authenticated coach
* POST -> submit interview answers for a client (coach / supervisor / admin) * POST -> submit interview answers for a client (coach / supervisor / admin)
*/ */
@ -207,33 +207,8 @@ if ($fetchClients) {
} }
if ($translations) { 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); qdb_discard($tmpDb, $lockFp);
json_success(["translations" => $result]); json_success(['translations' => qdb_build_app_translations_map($pdo)]);
} }
if ($qnID) { if ($qnID) {
@ -311,7 +286,11 @@ if ($qnID) {
} }
qdb_discard($tmpDb, $lockFp); 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 // Default: ordered questionnaire list

View File

@ -2,7 +2,7 @@
$tokenRec = require_valid_token(); $tokenRec = require_valid_token();
$validTypes = ['question', 'answer_option', 'string', 'language']; $validTypes = ['question', 'answer_option', 'string', 'app_string', 'language'];
switch ($method) { switch ($method) {
@ -26,8 +26,12 @@ case 'GET':
SELECT questionnaireID, name, orderIndex FROM questionnaire ORDER BY orderIndex SELECT questionnaireID, name, orderIndex FROM questionnaire ORDER BY orderIndex
")->fetchAll(PDO::FETCH_ASSOC); ")->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 = []; $questionnaires = [];
$allEntries = []; $allEntries = $appEntries;
foreach ($qnRows as $qn) { foreach ($qnRows as $qn) {
$lists = qdb_translation_entry_lists($pdo, $qn['questionnaireID']); $lists = qdb_translation_entry_lists($pdo, $qn['questionnaireID']);
@ -51,6 +55,7 @@ case 'GET':
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_success([ json_success([
'languages' => $languages, 'languages' => $languages,
'appStrings' => $appEntries,
'questionnaires' => $questionnaires, 'questionnaires' => $questionnaires,
'entries' => $allEntries, 'entries' => $allEntries,
'sourceLanguage' => QDB_SOURCE_LANGUAGE, 'sourceLanguage' => QDB_SOURCE_LANGUAGE,
@ -149,22 +154,7 @@ case 'PUT':
[$pdo, $tmpDb, $lockFp] = qdb_open(true); [$pdo, $tmpDb, $lockFp] = qdb_open(true);
try { try {
if ($type === 'question') { qdb_put_translation($pdo, $type, $id, $lang, $text);
$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_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success([]); json_success([]);
} catch (Throwable $e) { } 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-q { background: #dbeafe; color: #1e40af; }
.badge-opt { background: #e2e8f0; color: #475569; } .badge-opt { background: #e2e8f0; color: #475569; }
.badge-app { background: #d1fae5; color: #065f46; }
.badge-str { background: #ede9fe; color: #5b21b6; } .badge-str { background: #ede9fe; color: #5b21b6; }
.cell-trans { position: relative; } .cell-trans { position: relative; }

View File

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

View File

@ -57,7 +57,8 @@ function renderShell() {
<input type="search" id="transFilter" class="trans-search-input" placeholder="Search…" disabled> <input type="search" id="transFilter" class="trans-search-input" placeholder="Search…" disabled>
<select id="transTypeFilter" class="trans-select trans-type-select" disabled> <select id="transTypeFilter" class="trans-select trans-type-select" disabled>
<option value="">All types</option> <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="question">Questions</option>
<option value="answer_option">Answer options</option> <option value="answer_option">Answer options</option>
</select> </select>
@ -99,7 +100,7 @@ async function loadTranslations() {
const languages = transData.languages || []; const languages = transData.languages || [];
const questionnaires = transData.questionnaires || []; const questionnaires = transData.questionnaires || [];
const flat = flattenAllQuestionnaires(questionnaires); const flat = flattenAllQuestionnaires(questionnaires, data.appStrings || []);
allEntries = flat.allEntries; allEntries = flat.allEntries;
transData.sections = flat.sections; transData.sections = flat.sections;
@ -234,7 +235,10 @@ function renderEntryList() {
const headerOnce = translationListHeaderHTML(langLabel, sourceLabel); const headerOnce = translationListHeaderHTML(langLabel, sourceLabel);
const sectionsHtml = sections.map(sec => { 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); const groups = buildTranslationGroups(entries, selectedLang, sec.startIdx);
return ` return `
<div class="trans-qn-block" data-qn="${esc(sec.questionnaireID)}"> <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) { export function buildTranslationGroups(entries, targetLang, indexOffset = 0) {
const items = entries.map((entry, i) => ({ entry, idx: indexOffset + i })); const items = entries.map((entry, i) => ({ entry, idx: indexOffset + i }));
const strings = items.filter(({ entry }) => entry.type === 'string'); const appStrings = items.filter(({ entry }) => entry.type === 'app_string');
const content = items.filter(({ entry }) => entry.type !== 'string'); const qStrings = items.filter(({ entry }) => entry.type === 'string');
const content = items.filter(({ entry }) =>
entry.type !== 'string' && entry.type !== 'app_string'
);
const groups = []; 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({ groups.push({
id: 'strings', id: 'strings',
title: 'UI strings', title: 'UI strings',
items: sortGroupItems(strings, targetLang), items: sortGroupItems(qStrings, targetLang),
}); });
} }
if (content.length) { if (content.length) {
@ -107,11 +117,29 @@ export function renderGroupedTranslationsHTML(groups, targetLang, sourceLabel, e
return `<div class="${wrapClass}">${qnHeader}${header}${body}</div>`; return `<div class="${wrapClass}">${qnHeader}${header}${body}</div>`;
} }
/** Flatten questionnaires from ?all=1 into one entries array + section metadata */ /** Only global app UI rows (never questionnaire questions/options). */
export function flattenAllQuestionnaires(questionnaires) { 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 allEntries = [];
const sections = []; 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) { for (const qn of questionnaires) {
const normalized = (qn.entries || []).map(normalizeEntry); const normalized = (qn.entries || []).map(normalizeEntry);
if (!normalized.length) continue; if (!normalized.length) continue;
@ -122,6 +150,7 @@ export function flattenAllQuestionnaires(questionnaires) {
name: qn.name || 'Questionnaire', name: qn.name || 'Questionnaire',
startIdx, startIdx,
entryCount: normalized.length, entryCount: normalized.length,
isApp: false,
}); });
} }
@ -129,8 +158,8 @@ export function flattenAllQuestionnaires(questionnaires) {
} }
export function typeBadge(type) { export function typeBadge(type) {
const map = { question: 'Q', answer_option: 'Opt', string: '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' }; 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>`; 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 missing = !val.trim();
const disabled = editable ? '' : 'disabled'; 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" ? `<input type="text" class="trans-cell-input" data-idx="${idx}" data-field="german"
value="${escHtml(german)}" placeholder="German text…" autocomplete="off">` value="${escHtml(german)}" placeholder="German text…" autocomplete="off">`
: `<span class="trans-source-text" title="${escHtml(german)}">${escHtml(german)}</span>`; : `<span class="trans-source-text" title="${escHtml(german)}">${escHtml(german)}</span>`;