added quesionnaire export and import

This commit is contained in:
2026-05-22 17:36:40 +02:00
parent be01b85569
commit 244fd16140
8 changed files with 523 additions and 18 deletions

View File

@ -11,6 +11,24 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
exit; 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'] ?? ''; $qnID = $_GET['questionnaireID'] ?? '';
$format = strtolower($_GET['format'] ?? 'csv'); $format = strtolower($_GET['format'] ?? 'csv');

View File

@ -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. */ /** Short label for translation UI (Key column); full text stays in `key` for API lookup. */
function qdb_translation_display_key(array $e): string { function qdb_translation_display_key(array $e): string {
if (!empty($e['displayKey'])) { return qdb_translation_row_label($e);
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'] ?? '';
} }
function qdb_short_entity_label(string $entityId): string { 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) { foreach ($dbQuestions as $dbQ) {
$qOrder = (int)($dbQ['orderIndex'] ?? 0); $qOrder = (int)($dbQ['orderIndex'] ?? 0);
$qLocal = qdb_question_local_id($dbQ['questionID']);
$contentEntries[] = [ $contentEntries[] = [
'key' => $dbQ['defaultText'], 'key' => $dbQ['defaultText'],
'displayKey' => qdb_short_entity_label($dbQ['questionID']), 'displayKey' => preg_match('/^[a-f0-9]{32}$/i', $qLocal) ? ('#' . ($qOrder + 1)) : $qLocal,
'type' => 'question', 'type' => 'question',
'entityId' => $dbQ['questionID'], 'entityId' => $dbQ['questionID'],
'sortOrder' => $qOrder * 1000, 'sortOrder' => $qOrder * 1000,
@ -439,9 +432,12 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array {
"); ");
$aoStmt->execute([':qid' => $dbQ['questionID']]); $aoStmt->execute([':qid' => $dbQ['questionID']]);
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { 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[] = [ $contentEntries[] = [
'key' => $ao['defaultText'], 'key' => $ao['defaultText'],
'displayKey' => qdb_short_entity_label($ao['answerOptionID']), 'displayKey' => $optLabel,
'type' => 'answer_option', 'type' => 'answer_option',
'entityId' => $ao['answerOptionID'], 'entityId' => $ao['answerOptionID'],
'sortOrder' => $qOrder * 1000 + 10 + (int)($ao['orderIndex'] ?? 0), '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 $result;
} }
/** @return array<string, string> */
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<string, string> */
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 --- // --- AES-256-CBC: IV(16) + CIPHERTEXT ---
function aes256_cbc_encrypt_bytes(string $plain, string $key): string { function aes256_cbc_encrypt_bytes(string $plain, string $key): string {
$key = str_pad(substr($key, 0, 32), 32, "\0"); $key = str_pad(substr($key, 0, 32), 32, "\0");

View File

@ -6,6 +6,19 @@ if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); 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'] ?? ''; $qnID = $_GET['questionnaireID'] ?? '';
if (!$qnID) { if (!$qnID) {
json_error('MISSING_PARAM', 'questionnaireID query param required', 400); json_error('MISSING_PARAM', 'questionnaireID query param required', 400);

View File

@ -28,6 +28,28 @@ case 'GET':
case 'POST': case 'POST':
require_role(['admin', 'supervisor'], $tokenRec); require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body(); $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'])) { if (empty($body['name'])) {
json_error('NAME_REQUIRED', 'name is required', 400); json_error('NAME_REQUIRED', 'name is required', 400);
} }

View File

@ -45,7 +45,6 @@ case 'POST':
if (empty($body['questionnaireID']) || !isset($body['defaultText'])) { if (empty($body['questionnaireID']) || !isset($body['defaultText'])) {
json_error('BAD_REQUEST', 'questionnaireID and defaultText required', 400); json_error('BAD_REQUEST', 'questionnaireID and defaultText required', 400);
} }
$id = bin2hex(random_bytes(16));
$qnID = $body['questionnaireID']; $qnID = $body['questionnaireID'];
$text = trim($body['defaultText']); $text = trim($body['defaultText']);
qdb_require_non_empty_german($text); qdb_require_non_empty_german($text);
@ -66,6 +65,17 @@ case 'POST':
$max->execute([':id' => $qnID]); $max->execute([':id' => $qnID]);
$order = (int)$max->fetchColumn(); $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) $pdo->prepare("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qid, :t, :ty, :o, :r, :cj)") VALUES (:id, :qid, :t, :ty, :o, :r, :cj)")
->execute([':id' => $id, ':qid' => $qnID, ':t' => $text, ':ty' => $type, ->execute([':id' => $id, ':qid' => $qnID, ':t' => $text, ':ty' => $type,

View File

@ -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 { canEdit, showToast, getRole } from '../app.js';
import { navigate } from '../router.js'; import { navigate } from '../router.js';
@ -14,8 +14,11 @@ export async function dashboardPage() {
if (canEdit()) { if (canEdit()) {
document.getElementById('headerActions').innerHTML = ` document.getElementById('headerActions').innerHTML = `
<input type="file" id="importBundleFile" accept=".json,application/json" hidden>
<button type="button" class="btn" id="importBundleBtn">Import questionnaires</button>
<a href="#/questionnaire/new" class="btn btn-primary">+ New Questionnaire</a> <a href="#/questionnaire/new" class="btn btn-primary">+ New Questionnaire</a>
`; `;
bindImportBundle();
} }
try { 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) { function esc(s) {
const d = document.createElement('div'); const d = document.createElement('div');
d.textContent = s ?? ''; d.textContent = s ?? '';

View File

@ -1,5 +1,5 @@
import { apiGet } from '../api.js'; import { apiGet } from '../api.js';
import { showToast } from '../app.js'; import { canEdit, showToast } from '../app.js';
export async function exportPage() { export async function exportPage() {
const app = document.getElementById('app'); const app = document.getElementById('app');
@ -33,7 +33,19 @@ function renderExport(questionnaires) {
return; return;
} }
const bundleBlock = canEdit() ? `
<div class="card" style="margin-bottom:20px;padding:16px 20px">
<h3 style="margin:0 0 8px;font-size:1.05rem">Export questionnaires (JSON)</h3>
<p style="margin:0 0 14px;color:var(--text-secondary);font-size:.9rem">
Download all questionnaires with questions, answer options, and every translation.
Use <strong>Import questionnaires</strong> on the dashboard to restore this file on another server.
</p>
<button type="button" class="btn btn-primary" id="exportBundleBtn">Export all questionnaires (JSON)</button>
</div>
` : '';
container.innerHTML = ` container.innerHTML = `
${bundleBlock}
<p style="margin-bottom:16px;color:var(--text-secondary)"> <p style="margin-bottom:16px;color:var(--text-secondary)">
Select a questionnaire and click Export to download a CSV file with all client responses (filtered by your role's visibility). Select a questionnaire and click Export to download a CSV file with all client responses (filtered by your role's visibility).
</p> </p>
@ -66,6 +78,37 @@ function renderExport(questionnaires) {
</table> </table>
`; `;
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 => { container.querySelectorAll('.export-btn').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
btn.disabled = true; btn.disabled = true;

View File

@ -25,15 +25,24 @@ export function germanFor(entry) {
return entry.germanText || normalizeTranslations(entry.translations)[SOURCE_LANG] || entry.key || ''; 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) { 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') { if (entry.type === 'question' || entry.type === 'answer_option') {
const id = entry.entityId || ''; const id = entry.entityId || '';
const sep = id.lastIndexOf('__'); 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) { export function translationFor(entry, lang) {