Passed
Push — master ( ca27a7...8ba864 )
by
unknown
17:12 queued 08:14
created

resolveSavedQuestionId()   B

Complexity

Conditions 11
Paths 16

Size

Total Lines 29
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 11
eloc 15
nc 16
nop 2
dl 0
loc 29
rs 7.3166
c 1
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
use Chamilo\CoreBundle\Helpers\ChamiloHelper;
6
use PhpZip\ZipFile;
7
use Symfony\Component\DomCrawler\Crawler;
8
9
/**
10
 * @copyright (c) 2001-2006 Universite catholique de Louvain (UCL)
11
 * @author claro team <[email protected]>
12
 * @author Guillaume Lederer <[email protected]>
13
 * @author Yannick Warnier <[email protected]>
14
 */
15
16
/**
17
 * Read a ZIP entry content as string with best-effort compatibility across PhpZip versions.
18
 *
19
 * @param ZipFile $zipFile
20
 * @param string  $entryName
21
 *
22
 * @return string
23
 */
24
function getZipEntryContent(ZipFile $zipFile, string $entryName): string
25
{
26
    try {
27
        $data = $zipFile[$entryName];
28
        if (is_string($data)) {
29
            return $data;
30
        }
31
    } catch (Throwable $e) {
32
        // Ignore and try alternatives
33
    }
34
35
    // Alternatives depending on PhpZip versions
36
    try {
37
        if (method_exists($zipFile, 'getEntryContents')) {
38
            $data = $zipFile->getEntryContents($entryName);
39
            return is_string($data) ? $data : '';
40
        }
41
    } catch (Throwable $e) {
42
        // Ignore
43
    }
44
45
    try {
46
        if (method_exists($zipFile, 'getEntryContent')) {
47
            $data = $zipFile->getEntryContent($entryName);
48
            return is_string($data) ? $data : '';
49
        }
50
    } catch (Throwable $e) {
51
        // Ignore
52
    }
53
54
    return '';
55
}
56
57
/**
58
 * Resolve the saved question ID (iid) in a defensive way.
59
 *
60
 * @param object     $question
61
 * @param int|string $saveResult
62
 *
63
 * @return int
64
 */
65
function resolveSavedQuestionId(object $question, mixed $saveResult = null): int
66
{
67
    if (is_numeric($saveResult) && (int) $saveResult > 0) {
68
        return (int) $saveResult;
69
    }
70
71
    if (method_exists($question, 'getIid')) {
72
        $iid = (int) $question->getIid();
73
        if ($iid > 0) {
74
            return $iid;
75
        }
76
    }
77
78
    if (method_exists($question, 'getId')) {
79
        $id = (int) $question->getId();
80
        if ($id > 0) {
81
            return $id;
82
        }
83
    }
84
85
    if (property_exists($question, 'iid') && (int) $question->iid > 0) {
86
        return (int) $question->iid;
87
    }
88
89
    if (property_exists($question, 'id') && (int) $question->id > 0) {
90
        return (int) $question->id;
91
    }
92
93
    return 0;
94
}
95
96
/**
97
 * Imports an exercise in QTI format if the XML structure can be found in it.
98
 *
99
 * @param string $file
100
 *
101
 * @return string|array as a backlog of what was really imported, and error or debug messages to display
102
 */
