added quesionnaire export and import
This commit is contained in:
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");
|
||||
|
||||
Reference in New Issue
Block a user