This commit is contained in:
125
common.php
125
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 {
|
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'] ?? [];
|
||||||
|
|||||||
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;
|
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;
|
||||||
|
|
||||||
|
|||||||
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');
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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, '&')
|
.replace(/&/g, '&')
|
||||||
|
|||||||
Reference in New Issue
Block a user