103
function import_exercise($file)
104
{
105
    global $exerciseInfo;
106
    global $resourcesLinks;
107
108
    // set some default values for the new exercise
109
    $exerciseInfo = [];
110
    $baseName = basename($file);
111
    $exerciseInfo['name'] = preg_replace('/\.zip$/i', '', $baseName);
112
    $exerciseInfo['question'] = [];
113
114
    // if file is not a .zip, then we cancel all
115
    if (!preg_match('/\.zip$/i', $file)) {
116
        return 'UplZipCorrupt';
117
    }
118
119
    $zipFile = new ZipFile();
120
121
    try {
122
        $zipFile->openFile($file);
123
    } catch (Throwable $e) {
124
        Display::addFlash(Display::return_message('QTI import: unable to open ZIP file.', 'error'));
125
        return 'UplZipCorrupt';
126
    }
127
128
    try {
129
        $zipContentArray = $zipFile->getEntries();
130
131
        $fileFound = false;
132
        $result = false;
133
        $resourcesLinks = [];
134
135
        foreach ($zipContentArray as $entry) {
136
            $entryName = $entry->getName();
137
138
            if ($entry->isDirectory()) {
139
                continue;
140
            }
141
142
            $data = getZipEntryContent($zipFile, $entryName);
143
            if ($data === '') {
144
                continue;
145
            }
146
147
            $isQti = isQtiQuestionBank($data);
148
            if ($isQti) {
149
                $result = qti_parse_file($data);
150
                $fileFound = true;
151
            } else {
152
                $isManifest = isQtiManifest($data);
153
                if ($isManifest) {
154
                    $resourcesLinks = qtiProcessManifest($data);
155
                }
156
            }
157
        }
158
159
        if (!$fileFound) {
160
            return 'NoXMLFileFoundInTheZip';
161
        }
162
163
        if (false == $result) {
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like you are loosely comparing two booleans. Considering using the strict comparison === instead.

When comparing two booleans, it is generally considered safer to use the strict comparison operator.

Loading history...
164
            return false;
165
        }
166
167
        // Create exercise.
168
        $exercise = new Exercise();
169
        $exercise->exercise = $exerciseInfo['name'];
170
171
        // Random QTI support
172
        if (isset($exerciseInfo['order_type'])) {
173
            if ('Random' === $exerciseInfo['order_type']) {
174
                $exercise->setQuestionSelectionType(2);
175
                $exercise->random = -1;
176
            }
177
        }
178
179
        if (!empty($exerciseInfo['description'])) {
180
            $exercise->updateDescription(formatText(strip_tags($exerciseInfo['description'])));
181
        }
182
183
        $exercise->save();
184
        $last_exercise_id = (int) $exercise->getId();
185
        $courseId = (int) api_get_course_int_id();
186
187
        if (!empty($last_exercise_id)) {
188
189
            // For each question found...
190
            foreach ($exerciseInfo['question'] as $qtiIdent => $question_array) {
191
                if (!isset($question_array['type'])) {
192
                    continue;
193
                }
194
195
                if (!in_array($question_array['type'], [UNIQUE_ANSWER, MULTIPLE_ANSWER, FREE_ANSWER, FIB], true)) {
196
                    continue;
197
                }
198
199
                // Create question
200
                $question = new Ims2Question();
201
                $question->type = $question_array['type'];
202
203
                $question->setAnswer();
204
                $description = '';
205
                $question->updateTitle(formatText(strip_tags($question_array['title'] ?? '')));
206
207
                if (isset($question_array['category'])) {
208
                    $category = formatText(strip_tags($question_array['category']));
209
                    if (!empty($category)) {
210
                        $categoryId = TestCategory::get_category_id_for_title(
211
                            $category,
212
                            $courseId
213
                        );
214
215
                        if (empty($categoryId)) {
216
                            $cat = new TestCategory();
217
                            $cat->name = $category;
218
                            $cat->description = '';
219
                            $categoryId = $cat->save($courseId);
220
                            if ($categoryId) {
221
                                $question->category = $categoryId;
222
                            }
223
                        } else {
224
                            $question->category = $categoryId;
225
                        }
226
                    }
227
                }
228
229
                if (!empty($question_array['description'])) {
230
                    $description .= $question_array['description'];
231
                }
232
233
                $question->updateDescription($description);
234
235
                // Save question (capture potential return id if any)
236
                $saveResult = null;
237
                try {
238
                    $saveResult = $question->save($exercise);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $saveResult is correct as $question->save($exercise) targeting Question::save() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
239
                } catch (Throwable $e) {
240
                    Display::addFlash(Display::return_message('QTI import: failed to save a question.', 'error'));
241
                    continue;
242
                }
243
244
                $last_question_id = resolveSavedQuestionId($question, $saveResult);
245
246
                if ($last_question_id <= 0) {
247
                    Display::addFlash(Display::return_message('QTI import: question saved without a valid ID.', 'error'));
248
                    continue;
249
                }
250
251
                // Special handling for Fill in Blanks (FIB): it stores a single answer row.
252
                if (FIB == $question->type) {
253
                    [$fibAnswerString, $fibTotalWeight] = buildFibAnswerString($question_array);
254
                    $question->updateWeighting($fibTotalWeight);
255
                    try {
256
                        // Update question weighting in DB
257
                        $question->save($exercise);
258
                    } catch (Throwable $e) {
259
                        Display::addFlash(Display::return_message('QTI import: failed to update FIB question weighting.', 'error'));
260
                        continue;
261
                    }
262
263
                    try {
264
                        $fibAnswer = new Answer($last_question_id, $courseId, $exercise, false);
265
                        $fibAnswer->createAnswer($fibAnswerString, 0, '', 0, 1);
266
                        $fibAnswer->save();
267
                    } catch (Throwable $e) {
268
                        Display::addFlash(Display::return_message('QTI import: failed to save FIB answer row.', 'error'));
269
                        continue;
270
                    }
271
272
                    // Done for this question
273
                    continue;
274
                }
275
276
                // Build answers using correct context (do not read from DB)
277
                $answer = new Answer($last_question_id, $courseId, $exercise, false);
278
279
                $answerList = $question_array['answer'] ?? [];
280
                $correctAnswersRaw = $question_array['correct_answers'] ?? [];
281
                $correctAnswerIds = is_array($correctAnswersRaw) ? array_values($correctAnswersRaw) : [$correctAnswersRaw];
282
283
                $defaultWeight = isset($question_array['default_weighting']) ? (float) $question_array['default_weighting'] : 0.0;
284
                $totalCorrectWeight = 0.0;
285
286
                // Normalize positions 1..N
287
                $pos = 1;
288
289
                if (!empty($answerList) && is_array($answerList)) {
290
                    foreach ($answerList as $key => $answers) {
291
                        // Answer text
292
                        $answerValue = isset($answers['value']) ? formatText($answers['value']) : '';
293
                        // Comment must be a string to avoid TypeError in entity setter
294
                        $answerFeedback = isset($answers['feedback']) ? formatText($answers['feedback']) : '';
295
296
                        $isCorrect = in_array($key, $correctAnswerIds, true);
297
298
                        $weight = $defaultWeight;
299
                        if (isset($question_array['weighting']) && is_array($question_array['weighting']) && isset($question_array['weighting'][$key])) {
300
                            $weight = (float) $question_array['weighting'][$key];
301
                        }
302
303
                        $answer->new_answer[$pos] = $answerValue;
304
                        $answer->new_comment[$pos] = (string) $answerFeedback; // never null
305
                        $answer->new_position[$pos] = $pos;
306
                        $answer->new_correct[$pos] = $isCorrect ? 1 : 0; // never null
307
                        $answer->new_weighting[$pos] = $weight;
308
309
                        if ($isCorrect) {
310
                            $totalCorrectWeight += $weight;
311
                        }
312
313
                        $pos++;
314
                    }
315
                }
316
317
                $answer->new_nbrAnswers = $pos - 1;
318
319
                if (FREE_ANSWER == $question->type) {
320
                    $totalCorrectWeight = isset($question_array['weighting'][0]) ? (float) $question_array['weighting'][0] : 0.0;
321
                }
322
323
                $question->updateWeighting($totalCorrectWeight);
324
325
                try {
326
                    $question->save($exercise);
327
                } catch (Throwable $e) {
328
                    Display::addFlash(Display::return_message('QTI import: failed to update question weighting.', 'error'));
329
                    // Continue, but do not attempt answer save if question update failed
330
                    continue;
331
                }
332
333
                // Save answers (safe even if 0 answers for FREE_ANSWER)
334
                try {
335
                    $answer->save();
336
                } catch (Throwable $e) {
337
                    Display::addFlash(Display::return_message('QTI import: failed to save answers for a question.', 'error'));
338
                    continue;
339
                }
340
            }
341
342
            return $last_exercise_id;
343
        }
344
345
        return false;
346
    } finally {
347
        try {
348
            if (method_exists($zipFile, 'close')) {
349
                $zipFile->close();
350
            }
351
        } catch (Throwable $e) {
352
            // Ignore
353
        }
354
    }
355
}
356
357
/**
358
 * We assume the file charset is UTF8.
359
 */
