session management
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
tom.hempel
2026-06-30 08:35:18 +02:00
parent 7fefc264f4
commit 9db886a1ae
6 changed files with 7026 additions and 5 deletions

View File

@ -1669,17 +1669,86 @@ function qdb_build_app_translations_map(PDO $pdo): array {
function qdb_build_questionnaire_translations_map(PDO $pdo, string $qnID): array { function qdb_build_questionnaire_translations_map(PDO $pdo, string $qnID): array {
$lists = qdb_translation_entry_lists($pdo, $qnID); $lists = qdb_translation_entry_lists($pdo, $qnID);
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']); $entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
if (!$entries) { $translations = $entries ? qdb_load_translations_for_entries($pdo, $entries) : [];
return [];
}
$translations = qdb_load_translations_for_entries($pdo, $entries);
$result = []; $result = [];
$stringLabelCache = [];
$appDefaults = qdb_app_string_catalog()['germanDefaults'] ?? [];
foreach ($entries as $e) { foreach ($entries as $e) {
$key = $e['key']; $key = $e['key'];
if ($key === '') {
continue;
}
foreach ($translations[$e['entityId']] ?? [] as $lang => $text) { foreach ($translations[$e['entityId']] ?? [] as $lang => $text) {
$result[$lang][$key] = $text; $text = trim((string)$text);
if ($text !== '') {
$result[$lang][$key] = $text;
}
}
$german = trim($e['germanText'] ?? '');
if ($german === '' && ($e['type'] ?? '') === 'string') {
$trDe = trim($translations[$e['entityId']][QDB_SOURCE_LANGUAGE] ?? '');
if ($trDe !== '' && !qdb_is_stable_key($trDe)) {
$german = $trDe;
} elseif (!empty($appDefaults[$key])) {
$german = trim((string)$appDefaults[$key]);
} else {
$fetched = trim(qdb_string_german_label($pdo, $key, $stringLabelCache));
if ($fetched !== '' && !qdb_is_stable_key($fetched)) {
$german = $fetched;
}
}
}
if ($german !== '' && !isset($result[QDB_SOURCE_LANGUAGE][$key])) {
$result[QDB_SOURCE_LANGUAGE][$key] = $german;
} }
} }
$qStmt = $pdo->prepare(
'SELECT questionID, defaultText, configJson FROM question WHERE questionnaireID = :id ORDER BY orderIndex'
);
$qStmt->execute([':id' => $qnID]);
foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $dbQ) {
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
$qKey = qdb_question_key($config, $dbQ['defaultText']);
if ($qKey === '') {
continue;
}
$qGerman = qdb_question_german_label($pdo, $dbQ);
if ($qGerman !== '' && !qdb_is_stable_key($qGerman)) {
$result[QDB_SOURCE_LANGUAGE][$qKey] = $qGerman;
}
$noteBefore = trim((string)($config['noteBefore'] ?? ''));
if ($noteBefore !== '') {
$result[QDB_SOURCE_LANGUAGE][qdb_note_before_key($qKey)] = $noteBefore;
$textKey = trim((string)($config['textKey'] ?? ''));
if ($textKey !== '') {
$result[QDB_SOURCE_LANGUAGE][$textKey] = $noteBefore;
}
}
$noteAfter = trim((string)($config['noteAfter'] ?? ''));
if ($noteAfter !== '') {
$result[QDB_SOURCE_LANGUAGE][qdb_note_after_key($qKey)] = $noteAfter;
}
}
$aoStmt = $pdo->prepare(
'SELECT answerOptionID, defaultText FROM answer_option WHERE questionID IN (
SELECT questionID FROM question WHERE questionnaireID = :id
) ORDER BY questionID, orderIndex'
);
$aoStmt->execute([':id' => $qnID]);
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
$optKey = qdb_option_key($ao);
if ($optKey === '') {
continue;
}
$optGerman = qdb_option_german_label($pdo, $ao['answerOptionID'], $ao['defaultText']);
if ($optGerman !== '' && !qdb_is_stable_key($optGerman)) {
$result[QDB_SOURCE_LANGUAGE][$optKey] = $optGerman;
}
}
return $result; return $result;
} }
@ -2253,6 +2322,52 @@ function qdb_import_translations_bundle(PDO $pdo, array $bundle): array {
]; ];
} }
/**
* Remove translation rows from the database.
* By default keeps German source language rows and the de language entry.
*/
function qdb_delete_all_translations(PDO $pdo, bool $keepSourceLanguage = true): array {
if ($keepSourceLanguage) {
$de = QDB_SOURCE_LANGUAGE;
$count = static function (PDO $pdo, string $table) use ($de): int {
$stmt = $pdo->prepare("SELECT COUNT(*) FROM {$table} WHERE languageCode != :de");
$stmt->execute([':de' => $de]);
return (int)$stmt->fetchColumn();
};
$questionCount = $count($pdo, 'question_translation');
$answerCount = $count($pdo, 'answer_option_translation');
$stringCount = $count($pdo, 'string_translation');
$langStmt = $pdo->prepare('SELECT COUNT(*) FROM language WHERE languageCode != :de');
$langStmt->execute([':de' => $de]);
$languageCount = (int)$langStmt->fetchColumn();
$pdo->prepare('DELETE FROM question_translation WHERE languageCode != :de')->execute([':de' => $de]);
$pdo->prepare('DELETE FROM answer_option_translation WHERE languageCode != :de')->execute([':de' => $de]);
$pdo->prepare('DELETE FROM string_translation WHERE languageCode != :de')->execute([':de' => $de]);
$pdo->prepare('DELETE FROM language WHERE languageCode != :de')->execute([':de' => $de]);
} else {
$questionCount = (int)$pdo->query('SELECT COUNT(*) FROM question_translation')->fetchColumn();
$answerCount = (int)$pdo->query('SELECT COUNT(*) FROM answer_option_translation')->fetchColumn();
$stringCount = (int)$pdo->query('SELECT COUNT(*) FROM string_translation')->fetchColumn();
$languageCount = (int)$pdo->query('SELECT COUNT(*) FROM language')->fetchColumn();
$pdo->exec('DELETE FROM question_translation');
$pdo->exec('DELETE FROM answer_option_translation');
$pdo->exec('DELETE FROM string_translation');
$pdo->exec('DELETE FROM language');
}
qdb_ensure_source_language($pdo);
return [
'question' => $questionCount,
'answer_option' => $answerCount,
'string' => $stringCount,
'languages' => $languageCount,
'keptSource' => $keepSourceLanguage,
];
}
/** Import a full export bundle. */ /** Import a full export bundle. */
function qdb_import_questionnaires_bundle(PDO $pdo, array $bundle, bool $replaceIfExists = false): array { function qdb_import_questionnaires_bundle(PDO $pdo, array $bundle, bool $replaceIfExists = false): array {
$items = $bundle['questionnaires'] ?? []; $items = $bundle['questionnaires'] ?? [];

File diff suppressed because it is too large Load Diff

View File

@ -185,6 +185,22 @@ case 'POST':
} }
break; break;
} }
if (($body['action'] ?? '') === 'deleteAll') {
require_role(['admin'], $tokenRec);
$phrase = trim((string)($body['confirmPhrase'] ?? ''));
if ($phrase !== 'DELETE ALL TRANSLATIONS') {
json_error('CONFIRMATION_REQUIRED', 'Type DELETE ALL TRANSLATIONS to confirm', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$result = qdb_delete_all_translations($pdo, true);
qdb_save($tmpDb, $lockFp);
json_success($result);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete all translations', null, $tmpDb, $lockFp);
}
break;
}
json_error('BAD_REQUEST', 'Unknown action', 400); json_error('BAD_REQUEST', 'Unknown action', 400);
break; break;

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/common.php';
require dirname(__DIR__) . '/db_init.php';
$path = dirname(__DIR__) . '/data/barometer_collection_import_bundle.json';
$bundle = json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
[$pdo, $tmp, $lock] = qdb_open(true);
try {
$pdo->exec('PRAGMA foreign_keys = OFF;');
$result = qdb_import_questionnaires_bundle($pdo, $bundle, true);
$pdo->exec('PRAGMA foreign_keys = ON;');
qdb_save($tmp, $lock);
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
} catch (Throwable $e) {
qdb_discard($tmp, $lock);
fwrite(STDERR, 'ERROR: ' . $e->getMessage() . PHP_EOL);
exit(1);
}

View File

@ -80,4 +80,51 @@ final class TranslationsImportExportTest extends QdbTestCase
$codes = array_column($langs['data']['languages'], 'languageCode'); $codes = array_column($langs['data']['languages'], 'languageCode');
$this->assertNotContains('it', $codes); $this->assertNotContains('it', $codes);
} }
public function testDeleteAllTranslationsRequiresPhrase(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$bad = $this->api()->withToken($token, 'POST', 'translations', [
'action' => 'deleteAll',
'confirmPhrase' => 'wrong phrase',
]);
$this->assertApiError($bad, 'CONFIRMATION_REQUIRED', 400);
}
public function testDeleteAllTranslationsRemovesNonGerman(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$this->api()->withToken($token, 'PUT', 'translations', [
'type' => 'language',
'languageCode' => 'es',
'name' => 'Spanish',
]);
$this->api()->withToken($token, 'PUT', 'translations', [
'type' => 'question',
'id' => $f->questionId,
'languageCode' => 'es',
'text' => 'Consentimiento',
]);
$del = $this->api()->withToken($token, 'POST', 'translations', [
'action' => 'deleteAll',
'confirmPhrase' => 'DELETE ALL TRANSLATIONS',
]);
$this->assertApiOk($del);
$this->assertGreaterThan(0, $del['data']['question'] ?? 0);
$langs = $this->api()->withToken($token, 'GET', 'translations', null, ['languages' => '1']);
$codes = array_column($langs['data']['languages'], 'languageCode');
$this->assertContains('de', $codes);
$this->assertNotContains('es', $codes);
}
} }

