added quesionnaire export and import
This commit is contained in:
@ -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');
|
||||
|
||||
|
||||
354
common.php
354
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<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 ---
|
||||
function aes256_cbc_encrypt_bytes(string $plain, string $key): string {
|
||||
$key = str_pad(substr($key, 0, 32), 32, "\0");
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = `
|
||||
<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>
|
||||
`;
|
||||
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 ?? '';
|
||||
|
||||
@ -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() ? `
|
||||
<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 = `
|
||||
${bundleBlock}
|
||||
<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).
|
||||
</p>
|
||||
@ -66,6 +78,37 @@ function renderExport(questionnaires) {
|
||||
</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 => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
|
||||
@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user