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

@ -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");