Passed
Pull Request — master (#7066)
by
unknown
09:10
created

chExerciseHasAnswer()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 3
nc 3
nop 1
dl 0
loc 7
rs 10
c 1
b 0
f 0
1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
use Chamilo\CoreBundle\Enums\ActionIcon;
6
use Chamilo\CoreBundle\Enums\ObjectIcon;
7
use Chamilo\CoreBundle\Enums\StateIcon;
8
use Chamilo\CoreBundle\Framework\Container;
9
use Chamilo\CourseBundle\Entity\CQuizQuestion;
10
use Chamilo\CourseBundle\Entity\CQuizRelQuestion;
11
use ChamiloSession as Session;
12
13
/**
14
 * Exercise feedback modal (self-evaluation / popup).
15
 *
16
 * This script is loaded inside the global modal to show immediate feedback
17
 * (score, expected answers and navigation links) for a single question.
18
 * @author Julio Montoya <[email protected]>
19
 */
20
require_once __DIR__.'/../inc/global.inc.php';
21
$current_course_tool = TOOL_QUIZ;
22
23
api_protect_course_script();
24
25
require_once api_get_path(LIBRARY_PATH).'geometry.lib.php';
26
27
/** @var Exercise $objExercise */
28
$objExercise = Session::read('objExercise');
29
$exerciseResult = Session::read('exerciseResult');
30
31
if (empty($objExercise)) {
32
    api_not_allowed();
33
}
34
35
$feedbackType = $objExercise->getFeedbackType();
36
$exerciseType = $objExercise->type;
37
38
// Adaptive mode: direct feedback behaves as an adaptive flow.
39
$isAdaptative = (EXERCISE_FEEDBACK_TYPE_DIRECT === $feedbackType);
40
41
// Only direct or popup feedback are supported here.
42
if (!in_array($feedbackType, [EXERCISE_FEEDBACK_TYPE_DIRECT, EXERCISE_FEEDBACK_TYPE_POPUP], true)) {
43
    api_not_allowed();
44
}
45
46
$learnpath_id = (int) ($_REQUEST['learnpath_id'] ?? 0);
47
$learnpath_item_id = (int) ($_REQUEST['learnpath_item_id'] ?? 0);
48
$learnpath_item_view_id = (int) ($_REQUEST['learnpath_item_view_id'] ?? 0);
49
$exerciseId = (int) ($_GET['exerciseId'] ?? 0);
50
$exeId = (int) (Session::read('exe_id') ?? 0);
51
$preview = (int) ($_GET['preview'] ?? 0);
52
53
$cidreq = api_get_cidreq();
54
55
// Base URLs used for navigation from the modal.
56
$exerciseBaseUrl = api_get_path(WEB_CODE_PATH).'exercise/exercise_submit.php?'
57
    .$cidreq
58
    .'&exerciseId='.$exerciseId
59
    .'&learnpath_id='.$learnpath_id
60
    .'&learnpath_item_id='.$learnpath_item_id
61
    .'&learnpath_item_view_id='.$learnpath_item_view_id;
62
63
if ($preview) {
64
    $exerciseBaseUrl .= '&preview='.$preview;
65
}
66
67
$exerciseResultUrl = api_get_path(WEB_CODE_PATH).'exercise/exercise_result.php?'
68
    .$cidreq
69
    .'&exe_id='.$exeId
70
    .'&learnpath_id='.$learnpath_id
71
    .'&learnpath_item_id='.$learnpath_item_id
72
    .'&learnpath_item_view_id='.$learnpath_item_view_id;
73
74
// Question list and current question index.
75
$questionList = array_values(Session::read('questionList') ?? []);
76
$questionNum = max(0, ((int) ($_GET['num'] ?? 1)) - 1);
77
$questionId = $questionList[$questionNum] ?? null;
78
79
$logPrefix = '[exercise_submit_modal] ';
80
81
/**
82
 * Small helper: determine if an answer container really has data.
83
 *
84
 * - For arrays: at least one element.
85
 * - For scalars: '' and null are considered empty. "0" is a valid answer.
86
 */
87
function chExerciseHasAnswer($value): bool
88
{
89
    if (is_array($value)) {
90
        return count($value) > 0;
91
    }
92
93
    return $value !== null && $value !== '';
94
}
95
96
/**
97
 * Normalize a question answer container into a flat list of selected answer IDs.
98
 *
99
 * This tries to detect the most probable pattern:
100
 * - id => id  (e.g. [69432 => '69432'])
101
 * - 0..N-1 => id (e.g. [0 => '69432', 1 => '69433'])
102
 * - scalar '69432'
103
 */
104
function chExerciseExtractAnswerIds($value): array
105
{
106
    if (!is_array($value)) {
107
        return is_numeric($value) ? [(int) $value] : [];
108
    }
109
110
    $keys = array_keys($value);
111
    $vals = array_values($value);
112
113
    $allNumericKeys = true;
114
    foreach ($keys as $k) {
115
        if (!is_numeric($k)) {
116
            $allNumericKeys = false;
117
            break;
118
        }
119
    }
120
121
    $allNumericVals = true;
122
    foreach ($vals as $v) {
123
        if (!is_numeric($v)) {
124
            $allNumericVals = false;
125
            break;
126
        }
127
    }
128
129
    $sequentialKeys = $allNumericKeys && ($keys === range(0, count($keys) - 1));
130
131
    $ids = [];
132
133
    if ($allNumericKeys && $allNumericVals && $keys === $vals) {
134
        // Pattern: id => id
135
        $ids = $keys;
136
    } elseif ($sequentialKeys && $allNumericVals) {
137
        // Pattern: 0..N-1 => id
138
        $ids = $vals;
139
    } elseif ($allNumericKeys && !$allNumericVals) {
140
        // Fallback: keys look like ids
141
        $ids = $keys;
142
    } elseif (!$allNumericKeys && $allNumericVals) {
143
        // Fallback: values look like ids
144
        $ids = $vals;
145
    } else {
146
        // Worst case: keep all numeric keys and values
147
        foreach ($keys as $k) {
148
            if (is_numeric($k)) {
149
                $ids[] = (int) $k;
150
            }
151
        }
152
        foreach ($vals as $v) {
153
            if (is_numeric($v)) {
154
                $ids[] = (int) $v;
155
            }
156
        }
157
    }
158
159
    $ids = array_map('intval', $ids);
160
    $ids = array_values(array_unique($ids));
161
162
    return $ids;
163
}
164
165
// Log initial context.
166
error_log(
167
    $logPrefix.'Init: exerciseId='.$exerciseId
168
    .', exeId='.$exeId
169
    .', feedbackType='.$feedbackType
170
    .', isAdaptive='.($isAdaptative ? '1' : '0')
171
    .', questionNum='.$questionNum
172
    .', questionId='.var_export($questionId, true)
173
    .', questionList='.json_encode($questionList)
174
);
175
176
if (!$questionId) {
177
    exit;
178
}
179
180
// Try to read answer/hotspot from GET first.
181
$choiceValue = $_GET['choice'][$questionId] ?? ($_GET['choice'] ?? '');
182
$hotSpot = $_GET['hotspot'][$questionId] ?? ($_GET['hotspot'] ?? '');
183
$tryAgain = ((int) ($_GET['tryagain'] ?? 0) === 1);
184
$loaded = (int) ($_GET['loaded'] ?? 0);
185
186
// Question entity.
187
$repo = Container::getQuestionRepository();
188
/** @var CQuizQuestion|null $question */
189
$question = $repo->find($questionId);
190
191
if (null === $question) {
192
    exit;
193
}
194
195
// Relationship to get adaptive destinations.
196
$entityManager = Database::getManager();
197
/** @var CQuizRelQuestion|null $rel */
198
$rel = $entityManager
199
    ->getRepository(CQuizRelQuestion::class)
200
    ->findOneBy([
201
        'quiz' => $exerciseId,
202
        'question' => $questionId,
203
    ]);
204
205
$destinationArray = [];
206
if ($rel && $rel->getDestination()) {
207
    $decoded = json_decode($rel->getDestination(), true);
208
    if (is_array($decoded)) {
209
        $destinationArray = $decoded;
210
    }
211
}
212
213
// Normalize failure destinations.
214
$failure = [];
215
if (isset($destinationArray['failure'])) {
216
    $failure = is_array($destinationArray['failure'])
217
        ? $destinationArray['failure']
218
        : [$destinationArray['failure']];
219
}
220
221
$allowTryAgain = $tryAgain && !empty($failure) && in_array('repeat', $failure, true);
222
223
// If student clicked "Try again", clear previous answer for this question.
224
if ($allowTryAgain && is_array($exerciseResult)) {
225
    unset($exerciseResult[$questionId]);
226
    error_log($logPrefix.'Cleared previous result for questionId='.$questionId);
227
}
228
229
/**
230
 * If we still have no choice/hotspot, we need to fetch the answer from the
231
 * main exercise form (frm_exercise) in the parent page.
232
 *
233
 * First pass: redirect to the same script with "loaded=1".
234
 * Second pass (loaded=1 and still no answer): inject JS that reads the
235
 * current choice from the form and performs a third GET with the proper
236
 * parameters. That third GET will finally compute the feedback.
237
 */
238
if (
239
    !chExerciseHasAnswer($choiceValue) &&
240
    !chExerciseHasAnswer($hotSpot) &&
241
    !isset($_GET['loaded'])
242
) {
243
    $params = $_REQUEST;
244
    $params['loaded'] = 1;
245
    $redirectUrl = $_SERVER['PHP_SELF'].'?'.http_build_query($params);
246
247
    header("Location: $redirectUrl");
248
    exit;
249
}
250
251
if (
252
    !chExerciseHasAnswer($choiceValue) &&
253
    !chExerciseHasAnswer($hotSpot) &&
254
    isset($_GET['loaded'])
255
) {
256
    $url = api_get_path(WEB_CODE_PATH).'exercise/exercise_submit_modal.php?'.api_get_cidreq()
257
        .'&loaded=1&exerciseId='.$exerciseId
258
        .'&num='.($questionNum + 1)
259
        .'&learnpath_id='.$learnpath_id
260
        .'&learnpath_item_id='.$learnpath_item_id
261
        .'&learnpath_item_view_id='.$learnpath_item_view_id
262
        .'&preview='.$preview;
263
264
    echo "<script>
265
    $(document).ready(function() {
266
        var f = document.frm_exercise;
267
        if (!f) {
268
            // No exercise form found; nothing to do.
269
            if (window.console && console.warn) {
270
                console.warn('[exercise_submit_modal] frm_exercise not found in document');
271
            }
272
            return;
273
        }
274
275
        var finalUrl = '".addslashes($url)."';
276
277
        // Collect all controls for this question using the \"choice[{$questionId}]\" prefix.
278
        // This should work for:
279
        // - Single choice (radio)
280
        // - Multiple choice (checkboxes)
281
        // - Matching (selects)
282
        // - Fill in the blanks (text/textarea using choice[...] naming)
283
        // - Other types reusing the same prefix
284
        var controls = f.querySelectorAll(
285
            'input[name^=\"choice[{$questionId}]\"],'
286
          + 'select[name^=\"choice[{$questionId}]\"],'
287
          + 'textarea[name^=\"choice[{$questionId}]\"]'
288
        );
289
290
        var hasChoice = false;
291
292
        controls.forEach(function(el) {
293
            var type = (el.type || '').toLowerCase();
294
295
            // For checkboxes/radios we only keep checked ones.
296
            if ((type === 'checkbox' || type === 'radio') && !el.checked) {
297
                return;
298
            }
299
300
            var val = el.value;
301
            if (val === undefined || val === null || val === '') {
302
                return;
303
            }
304
305
            hasChoice = true;
306
307
            // Preserve the original field name so PHP builds the same array structure.
308
            finalUrl += '&' + encodeURIComponent(el.name) + '=' + encodeURIComponent(val);
309
        });
310
311
        // Hotspot input (if any).
312
        var hotspotInput = f.querySelector('input[name^=\"hotspot[{$questionId}]\"]');
313
        var hotspotVal = hotspotInput ? hotspotInput.value : '';
314
        var hasHotspot = !!hotspotVal;
315
316
        // avoid infinite loop when no answer is selected
317
        if (!hasChoice && !hasHotspot) {
318
            var \$container = \$('#global-modal .modal-body');
319
            if (!\$container.length) {
320
                // Fallback for legacy templates that use #global-modal-body.
321
                \$container = \$('#global-modal-body');
322
            }
323
            if (\$container.length) {
324
                \$container.html('<p>".addslashes(get_lang('Please select an answer before checking the result.'))."</p>');
325
            }
326
            if (window.console && console.warn) {
327
                console.warn('[exercise_submit_modal] No answer/hotspot found in frm_exercise; aborting extra request.');
328
            }
329
            return;
330
        }
331
332
        if (hotspotVal) {
333
            finalUrl += '&hotspot[{$questionId}]=' + encodeURIComponent(hotspotVal);
334
        }
335
336
        $.get(finalUrl, function(data) {
337
            // Prefer the standard global modal body used by exercise_submit.php.
338
            var \$container = \$('#global-modal .modal-body');
339
            if (!\$container.length) {
340
                // Fallback for legacy templates that use #global-modal-body.
341
                \$container = \$('#global-modal-body');
342
            }
343
            if (\$container.length) {
344
                \$container.html(data);
345
            } else if (window.console && console.warn) {
346
                console.warn('[exercise_submit_modal] No modal container found (#global-modal or #global-modal-body)');
347
            }
348
        });
349
    });
350
    </script>";
351
    exit;
352
}
353
354
/**
355
 * Merge the current choice into exerciseResult, keeping all previous answers.
356
 * At this point $allowTryAgain might have removed the previous entry for this
357
 * question id, so we will store the fresh, normalized value.
358
 */
359
if (!is_array($exerciseResult)) {
360
    $exerciseResult = [];
361
}
362
363
$answerType = $question->getType();
364
$showResult = $isAdaptative;
365
366
$objAnswerTmp = new Answer($questionId, api_get_course_int_id());
367
368
// Normalize choice value depending on answer type.
369
if (MULTIPLE_ANSWER == $answerType && is_array($choiceValue)) {
370
    // For multiple answers we expect an associative array id => id.
371
    $choiceValue = array_combine(array_values($choiceValue), array_values($choiceValue));
372
}
373
374
if (UNIQUE_ANSWER == $answerType && is_array($choiceValue)) {
375
    // For unique answer we keep a single selected id; prefer "id => id" format.
376
    $ids = chExerciseExtractAnswerIds($choiceValue);
377
    if (!empty($ids)) {
378
        $choiceValue = $ids[0];
379
    }
380
}
381
382
if (HOT_SPOT_DELINEATION == $answerType && is_array($hotSpot)) {
383
    // For hotspot delineation we keep coordinates in a dedicated structure.
384
    $choiceValue = $hotSpot[1] ?? '';
385
    $_SESSION['exerciseResultCoordinates'][$questionId] = $choiceValue;
386
    $_SESSION['hotspot_coord'][$questionId][1] = $objAnswerTmp->selectHotspotCoordinates(1);
387
    $_SESSION['hotspot_dest'][$questionId][1] = $objAnswerTmp->selectDestination(1);
388
}
389
390
// Only persist if we actually have an answer for this question.
391
if (chExerciseHasAnswer($choiceValue) || chExerciseHasAnswer($hotSpot)) {
392
    $exerciseResult[$questionId] = $choiceValue;
393
    Session::write('exerciseResult', $exerciseResult);
394
}
395
396
// Capture HTML output from manage_answer; we only use it for some types.
397
ob_start();
398
$result = $objExercise->manage_answer(
399
    $exeId,
400
    $questionId,
401
    $choiceValue,
402
    'exercise_result',
403
    [],
404
    (EXERCISE_FEEDBACK_TYPE_POPUP === $feedbackType),
405
    false,
406
    $showResult,
407
    null,
408
    [],
409
    true,
410
    false,
411
    true
412
);
413
$manageAnswerHtmlContent = ob_get_clean();
414
415
// -----------------------------------------------------------------------------
416
// Decide success / failure (adaptive routing and feedback flags)
417
// -----------------------------------------------------------------------------
418
// We fully trust manage_answer() for the scoring logic and use the ratio
419
// score/weight to decide if the question is correct or not, similar to the
420
// regular exercise flow (save_exercise_by_now).
421
$contents = '';
422
$answerCorrect = false;
423
$partialCorrect = false;
424
425
$score = isset($result['score']) ? (float) $result['score'] : 0.0;
426
$weight = isset($result['weight']) ? (float) $result['weight'] : 0.0;
427
428
if ($weight > 0.0) {
429
    // Full success only when the achieved score reaches the max weight.
430
    $answerCorrect = ($score >= $weight);
431
    // Partial success when there is some score but not the full weight.
432
    $partialCorrect = !$answerCorrect && $score > 0.0;
433
} else {
434
    // Zero or undefined weight: any positive score counts as correct.
435
    $answerCorrect = ($score > 0.0);
436
    $partialCorrect = false;
437
}
438
439
$routeKey = $answerCorrect ? 'success' : 'failure';
440
441
// Compute destination based on adaptive routing or default sequential order.
442
$destinationId = null;
443
if ($isAdaptative && !empty($destinationArray) && isset($destinationArray[$routeKey])) {
444
    $firstDest = $destinationArray[$routeKey];
445
446
    if (is_string($firstDest) && is_numeric($firstDest)) {
447
        $firstDest = (int) $firstDest;
448
    }
449
450
    if ($firstDest === 'repeat') {
451
        // Repeat the same question.
452
        $destinationId = $questionId;
453
    } elseif ($firstDest === -1) {
454
        // End of activity.
455
        $destinationId = -1;
456
    } elseif (is_int($firstDest)) {
457
        // Go to question with id = $firstDest.
458
        $destinationId = $firstDest;
459
    } elseif (is_string($firstDest) && str_starts_with($firstDest, '/')) {
460
        // Go to an external resource (relative path to WEB_PATH).
461
        $destinationId = $firstDest;
462
    }
463
} else {
464
    // Default: next question in the list (or -1 if there is no next question).
465
    $nextQuestion = $questionNum + 1;
466
    $destinationId = $questionList[$nextQuestion] ?? -1;
467
}
468
469
if (is_string($destinationId) && is_numeric($destinationId)) {
470
    $destinationId = (int) $destinationId;
471
}
472
473
// Build feedback contents depending on feedback model.
474
if ($isAdaptative && isset($result['correct_answer_id'])) {
475
    // Adaptive mode: show specific comments for correct answers.
476
    foreach ($result['correct_answer_id'] as $answerId) {
477
        $contents .= $objAnswerTmp->selectComment($answerId);
478
    }
479
} elseif (EXERCISE_FEEDBACK_TYPE_POPUP === $feedbackType) {
480
    $message = get_lang(
481
        $answerCorrect ? 'Correct' : ($partialCorrect ? 'PartialCorrect' : 'Incorrect')
482
    );
483
    $comments = '';
484
485
    if (HOT_SPOT_DELINEATION !== $answerType && isset($result['correct_answer_id'])) {
486
        $table = new HTML_Table(['class' => 'table data_table']);
487
        $table->setCellContents(0, 0, get_lang('Your answer'));
488
        if (DRAGGABLE !== $answerType) {
489
            $table->setCellContents(0, 1, get_lang('Comment'));
490
        }
491
492
        $row = 1;
493
        foreach ($result['correct_answer_id'] as $answerId) {
494
            $a = $objAnswerTmp->getAnswerByAutoId($answerId);
495
            $table->setCellContents(
496
                $row,
497
                0,
498
                $a['answer'] ?? $objAnswerTmp->selectAnswer($answerId)
499
            );
500
            $table->setCellContents(
501
                $row,
502
                1,
503
                $a['comment'] ?? $objAnswerTmp->selectComment($answerId)
504
            );
505
            $row++;
506
        }
507
508
        $comments = $table->toHtml();
509
    }
510
511
    // If there is no specific comment, at least show a basic message.
512
    if ('' === trim($comments)) {
513
        $comments = '<p>'.get_lang('No detailed feedback is available for this question.').'</p>';
514
    }
515
516
    $contents .= $comments;
517
518
    echo '
519
        <div class="modal-header">
520
            <h4 class="modal-title" id="global-modal-title">'.$message.'</h4>
521
        </div>';
522
}
523
524
// For hotspot delineation we keep the HTML generated by manage_answer.
525
if (HOT_SPOT_DELINEATION === $answerType) {
526
    $contents = $manageAnswerHtmlContent;
527
}
528
529
// Build navigation links for the adaptive / popup flow.
530
$links = '';
531
$navBranch = 'none';
532
$indexForLog = null;
533
534
// Small JS helper that navigates explicitly, without relying on SendEx().
535
echo '<script>
536
var chExerciseBaseUrl = "'.addslashes($exerciseBaseUrl).'";
537
var chExerciseResultUrl = "'.addslashes($exerciseResultUrl). '";
538
539
/**
540
 * Navigate to the given question index (0-based) or to the result page.
541
 *
542
 * idx >= 0 → go to exercise_submit.php with num = idx + 1
543
 * idx  = -1 → go to exercise_result.php
544
 *
545
 * tryAgain = true → append tryagain=1, used when repeating the same question.
546
 */
547
function chExerciseSendEx(idx, tryAgain) {
548
    // Always navigate in the current window/frame.
549
    // When the exercise is launched from a learning path, exercise_submit.php
550
    // runs inside a frame; using window.parent here would navigate the LP
551
    // shell away and break the learning path context.
552
    var target = window;
553
554
    if (idx === -1) {
555
        // End of activity → show results.
556
        target.location.href = chExerciseResultUrl;
557
        return false;
558
    }
559
560
    var qIndex = parseInt(idx, 10);
561
    if (isNaN(qIndex) || qIndex < 0) {
562
        if (window.console && console.warn) {
563
            console.warn("[exercise_submit_modal] Invalid idx for chExerciseSendEx:", idx);
564
        }
565
        return false;
566
    }
567
568
    // Questions are 1-based in the URL.
569
    var num = qIndex + 1;
570
    var url = chExerciseBaseUrl + "&num=" + num;
571
572
    if (tryAgain) {
573
        url += "&tryagain=1";
574
    }
575
576
    target.location.href = url;
577
578
    return false;
579
}
580
</script>';
581
582
if ($destinationId === $questionId) {
583
    // Repeat same question.
584
    $index = array_search($questionId, $questionList, true);
585
    $indexForLog = $index;
586
    $navBranch = 'repeatQuestion';
587
588
    $links .= Display::getMdiIcon(
589
            ActionIcon::REFRESH,
590
            'ch-tool-icon',
591
            'padding-left:0px;padding-right:5px;',
592
            ICON_SIZE_SMALL
593
        )
594
        .'<a onclick="return chExerciseSendEx('.$index.', true);" href="#">'
595
        .get_lang('Try again').'</a><br /><br />';
596
} elseif (-1 === $destinationId) {
597
    // End of activity.
598
    $navBranch = 'endActivity';
599
600
    $links .= Display::getMdiIcon(
601
            StateIcon::COMPLETE,
602
            'ch-tool-icon',
603
            'padding-left:0px;padding-right:5px;',
604
            ICON_SIZE_SMALL
605
        )
606
        .'<a onclick="return chExerciseSendEx(-1, false);" href="#">'
607
        .get_lang('End of activity').'</a><br /><br />';
608
} elseif (is_int($destinationId) && in_array($destinationId, $questionList, true)) {
609
    // Go to another question by id.
610
    $index = array_search($destinationId, $questionList, true);
611
    $indexForLog = $index;
612
    $navBranch = 'nextQuestion';
613
614
    $icon = Display::getMdiIcon(
615
        ObjectIcon::TEST,
616
        'ch-tool-icon',
617
        'padding-left:0px;padding-right:5px;',
618
        ICON_SIZE_SMALL
619
    );
620
    $links .= '<a onclick="return chExerciseSendEx('.$index.', false);" href="#">'
621
        .get_lang('Question').' '.($index + 1).'</a>&nbsp;'.$icon;
622
} elseif (is_string($destinationId) && str_starts_with($destinationId, '/')) {
623
    // External resource.
624
    $navBranch = 'externalResource';
625
626
    $icon = Display::getMdiIcon(
627
        ObjectIcon::LINK,
628
        'ch-tool-icon',
629
        'padding-left:0px;padding-right:5px;',
630
        ICON_SIZE_SMALL
631
    );
632
    $fullUrl = api_get_path(WEB_PATH).ltrim($destinationId, '/');
633
    $links .= '<a href="'.$fullUrl.'">'.get_lang('Go to resource').'</a>&nbsp;'.$icon;
634
}
635
636
error_log(
637
    $logPrefix.'Navigation: navBranch='.$navBranch
638
    .', indexForLog='.var_export($indexForLog, true)
639
    .', linksEmpty='.((trim(strip_tags($links)) === '') ? '1' : '0')
640
);
641
642
// Final safety logs if modal content/links are empty.
643
if (trim(strip_tags($contents)) === '') {
644
    error_log(
645
        $logPrefix.'Final contents is empty for questionId='.$questionId
646
        .', answerType='.$answerType
647
        .', feedbackType='.$feedbackType
648
        .', isAdaptive='.($isAdaptative ? '1' : '0')
649
    );
650
}
651
652
if (trim(strip_tags($links)) === '') {
653
    error_log(
654
        $logPrefix.'Final links is empty for questionId='.$questionId
655
        .', destinationId='.var_export($destinationId, true)
656
    );
657
}
658
659
// Body + navigation block.
660
echo '<div>'.$contents.'</div>';
661
echo '<div style="padding-left: 450px"><h5>'.$links.'</h5></div>';
662