From 244fd161403b66d4fa1f5759a9741690afdb5f5f Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Fri, 22 May 2026 17:36:40 +0200 Subject: [PATCH] added quesionnaire export and import --- api/export.php | 18 ++ common.php | 354 ++++++++++++++++++++++++++++- handlers/export.php | 13 ++ handlers/questionnaires.php | 22 ++ handlers/questions.php | 12 +- website/js/pages/dashboard.js | 60 ++++- website/js/pages/export.js | 45 +++- website/js/translations-helpers.js | 17 +- 8 files changed, 523 insertions(+), 18 deletions(-) diff --git a/api/export.php b/api/export.php index 1d0e8fd..691b12f 100644 --- a/api/export.php +++ b/api/export.php @@ -11,6 +11,24 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') { exit; } +if (!empty($_GET['bundle'])) { + $role = $tokenRec['role'] ?? ''; + if (!in_array($role, ['admin', 'supervisor'], true)) { + header('Content-Type: application/json; charset=UTF-8'); + http_response_code(403); + echo json_encode(["error" => "Permission denied"]); + exit; + } + [$pdo, $tmpDb, $lockFp] = qdb_open(false); + $bundle = qdb_export_all_questionnaires_bundle($pdo); + qdb_discard($tmpDb, $lockFp); + $filename = 'questionnaires_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; +} + $qnID = $_GET['questionnaireID'] ?? ''; $format = strtolower($_GET['format'] ?? 'csv'); diff --git a/common.php b/common.php index b5be4fa..1a3b33a 100644 --- a/common.php +++ b/common.php @@ -385,15 +385,7 @@ function qdb_put_translation(PDO $pdo, string $type, string $id, string $lang, s /** Short label for translation UI (Key column); full text stays in `key` for API lookup. */ function qdb_translation_display_key(array $e): string { - if (!empty($e['displayKey'])) { - return $e['displayKey']; - } - if (($e['type'] ?? '') === 'question' || ($e['type'] ?? '') === 'answer_option') { - $id = $e['entityId'] ?? ''; - $pos = strrpos($id, '__'); - return $pos !== false ? substr($id, $pos + 2) : $id; - } - return $e['key'] ?? ''; + return qdb_translation_row_label($e); } function qdb_short_entity_label(string $entityId): string { @@ -425,9 +417,10 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array { foreach ($dbQuestions as $dbQ) { $qOrder = (int)($dbQ['orderIndex'] ?? 0); + $qLocal = qdb_question_local_id($dbQ['questionID']); $contentEntries[] = [ 'key' => $dbQ['defaultText'], - 'displayKey' => qdb_short_entity_label($dbQ['questionID']), + 'displayKey' => preg_match('/^[a-f0-9]{32}$/i', $qLocal) ? ('#' . ($qOrder + 1)) : $qLocal, 'type' => 'question', 'entityId' => $dbQ['questionID'], 'sortOrder' => $qOrder * 1000, @@ -439,9 +432,12 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array { "); $aoStmt->execute([':qid' => $dbQ['questionID']]); foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { + $optLabel = preg_match('/^[a-f0-9]{32}$/i', $qLocal) + ? ('#' . ($qOrder + 1) . ' · option ' . ((int)$ao['orderIndex'] + 1)) + : ($qLocal . ' · option ' . ((int)$ao['orderIndex'] + 1)); $contentEntries[] = [ 'key' => $ao['defaultText'], - 'displayKey' => qdb_short_entity_label($ao['answerOptionID']), + 'displayKey' => $optLabel, 'type' => 'answer_option', 'entityId' => $ao['answerOptionID'], 'sortOrder' => $qOrder * 1000 + 10 + (int)($ao['orderIndex'] ?? 0), @@ -588,6 +584,342 @@ function qdb_build_questionnaire_translations_map(PDO $pdo, string $qnID): array return $result; } +/** @return array */ +function qdb_translation_rows_to_map(array $rows): array { + $map = []; + foreach ($rows as $row) { + if (is_array($row) && isset($row['languageCode'])) { + $map[$row['languageCode']] = $row['text'] ?? ''; + } + } + return $map; +} + +/** @return array */ +function qdb_fetch_translation_map(PDO $pdo, string $type, string $entityId): array { + if ($type === 'question') { + $stmt = $pdo->prepare('SELECT languageCode, text FROM question_translation WHERE questionID = :id'); + } elseif ($type === 'answer_option') { + $stmt = $pdo->prepare('SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id'); + } else { + $stmt = $pdo->prepare('SELECT languageCode, text FROM string_translation WHERE stringKey = :id'); + } + $stmt->execute([':id' => $entityId]); + return qdb_translation_rows_to_map($stmt->fetchAll(PDO::FETCH_ASSOC)); +} + +function qdb_apply_translation_map(PDO $pdo, string $type, string $entityId, array $map): void { + foreach ($map as $lang => $text) { + if (!is_string($lang) || $lang === '') { + continue; + } + qdb_put_translation($pdo, $type, $entityId, $lang, (string)$text); + } +} + +function qdb_question_local_id(string $questionID, ?string $questionnaireID = null): string { + $pos = strrpos($questionID, '__'); + return $pos !== false ? substr($questionID, $pos + 2) : $questionID; +} + +function qdb_make_question_id(string $questionnaireID, string $localId): string { + return $questionnaireID . '__' . $localId; +} + +/** Next short id for a new question (q1, q2, …) within one questionnaire. */ +function qdb_allocate_question_local_id(PDO $pdo, string $questionnaireID): string { + $stmt = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id'); + $stmt->execute([':id' => $questionnaireID]); + $max = 0; + foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $questionID) { + $local = qdb_question_local_id($questionID); + if (preg_match('/^q(\d+)$/i', $local, $m)) { + $max = max($max, (int)$m[1]); + } + } + return 'q' . ($max + 1); +} + +/** Human-readable Key column label (not the app translation lookup key). */ +function qdb_translation_row_label(array $e): string { + if (!empty($e['displayKey']) && !preg_match('/^[a-f0-9]{32}$/i', $e['displayKey'])) { + return $e['displayKey']; + } + $type = $e['type'] ?? ''; + $key = $e['key'] ?? ''; + if ($type === 'question' || $type === 'answer_option') { + if ($key !== '') { + $short = mb_strlen($key) > 56 ? mb_substr($key, 0, 56) . '…' : $key; + return $short; + } + } + $id = $e['entityId'] ?? ''; + $pos = strrpos($id, '__'); + if ($pos !== false) { + return substr($id, $pos + 2); + } + return $key !== '' ? $key : $id; +} + +/** Export one questionnaire with structure + all translations (portable JSON). */ +function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array { + $qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id'); + $qn->execute([':id' => $qnID]); + $qnRow = $qn->fetch(PDO::FETCH_ASSOC); + if (!$qnRow) { + return null; + } + + $qStmt = $pdo->prepare(" + SELECT questionID, defaultText, type, orderIndex, isRequired, configJson + FROM question WHERE questionnaireID = :id ORDER BY orderIndex + "); + $qStmt->execute([':id' => $qnID]); + $questionsOut = []; + + foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $dbQ) { + $localId = qdb_question_local_id($dbQ['questionID'], $qnID); + $config = json_decode($dbQ['configJson'] ?: '{}', true) ?: []; + + $optionsOut = []; + $aoStmt = $pdo->prepare(" + SELECT answerOptionID, defaultText, points, orderIndex, nextQuestionId + FROM answer_option WHERE questionID = :qid ORDER BY orderIndex + "); + $aoStmt->execute([':qid' => $dbQ['questionID']]); + foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { + $optionsOut[] = [ + 'defaultText' => $ao['defaultText'], + 'points' => (int)$ao['points'], + 'orderIndex' => (int)$ao['orderIndex'], + 'nextQuestionId' => $ao['nextQuestionId'] ?? '', + 'translations' => qdb_fetch_translation_map($pdo, 'answer_option', $ao['answerOptionID']), + ]; + } + + $questionsOut[] = [ + 'localId' => $localId, + 'defaultText' => $dbQ['defaultText'], + 'type' => $dbQ['type'], + 'orderIndex' => (int)$dbQ['orderIndex'], + 'isRequired' => (int)$dbQ['isRequired'], + 'config' => $config, + 'answerOptions' => $optionsOut, + 'translations' => qdb_fetch_translation_map($pdo, 'question', $dbQ['questionID']), + ]; + } + + $lists = qdb_translation_entry_lists($pdo, $qnID); + $stringTranslations = []; + if (!empty($lists['stringEntries'])) { + $tr = qdb_load_translations_for_entries($pdo, $lists['stringEntries']); + foreach ($lists['stringEntries'] as $e) { + $stringTranslations[] = [ + 'stringKey' => $e['key'], + 'translations' => $tr[$e['entityId']] ?? [], + ]; + } + } + + return [ + 'questionnaire' => [ + 'questionnaireID' => $qnRow['questionnaireID'], + 'name' => $qnRow['name'], + 'version' => $qnRow['version'], + 'state' => $qnRow['state'], + 'orderIndex' => (int)$qnRow['orderIndex'], + 'showPoints' => (int)$qnRow['showPoints'], + 'conditionJson' => $qnRow['conditionJson'] ?: '{}', + ], + 'questions' => $questionsOut, + 'stringTranslations' => $stringTranslations, + 'translationsFlat' => qdb_build_questionnaire_translations_map($pdo, $qnID), + ]; +} + +/** Export every questionnaire as a portable bundle. */ +function qdb_export_all_questionnaires_bundle(PDO $pdo): array { + $ids = $pdo->query('SELECT questionnaireID FROM questionnaire ORDER BY orderIndex, name') + ->fetchAll(PDO::FETCH_COLUMN); + $items = []; + foreach ($ids as $id) { + $item = qdb_export_questionnaire_bundle($pdo, $id); + if ($item !== null) { + $items[] = $item; + } + } + return [ + 'exportVersion' => 1, + 'exportedAt' => time(), + 'sourceLanguage' => QDB_SOURCE_LANGUAGE, + 'questionnaireCount' => count($items), + 'questionnaires' => $items, + ]; +} + +/** Remove questionnaire and related rows (no client_answer cleanup for other qns). */ +function qdb_delete_questionnaire_cascade(PDO $pdo, string $qnID): void { + $qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id'); + $qIds->execute([':id' => $qnID]); + $questionIDs = $qIds->fetchAll(PDO::FETCH_COLUMN); + + foreach ($questionIDs as $qid) { + $aoIds = $pdo->prepare('SELECT answerOptionID FROM answer_option WHERE questionID = :qid'); + $aoIds->execute([':qid' => $qid]); + foreach ($aoIds->fetchAll(PDO::FETCH_COLUMN) as $aoid) { + $pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id') + ->execute([':id' => $aoid]); + } + $pdo->prepare('DELETE FROM answer_option WHERE questionID = :qid')->execute([':qid' => $qid]); + $pdo->prepare('DELETE FROM question_translation WHERE questionID = :qid')->execute([':qid' => $qid]); + $pdo->prepare('DELETE FROM client_answer WHERE questionID = :qid')->execute([':qid' => $qid]); + } + $pdo->prepare('DELETE FROM question WHERE questionnaireID = :id')->execute([':id' => $qnID]); + $pdo->prepare('DELETE FROM completed_questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]); + $pdo->prepare('DELETE FROM questionnaire WHERE questionnaireID = :id')->execute([':id' => $qnID]); +} + +/** + * Import one questionnaire from a bundle item. + * @return array{questionnaireID: string, created: bool, replaced: bool} + */ +function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfExists = false): array { + $meta = $item['questionnaire'] ?? []; + if (empty($meta['name'])) { + json_error('MISSING_FIELDS', 'questionnaire.name is required', 400); + } + + $wantedId = trim($meta['questionnaireID'] ?? ''); + $chk = $wantedId !== '' ? $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id') : null; + if ($chk) { + $chk->execute([':id' => $wantedId]); + } + $exists = $chk && (bool)$chk->fetch(); + + $replaced = false; + if ($exists && $replaceIfExists) { + qdb_delete_questionnaire_cascade($pdo, $wantedId); + $replaced = true; + $qnID = $wantedId; + } elseif ($exists) { + $qnID = bin2hex(random_bytes(16)); + } elseif ($wantedId !== '') { + $qnID = $wantedId; + } else { + $qnID = bin2hex(random_bytes(16)); + } + + $order = (int)($meta['orderIndex'] ?? 0); + if ($order === 0) { + $order = (int)$pdo->query('SELECT COALESCE(MAX(orderIndex),0)+1 FROM questionnaire')->fetchColumn(); + } + + $condJson = $meta['conditionJson'] ?? '{}'; + if (is_array($condJson)) { + $condJson = json_encode($condJson, JSON_UNESCAPED_UNICODE); + } + + $pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson) + VALUES (:id, :n, :v, :s, :o, :sp, :cj)") + ->execute([ + ':id' => $qnID, + ':n' => trim($meta['name']), + ':v' => trim($meta['version'] ?? ''), + ':s' => trim($meta['state'] ?? 'draft'), + ':o' => $order, + ':sp' => (int)($meta['showPoints'] ?? 0), + ':cj' => $condJson ?: '{}', + ]); + + foreach ($item['questions'] ?? [] as $q) { + $localId = trim($q['localId'] ?? ''); + if ($localId === '') { + $localId = 'q_' . bin2hex(random_bytes(4)); + } + $questionID = $qnID . '__' . $localId; + $text = trim($q['defaultText'] ?? ''); + qdb_require_non_empty_german($text, 'Question German text'); + + $config = $q['config'] ?? $q['configJson'] ?? []; + $configJson = is_string($config) ? $config : json_encode($config ?: [], JSON_UNESCAPED_UNICODE); + + $pdo->prepare("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson) + VALUES (:id, :qid, :t, :ty, :o, :r, :cj)") + ->execute([ + ':id' => $questionID, + ':qid' => $qnID, + ':t' => $text, + ':ty' => trim($q['type'] ?? ''), + ':o' => (int)($q['orderIndex'] ?? 0), + ':r' => (int)($q['isRequired'] ?? 0), + ':cj' => $configJson, + ]); + + $qTr = $q['translations'] ?? []; + if (is_array($qTr) && isset($qTr[0]['languageCode'])) { + $qTr = qdb_translation_rows_to_map($qTr); + } + qdb_upsert_source_translation($pdo, 'question', $questionID, $text); + qdb_apply_translation_map($pdo, 'question', $questionID, is_array($qTr) ? $qTr : []); + + foreach ($q['answerOptions'] ?? [] as $opt) { + $optText = trim($opt['defaultText'] ?? ''); + qdb_require_non_empty_german($optText, 'Answer option German text'); + $aoId = bin2hex(random_bytes(16)); + $pdo->prepare("INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId) + VALUES (:id, :qid, :t, :p, :o, :nq)") + ->execute([ + ':id' => $aoId, + ':qid' => $questionID, + ':t' => $optText, + ':p' => (int)($opt['points'] ?? 0), + ':o' => (int)($opt['orderIndex'] ?? 0), + ':nq' => trim($opt['nextQuestionId'] ?? ''), + ]); + $oTr = $opt['translations'] ?? []; + if (is_array($oTr) && isset($oTr[0]['languageCode'])) { + $oTr = qdb_translation_rows_to_map($oTr); + } + qdb_upsert_source_translation($pdo, 'answer_option', $aoId, $optText); + qdb_apply_translation_map($pdo, 'answer_option', $aoId, is_array($oTr) ? $oTr : []); + } + } + + foreach ($item['stringTranslations'] ?? [] as $st) { + $sk = trim($st['stringKey'] ?? ''); + if ($sk === '') { + continue; + } + $map = $st['translations'] ?? []; + if (is_array($map) && isset($map[0]['languageCode'])) { + $map = qdb_translation_rows_to_map($map); + } + qdb_apply_translation_map($pdo, 'string', $sk, is_array($map) ? $map : []); + } + + return [ + 'questionnaireID' => $qnID, + 'created' => true, + 'replaced' => $replaced, + ]; +} + +/** Import a full export bundle. */ +function qdb_import_questionnaires_bundle(PDO $pdo, array $bundle, bool $replaceIfExists = false): array { + $items = $bundle['questionnaires'] ?? []; + if (!is_array($items) || !$items) { + json_error('MISSING_FIELDS', 'bundle.questionnaires array is required', 400); + } + $results = []; + foreach ($items as $item) { + $results[] = qdb_import_questionnaire_bundle($pdo, $item, $replaceIfExists); + } + return [ + 'imported' => count($results), + 'results' => $results, + ]; +} + // --- AES-256-CBC: IV(16) + CIPHERTEXT --- function aes256_cbc_encrypt_bytes(string $plain, string $key): string { $key = str_pad(substr($key, 0, 32), 32, "\0"); diff --git a/handlers/export.php b/handlers/export.php index f4df063..a893a41 100644 --- a/handlers/export.php +++ b/handlers/export.php @@ -6,6 +6,19 @@ if ($method !== 'GET') { json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); } +if (!empty($_GET['bundle'])) { + require_role(['admin', 'supervisor'], $tokenRec); + [$pdo, $tmpDb, $lockFp] = qdb_open(false); + $bundle = qdb_export_all_questionnaires_bundle($pdo); + qdb_discard($tmpDb, $lockFp); + + $filename = 'questionnaires_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; +} + $qnID = $_GET['questionnaireID'] ?? ''; if (!$qnID) { json_error('MISSING_PARAM', 'questionnaireID query param required', 400); diff --git a/handlers/questionnaires.php b/handlers/questionnaires.php index 48ad455..ba331bc 100644 --- a/handlers/questionnaires.php +++ b/handlers/questionnaires.php @@ -28,6 +28,28 @@ case 'GET': 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); + } + $replaceIfExists = !empty($body['replaceIfExists']); + [$pdo, $tmpDb, $lockFp] = qdb_open(true); + try { + $pdo->exec('PRAGMA foreign_keys = OFF;'); + $result = qdb_import_questionnaires_bundle($pdo, $bundle, $replaceIfExists); + $pdo->exec('PRAGMA foreign_keys = ON;'); + qdb_save($tmpDb, $lockFp); + json_success($result); + } catch (Throwable $e) { + qdb_discard($tmpDb, $lockFp); + error_log('importBundle: ' . $e->getMessage()); + json_error('SERVER_ERROR', $e->getMessage(), 500); + } + break; + } + if (empty($body['name'])) { json_error('NAME_REQUIRED', 'name is required', 400); } diff --git a/handlers/questions.php b/handlers/questions.php index 73cf5dd..43d15d3 100644 --- a/handlers/questions.php +++ b/handlers/questions.php @@ -45,7 +45,6 @@ case 'POST': if (empty($body['questionnaireID']) || !isset($body['defaultText'])) { json_error('BAD_REQUEST', 'questionnaireID and defaultText required', 400); } - $id = bin2hex(random_bytes(16)); $qnID = $body['questionnaireID']; $text = trim($body['defaultText']); qdb_require_non_empty_german($text); @@ -66,6 +65,17 @@ case 'POST': $max->execute([':id' => $qnID]); $order = (int)$max->fetchColumn(); } + $localId = trim($body['localId'] ?? ''); + if ($localId === '') { + $localId = qdb_allocate_question_local_id($pdo, $qnID); + } + $id = qdb_make_question_id($qnID, $localId); + $dup = $pdo->prepare('SELECT 1 FROM question WHERE questionID = :id'); + $dup->execute([':id' => $id]); + if ($dup->fetch()) { + qdb_discard($tmpDb, $lockFp); + json_error('CONFLICT', "Question id \"$localId\" already exists in this questionnaire", 409); + } $pdo->prepare("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson) VALUES (:id, :qid, :t, :ty, :o, :r, :cj)") ->execute([':id' => $id, ':qid' => $qnID, ':t' => $text, ':ty' => $type, diff --git a/website/js/pages/dashboard.js b/website/js/pages/dashboard.js index 0cab215..f306760 100644 --- a/website/js/pages/dashboard.js +++ b/website/js/pages/dashboard.js @@ -1,4 +1,4 @@ -import { apiGet, apiPut, apiDelete } from '../api.js'; +import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { canEdit, showToast, getRole } from '../app.js'; import { navigate } from '../router.js'; @@ -14,8 +14,11 @@ export async function dashboardPage() { if (canEdit()) { document.getElementById('headerActions').innerHTML = ` + + + New Questionnaire `; + bindImportBundle(); } try { @@ -143,6 +146,61 @@ function initGridDrag(questionnaires) { }); } +function bindImportBundle() { + const fileInput = document.getElementById('importBundleFile'); + const btn = document.getElementById('importBundleBtn'); + if (!fileInput || !btn) return; + + btn.addEventListener('click', () => fileInput.click()); + + fileInput.addEventListener('change', async () => { + const file = fileInput.files?.[0]; + fileInput.value = ''; + if (!file) return; + + let bundle; + try { + const text = await file.text(); + bundle = JSON.parse(text); + } catch { + showToast('Invalid JSON file', 'error'); + return; + } + + if (!bundle?.questionnaires || !Array.isArray(bundle.questionnaires)) { + showToast('Not a questionnaire bundle (missing questionnaires array)', 'error'); + return; + } + + const count = bundle.questionnaires.length; + if (!confirm(`Import ${count} questionnaire(s) from this file?`)) return; + const replace = confirm( + 'If a questionnaire ID from the file already exists:\n\n' + + 'OK — replace that questionnaire\n' + + 'Cancel — import as a new copy (new ID)' + ); + + btn.disabled = true; + const prev = btn.textContent; + btn.textContent = 'Importing…'; + try { + const result = await apiPost('questionnaires.php', { + action: 'importBundle', + bundle, + replaceIfExists: replace, + }); + const n = result.imported ?? count; + showToast(`Imported ${n} questionnaire(s)`, 'success'); + await dashboardPage(); + } catch (e) { + showToast(e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = prev; + } + }); +} + function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; diff --git a/website/js/pages/export.js b/website/js/pages/export.js index aeb8956..9708b9c 100644 --- a/website/js/pages/export.js +++ b/website/js/pages/export.js @@ -1,5 +1,5 @@ import { apiGet } from '../api.js'; -import { showToast } from '../app.js'; +import { canEdit, showToast } from '../app.js'; export async function exportPage() { const app = document.getElementById('app'); @@ -33,7 +33,19 @@ function renderExport(questionnaires) { return; } + const bundleBlock = canEdit() ? ` +
+

Export questionnaires (JSON)

+

+ Download all questionnaires with questions, answer options, and every translation. + Use Import questionnaires on the dashboard to restore this file on another server. +

+ +
+ ` : ''; + container.innerHTML = ` + ${bundleBlock}

Select a questionnaire and click Export to download a CSV file with all client responses (filtered by your role's visibility).

@@ -66,6 +78,37 @@ function renderExport(questionnaires) { `; + document.getElementById('exportBundleBtn')?.addEventListener('click', async () => { + const btn = document.getElementById('exportBundleBtn'); + btn.disabled = true; + const prev = btn.textContent; + btn.textContent = 'Preparing…'; + try { + const token = localStorage.getItem('qdb_token'); + const res = await fetch('../api/export?bundle=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 = `questionnaires_bundle_${stamp}.json`; + a.click(); + URL.revokeObjectURL(url); + showToast('Questionnaire bundle downloaded', 'success'); + } catch (e) { + showToast(e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = prev; + } + }); + container.querySelectorAll('.export-btn').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; diff --git a/website/js/translations-helpers.js b/website/js/translations-helpers.js index 7ca10aa..f3ed0d5 100644 --- a/website/js/translations-helpers.js +++ b/website/js/translations-helpers.js @@ -25,15 +25,24 @@ export function germanFor(entry) { return entry.germanText || normalizeTranslations(entry.translations)[SOURCE_LANG] || entry.key || ''; } -/** Key column label (short id for questions/options; catalog key for UI strings). */ +const HEX_ID_RE = /^[a-f0-9]{32}$/i; + +/** Key column label (short id or readable preview — not the app lookup key). */ export function entryDisplayKey(entry) { - if (entry.displayKey) return entry.displayKey; + const dk = entry.displayKey || ''; + if (dk && !HEX_ID_RE.test(dk)) return dk; + if (entry.type === 'question' || entry.type === 'answer_option') { + const key = (entry.key || '').trim(); + if (key) { + return key.length > 56 ? `${key.slice(0, 56)}…` : key; + } + } if (entry.type === 'question' || entry.type === 'answer_option') { const id = entry.entityId || ''; const sep = id.lastIndexOf('__'); - return sep >= 0 ? id.slice(sep + 2) : id; + if (sep >= 0) return id.slice(sep + 2); } - return entry.key || ''; + return dk || entry.key || ''; } export function translationFor(entry, lang) {