360
function formatText($text)
361
{
362
    return api_html_entity_decode($text);
363
}
364
365
/**
366
 * Parses a given XML file and fills global arrays with the elements.
367
 *
368
 * @param string $exercisePath
369
 * @param string $file
370
 * @param string $questionFile
371
 *
372
 * @return bool
373
 */
374
function qti_parse_file($data)
375
{
376
    global $record_item_body;
377
    global $questionTempDir;
378
379
    if (empty($data)) {
380
        Display::addFlash(Display::return_message(get_lang('Error opening XML file'), 'error'));
381
382
        return false;
383
    }
384
385
    //parse XML question file
386
    //$data = str_replace(array('<p>', '</p>', '<front>', '</front>'), '', $data);
387
    $data = ChamiloHelper::stripGivenTags($data, ['p', 'front']);
388
    $qtiVersion = [];
389
    $match = preg_match('/ims_qtiasiv(\d)p(\d)/', $data, $qtiVersion);
390
    $qtiMainVersion = 2; //by default, assume QTI version 2
391
    if ($match) {
392
        $qtiMainVersion = $qtiVersion[1];
393
    }
394
395
    //used global variable start values declaration:
396
    $record_item_body = false;
397
398
    if (2 != $qtiMainVersion) {
399
        Display::addFlash(
400
            Display::return_message(
401
                get_lang('Unsupported IMS/QTI version.'),
402
                'error'
403
            )
404
        );
405
406
        return false;
407
    }
408
409
    parseQti2($data);
410
411
    return true;
412
}
413
414
/**
415
 * Function used to parser a QTI2 xml file.
416
 *
417
 * @param string $xmlData
418
 */
