This commit is contained in:
123
common.php
123
common.php
@ -1669,17 +1669,86 @@ function qdb_build_app_translations_map(PDO $pdo): array {
|
||||
function qdb_build_questionnaire_translations_map(PDO $pdo, string $qnID): array {
|
||||
$lists = qdb_translation_entry_lists($pdo, $qnID);
|
||||
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
|
||||
if (!$entries) {
|
||||
return [];
|
||||
}
|
||||
$translations = qdb_load_translations_for_entries($pdo, $entries);
|
||||
$translations = $entries ? qdb_load_translations_for_entries($pdo, $entries) : [];
|
||||
$result = [];
|
||||
$stringLabelCache = [];
|
||||
$appDefaults = qdb_app_string_catalog()['germanDefaults'] ?? [];
|
||||
|
||||
foreach ($entries as $e) {
|
||||
$key = $e['key'];
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ($translations[$e['entityId']] ?? [] as $lang => $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;
|
||||
}
|
||||
|
||||
@ -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. */
|
||||
function qdb_import_questionnaires_bundle(PDO $pdo, array $bundle, bool $replaceIfExists = false): array {
|
||||
$items = $bundle['questionnaires'] ?? [];
|
||||
|
||||
6755
data/barometer_collection_import_bundle.json
Normal file
6755
data/barometer_collection_import_bundle.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -185,6 +185,22 @@ case 'POST':
|
||||
}
|
||||
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);
|
||||
break;
|
||||
|
||||
|
||||
21
scripts/validate_import_bundle.php
Normal file
21
scripts/validate_import_bundle.php
Normal 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);
|
||||
}
|
||||
@ -80,4 +80,51 @@ final class TranslationsImportExportTest extends QdbTestCase
|
||||
$codes = array_column($langs['data']['languages'], 'languageCode');
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { confirmAction } from '../confirm-modal.js';
|
||||
import { navigate } from '../router.js';
|
||||
|
||||
const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS';
|
||||
const DELETE_ALL_TRANSLATIONS_CONFIRM = 'DELETE ALL TRANSLATIONS';
|
||||
|
||||
const ACTIVITY_LABELS = {
|
||||
app_sync: 'App sync',
|
||||
@ -49,6 +50,23 @@ export function devPage() {
|
||||
</button>
|
||||
</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">
|
||||
<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">
|
||||
@ -248,6 +266,7 @@ export function devPage() {
|
||||
wireAdminZipExport();
|
||||
wireActivityLog();
|
||||
wireSecuritySettings();
|
||||
wireDeleteAllTranslations();
|
||||
|
||||
document.getElementById('devImportBtn').addEventListener('click', async () => {
|
||||
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) {
|
||||
return String(v ?? '')
|
||||
.replace(/&/g, '&')
|
||||
|
||||
Reference in New Issue
Block a user