added translations export and import
This commit is contained in:
163
common.php
163
common.php
@ -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'] ?? [];
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user