419
function parseQti2($xmlData)
420
{
421
    global $exerciseInfo;
422
    global $questionTempDir;
423
    global $resourcesLinks;
424
425
    $crawler = new Crawler($xmlData);
426
    $nodes = $crawler->filter('*');
427
428
    $currentQuestionIdent = '';
429
    $currentAnswerId = '';
430
    $currentQuestionItemBody = '';
431
    $cardinality = '';
432
    $nonHTMLTagToAvoid = [
433
        'prompt',
434
        'simpleChoice',
435
        'choiceInteraction',
436
        'inlineChoiceInteraction',
437
        'inlineChoice',
438
        'soMPLEMATCHSET',
439
        'simpleAssociableChoice',
440
        'textEntryInteraction',
441
        'feedbackInline',
442
        'matchInteraction',
443
        'extendedTextInteraction',
444
        'itemBody',
445
        'br',
446
        'img',
447
    ];
448
    $currentMatchSet = null;
449
450
    /** @var DOMElement $node */
451
    foreach ($nodes as $node) {
452
        if ('#text' === $node->nodeName) {
453
            continue;
454
        }
455
456
        switch ($node->nodeName) {
457
            case 'assessmentItem':
458
                $currentQuestionIdent = $node->getAttribute('identifier');
459
460
                $exerciseInfo['question'][$currentQuestionIdent] = [
461
                    'answer' => [],
462
                    'correct_answers' => [],
463
                    'title' => $node->getAttribute('title'),
464
                    'category' => $node->getAttribute('category'),
465
                    'type' => '',
466
                    'subtype' => null,
467
                    'tempdir' => $questionTempDir,
468
                    'description' => null,
469
                    'response_text' => null,
470
                    'fib_options' => [],
471
                ];
472
473
                break;
474
            case 'section':
475
                $title = $node->getAttribute('title');
476
477
                if (!empty($title)) {
478
                    $exerciseInfo['name'] = $title;
479
                }
480
481
                break;
482
            case 'responseDeclaration':
483
                $currentAnswerId = $node->getAttribute('identifier');
484
                $cardinalityAttr = $node->getAttribute('cardinality');
485
                $cardinality = $cardinalityAttr;
486
487
                // Legacy Chamilo/Claroline FIB: responseDeclaration id like "fill_1", "fill_2", ...
488
                if (!empty($currentAnswerId) && 0 === strpos($currentAnswerId, 'fill_')) {
489
                    $exerciseInfo['question'][$currentQuestionIdent]['type'] = FIB;
490
                    $exerciseInfo['question'][$currentQuestionIdent]['subtype'] = 'TEXTFIELD_FILL';
491
                    // Do NOT force MCUA here even if cardinality is "single"
492
                    break;
493
                }
494
495
                if ('multiple' === $cardinalityAttr) {
496
                    $exerciseInfo['question'][$currentQuestionIdent]['type'] = MCMA;
497
                    $cardinality = 'multiple';
498
                } elseif ('single' === $cardinalityAttr) {
499
                    $exerciseInfo['question'][$currentQuestionIdent]['type'] = MCUA;
500
                    $cardinality = 'single';
501
                }
502
                break;
503
            case 'inlineChoiceInteraction':
504
                $exerciseInfo['question'][$currentQuestionIdent]['type'] = FIB;
505
                $exerciseInfo['question'][$currentQuestionIdent]['subtype'] = 'LISTBOX_FILL';
506
                $currentAnswerId = $node->getAttribute('responseIdentifier');
507
508
                break;
509
            case 'inlineChoice':
510
                $responseId = '';
511
                if ($node->parentNode && 'inlineChoiceInteraction' === $node->parentNode->nodeName) {
512
                    $responseId = $node->parentNode->getAttribute('responseIdentifier');
513
                }
514
515
                if ($responseId === '') {
516
                    break;
517
                }
518
519
                $correctChoiceId = $exerciseInfo['question'][$currentQuestionIdent]['correct_answers'][$responseId] ?? null;
520
                $choiceId = $node->getAttribute('identifier');
521
                $choiceText = trim((string) $node->nodeValue);
522
523
                if (!isset($exerciseInfo['question'][$currentQuestionIdent]['fib_options'][$responseId])) {
524
                    $exerciseInfo['question'][$currentQuestionIdent]['fib_options'][$responseId] = [
525
                        'correct' => null,
526
                        'wrongs' => [],
527
                    ];
528
                }
529
530
                if ($correctChoiceId !== null && $choiceId === $correctChoiceId) {
531
                    $exerciseInfo['question'][$currentQuestionIdent]['fib_options'][$responseId]['correct'] = $choiceText;
532
                } else {
533
                    $exerciseInfo['question'][$currentQuestionIdent]['fib_options'][$responseId]['wrongs'][] = $choiceText;
534
                }
535
536
                break;
537
            case 'textEntryInteraction':
538
                $exerciseInfo['question'][$currentQuestionIdent]['type'] = FIB;
539
                $exerciseInfo['question'][$currentQuestionIdent]['subtype'] = 'TEXTFIELD_FILL';
540
                $exerciseInfo['question'][$currentQuestionIdent]['response_text'] = $currentQuestionItemBody;
541
542
                break;
543
            case 'matchInteraction':
544
                $exerciseInfo['question'][$currentQuestionIdent]['type'] = MATCHING;
545
546
                break;
547
            case 'extendedTextInteraction':
548
                $exerciseInfo['question'][$currentQuestionIdent]['type'] = FREE_ANSWER;
549
                $exerciseInfo['question'][$currentQuestionIdent]['description'] = $node->nodeValue;
550
551
                break;
552
            case 'simpleMatchSet':
553
                if (!isset($currentMatchSet)) {
554
                    $currentMatchSet = 1;
555
                } else {
556
                    $currentMatchSet++;
557
                }
558
                $exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentMatchSet] = [];
559
560
                break;
561
            case 'simpleAssociableChoice':
562
                $currentAssociableChoice = $node->getAttribute('identifier');
563
564
                $exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentMatchSet][$currentAssociableChoice] = trim($node->nodeValue);
565
566
                break;
567
            case 'simpleChoice':
568
                $currentAnswerId = $node->getAttribute('identifier');
569
                if (!isset($exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentAnswerId])) {
570
                    $exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentAnswerId] = [];
