enhanced questionnaire handling with stable key validation, improved note management, and added option key support
This commit is contained in:
266
common.php
266
common.php
@ -305,10 +305,19 @@ function qdb_all_questionnaire_translation_key_set(PDO $pdo): array {
|
||||
$keys = [];
|
||||
$rows = $pdo->query("SELECT defaultText, configJson FROM question")->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $dbQ) {
|
||||
if ($dbQ['defaultText'] !== '') {
|
||||
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
|
||||
$qk = qdb_question_key($config, $dbQ['defaultText']);
|
||||
if ($qk !== '') {
|
||||
$keys[$qk] = true;
|
||||
if (!empty($config['noteBefore'])) {
|
||||
$keys[qdb_note_before_key($qk)] = true;
|
||||
}
|
||||
if (!empty($config['noteAfter'])) {
|
||||
$keys[qdb_note_after_key($qk)] = true;
|
||||
}
|
||||
} elseif ($dbQ['defaultText'] !== '') {
|
||||
$keys[$dbQ['defaultText']] = true;
|
||||
}
|
||||
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
|
||||
foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2'] as $field) {
|
||||
if (!empty($config[$field])) {
|
||||
$keys[$config[$field]] = true;
|
||||
@ -373,10 +382,7 @@ function qdb_put_translation(PDO $pdo, string $type, string $id, string $lang, s
|
||||
VALUES (:id, :lang, :t)
|
||||
ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text")
|
||||
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
|
||||
if ($lang === QDB_SOURCE_LANGUAGE) {
|
||||
$pdo->prepare("UPDATE answer_option SET defaultText = :t WHERE answerOptionID = :id")
|
||||
->execute([':t' => $text, ':id' => $id]);
|
||||
}
|
||||
// defaultText holds optionKey; German label lives only in answer_option_translation.
|
||||
} elseif ($type === 'string' || $type === 'app_string') {
|
||||
$pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text)
|
||||
VALUES (:id, :lang, :t)
|
||||
@ -401,6 +407,135 @@ function qdb_require_non_empty_german(string $text, string $label = 'German text
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable identifier: snake_case starting with a letter (case preserved for LM keys). */
|
||||
function qdb_is_stable_key(string $s): bool {
|
||||
return $s !== '' && (bool)preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $s);
|
||||
}
|
||||
|
||||
function qdb_validate_stable_key(string $s, string $label = 'Key'): string {
|
||||
$s = trim($s);
|
||||
if (!qdb_is_stable_key($s)) {
|
||||
json_error('INVALID_FIELD', "$label must match [a-zA-Z][a-zA-Z0-9_]*", 400);
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
|
||||
/** Resolve app lookup key for a question row. */
|
||||
function qdb_question_key(array $config, string $defaultText): string {
|
||||
$key = trim((string)($config['questionKey'] ?? ''));
|
||||
if ($key !== '' && qdb_is_stable_key($key)) {
|
||||
return $key;
|
||||
}
|
||||
if (qdb_is_stable_key($defaultText)) {
|
||||
return $defaultText;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Option key is stored in answer_option.defaultText. */
|
||||
function qdb_option_key(array $aoRow): string {
|
||||
$dt = trim((string)($aoRow['defaultText'] ?? ''));
|
||||
return qdb_is_stable_key($dt) ? $dt : '';
|
||||
}
|
||||
|
||||
function qdb_note_before_key(string $questionKey): string {
|
||||
return $questionKey . '_note_before';
|
||||
}
|
||||
|
||||
function qdb_note_after_key(string $questionKey): string {
|
||||
return $questionKey . '_note_after';
|
||||
}
|
||||
|
||||
/** Strip deprecated fields and persist questionKey + notes in config. */
|
||||
function qdb_normalize_question_config(array $config, string $questionKey, ?string $noteBefore = null, ?string $noteAfter = null): array {
|
||||
unset($config['multiline']);
|
||||
$config['questionKey'] = $questionKey;
|
||||
if ($noteBefore !== null) {
|
||||
$nb = trim($noteBefore);
|
||||
if ($nb !== '') {
|
||||
$config['noteBefore'] = $nb;
|
||||
} else {
|
||||
unset($config['noteBefore']);
|
||||
}
|
||||
}
|
||||
if ($noteAfter !== null) {
|
||||
$na = trim($noteAfter);
|
||||
if ($na !== '') {
|
||||
$config['noteAfter'] = $na;
|
||||
} else {
|
||||
unset($config['noteAfter']);
|
||||
}
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
function qdb_parse_config_json($configJson): array {
|
||||
if (is_array($configJson)) {
|
||||
$config = $configJson;
|
||||
} else {
|
||||
$config = json_decode($configJson ?: '{}', true) ?: [];
|
||||
}
|
||||
unset($config['multiline']);
|
||||
return $config;
|
||||
}
|
||||
|
||||
/** Sync note German text into string_translation rows for the app. */
|
||||
function qdb_sync_question_note_strings(PDO $pdo, string $questionKey, array $config): void {
|
||||
if ($questionKey === '') {
|
||||
return;
|
||||
}
|
||||
foreach ([
|
||||
'noteBefore' => qdb_note_before_key($questionKey),
|
||||
'noteAfter' => qdb_note_after_key($questionKey),
|
||||
] as $field => $stringKey) {
|
||||
$text = trim((string)($config[$field] ?? ''));
|
||||
if ($text !== '') {
|
||||
qdb_put_translation($pdo, 'string', $stringKey, QDB_SOURCE_LANGUAGE, $text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_assert_unique_question_key(PDO $pdo, string $qnID, string $questionKey, ?string $excludeQuestionID = null): void {
|
||||
$stmt = $pdo->prepare('SELECT questionID, configJson, defaultText FROM question WHERE questionnaireID = :qn');
|
||||
$stmt->execute([':qn' => $qnID]);
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
if ($excludeQuestionID !== null && $row['questionID'] === $excludeQuestionID) {
|
||||
continue;
|
||||
}
|
||||
$cfg = json_decode($row['configJson'] ?: '{}', true) ?: [];
|
||||
$existing = qdb_question_key($cfg, $row['defaultText']);
|
||||
if ($existing !== '' && $existing === $questionKey) {
|
||||
json_error('CONFLICT', "Question key \"$questionKey\" already exists in this questionnaire", 409);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_assert_unique_option_key(PDO $pdo, string $questionID, string $optionKey, ?string $excludeOptionID = null): void {
|
||||
$stmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid');
|
||||
$stmt->execute([':qid' => $questionID]);
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
if ($excludeOptionID !== null && $row['answerOptionID'] === $excludeOptionID) {
|
||||
continue;
|
||||
}
|
||||
if ($row['defaultText'] === $optionKey) {
|
||||
json_error('CONFLICT', "Option key \"$optionKey\" already exists on this question", 409);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** German label for an option (translation de, else legacy defaultText when not a stable key). */
|
||||
function qdb_option_german_label(PDO $pdo, string $answerOptionID, string $storedDefaultText): string {
|
||||
$tr = qdb_fetch_translation_map($pdo, 'answer_option', $answerOptionID);
|
||||
$de = trim($tr[QDB_SOURCE_LANGUAGE] ?? '');
|
||||
if ($de !== '') {
|
||||
return $de;
|
||||
}
|
||||
if (!qdb_is_stable_key($storedDefaultText)) {
|
||||
return $storedDefaultText;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build translation entry lists for one questionnaire.
|
||||
* Returns ['stringEntries' => [...], 'contentEntries' => [...]] (questions + options interleaved).
|
||||
@ -420,9 +555,16 @@ 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']);
|
||||
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
|
||||
$qKey = qdb_question_key($config, $dbQ['defaultText']);
|
||||
$germanQ = trim($dbQ['defaultText']);
|
||||
if ($qKey !== '' && qdb_is_stable_key($germanQ) && $germanQ === $qKey) {
|
||||
$germanQ = '';
|
||||
}
|
||||
$contentEntries[] = [
|
||||
'key' => $dbQ['defaultText'],
|
||||
'displayKey' => preg_match('/^[a-f0-9]{32}$/i', $qLocal) ? ('#' . ($qOrder + 1)) : $qLocal,
|
||||
'key' => $qKey !== '' ? $qKey : $dbQ['defaultText'],
|
||||
'displayKey' => $qKey !== '' ? $qKey : (preg_match('/^[a-f0-9]{32}$/i', $qLocal) ? ('#' . ($qOrder + 1)) : $qLocal),
|
||||
'germanText' => $germanQ,
|
||||
'type' => 'question',
|
||||
'entityId' => $dbQ['questionID'],
|
||||
'sortOrder' => $qOrder * 1000,
|
||||
@ -434,20 +576,23 @@ 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));
|
||||
$optKey = qdb_option_key($ao);
|
||||
$optLabel = $optKey !== ''
|
||||
? ($qLocal . ' · ' . $optKey)
|
||||
: (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'],
|
||||
'key' => $optKey !== '' ? $optKey : $ao['defaultText'],
|
||||
'displayKey' => $optLabel,
|
||||
'germanText' => qdb_option_german_label($pdo, $ao['answerOptionID'], $ao['defaultText']),
|
||||
'type' => 'answer_option',
|
||||
'entityId' => $ao['answerOptionID'],
|
||||
'sortOrder' => $qOrder * 1000 + 10 + (int)($ao['orderIndex'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
|
||||
foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2'] as $field) {
|
||||
foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2', 'otherOptionKey'] as $field) {
|
||||
if (!empty($config[$field])) {
|
||||
$stringKeys[$config[$field]] = true;
|
||||
}
|
||||
@ -457,6 +602,14 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array {
|
||||
$stringKeys[$s] = true;
|
||||
}
|
||||
}
|
||||
if ($qKey !== '') {
|
||||
if (!empty($config['noteBefore'])) {
|
||||
$stringKeys[qdb_note_before_key($qKey)] = true;
|
||||
}
|
||||
if (!empty($config['noteAfter'])) {
|
||||
$stringKeys[qdb_note_after_key($qKey)] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$appKeySet = qdb_app_string_key_set();
|
||||
@ -689,18 +842,27 @@ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
|
||||
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
|
||||
");
|
||||
$aoStmt->execute([':qid' => $dbQ['questionID']]);
|
||||
$qKey = qdb_question_key($config, $dbQ['defaultText']);
|
||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$optKey = qdb_option_key($ao);
|
||||
$optTr = qdb_fetch_translation_map($pdo, 'answer_option', $ao['answerOptionID']);
|
||||
$optGerman = trim($optTr[QDB_SOURCE_LANGUAGE] ?? '');
|
||||
if ($optGerman === '' && !qdb_is_stable_key($ao['defaultText'])) {
|
||||
$optGerman = $ao['defaultText'];
|
||||
}
|
||||
$optionsOut[] = [
|
||||
'defaultText' => $ao['defaultText'],
|
||||
'optionKey' => $optKey !== '' ? $optKey : $ao['defaultText'],
|
||||
'defaultText' => $optGerman,
|
||||
'points' => (int)$ao['points'],
|
||||
'orderIndex' => (int)$ao['orderIndex'],
|
||||
'nextQuestionId' => $ao['nextQuestionId'] ?? '',
|
||||
'translations' => qdb_fetch_translation_map($pdo, 'answer_option', $ao['answerOptionID']),
|
||||
'translations' => $optTr,
|
||||
];
|
||||
}
|
||||
|
||||
$questionsOut[] = [
|
||||
'localId' => $localId,
|
||||
'questionKey' => $qKey,
|
||||
'defaultText' => $dbQ['defaultText'],
|
||||
'type' => $dbQ['type'],
|
||||
'orderIndex' => (int)$dbQ['orderIndex'],
|
||||
@ -839,11 +1001,33 @@ function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfE
|
||||
$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);
|
||||
$config = qdb_parse_config_json($q['config'] ?? $q['configJson'] ?? []);
|
||||
$questionKey = trim((string)($q['questionKey'] ?? $config['questionKey'] ?? ''));
|
||||
$text = trim($q['defaultText'] ?? '');
|
||||
$qTr = $q['translations'] ?? [];
|
||||
if (is_array($qTr) && isset($qTr[0]['languageCode'])) {
|
||||
$qTr = qdb_translation_rows_to_map($qTr);
|
||||
}
|
||||
if ($questionKey === '' && qdb_is_stable_key($text) && trim($qTr[QDB_SOURCE_LANGUAGE] ?? '') !== '' && $qTr[QDB_SOURCE_LANGUAGE] !== $text) {
|
||||
$questionKey = $text;
|
||||
$text = trim($qTr[QDB_SOURCE_LANGUAGE]);
|
||||
}
|
||||
if ($questionKey === '') {
|
||||
json_error(
|
||||
'MISSING_FIELDS',
|
||||
"Question key is required for {$qnID} / {$localId} (stable key [a-z][a-z0-9_]*)",
|
||||
400
|
||||
);
|
||||
}
|
||||
$questionKey = qdb_validate_stable_key($questionKey, 'Question key');
|
||||
// Duplicate question keys in one questionnaire are allowed (same prompt in different branches).
|
||||
|
||||
qdb_require_non_empty_german($text, 'Question German text');
|
||||
$noteBefore = $config['noteBefore'] ?? null;
|
||||
$noteAfter = $config['noteAfter'] ?? null;
|
||||
$config = qdb_normalize_question_config($config, $questionKey, $noteBefore, $noteAfter);
|
||||
$configJson = 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)")
|
||||
@ -857,32 +1041,50 @@ function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfE
|
||||
':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 : []);
|
||||
qdb_sync_question_note_strings($pdo, $questionKey, $config);
|
||||
|
||||
foreach ($q['answerOptions'] ?? [] as $opt) {
|
||||
$optText = trim($opt['defaultText'] ?? '');
|
||||
qdb_require_non_empty_german($optText, 'Answer option German text');
|
||||
$optKey = trim((string)($opt['optionKey'] ?? ''));
|
||||
$optGerman = trim($opt['defaultText'] ?? '');
|
||||
$oTr = $opt['translations'] ?? [];
|
||||
if (is_array($oTr) && isset($oTr[0]['languageCode'])) {
|
||||
$oTr = qdb_translation_rows_to_map($oTr);
|
||||
}
|
||||
if ($optKey === '' && qdb_is_stable_key($optGerman) && trim($oTr[QDB_SOURCE_LANGUAGE] ?? '') !== '' && $oTr[QDB_SOURCE_LANGUAGE] !== $optGerman) {
|
||||
$optKey = $optGerman;
|
||||
$optGerman = trim($oTr[QDB_SOURCE_LANGUAGE]);
|
||||
}
|
||||
if ($optKey === '' && qdb_is_stable_key($optGerman)) {
|
||||
$optKey = $optGerman;
|
||||
$optGerman = trim($oTr[QDB_SOURCE_LANGUAGE] ?? $optGerman);
|
||||
}
|
||||
if ($optKey === '') {
|
||||
json_error(
|
||||
'MISSING_FIELDS',
|
||||
"Option key is required for {$qnID} / {$localId} option #" . ((int)($opt['orderIndex'] ?? 0) + 1),
|
||||
400
|
||||
);
|
||||
}
|
||||
$optKey = qdb_validate_stable_key($optKey, 'Option key');
|
||||
if ($optGerman === '') {
|
||||
$optGerman = trim($oTr[QDB_SOURCE_LANGUAGE] ?? '');
|
||||
}
|
||||
qdb_require_non_empty_german($optGerman, '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,
|
||||
':t' => $optKey,
|
||||
':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_upsert_source_translation($pdo, 'answer_option', $aoId, $optGerman);
|
||||
qdb_apply_translation_map($pdo, 'answer_option', $aoId, is_array($oTr) ? $oTr : []);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user