added translations export and import

This commit is contained in:
2026-05-27 10:53:05 +02:00
parent 9a75577f14
commit 0f89ed6d6f
3 changed files with 293 additions and 2 deletions

View File

@ -1297,6 +1297,169 @@ function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfE
];
}
/** One translation row for a portable translations JSON bundle. */
function qdb_translation_entry_to_bundle_row(array $e, ?string $questionnaireID = null, ?string $questionnaireName = null): array {
$tr = $e['translations'] ?? [];
if ($tr instanceof stdClass) {
$tr = (array)$tr;
}
if (is_array($tr) && isset($tr[0]) && is_array($tr[0]) && isset($tr[0]['languageCode'])) {
$tr = qdb_translation_rows_to_map($tr);
}
$row = [
'type' => $e['type'],
'entityId' => $e['entityId'],
'key' => $e['key'] ?? '',
'translations' => is_array($tr) ? $tr : [],
];
if (!empty($e['displayKey'])) {
$row['displayKey'] = $e['displayKey'];
}
if ($questionnaireID !== null && $questionnaireID !== '') {
$row['questionnaireID'] = $questionnaireID;
}
if ($questionnaireName !== null && $questionnaireName !== '') {
$row['questionnaireName'] = $questionnaireName;
}
return $row;
}
/** @return array<string, string> */
function qdb_normalize_bundle_translations($raw): array {
if (!is_array($raw)) {
return [];
}
if (isset($raw[0]) && is_array($raw[0]) && isset($raw[0]['languageCode'])) {
return qdb_translation_rows_to_map($raw);
}
$map = [];
foreach ($raw as $lang => $text) {
if (is_string($lang) && $lang !== '') {
$map[$lang] = (string)$text;
}
}
return $map;
}
function qdb_translation_entity_exists(PDO $pdo, string $type, string $entityId): bool {
if ($type === 'string' || $type === 'app_string') {
return true;
}
if ($type === 'question') {
$stmt = $pdo->prepare('SELECT 1 FROM question WHERE questionID = :id LIMIT 1');
} elseif ($type === 'answer_option') {
$stmt = $pdo->prepare('SELECT 1 FROM answer_option WHERE answerOptionID = :id LIMIT 1');
} else {
return false;
}
$stmt->execute([':id' => $entityId]);
return (bool)$stmt->fetchColumn();
}
/** Export all translations (languages + flat entries) as portable JSON. */
function qdb_export_translations_bundle(PDO $pdo): array {
$languages = $pdo->query('SELECT languageCode, name FROM language ORDER BY languageCode')
->fetchAll(PDO::FETCH_ASSOC);
if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) {
array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German']);
}
qdb_ensure_source_language($pdo);
$exportEntries = [];
$appEntries = qdb_app_ui_string_entries_for_website();
$appTranslations = qdb_load_translations_for_entries($pdo, $appEntries);
qdb_attach_translation_texts($appEntries, $appTranslations);
foreach ($appEntries as $e) {
$exportEntries[] = qdb_translation_entry_to_bundle_row($e);
}
$qnRows = $pdo->query('SELECT questionnaireID, name FROM questionnaire ORDER BY orderIndex, name')
->fetchAll(PDO::FETCH_ASSOC);
foreach ($qnRows as $qn) {
$lists = qdb_translation_entry_lists($pdo, $qn['questionnaireID']);
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
if (!$entries) {
continue;
}
$translations = qdb_load_translations_for_entries($pdo, $entries);
qdb_attach_translation_texts($entries, $translations);
foreach ($entries as $e) {
$exportEntries[] = qdb_translation_entry_to_bundle_row(
$e,
$qn['questionnaireID'],
$qn['name']
);
}
}
return [
'exportVersion' => 1,
'exportedAt' => time(),
'sourceLanguage' => QDB_SOURCE_LANGUAGE,
'languageCount' => count($languages),
'languages' => $languages,
'entryCount' => count($exportEntries),
'entries' => $exportEntries,
];
}
/** Import translations bundle (upsert languages and translation rows). */
function qdb_import_translations_bundle(PDO $pdo, array $bundle): array {
$entries = $bundle['entries'] ?? null;
if (!is_array($entries)) {
json_error('MISSING_FIELDS', 'bundle.entries array is required', 400);
}
qdb_ensure_source_language($pdo);
foreach ($bundle['languages'] ?? [] as $langRow) {
if (!is_array($langRow)) {
continue;
}
$lc = trim($langRow['languageCode'] ?? '');
$name = trim($langRow['name'] ?? '');
if ($lc === '' || $lc === QDB_SOURCE_LANGUAGE) {
continue;
}
$pdo->prepare('INSERT INTO language (languageCode, name) VALUES (:lc, :n)
ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name')
->execute([':lc' => $lc, ':n' => $name]);
}
$validTypes = ['question', 'answer_option', 'string', 'app_string'];
$imported = 0;
$skipped = 0;
foreach ($entries as $entry) {
if (!is_array($entry)) {
$skipped++;
continue;
}
$type = $entry['type'] ?? '';
$entityId = trim((string)($entry['entityId'] ?? ''));
if (!in_array($type, $validTypes, true) || $entityId === '') {
$skipped++;
continue;
}
if (!qdb_translation_entity_exists($pdo, $type, $entityId)) {
$skipped++;
continue;
}
$map = qdb_normalize_bundle_translations($entry['translations'] ?? []);
if ($map) {
qdb_apply_translation_map($pdo, $type, $entityId, $map);
$imported++;
} else {
$skipped++;
}
}
return [
'imported' => $imported,
'skipped' => $skipped,
'total' => count($entries),
];
}
/** Import a full export bundle. */
function qdb_import_questionnaires_bundle(PDO $pdo, array $bundle, bool $replaceIfExists = false): array {
$items = $bundle['questionnaires'] ?? [];

View File

@ -7,6 +7,19 @@ $validTypes = ['question', 'answer_option', 'string', 'app_string', 'language'];
switch ($method) {
case 'GET':
if (!empty($_GET['exportBundle'])) {
require_role(['admin', 'supervisor'], $tokenRec);
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$bundle = qdb_export_translations_bundle($pdo);
qdb_discard($tmpDb, $lockFp);
$filename = 'translations_bundle_' . date('Y-m-d_His') . '.json';
header('Content-Type: application/json; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
echo json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
if (isset($_GET['languages'])) {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$rows = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
@ -115,6 +128,29 @@ case 'GET':
json_success(["translations" => $rows]);
break;
case 'POST':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (($body['action'] ?? '') === 'importBundle') {
$bundle = $body['bundle'] ?? null;
if (!is_array($bundle)) {
json_error('MISSING_FIELDS', 'bundle object is required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
$result = qdb_import_translations_bundle($pdo, $bundle);
qdb_save($tmpDb, $lockFp);
json_success($result);
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log('translations importBundle: ' . $e->getMessage());
json_error('SERVER_ERROR', $e->getMessage(), 500);
}
break;
}
json_error('BAD_REQUEST', 'Unknown action', 400);
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();

View File

@ -1,5 +1,5 @@
import { apiGet, apiPut, apiDelete } from '../api.js';
import { showToast } from '../app.js';
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { canEdit, showToast } from '../app.js';
import {
SOURCE_LANG,
normalizeEntry,
@ -23,10 +23,20 @@ export async function translationsPage() {
app.innerHTML = `
<div class="page-header">
<h1>Translations</h1>
<div class="actions" id="transHeaderActions"></div>
</div>
<div id="transPageRoot"><div class="spinner"></div></div>
`;
if (canEdit()) {
document.getElementById('transHeaderActions').innerHTML = `
<input type="file" id="importTransBundleFile" accept=".json,application/json" hidden>
<button type="button" class="btn" id="importTransBundleBtn">Import translations</button>
<button type="button" class="btn btn-primary" id="exportTransBundleBtn">Export translations</button>
`;
bindTranslationBundleActions();
}
try {
renderShell();
await loadTranslations();
@ -387,3 +397,85 @@ function applyFilters() {
}
}
}
function bindTranslationBundleActions() {
const fileInput = document.getElementById('importTransBundleFile');
const importBtn = document.getElementById('importTransBundleBtn');
const exportBtn = document.getElementById('exportTransBundleBtn');
if (!fileInput || !importBtn || !exportBtn) return;
exportBtn.addEventListener('click', async () => {
exportBtn.disabled = true;
const prev = exportBtn.textContent;
exportBtn.textContent = 'Preparing…';
try {
const token = localStorage.getItem('qdb_token');
const res = await fetch('../api/translations?exportBundle=1', {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error?.message || err.error || 'Export failed');
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const stamp = new Date().toISOString().slice(0, 10);
a.download = `translations_bundle_${stamp}.json`;
a.click();
URL.revokeObjectURL(url);
showToast('Translations bundle downloaded', 'success');
} catch (e) {
showToast(e.message, 'error');
} finally {
exportBtn.disabled = false;
exportBtn.textContent = prev;
}
});
importBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
fileInput.value = '';
if (!file) return;
let bundle;
try {
bundle = JSON.parse(await file.text());
} catch {
showToast('Invalid JSON file', 'error');
return;
}
if (!bundle?.entries || !Array.isArray(bundle.entries)) {
showToast('Not a translations bundle (missing entries array)', 'error');
return;
}
const count = bundle.entries.length;
if (!confirm(`Import translations for ${count} entries from this file?`)) return;
importBtn.disabled = true;
const prev = importBtn.textContent;
importBtn.textContent = 'Importing…';
try {
const result = await apiPost('translations.php', {
action: 'importBundle',
bundle,
});
const imported = result.imported ?? 0;
const skipped = result.skipped ?? 0;
let msg = `Imported ${imported} translation(s)`;
if (skipped) msg += ` (${skipped} skipped)`;
showToast(msg, 'success');
await loadTranslations();
} catch (e) {
showToast(e.message, 'error');
} finally {
importBtn.disabled = false;
importBtn.textContent = prev;
}
});
}