571
                }
572
573
                //$simpleChoiceValue = $node->nodeValue;
574
                $simpleChoiceValue = '';
575
                /** @var DOMElement $childNode */
576
                foreach ($node->childNodes as $childNode) {
577
                    if ('feedbackInline' === $childNode->nodeName) {
578
                        continue;
579
                    }
580
                    $simpleChoiceValue .= $childNode->nodeValue;
581
                }
582
                $simpleChoiceValue = trim($simpleChoiceValue);
583
                if (!isset($exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentAnswerId]['value'])) {
584
                    $exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentAnswerId]['value'] = $simpleChoiceValue;
585
                } else {
586
                    $exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentAnswerId]['value'] .= $simpleChoiceValue;
587
                }
588
589
                break;
590
            case 'mapEntry':
591
                if (in_array($node->parentNode->nodeName, ['mapping', 'mapEntry'])) {
592
                    $answer_id = $node->getAttribute('mapKey');
593
594
                    if (!isset($exerciseInfo['question'][$currentQuestionIdent]['weighting'])) {
595
                        $exerciseInfo['question'][$currentQuestionIdent]['weighting'] = [];
596
                    }
597
598
                    $exerciseInfo['question'][$currentQuestionIdent]['weighting'][$answer_id] = $node->getAttribute(
599
                        'mappedValue'
600
                    );
601
                }
602
603
                break;
604
            case 'mapping':
605
                $defaultValue = $node->getAttribute('defaultValue');
606
                if (!empty($defaultValue)) {
607
                    $exerciseInfo['question'][$currentQuestionIdent]['default_weighting'] = $defaultValue;
608
                }
609
                // no break ?
610
            case 'itemBody':
611
                $currentQuestionItemBody = '';
612
613
                /** @var DOMElement $childNode */
614
                foreach ($node->childNodes as $childNode) {
615
                    if ('#text' === $childNode->nodeName) {
616
                        continue;
617
                    }
618
619
                    if (!in_array($childNode->nodeName, $nonHTMLTagToAvoid, true)) {
620
                        $currentQuestionItemBody .= '<' . $childNode->nodeName;
621
622
                        if ($childNode->attributes) {
623
                            foreach ($childNode->attributes as $attribute) {
624
                                $currentQuestionItemBody .= ' ' . $attribute->nodeName . '="' . $attribute->nodeValue . '"';
625
                            }
626
                        }
627
628
                        // Close with the CHILD tag name, not itemBody
629
                        $currentQuestionItemBody .= '>' . $childNode->nodeValue . '</' . $childNode->nodeName . '>';
630
631
                        continue;
632
                    }
633
634
                    if ('inlineChoiceInteraction' === $childNode->nodeName) {
635
                        $currentQuestionItemBody .= '**claroline_start**' . $childNode->getAttribute('responseIdentifier')
636
                            . '**claroline_end**';
637
                        continue;
638
                    }
639
640
                    if ('textEntryInteraction' === $childNode->nodeName) {
641
                        $rid = $childNode->getAttribute('responseIdentifier');
642
                        $currentQuestionItemBody .= '**claroline_start**' . $rid . '**claroline_end**';
643
                        continue;
644
                    }
645
646
                    if ('br' === $childNode->nodeName) {
647
                        $currentQuestionItemBody .= '<br>';
648
                    }
649
                }
650
651
                // Append firstChild text ONLY if it's real (avoid "\n  " from empty itemBody)
652
                if ($node->firstChild && '#text' === $node->firstChild->nodeName) {
653
                    $firstText = trim((string) $node->firstChild->nodeValue);
654
                    if ($firstText !== '') {
655
                        $currentQuestionItemBody .= $firstText;
656
                    }
657
                }
658
659
                // Replace relative links by links to the documents in the course
660
                // $resourcesLinks is only defined by qtiProcessManifest()
661
                if (isset($resourcesLinks, $resourcesLinks['manifest'], $resourcesLinks['web'])) {
662
                    foreach ($resourcesLinks['manifest'] as $key => $value) {
663
                        $currentQuestionItemBody = preg_replace('|'.$value.'|', $resourcesLinks['web'][$key], $currentQuestionItemBody);
664
                    }
665
                }
