diff --git a/common.php b/common.php index 769e413..224ca39 100644 --- a/common.php +++ b/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 */ +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'] ?? []; diff --git a/handlers/translations.php b/handlers/translations.php index 806f92c..17dc195 100644 --- a/handlers/translations.php +++ b/handlers/translations.php @@ -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(); diff --git a/website/js/pages/translations.js b/website/js/pages/translations.js index 3a07330..92fb8fd 100644 --- a/website/js/pages/translations.js +++ b/website/js/pages/translations.js @@ -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 = `
`; + if (canEdit()) { + document.getElementById('transHeaderActions').innerHTML = ` + + + + `; + 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; + } + }); +}