View File

@ -4,6 +4,7 @@ import { confirmAction } from '../confirm-modal.js';
import { navigate } from '../router.js'; import { navigate } from '../router.js';
const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS'; const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS';
const DELETE_ALL_TRANSLATIONS_CONFIRM = 'DELETE ALL TRANSLATIONS';
const ACTIVITY_LABELS = { const ACTIVITY_LABELS = {
app_sync: 'App sync', app_sync: 'App sync',
@ -49,6 +50,23 @@ export function devPage() {
</button> </button>
</div> </div>
</div> </div>
<div class="card" style="max-width:720px;margin-bottom:16px">
<h2 style="margin:0 0 8px;font-size:1.1rem">Translations</h2>
<p class="field-hint callout-danger" style="margin:0 0 12px">
<strong>Removes all non-German translations</strong> from question, answer-option, and string tables,
and deletes all languages except German (<code>de</code>). Questionnaire structure and German source
text are kept. Re-import a bundle from the Translations page to restore other languages.
</p>
<label for="deleteAllTranslationsConfirm" style="font-size:.85rem;font-weight:500;display:block;margin-bottom:6px">
Type <code>${DELETE_ALL_TRANSLATIONS_CONFIRM}</code> to confirm
</label>
<input type="text" id="deleteAllTranslationsConfirm" class="revoke-confirm-input" autocomplete="off" spellcheck="false"
placeholder="${DELETE_ALL_TRANSLATIONS_CONFIRM}">
<button type="button" class="btn btn-danger" id="deleteAllTranslationsBtn" style="margin-top:10px" disabled>
Delete all translations
</button>
<pre id="deleteAllTranslationsResult" style="margin-top:12px;font-size:.8rem;max-height:160px;overflow:auto;display:none"></pre>
</div>
<div class="card" style="max-width:720px;margin-bottom:16px"> <div class="card" style="max-width:720px;margin-bottom:16px">
<h2 style="margin:0 0 8px;font-size:1.1rem">Export all response data (ZIP)</h2> <h2 style="margin:0 0 8px;font-size:1.1rem">Export all response data (ZIP)</h2>
<p class="field-hint" style="margin:0 0 14px"> <p class="field-hint" style="margin:0 0 14px">
@ -248,6 +266,7 @@ export function devPage() {
wireAdminZipExport(); wireAdminZipExport();
wireActivityLog(); wireActivityLog();
wireSecuritySettings(); wireSecuritySettings();
wireDeleteAllTranslations();
document.getElementById('devImportBtn').addEventListener('click', async () => { document.getElementById('devImportBtn').addEventListener('click', async () => {
if (!parsedFixture) return; if (!parsedFixture) return;
@ -635,6 +654,54 @@ async function saveSecuritySettings(formEl, metaEl) {
} }
} }
function wireDeleteAllTranslations() {
const input = document.getElementById('deleteAllTranslationsConfirm');
const btn = document.getElementById('deleteAllTranslationsBtn');
const result = document.getElementById('deleteAllTranslationsResult');
if (!input || !btn) return;
input.addEventListener('input', () => {
btn.disabled = input.value.trim() !== DELETE_ALL_TRANSLATIONS_CONFIRM;
});
btn.addEventListener('click', async () => {
if (input.value.trim() !== DELETE_ALL_TRANSLATIONS_CONFIRM) return;
if (!(await confirmAction({
title: 'Delete all translations',
message: 'Remove every non-German translation and language from the database?\n\nGerman source text is kept.',
confirmLabel: 'Delete all',
variant: 'danger',
}))) {
return;
}
btn.disabled = true;
if (result) result.style.display = 'none';
try {
const data = await apiPost('translations', {
action: 'deleteAll',
confirmPhrase: DELETE_ALL_TRANSLATIONS_CONFIRM,
});
if (!data.success) throw new Error(data.error || 'Failed');
const d = data.data || {};
const summary = [
`Question translations removed: ${d.question ?? 0}`,
`Answer-option translations removed: ${d.answer_option ?? 0}`,
`String translations removed: ${d.string ?? 0}`,
`Languages removed: ${d.languages ?? 0}`,
].join('\n');
if (result) {
result.textContent = summary;
result.style.display = 'block';
}
showToast('All non-German translations deleted', 'success');
input.value = '';
} catch (err) {
showToast(err.message, 'error');
btn.disabled = input.value.trim() !== DELETE_ALL_TRANSLATIONS_CONFIRM;
}
});
}
function escAttr(v) { function escAttr(v) {
return String(v ?? '') return String(v ?? '')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')