666
667
                if (FIB == $exerciseInfo['question'][$currentQuestionIdent]['type']) {
668
                    $candidate = (string) $currentQuestionItemBody;
669
670
                    // Only overwrite response_text if itemBody actually carries meaningful content
671
                    $hasPlaceholders = (false !== strpos($candidate, '**claroline_start**'));
672
                    $hasRealContent = (trim(strip_tags($candidate)) !== '');
673
674
                    if ($hasPlaceholders || $hasRealContent) {
675
                        $exerciseInfo['question'][$currentQuestionIdent]['response_text'] = $candidate;
676
                    }
677
                    // else: keep the response_text already captured from <correctResponse><value> (your legacy export case)
678
                } else {
679
                    if (FREE_ANSWER == $exerciseInfo['question'][$currentQuestionIdent]['type']) {
680
                        $currentQuestionItemBody = trim($currentQuestionItemBody);
681
682
                        if (!empty($currentQuestionItemBody)) {
683
                            $exerciseInfo['question'][$currentQuestionIdent]['description'] = $currentQuestionItemBody;
684
                        }
685
                    } else {
686
                        $exerciseInfo['question'][$currentQuestionIdent]['statement'] = $currentQuestionItemBody;
687
                    }
688
                }
689
690
                break;
691
            case 'img':
692
                $exerciseInfo['question'][$currentQuestionIdent]['attached_file_url'] = $node->getAttribute('src');
693
694
                break;
695
            case 'order':
696
                $orderType = $node->getAttribute('order_type');
697
698
                if (!empty($orderType)) {
699
                    $exerciseInfo['order_type'] = $orderType;
700
                }
701
702
                break;
703
            case 'feedbackInline':
704
                if (!isset($exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentAnswerId]['feedback'])) {
705
                    $exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentAnswerId]['feedback'] = trim(
706
                        $node->nodeValue
707
                    );
708
                } else {
709
                    $exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentAnswerId]['feedback'] .= trim(
710
                        $node->nodeValue
711
                    );
712
                }
713
714
                break;
715
            case 'value':
716
                if ('correctResponse' === $node->parentNode->nodeName) {
717
                    $nodeValue = trim($node->nodeValue);
718
719
                    // Legacy FIB exports: the whole statement + "::weights:sizes:sep@" is stored here
720
                    if (!empty($currentAnswerId) && 0 === strpos($currentAnswerId, 'fill_')) {
721
                        $exerciseInfo['question'][$currentQuestionIdent]['type'] = FIB;
722
                        $exerciseInfo['question'][$currentQuestionIdent]['subtype'] = $exerciseInfo['question'][$currentQuestionIdent]['subtype'] ?? 'TEXTFIELD_FILL';
723
724
                        $exerciseInfo['question'][$currentQuestionIdent]['correct_answers'][$currentAnswerId] = $nodeValue;
725
726
                        // Because <itemBody> is empty in this export, use value as response_text
727
                        if (empty($exerciseInfo['question'][$currentQuestionIdent]['response_text'])) {
728
                            $exerciseInfo['question'][$currentQuestionIdent]['response_text'] = $nodeValue;
729
                        }
730
                    } else {
731
                        // Default behavior for other question types
732
                        if ('single' === $cardinality) {
733
                            $exerciseInfo['question'][$currentQuestionIdent]['correct_answers'][$nodeValue] = $nodeValue;
734
                        } else {
735
                            $exerciseInfo['question'][$currentQuestionIdent]['correct_answers'][] = $nodeValue;
736
                        }
737
                    }
738
                }
739
740
                if ('outcomeDeclaration' === $node->parentNode->parentNode->nodeName) {
741
                    $nodeValue = trim($node->nodeValue);
742
743
                    if (!empty($nodeValue)) {
744
                        $exerciseInfo['question'][$currentQuestionIdent]['weighting'][0] = $nodeValue;
745
                    }
746
                }
747
748
                break;
749
            case 'mattext':
750
                if ('flow_mat' === $node->parentNode->parentNode->nodeName &&
751
                    ('presentation_material' === $node->parentNode->parentNode->parentNode->nodeName ||
752
                        'section' === $node->parentNode->parentNode->parentNode->nodeName
753
                    )
754
                ) {
755
                    $nodeValue = trim($node->nodeValue);
756
757
                    if (!empty($nodeValue)) {
758
                        $exerciseInfo['description'] = $node->nodeValue;
759
                    }
760
                }
761
                break;
762
            case 'prompt':
763
                $description = trim($node->nodeValue);
764
                $description = htmlspecialchars_decode($description);
765
                $description = Security::remove_XSS($description);
766
767
                if (!empty($description)) {
768
                    $exerciseInfo['question'][$currentQuestionIdent]['description'] = $description;
769
                }
