added app translations for better maintainability
This commit is contained in:
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
208
common.php
208
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 {
|
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
146
data/app_ui_strings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
140
scripts/build_app_ui_strings.py
Normal file
140
scripts/build_app_ui_strings.py
Normal 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()
|
||||||
@ -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; }
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)}">
|
||||||
|
|||||||
@ -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>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user