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. */
|
/** 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'] ?? [];
|
||||||
|
|||||||
@ -7,6 +7,19 @@ $validTypes = ['question', 'answer_option', 'string', 'app_string', 'language'];
|
|||||||
switch ($method) {
|
switch ($method) {
|
||||||
|
|
||||||
case 'GET':
|
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'])) {
|
if (isset($_GET['languages'])) {
|
||||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||||
$rows = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
|
$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]);
|
json_success(["translations" => $rows]);
|
||||||
break;
|
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':
|
case 'PUT':
|
||||||
require_role(['admin', 'supervisor'], $tokenRec);
|
require_role(['admin', 'supervisor'], $tokenRec);
|
||||||
$body = read_json_body();
|
$body = read_json_body();
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { apiGet, apiPut, apiDelete } from '../api.js';
|
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
|
||||||
import { showToast } from '../app.js';
|
import { canEdit, showToast } from '../app.js';
|
||||||
import {
|
import {
|
||||||
SOURCE_LANG,
|
SOURCE_LANG,
|
||||||
normalizeEntry,
|
normalizeEntry,
|
||||||
@ -23,10 +23,20 @@ export async function translationsPage() {
|
|||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Translations</h1>
|
<h1>Translations</h1>
|
||||||
|
<div class="actions" id="transHeaderActions"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="transPageRoot"><div class="spinner"></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 {
|
try {
|
||||||
renderShell();
|
renderShell();
|
||||||
await loadTranslations();
|
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