770
                break;
771
        }
772
    }
773
774
    // Post-process FIB placeholders (markers) into Chamilo's "[...]" format.
775
    foreach ($exerciseInfo['question'] as $ident => &$q) {
776
        if (!isset($q['type']) || FIB != $q['type']) {
777
            continue;
778
        }
779
780
        $text = isset($q['response_text']) ? (string) $q['response_text'] : '';
781
        if ($text === '') {
782
            continue;
783
        }
784
785
        $subtype = $q['subtype'] ?? null;
786
787
        if ('LISTBOX_FILL' === $subtype) {
788
            $opts = $q['fib_options'] ?? [];
789
            foreach ($opts as $responseId => $data) {
790
                $correct = isset($data['correct']) ? (string) $data['correct'] : '';
791
                $wrongs = isset($data['wrongs']) && is_array($data['wrongs']) ? $data['wrongs'] : [];
792
793
                $final = [];
794
                if ($correct !== '') {
795
                    $final[] = $correct;
796
                }
797
                foreach ($wrongs as $w) {
798
                    $w = trim((string) $w);
799
                    if ($w === '' || $w === $correct) {
800
                        continue;
801
                    }
802
                    $final[] = $w;
803
                }
804
805
                $replacement = '['.implode('|', $final).']';
806
                $marker = '**claroline_start**'.$responseId.'**claroline_end**';
807
                $text = str_replace($marker, $replacement, $text);
808
            }
809
        } else {
810
            // TEXTFIELD_FILL: use correct answer text
811
            $corrects = $q['correct_answers'] ?? [];
812
            if (is_array($corrects)) {
813
                foreach ($corrects as $responseId => $answerValue) {
814
                    if (0 !== strpos((string) $responseId, 'fill_')) {
815
                        continue;
816
                    }
817
                    $marker = '**claroline_start**'.$responseId.'**claroline_end**';
818
                    $text = str_replace($marker, '['.trim((string) $answerValue).']', $text);
819
                }
820
            }
821
        }
822
823
        // Replace any leftover markers with empty blanks to avoid broken text.
824
        $text = preg_replace('/\*\*claroline_start\*\*fill_[^*]+\*\*claroline_end\*\*/', '[]', $text);
825
826
        $q['response_text'] = $text;
827
    }
828
    unset($q);
829
}
830
831
/**
832
 * Check if a given file is an IMS/QTI question bank file.
833
 *
834
 * @param string $filePath The absolute filepath
835
 *
836
 * @return bool Whether it is an IMS/QTI question bank or not
837
 */
838
function isQtiQuestionBank($data)
839
{
840
    if (!is_string($data) || $data === '') {
841
        return false;
842
    }
843
844
    return (bool) preg_match('/ims_qtiasiv(\d)p(\d)/', $data);
845
}
846
847
/**
848
 * Check if a given file is an IMS/QTI manifest file (listing of extra files).
849
 *
850
 * @param string $filePath The absolute filepath
851
 *
852
 * @return bool Whether it is an IMS/QTI manifest file or not
853
 */
854
function isQtiManifest($data): bool
855
{
856
    if (!is_string($data) || $data === '') {
857
        return false;
858
    }
859
    return (bool) preg_match('/imsccv(\d)p(\d)/', $data);
860
}
861
862
/**
863
 * Processes an IMS/QTI manifest file: store links to new files
864
 * to be able to transform them into the questions text.
865
 *
866
 * @param string $filePath The absolute filepath
867
 *
868
 * @return bool
869
 */
