changes to translation system
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
tom.hempel
2026-07-09 14:59:56 +02:00
parent 4afab336ee
commit f04388e0ec
24 changed files with 1882 additions and 415 deletions

View File

@ -4,6 +4,13 @@
require_once __DIR__ . '/lib/text_sanitize.php';
/** IANA timezone for all human-readable timestamps (CET/CEST). */
function qdb_app_timezone(): string {
return 'Europe/Berlin';
}
date_default_timezone_set(qdb_app_timezone());
/**
* Values loaded from the project .env file (see qdb_load_dotenv).
*
@ -374,11 +381,273 @@ function rbac_client_filter(
define('QDB_SOURCE_LANGUAGE', 'de');
function qdb_ensure_source_language(PDO $pdo): void {
if (qdb_column_exists($pdo, 'language', 'enabled')) {
$pdo->prepare("INSERT INTO language (languageCode, name, enabled) VALUES (:lc, :n, 1)
ON CONFLICT(languageCode) DO UPDATE SET enabled = 1")
->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']);
return;
}
$pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n)
ON CONFLICT(languageCode) DO NOTHING")
->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']);
}
/** @return list<array{languageCode:string,name:string,enabled:int}> */
function qdb_load_languages(PDO $pdo): array {
qdb_ensure_source_language($pdo);
$hasEnabled = qdb_column_exists($pdo, 'language', 'enabled');
if ($hasEnabled) {
$rows = $pdo->query(
'SELECT languageCode, name, COALESCE(enabled, 1) AS enabled
FROM language ORDER BY languageCode'
)->fetchAll(PDO::FETCH_ASSOC);
} else {
$rows = $pdo->query('SELECT languageCode, name FROM language ORDER BY languageCode')
->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as &$row) {
$row['enabled'] = 1;
}
unset($row);
}
if (!array_filter($rows, fn($l) => ($l['languageCode'] ?? '') === QDB_SOURCE_LANGUAGE)) {
array_unshift($rows, [
'languageCode' => QDB_SOURCE_LANGUAGE,
'name' => 'German',
'enabled' => 1,
]);
}
foreach ($rows as &$row) {
if (($row['languageCode'] ?? '') === QDB_SOURCE_LANGUAGE) {
$row['enabled'] = 1;
} else {
$row['enabled'] = (int)($row['enabled'] ?? 1) ? 1 : 0;
}
}
unset($row);
return $rows;
}
function qdb_set_language_enabled(PDO $pdo, string $languageCode, bool $enabled): void {
$languageCode = trim($languageCode);
if ($languageCode === '') {
throw new InvalidArgumentException('languageCode required');
}
if ($languageCode === QDB_SOURCE_LANGUAGE && !$enabled) {
throw new InvalidArgumentException('German (de) cannot be disabled');
}
qdb_ensure_source_language($pdo);
if (!qdb_column_exists($pdo, 'language', 'enabled')) {
throw new RuntimeException('Language enablement is not available on this database');
}
$pdo->prepare('UPDATE language SET enabled = :en WHERE languageCode = :lc')
->execute([':en' => $enabled ? 1 : 0, ':lc' => $languageCode]);
if ($pdo->query('SELECT changes()')->fetchColumn() == 0) {
throw new InvalidArgumentException('Language not found');
}
}
/** @return list<array{questionnaireID:string,languageCode:string}> */
function qdb_questionnaire_language_disables(PDO $pdo): array {
if (!qdb_table_exists($pdo, 'questionnaire_language_disable')) {
return [];
}
return $pdo->query(
'SELECT questionnaireID, languageCode
FROM questionnaire_language_disable
ORDER BY questionnaireID, languageCode'
)->fetchAll(PDO::FETCH_ASSOC);
}
/** @return list<string> */
function qdb_questionnaire_disabled_language_codes(PDO $pdo, string $questionnaireID): array {
if (!qdb_table_exists($pdo, 'questionnaire_language_disable')) {
return [];
}
$stmt = $pdo->prepare(
'SELECT languageCode FROM questionnaire_language_disable
WHERE questionnaireID = :qid ORDER BY languageCode'
);
$stmt->execute([':qid' => $questionnaireID]);
return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'languageCode');
}
function qdb_set_questionnaire_language_disabled(
PDO $pdo,
string $questionnaireID,
string $languageCode,
bool $disabled
): void {
$questionnaireID = trim($questionnaireID);
$languageCode = trim($languageCode);
if ($questionnaireID === '' || $languageCode === '') {
throw new InvalidArgumentException('questionnaireID and languageCode required');
}
if ($languageCode === QDB_SOURCE_LANGUAGE) {
throw new InvalidArgumentException('German (de) cannot be disabled per questionnaire');
}
if (!qdb_table_exists($pdo, 'questionnaire_language_disable')) {
throw new RuntimeException('Per-questionnaire language settings are not available on this database');
}
$exists = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id LIMIT 1');
$exists->execute([':id' => $questionnaireID]);
if (!$exists->fetchColumn()) {
throw new InvalidArgumentException('Questionnaire not found');
}
if ($disabled) {
$pdo->prepare(
'INSERT INTO questionnaire_language_disable (questionnaireID, languageCode)
VALUES (:qid, :lc) ON CONFLICT(questionnaireID, languageCode) DO NOTHING'
)->execute([':qid' => $questionnaireID, ':lc' => $languageCode]);
return;
}
$pdo->prepare(
'DELETE FROM questionnaire_language_disable
WHERE questionnaireID = :qid AND languageCode = :lc'
)->execute([':qid' => $questionnaireID, ':lc' => $languageCode]);
}
/**
* Language codes selectable in the app for global UI or a questionnaire.
*
* @return list<string>
*/
function qdb_available_language_codes(PDO $pdo, ?string $questionnaireID = null): array {
$codes = [];
foreach (qdb_load_languages($pdo) as $lang) {
if ((int)($lang['enabled'] ?? 1) === 1) {
$codes[] = $lang['languageCode'];
}
}
if ($questionnaireID !== null && $questionnaireID !== '') {
$disabled = qdb_questionnaire_disabled_language_codes($pdo, $questionnaireID);
if ($disabled) {
$blocked = array_fill_keys($disabled, true);
$codes = array_values(array_filter($codes, fn($code) => !isset($blocked[$code])));
}
}
if (!in_array(QDB_SOURCE_LANGUAGE, $codes, true)) {
array_unshift($codes, QDB_SOURCE_LANGUAGE);
}
sort($codes);
return $codes;
}
/** Delete non-source translations for app UI, one questionnaire, or everything. */
function qdb_delete_translations_for_scope(
PDO $pdo,
string $scope,
?string $questionnaireID = null,
?string $languageCode = null
): int {
$scope = trim($scope);
if (!in_array($scope, ['app', 'questionnaire', 'all'], true)) {
throw new InvalidArgumentException('scope must be app, questionnaire, or all');
}
if ($scope === 'questionnaire' && trim((string)$questionnaireID) === '') {
throw new InvalidArgumentException('questionnaireID required for questionnaire scope');
}
$languageCode = $languageCode !== null ? trim($languageCode) : '';
if ($languageCode === QDB_SOURCE_LANGUAGE) {
throw new InvalidArgumentException('German (de) source translations cannot be bulk-deleted');
}
$languageCodes = [];
if ($languageCode !== '') {
$languageCodes = [$languageCode];
} else {
foreach (qdb_load_languages($pdo) as $lang) {
if (($lang['languageCode'] ?? '') !== QDB_SOURCE_LANGUAGE) {
$languageCodes[] = $lang['languageCode'];
}
}
}
if (!$languageCodes) {
return 0;
}
$deleted = 0;
if ($scope === 'app' || $scope === 'all') {
$appKeys = array_column(qdb_app_ui_string_entries_for_website($pdo), 'entityId');
if ($appKeys) {
$ph = implode(',', array_fill(0, count($appKeys), '?'));
$stmt = $pdo->prepare(
"DELETE FROM string_translation
WHERE stringKey IN ($ph) AND languageCode = :lang"
);
foreach ($languageCodes as $lc) {
$stmt->execute(array_merge($appKeys, [$lc]));
$deleted += (int)$stmt->rowCount();
}
}
}
if ($scope === 'questionnaire' || $scope === 'all') {
$questionnaireIDs = [];
if ($scope === 'questionnaire') {
$questionnaireIDs = [trim((string)$questionnaireID)];
} else {
$questionnaireIDs = array_column(
$pdo->query('SELECT questionnaireID FROM questionnaire ORDER BY orderIndex, name')->fetchAll(PDO::FETCH_ASSOC),
'questionnaireID'
);
}
foreach ($questionnaireIDs as $qnID) {
$lists = qdb_translation_entry_lists($pdo, $qnID);
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
$questionIDs = [];
$answerOptionIDs = [];
$stringKeys = [];
foreach ($entries as $entry) {
if (($entry['type'] ?? '') === 'question') {
$questionIDs[] = $entry['entityId'];
} elseif (($entry['type'] ?? '') === 'answer_option') {
$answerOptionIDs[] = $entry['entityId'];
} elseif (($entry['type'] ?? '') === 'string') {
$stringKeys[] = $entry['entityId'];
}
}
if ($questionIDs) {
$ph = implode(',', array_fill(0, count($questionIDs), '?'));
$stmt = $pdo->prepare(
"DELETE FROM question_translation
WHERE questionID IN ($ph) AND languageCode = ?"
);
foreach ($languageCodes as $lc) {
$stmt->execute(array_merge($questionIDs, [$lc]));
$deleted += (int)$stmt->rowCount();
}
}
if ($answerOptionIDs) {
$ph = implode(',', array_fill(0, count($answerOptionIDs), '?'));
$stmt = $pdo->prepare(
"DELETE FROM answer_option_translation
WHERE answerOptionID IN ($ph) AND languageCode = ?"
);
foreach ($languageCodes as $lc) {
$stmt->execute(array_merge($answerOptionIDs, [$lc]));
$deleted += (int)$stmt->rowCount();
}
}
if ($stringKeys) {
$ph = implode(',', array_fill(0, count($stringKeys), '?'));
$stmt = $pdo->prepare(
"DELETE FROM string_translation
WHERE stringKey IN ($ph) AND languageCode = ?"
);
foreach ($languageCodes as $lc) {
$stmt->execute(array_merge($stringKeys, [$lc]));
$deleted += (int)$stmt->rowCount();
}
}
}
}
return $deleted;
}
function qdb_upsert_source_translation(PDO $pdo, string $type, string $id, string $text): void {
qdb_put_translation($pdo, $type, $id, QDB_SOURCE_LANGUAGE, $text);
}
@ -972,6 +1241,21 @@ function qdb_validate_stable_key(string $s, string $label = 'Key'): string {
return $normalized;
}
/** Question keys omitted from results tables and CSV exports (e.g. last-page warnings). */
function qdb_results_excluded_question_keys(): array {
return ['data_final_warning'];
}
/** Whether a question row should be hidden from results tables and CSV exports. */
function qdb_question_is_results_excluded(array $qRow): bool {
if (($qRow['type'] ?? '') === 'last_page') {
return true;
}
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
$key = qdb_question_key($cfg, (string)($qRow['defaultText'] ?? ''));
return in_array($key, qdb_results_excluded_question_keys(), true);
}
/** Resolve app lookup key for a question row. */
function qdb_question_key(array $config, string $defaultText): string {
$key = trim((string)($config['questionKey'] ?? ''));
@ -1078,6 +1362,9 @@ function qdb_results_export_columns(array $questions, string $qnID): array
{
$columns = [];
foreach ($questions as $q) {
if (qdb_question_is_results_excluded($q)) {
continue;
}
$cfg = json_decode($q['configJson'] ?? '{}', true) ?: [];
$type = $q['type'] ?? '';
if ($type === 'glass_scale_question') {