added quesionnaire export and import
This commit is contained in:
@ -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');
|
||||||
|
|
||||||
|
|||||||
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. */
|
/** 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");
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 ?? '';
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user