870
function qtiProcessManifest($data)
871
{
872
    $xml = simplexml_load_string($data);
873
    $course = api_get_course_info();
874
    $sessionId = api_get_session_id();
875
    $exercisesSysPath = '/';
876
    $webPath = api_get_path(WEB_CODE_PATH);
877
    $exercisesWebPath = $webPath.'document/document.php?'.api_get_cidreq().'&action=download&id=';
878
    $links = [
879
        'manifest' => [],
880
        'system' => [],
881
        'web' => [],
882
    ];
883
    $tableDocuments = Database::get_course_table(TABLE_DOCUMENT);
884
    $countResources = count($xml->resources->resource->file);
885
    for ($i = 0; $i < $countResources; $i++) {
886
        $file = $xml->resources->resource->file[$i];
887
        $href = '';
888
        foreach ($file->attributes() as $key => $value) {
889
            if ('href' == $key) {
890
                if ('xml' != substr($value, -3, 3)) {
891
                    $href = $value;
892
                }
893
            }
894
        }
895
        if (!empty($href)) {
896
            $links['manifest'][] = (string) $href;
897
            $links['system'][] = $exercisesSysPath.strtolower($href);
898
            $specialHref = Database::escape_string(preg_replace('/_/', '-', strtolower($href)));
899
            $specialHref = preg_replace('/(-){2,8}/', '-', $specialHref);
900
901
            $sql = "SELECT iid FROM $tableDocuments
902
                    WHERE
903
                        c_id = ".$course['real_id']." AND
904
                        session_id = $sessionId AND
905
                        path = '/".$specialHref."'";
906
            $result = Database::query($sql);
907
            $documentId = 0;
908
            while ($row = Database::fetch_assoc($result)) {
909
                $documentId = $row['iid'];
910
            }
911
            $links['web'][] = $exercisesWebPath.$documentId;
912
        }
913
    }
914
915
    return $links;
916
}
917
918
function parseExistingFibMeta(string $text): ?array
919
{
920
    $text = trim($text);
921
922
    $pos = strrpos($text, '::');
923
    if (false === $pos) {
924
        return null;
925
    }
926
927
    $tail = substr($text, $pos + 2);
928
929
    // Accept both "...:0@" and "...:0@0"
930
    if (preg_match('/^([0-9]+(?:\.[0-9]+)?(?:,[0-9]+(?:\.[0-9]+)?)*)\:([0-9]+(?:,[0-9]+)*)\:(\d+)@(\d+)$/', $tail, $m)) {
931
        $weights = array_map('floatval', explode(',', $m[1]));
932
        return [$text, array_sum($weights)];
933
    }
934
935
    if (preg_match('/^([0-9]+(?:\.[0-9]+)?(?:,[0-9]+(?:\.[0-9]+)?)*)\:([0-9]+(?:,[0-9]+)*)\:(\d+)@$/', $tail, $m)) {
936
        // Missing switchable flag -> normalize to @0
937
        $weights = array_map('floatval', explode(',', $m[1]));
938
        return [$text.'0', array_sum($weights)];
939
    }
940
941
    return null;
942
}
943
944
/**
945
 * Build Chamilo FillBlanks answer string (single row in c_quiz_answer).
946
 *
947
 * Format:
948
 *   <html with [blank]...>::w1,w2:sz1,sz2:separator@switchable
949
 *
950
 * @return array{0:string,1:float} [answerString, totalWeight]
951
 */
952
function buildFibAnswerString(array $questionArray): array
953
{
954
    $text = (string) ($questionArray['response_text'] ?? '');
955
    $text = trim($text);
956
    $looksEmptyOrWrong =
957
        ($text === '') ||
958
        (false === strpos($text, '[') && false === strpos($text, '::') && false === strpos($text, '**claroline_start**'));
959
960
    if ($looksEmptyOrWrong && isset($questionArray['correct_answers']) && is_array($questionArray['correct_answers'])) {
961
        foreach ($questionArray['correct_answers'] as $k => $v) {
962
            if (0 === strpos((string) $k, 'fill_')) {
963
                $text = trim((string) $v);
964
                break;
965
            }
966
        }
967
    }
968
969
    $text = formatText($text);
970
    $text = api_preg_replace("/\xc2\xa0/", ' ', $text);
971
    $text = trim($text);
972
973
    $existing = parseExistingFibMeta($text);
974
    if (null !== $existing) {
975
        return $existing;
976
    }
977
978
    // Only now do the safe cleanup for normal builds
979
    $text = str_replace('::', '', $text);
980
981
    $text = api_preg_replace("/\xc2\xa0/", ' ', $text);
982
983
    $weightsMap = $questionArray['weighting'] ?? [];
984
    if (!is_array($weightsMap)) {
985
        $weightsMap = [];
986
    }
987
988
    $nb = preg_match_all('/\[[^\]]*\]/', $text, $m);
989
    $weights = [];
990
    $sizes = [];
991
    $total = 0.0;
992
993
    if ($nb && !empty($m[0])) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $nb of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
994
        foreach ($m[0] as $blankRaw) {
995
            $inside = trim((string) $blankRaw, '[]');
996
997
            // For menu blanks: "Correct|Wrong1|Wrong2" (correct must be first)
998
            // For several answers: "Correct||Alt1||Alt2" (first is the reference)
999
            $correct = $inside;
1000
            if (false !== strpos($correct, '||')) {
1001
                $parts = explode('||', $correct);
1002
                $correct = $parts[0] ?? $correct;
1003
            } elseif (false !== strpos($correct, '|')) {
1004
                $parts = explode('|', $correct);
1005
                $correct = $parts[0] ?? $correct;
1006
            }
1007
            $correct = trim((string) $correct);
1008
1009
            $w = 1.0;
1010
            if ($correct !== '' && array_key_exists($correct, $weightsMap)) {
1011
                $w = (float) $weightsMap[$correct];
1012
            }
1013
1014
            $weights[] = $w;
1015
            $sizes[] = 200; // default input size
1016
            $total += $w;
1017
        }
1018
1019
        $text .= '::'.implode(',', $weights).':'.implode(',', $sizes);
1020
    }
1021
1022
    // Separator 0 means "[...]" and switchable defaults to 0
1023
    $text .= ':0@0';
1024
1025
    return [$text, $total];
1026
}
1027