Passed
Pull Request — master (#6921)
by
unknown
09:03
created

Cc13Quiz::generateInstances()   B

Complexity

Conditions 9
Paths 3

Size

Total Lines 69
Code Lines 38

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 9
eloc 38
c 1
b 0
f 0
nc 3
nop 0
dl 0
loc 69
rs 7.7564

How to fix   Long Method   

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
declare(strict_types=1);
6
7
namespace Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Import\Converter;
8
9
use Answer;
10
use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Import\Cc1p3Convert;
11
use DOMDocument;
12
use DOMXPath;
13
use Exercise;
14
use Question;
15
use Security;
16
17
use const DIRECTORY_SEPARATOR;
18
19
/**
20
 * CC 1.3 Quiz importer (namespace-agnostic / no legacy constants).
21
 * - Uses string keys ('quiz', 'question_bank') created by Cc1p3Convert::$instances.
22
 * - Accepts CC profile strings and normalizes (e.g., "cc.multiple_choice.v0p1" -> "multiple_choice").
23
 */
24
class Cc13Quiz extends Cc13Entities
25
{
26
    /**
27
     * Extracts quiz data (non-question-bank) from QTI assessments.
28
     */
29
    public function generateData()
30
    {
31
        $data = [];
32
        $instances = $this->generateInstances();
33
        if (!empty($instances)) {
34
            foreach ($instances as $instance) {
35
                if (0 === (int) ($instance['is_question_bank'] ?? 0)) {
36
                    $data[] = $this->getQuizData($instance);
37
                }
38
            }
39
        }
40
41
        return $data;
42
    }
43
44
    /**
45
     * Creates a Chamilo quiz (Exercise) and inserts questions/answers.
46
     *
47
     * @param array $quiz
48
     */
49
    public function storeQuiz($quiz): void
50
    {
51
        // Replace $IMS/1EdTech-CC-FILEBASE$ with course document path.
52
        $token = '/\$(?:IMS|1EdTech)[-_]CC[-_]FILEBASE\$\.\.\//';
53
        $courseInfo = api_get_course_info();
54
55
        // Path in Documents where we place Common Cartridge files (matches storeDocuments()).
56
        $replacementPath = '/courses/'.$courseInfo['directory'].'/document/commoncartridge/';
57
58
        $exercise = new Exercise($courseInfo['real_id']);
59
        $title = Exercise::format_title_variable($quiz['title']);
60
        $exercise->updateTitle($title);
61
62
        $description = preg_replace($token, $replacementPath, (string) $quiz['description']);
63
        $exercise->updateDescription($description);
64
65
        $exercise->updateAttempts((int) ($quiz['max_attempts'] ?? 0));
66
        $exercise->updateFeedbackType(0);
67
        $exercise->setRandom(0);
68
69
        // Respect shuffleanswers if provided; default to off.
70
        $exercise->updateRandomAnswers(!empty($quiz['shuffleanswers']));
71
72
        $exercise->updateExpiredTime((int) ($quiz['timelimit'] ?? 0));
73
        $exercise->updateType(1);
74
75
        // Persist the new Exercise.
76
        $exercise->save();
77
78
        if (!empty($quiz['questions'])) {
79
            foreach ($quiz['questions'] as $question) {
80
                $qtype = $question['type'];
81
82
                // Map our internal string types to Chamilo constants.
83
                $types = [
84
                    'unique_answer' => UNIQUE_ANSWER,
85
                    'multiple_answer' => MULTIPLE_ANSWER,
86
                    'fib' => FILL_IN_BLANKS,
87
                    'essay' => FREE_ANSWER,
88
                ];
89
                if (!isset($types[$qtype])) {
90
                    // Unknown question type; skip gracefully. // Tipo desconocido: ignorar sin romper.
91
                    continue;
92
                }
93
                $questionType = $types[$qtype];
94
95
                $questionInstance = Question::getInstance($questionType);
96
                if (empty($questionInstance)) {
97
                    continue;
98
                }
99
100
                $questionInstance->updateTitle(substr(
101
                    Security::remove_XSS(strip_tags_blacklist($question['title'], ['br', 'p'])),
102
                    0,
103
                    20
104
                ));
105
106
                $questionText = Security::remove_XSS(strip_tags_blacklist($question['title'], ['br', 'p']));
107
                // Replace placeholder to real Chamilo path inside question text.
108
                $questionText = preg_replace($token, $replacementPath, $questionText);
109
                $questionInstance->updateDescription($questionText);
110
111
                $questionInstance->updateLevel(1);
112
                $questionInstance->updateCategory(0);
113
114
                // Save normal question if NOT media
115
                if (MEDIA_QUESTION != $questionInstance->type) {
116
                    $questionInstance->save($exercise);
117
                    $exercise->addToList($questionInstance->iid);
118
                    $exercise->update_question_positions();
119
                }
120
121
                if ('unique_answer' === $qtype) {
122
                    $objAnswer = new Answer($questionInstance->iid);
123
                    $questionWeighting = 0.0;
124
125
                    foreach ($question['answers'] as $slot => $answerValues) {
126
                        $correct = !empty($answerValues['score']) ? (int) $answerValues['score'] : 0;
127
                        $answer = Security::remove_XSS((string) preg_replace($token, $replacementPath, (string) ($answerValues['title'] ?? '')));
128
                        $comment = Security::remove_XSS((string) preg_replace($token, $replacementPath, (string) ($answerValues['feedback'] ?? '')));
129
                        $weighting = (float) ($answerValues['score'] ?? 0);
130
                        $weighting = abs($weighting);
131
                        if ($weighting > 0) {
132
                            $questionWeighting += $weighting;
133
                        }
134
                        $goodAnswer = $correct ? true : false;
135
136
                        $objAnswer->createAnswer(
137
                            $answer,
138
                            $goodAnswer,
139
                            $comment,
140
                            $weighting,
141
                            $slot + 1,
142
                            null,
143
                            null,
144
                            ''
145
                        );
146
                    }
147
                    // Save answers and update question weighting.
148
                    $objAnswer->save();
149
                    $questionInstance->updateWeighting($questionWeighting);
150
                    $questionInstance->save($exercise);
151
                } else {
152
                    // Multiple-answer, FIB, essay, etc.
153
                    $objAnswer = new Answer($questionInstance->iid);
154
                    $questionWeighting = 0.0;
155
156
                    if (\is_array($question['answers'])) {
157
                        foreach ($question['answers'] as $slot => $answerValues) {
158
                            $answer = Security::remove_XSS((string) preg_replace($token, $replacementPath, (string) ($answerValues['title'] ?? '')));
159
                            $comment = Security::remove_XSS((string) preg_replace($token, $replacementPath, (string) ($answerValues['feedback'] ?? '')));
160
                            $weighting = (float) ($answerValues['score'] ?? 0);
161
                            if ($weighting > 0) {
162
                                $questionWeighting += $weighting;
163
                            }
164
                            $goodAnswer = ($weighting > 0);
165
166
                            $objAnswer->createAnswer(
167
                                $answer,
168
                                $goodAnswer,
169
                                $comment,
170
                                $weighting,
171
                                $slot + 1,
172
                                null,
173
                                null,
174
                                ''
175
                            );
176
                        }
177
                    } elseif ('essay' === $qtype) {
178
                        $questionWeighting = (float) ($question['ponderation'] ?? 1);
179
                    }
180
181
                    $objAnswer->save();
182
                    $questionInstance->updateWeighting($questionWeighting);
183
                    $questionInstance->save($exercise);
184
                }
185
            }
186
        }
187
    }
188
189
    public function storeQuizzes($quizzes): void
190
    {
191
        if (!empty($quizzes)) {
192
            foreach ($quizzes as $quiz) {
193
                $this->storeQuiz($quiz);
194
            }
195
        }
196
    }
197
198
    public function getQuizData($instance)
199
    {
200
        $values = [];
201
        if (!empty($instance)) {
202
            $questions = [];
203
            if (!empty($instance['questions'])) {
204
                foreach ($instance['questions'] as $question) {
205
                    $questions[$question['id']] = [
206
                        'title' => $question['title'],
207
                        'type' => $question['qtype'],
208
                        'ponderation' => $question['defaultgrade'],
209
                        'answers' => $question['answers'],
210
                    ];
211
                }
212
            }
213
            $values = [
214
                'id' => $instance['id'],
215
                'title' => $instance['title'],
216
                'description' => $instance['description'],
217
                'timelimit' => $instance['options']['timelimit'],
218
                'max_attempts' => $instance['options']['max_attempts'],
219
                'questions' => $questions,
220
            ];
221
        }
222
223
        return $values;
224
    }
225
226
    /**
227
     * Build instances from Cc1p3Convert::$instances for both assessments and banks.
228
     * Uses string keys as produced by the converter ('quiz', 'question_bank').
229
     */
230
    private function generateInstances()
231
    {
232
        $lastInstanceId = 0;
233
        $lastQuestionId = 0;
234
        $lastAnswerId = 0;
235
236
        $instances = [];
237
238
        // Keys as filled by Cc1p3Convert::createInstances()
239
        $types = ['quiz', 'question_bank'];
240
241
        foreach ($types as $type) {
242
            if (empty(Cc1p3Convert::$instances['instances'][$type])) {
243
                continue;
244
            }
245
            foreach (Cc1p3Convert::$instances['instances'][$type] as $instance) {
246
                $is_question_bank = ('quiz' === $type) ? 0 : 1;
247
248
                // Path to assessment.xml
249
                $assessmentFile = $this->getExternalXml($instance['resource_identifier']);
250
251
                if (empty($assessmentFile)) {
252
                    continue;
253
                }
254
255
                $assessment = $this->loadXmlResource(
256
                    Cc1p3Convert::$pathToManifestFolder.DIRECTORY_SEPARATOR.$assessmentFile
257
                );
258
259
                if (empty($assessment)) {
260
                    continue;
261
                }
262
263
                Cc1p3Convert::logAction(
264
                    'QTI loaded',
265
                    [
266
                        'resource' => $assessmentFile,
267
                        'rootNS' => (string) ($assessment->documentElement?->namespaceURI ?? ''),
268
                    ]
269
                );
270
271
                $replaceValues = ['unlimited' => 0];
272
273
                $questions = $this->getQuestions($assessment, $lastQuestionId, $lastAnswerId, \dirname($assessmentFile), $is_question_bank);
274
                $questionCount = \is_array($questions) ? \count($questions) : 0;
275
276
                Cc1p3Convert::logAction(
277
                    'QTI questions detected',
278
                    [
279
                        'resource' => $assessmentFile,
280
                        'count' => (int) $questionCount,
281
                    ]
282
                );
283
284
                if ($questionCount > 0) {
285
                    $lastInstanceId++;
286
287
                    $instances[$instance['resource_identifier']]['questions'] = $questions;
288
                    $instances[$instance['resource_identifier']]['id'] = $lastInstanceId;
289
                    $instances[$instance['resource_identifier']]['title'] = $instance['title'];
290
                    $instances[$instance['resource_identifier']]['description'] = $this->getQuizDescription($assessment);
291
                    $instances[$instance['resource_identifier']]['is_question_bank'] = $is_question_bank;
292
                    $instances[$instance['resource_identifier']]['options']['timelimit'] = $this->getGlobalConfig($assessment, 'qmd_timelimit', 0);
293
                    $instances[$instance['resource_identifier']]['options']['max_attempts'] = $this->getGlobalConfig($assessment, 'cc_maxattempts', 0, $replaceValues);
294
                }
295
            }
296
        }
297
298
        return $instances;
299
    }
300
301
    private function getGlobalConfig($assessment, $option, $defaultValue, $replaceValues = '')
302
    {
303
        $xp = $this->xp($assessment);
304
        $nodes = $xp->query(
305
            '/*[local-name()="questestinterop"]/*[local-name()="assessment"]'
306
            .'/*[local-name()="qtimetadata"]/*[local-name()="qtimetadatafield"]'
307
        );
308
309
        $response = '';
310
        foreach ($nodes as $field) {
311
            $label = $xp->query('*[local-name()="fieldlabel"]/text()', $field)->item(0)?->nodeValue ?? '';
312
            if (0 === strcasecmp((string) $label, (string) $option)) {
313
                $response = $xp->query('*[local-name()="fieldentry"]/text()', $field)->item(0)?->nodeValue ?? '';
314
315
                break;
316
            }
317
        }
318
319
        $response = trim((string) $response);
320
321
        if (!empty($replaceValues)) {
322
            foreach ($replaceValues as $key => $value) {
323
                $response = ($key == $response) ? (string) $value : $response;
324
            }
325
        }
326
327
        return ('' === $response) ? $defaultValue : $response;
328
    }
329
330
    private function getQuizDescription(DOMDocument $assessment): string
331
    {
332
        $xp = $this->xp($assessment);
333
        $n = $xp->query(
334
            '/*[local-name()="questestinterop"]/*[local-name()="assessment"]'
335
            .'/*[local-name()="rubric"]/*[local-name()="material"]/*[local-name()="mattext"]/text()'
336
        );
337
338
        return $n && $n->length > 0 ? (string) $n->item(0)->nodeValue : '';
339
    }
340
341
    private function getQuestions($assessment, &$lastQuestionId, &$last_answer_id, $rootPath, $is_question_bank)
342
    {
343
        $questions = [];
344
        $xp = $this->xp($assessment);
345
346
        $itemPath = $is_question_bank
347
            ? '/*[local-name()="questestinterop"]/*[local-name()="objectbank"]/*[local-name()="item"]'
348
            : '/*[local-name()="questestinterop"]/*[local-name()="assessment"]/*[local-name()="section"]/*[local-name()="item"]';
349
350
        $items = $xp->query($itemPath);
351
352
        foreach ($items as $item) {
353
            $questionIdentifier = trim((string) ($item->getAttribute('ident') ?? ''));
354
            if ('' === $questionIdentifier) {
355
                continue;
356
            }
357
358
            // Title inside <presentation> (with/without <flow>)
359
            $titleNode = $xp->query(
360
                '(*[local-name()="presentation"]/*[local-name()="flow"]/*[local-name()="material"]/*[local-name()="mattext"]'
361
                .' | *[local-name()="presentation"]/*[local-name()="material"]/*[local-name()="mattext"])[1]/text()',
362
                $item
363
            );
364
            $questionTitle = $titleNode->item(0)?->nodeValue ?? '';
365
366
            $qTypeInfo = $this->getQuestionType($questionIdentifier, $assessment);
367
            if (empty($qTypeInfo['qtype'])) {
368
                continue;
369
            }
370
371
            $lastQuestionId++;
372
            $questions[$questionIdentifier]['id'] = $lastQuestionId;
373
374
            $questionTitle = $this->updateSources($questionTitle, $rootPath);
375
            $questionTitle = '' !== $questionTitle ? str_replace('%24', '$', $this->includeTitles($questionTitle)) : '';
376
377
            $questionname = $item->getAttribute('title') ?? '';
378
379
            $questions[$questionIdentifier]['title'] = $questionTitle;
380
            $questions[$questionIdentifier]['name'] = $questionname;
381
            $questions[$questionIdentifier]['identifier'] = $questionIdentifier;
382
            $questions[$questionIdentifier]['qtype'] = $qTypeInfo['qtype']; // 'unique_answer', 'multiple_answer', 'fib', 'essay'
383
            $questions[$questionIdentifier]['cc_type'] = $qTypeInfo['cc'];    // raw CC type string
384
            $questions[$questionIdentifier]['feedback'] = $this->getGeneralFeedback($assessment, $questionIdentifier);
385
            $questions[$questionIdentifier]['defaultgrade'] = $this->getDefaultgrade($assessment, $questionIdentifier);
386
            $questions[$questionIdentifier]['answers'] = $this->getAnswers($questionIdentifier, $assessment, $last_answer_id);
387
        }
388
389
        return !empty($questions) ? $questions : '';
390
    }
391
392
    private function getDefaultgrade($assessment, $questionIdentifier)
393
    {
394
        $xp = $this->xp($assessment);
395
        $n = $xp->query(
396
            '//*[local-name()="item" and @ident="'.$questionIdentifier.'"]'
397
            .'//*[local-name()="qtimetadatafield"][*[local-name()="fieldlabel" and text()="cc_weighting"]]'
398
            .'/*[local-name()="fieldentry"]/text()'
399
        );
400
        $result = 1;
401
        if ($n && $n->length > 0) {
402
            $resp = (int) $n->item(0)->nodeValue;
403
            if ($resp >= 0 && $resp <= 99) {
404
                $result = $resp;
405
            }
406
        }
407
408
        return $result;
409
    }
410
411
    private function getGeneralFeedback($assessment, $questionIdentifier)
412
    {
413
        $xp = $this->xp($assessment);
414
415
        $respconditions = $xp->query(
416
            '//*[local-name()="item" and @ident="'.$questionIdentifier.'"]'
417
            .'/*[local-name()="resprocessing"]/*[local-name()="respcondition"]'
418
        );
419
420
        $feedbackIds = [];
421
        foreach ($respconditions as $rc) {
422
            $cont = strtolower((string) ($rc->getAttribute('continue') ?? ''));
423
            if ('yes' === $cont) {
424
                $dfs = $xp->query('*[local-name()="displayfeedback"]', $rc);
425
                foreach ($dfs as $df) {
426
                    $link = $df->getAttribute('linkrefid') ?? '';
427
                    if ('' !== $link) {
428
                        $feedbackIds[] = $link;
429
                    }
430
                }
431
            }
432
        }
433
434
        $feedback = '';
435
        foreach ($feedbackIds as $fid) {
436
            $texts = $xp->query(
437
                '//*[local-name()="item" and @ident="'.$questionIdentifier.'"]'
438
                .'/*[local-name()="itemfeedback" and @ident="'.$fid.'"]'
439
                .'/*[local-name()="flow_mat"]/*[local-name()="material"]/*[local-name()="mattext"]/text()'
440
            );
441
            if ($texts && $texts->length > 0) {
442
                $feedback .= $texts->item(0)->nodeValue.' ';
443
            }
444
        }
445
446
        return trim($feedback);
447
    }
448
449
    private function getFeedback($assessment, $identifier, $itemIdentifier, $questionType)
450
    {
451
        $xp = $this->xp($assessment);
452
453
        $rcs = $xp->query(
454
            '//*[local-name()="item" and @ident="'.$itemIdentifier.'"]'
455
            .'/*[local-name()="resprocessing"]/*[local-name()="respcondition"]'
456
        );
457
458
        $ids = [];
459
        foreach ($rcs as $rc) {
460
            $ve = $xp->query('*[local-name()="conditionvar"]/*[local-name()="varequal"]/text()', $rc)
461
                ->item(0)?->nodeValue ?? ''
462
            ;
463
            if (0 === strcasecmp((string) $ve, (string) $identifier) || ('essay' === $questionType)) {
464
                $dfs = $xp->query('*[local-name()="displayfeedback"]', $rc);
465
                foreach ($dfs as $df) {
466
                    $link = $df->getAttribute('linkrefid') ?? '';
467
                    if ('' !== $link) {
468
                        $ids[] = $link;
469
                    }
470
                }
471
            }
472
        }
473
474
        $feedback = '';
475
        foreach ($ids as $fid) {
476
            $texts = $xp->query(
477
                '//*[local-name()="item" and @ident="'.$itemIdentifier.'"]'
478
                .'/*[local-name()="itemfeedback" and @ident="'.$fid.'"]'
479
                .'/*[local-name()="flow_mat"]/*[local-name()="material"]/*[local-name()="mattext"]/text()'
480
            );
481
            if ($texts && $texts->length > 0) {
482
                $feedback .= $texts->item(0)->nodeValue.' ';
483
            }
484
        }
485
486
        return trim($feedback);
487
    }
488
489
    /**
490
     * Namespace-agnostic FIB answers (Fill-in-the-blank).
491
     *
492
     * @param mixed $questionIdentifier
493
     * @param mixed $identifier
494
     * @param mixed $assessment
495
     * @param mixed $lastAnswerId
496
     */
497
    private function getAnswersFib($questionIdentifier, $identifier, $assessment, &$lastAnswerId)
498
    {
499
        $xp = $this->xp($assessment);
500
501
        $correct = [];
502
        $incorrect = [];
503
504
        // All respconditions for this item
505
        $responseItems = $xp->query(
506
            '//*[local-name()="item" and @ident="'.$questionIdentifier.'"]'
507
            .'/*[local-name()="resprocessing"]/*[local-name()="respcondition"]'
508
        );
509
510
        // Find the condition with setvar=100 (correct) and collect varequal values
511
        $correctResp = $xp->query(
512
            '//*[local-name()="item" and @ident="'.$questionIdentifier.'"]'
513
            .'/*[local-name()="resprocessing"]/*[local-name()="respcondition"][*[local-name()="setvar" and normalize-space(text())="100"]]'
514
        );
515
516
        if ($correctResp && $correctResp->length > 0) {
517
            $canswers = $xp->query('*[local-name()="conditionvar"]/*[local-name()="varequal"]/text()', $correctResp->item(0));
518
            foreach ($canswers as $cnode) {
519
                $answertitle = trim((string) $cnode->nodeValue);
520
                if ('' === $answertitle) {
521
                    continue;
522
                }
523
                $lastAnswerId++;
524
                $correct[$answertitle] = [
525
                    'id' => $lastAnswerId,
526
                    'title' => $answertitle,
527
                    'score' => 1,
528
                    'feedback' => '',
529
                    'case' => 0,
530
                ];
531
            }
532
        }
533
534
        // Iterate through all respconditions to attach feedback and collect incorrects
535
        foreach ($responseItems as $rc) {
536
            // Skip the correct one (setvar=100) here
537
            $sv = $xp->query('*[local-name()="setvar"]/text()', $rc)->item(0)?->nodeValue ?? null;
538
            if (null !== $sv && '100' === trim($sv)) {
539
                continue;
540
            }
541
542
            $ve = $xp->query('*[local-name()="conditionvar"]/*[local-name()="varequal"]/text()', $rc);
543
            if (!$ve || 0 === $ve->length) {
544
                continue;
545
            }
546
            $answerTitle = trim((string) $ve->item(0)->nodeValue);
547
548
            // Gather feedback ids
549
            $dfs = $xp->query('*[local-name()="displayfeedback"]', $rc);
550
            $fbids = [];
551
            foreach ($dfs as $df) {
552
                $link = $df->getAttribute('linkrefid') ?? '';
553
                if ('' !== $link) {
554
                    $fbids[] = $link;
555
                }
556
            }
557
558
            // Resolve feedback text(s)
559
            $feedback = '';
560
            foreach ($fbids as $fid) {
561
                $fbt = $xp->query(
562
                    '//*[local-name()="item" and @ident="'.$questionIdentifier.'"]'
563
                    .'/*[local-name()="itemfeedback" and @ident="'.$fid.'"]'
564
                    .'/*[local-name()="flow_mat"]/*[local-name()="material"]/*[local-name()="mattext"]/text()'
565
                );
566
                if ($fbt && $fbt->length > 0) {
567
                    $feedback .= $fbt->item(0)->nodeValue.' ';
568
                }
569
            }
570
            $feedback = trim($feedback);
571
572
            if (\array_key_exists($answerTitle, $correct)) {
573
                $correct[$answerTitle]['feedback'] = $feedback;
574
            } else {
575
                $lastAnswerId++;
576
                $incorrect[] = [
577
                    'id' => $lastAnswerId,
578
                    'title' => $answerTitle,
579
                    'score' => 0,
580
                    'feedback' => $feedback,
581
                    'case' => 0,
582
                ];
583
            }
584
        }
585
586
        $answers = array_merge($correct, $incorrect);
587
588
        return empty($answers) ? '' : $answers;
589
    }
590
591
    /**
592
     * Namespace-agnostic Pattern Match answers.
593
     *
594
     * @param mixed $questionIdentifier
595
     * @param mixed $identifier
596
     * @param mixed $assessment
597
     * @param mixed $lastAnswerId
598
     */
599
    private function getAnswersPatternMatch($questionIdentifier, $identifier, $assessment, &$lastAnswerId)
600
    {
601
        $xp = $this->xp($assessment);
602
        $answers = [];
603
604
        $responseItems = $xp->query(
605
            '//*[local-name()="item" and @ident="'.$questionIdentifier.'"]'
606
            .'/*[local-name()="resprocessing"]/*[local-name()="respcondition"]'
607
        );
608
609
        foreach ($responseItems as $rc) {
610
            $sv = $xp->query('*[local-name()="setvar"]/text()', $rc)->item(0)?->nodeValue ?? '';
611
            $sv = trim((string) $sv);
612
613
            if ('' !== $sv) {
614
                $lastAnswerId++;
615
616
                // varequal with respident or varsubstring
617
                $answerTitle = $xp->query('*[local-name()="conditionvar"]/*[local-name()="varequal"][@respident="'.$identifier.'"]/text()', $rc)->item(0)?->nodeValue ?? '';
618
                $answerTitle = trim((string) $answerTitle);
619
620
                if ('' === $answerTitle) {
621
                    $sub = $xp->query('*[local-name()="conditionvar"]/*[local-name()="varsubstring"][@respident="'.$identifier.'"]/text()', $rc)->item(0)?->nodeValue ?? '';
622
                    $sub = trim((string) $sub);
623
                    $answerTitle = '' !== $sub ? ('*'.$sub.'*') : '';
624
                }
625
                if ('' === $answerTitle) {
626
                    $answerTitle = '*';
627
                }
628
629
                $caseAttr = $xp->query('*[local-name()="conditionvar"]/*[local-name()="varequal"]/@case', $rc)->item(0)?->nodeValue ?? 'no';
630
                $case = 'yes' === strtolower((string) $caseAttr) ? 1 : 0;
631
632
                // Feedback
633
                $dfs = $xp->query('*[local-name()="displayfeedback"]', $rc);
634
                $fbids = [];
635
                foreach ($dfs as $df) {
636
                    $link = $df->getAttribute('linkrefid') ?? '';
637
                    if ('' !== $link) {
638
                        $fbids[] = $link;
639
                    }
640
                }
641
                $feedback = '';
642
                foreach ($fbids as $fid) {
643
                    $fbt = $xp->query(
644
                        '//*[local-name()="item" and @ident="'.$questionIdentifier.'"]'
645
                        .'/*[local-name()="itemfeedback" and @ident="'.$fid.'"]'
646
                        .'/*[local-name()="flow_mat"]/*[local-name()="material"]/*[local-name()="mattext"]/text()'
647
                    );
648
                    if ($fbt && $fbt->length > 0) {
649
                        $feedback .= $fbt->item(0)->nodeValue.' ';
650
                    }
651
                }
652
653
                $answers[] = [
654
                    'id' => $lastAnswerId,
655
                    'title' => $answerTitle,
656
                    'score' => $sv,
657
                    'feedback' => trim($feedback),
658
                    'case' => $case,
659
                ];
660
            }
661
        }
662
663
        return empty($answers) ? '' : $answers;
664
    }
665
666
    private function getAnswers($identifier, $assessment, &$lastAnswerId)
667
    {
668
        $xp = $this->xp($assessment);
669
        $answers = [];
670
671
        $tinfo = $this->getQuestionType($identifier, $assessment);
672
        $ccType = $tinfo['cc']; // e.g. 'multiple_choice', 'multiple_response', 'fib', 'pattern_match', 'essay'
673
        $isMultiresponse = str_contains($ccType, 'multiple_response');
674
675
        if (str_contains($ccType, 'fib') || str_contains($ccType, 'pattern_match')) {
676
            // Find response_str ident first
677
            $aid = $xp->query(
678
                '//*[local-name()="item" and @ident="'.$identifier.'"]'
679
                .'/*[local-name()="presentation"]//*[local-name()="response_str"]/@ident'
680
            )->item(0)?->nodeValue ?? '';
681
            if ('' === $aid) {
682
                return '';
683
            }
684
685
            if (str_contains($ccType, 'fib')) {
686
                return $this->getAnswersFib($identifier, $aid, $assessment, $lastAnswerId);
687
            }
688
689
            return $this->getAnswersPatternMatch($identifier, $aid, $assessment, $lastAnswerId);
690
        }
691
692
        if (str_contains($ccType, 'essay')) {
693
            return '';
694
        }
695
696
        // multiple_choice / true_false / multiple_response
697
        $labels = $xp->query(
698
            '//*[local-name()="item" and @ident="'.$identifier.'"]'
699
            .'/*[local-name()="presentation"]'
700
            .'/*[local-name()="response_lid"]/*[local-name()="render_choice"]/*[local-name()="response_label"]'
701
            .' | '
702
            .'//*[local-name()="item" and @ident="'.$identifier.'"]'
703
            .'/*[local-name()="presentation"]/*[local-name()="flow"]'
704
            .'/*[local-name()="response_lid"]/*[local-name()="render_choice"]/*[local-name()="response_label"]'
705
        );
706
707
        $correctIds = [];
708
        $correctFrac = 1.0;
709
710
        if ($isMultiresponse) {
711
            // Determine how many options yield SCORE=100 to split equally.
712
            $c = $xp->query(
713
                '//*[local-name()="item" and @ident="'.$identifier.'"]'
714
                .'/*[local-name()="resprocessing"]/*[local-name()="respcondition"][*[local-name()="setvar" and normalize-space(text())="100"]]'
715
                .'/*[local-name()="conditionvar"]/*[local-name()="varequal"]/text()'
716
            );
717
            $n = $c?->length ?? 0;
718
            if ($n > 0) {
719
                $correctFrac = round(1.0 / (float) $n, 7);
720
                foreach ($c as $node) {
721
                    $correctIds[trim((string) $node->nodeValue)] = true;
722
                }
723
            }
724
        }
725
726
        foreach ($labels as $lab) {
727
            $lastAnswerId++;
728
            $aid = $lab->getAttribute('ident') ?? '';
729
            $text = $xp->query('*[local-name()="material"]/*[local-name()="mattext"]/text()', $lab)->item(0)?->nodeValue ?? '';
730
731
            $feedback = $this->getFeedback($assessment, $aid, $identifier, $ccType);
732
            $score = $this->getScore($assessment, $aid, $identifier);
733
734
            if ($isMultiresponse && isset($correctIds[$aid])) {
735
                $score = $correctFrac;
736
            }
737
738
            $answers[] = [
739
                'id' => $lastAnswerId,
740
                'title' => $text,
741
                'score' => $score,
742
                'identifier' => $aid,
743
                'feedback' => $feedback,
744
            ];
745
        }
746
747
        return empty($answers) ? '' : $answers;
748
    }
749
750
    private function getScore($assessment, $identifier, $questionIdentifier)
751
    {
752
        $xp = $this->xp($assessment);
753
754
        $rcs = $xp->query(
755
            '//*[local-name()="item" and @ident="'.$questionIdentifier.'"]'
756
            .'/*[local-name()="resprocessing"]/*[local-name()="respcondition"]'
757
        );
758
759
        $scoreValue = null;
760
        foreach ($rcs as $rc) {
761
            $ve = $xp->query('*[local-name()="conditionvar"]/*[local-name()="varequal"]/text()', $rc)
762
                ->item(0)?->nodeValue ?? ''
763
            ;
764
            if (0 === strcasecmp((string) $ve, (string) $identifier)) {
765
                $sv = $xp->query('*[local-name()="setvar"]/text()', $rc)->item(0)?->nodeValue ?? null;
766
                if (null !== $sv) {
767
                    $scoreValue = trim($sv);
768
769
                    break;
770
                }
771
            }
772
        }
773
774
        // Normalize to [0,1] granularity expected later.
775
        if (null === $scoreValue) {
776
            return '0.0000000';
777
        }
778
779
        return ((float) $scoreValue > 0) ? '1.0000000' : '0.0000000';
780
    }
781
782
    /**
783
     * Reads cc_profile and maps to our internal qtypes.
784
     * Returns:
785
     *  - 'qtype' => one of: unique_answer, multiple_answer, fib, essay
786
     *  - 'cc'    => the normalized cc_profile string.
787
     *
788
     * @param mixed $identifier
789
     * @param mixed $assessment
790
     */
791
    private function getQuestionType($identifier, $assessment)
792
    {
793
        // Namespace-agnostic XPath
794
        $x = new DOMXPath($assessment);
795
        $metadata = $x->query(
796
            '//*[local-name()="item" and @ident="'.$identifier.'"]'.
797
            '/*[local-name()="itemmetadata"]/*[local-name()="qtimetadata"]/*[local-name()="qtimetadatafield"]'
798
        );
799
800
        $type = '';
801
        foreach ($metadata as $field) {
802
            $label = $x->query('./*[local-name()="fieldlabel"]/text()', $field);
803
            $lab = ($label && $label->length > 0) ? trim((string) $label->item(0)->nodeValue) : '';
804
            if (0 === strcasecmp($lab, 'cc_profile')) {
805
                $entry = $x->query('./*[local-name()="fieldentry"]/text()', $field);
806
                $type = ($entry && $entry->length > 0) ? trim((string) $entry->item(0)->nodeValue) : '';
807
808
                break;
809
            }
810
        }
811
812
        // Normalize patterns like "cc.multiple_choice.v0p1" -> "multiple_choice"
813
        $raw = $type;
814
        $type = preg_replace('~^cc\.~i', '', (string) $type);
815
        $type = preg_replace('~\.v\d+p\d+$~i', '', (string) $type);
816
817
        // Map to internal set
818
        $qtype = '';
819
820
        switch ($type) {
821
            case 'multiple_choice':
822
            case 'true_false':
823
                $qtype = 'unique_answer';
824
825
                break;
826
827
            case 'multiple_response':
828
                $qtype = 'multiple_answer';
829
830
                break;
831
832
            case 'fib':
833
            case 'pattern_match':
834
                $qtype = 'fib';
835
836
                break;
837
838
            case 'essay':
839
                $qtype = 'essay';
840
841
                break;
842
843
            default:
844
                $qtype = '';
845
        }
846
847
        Cc1p3Convert::logAction(
848
            'QTI cc_profile mapped',
849
            ['identifier' => $identifier, 'raw' => $raw, 'norm' => $type, 'qtype' => $qtype]
850
        );
851
852
        return ['qtype' => $qtype, 'cc' => $type];
853
    }
854
855
    /**
856
     * Build an NS-agnostic XPath (we'll use local-name() in expressions).
857
     */
858
    private function xp(DOMDocument $doc): DOMXPath
859
    {
860
        return new DOMXPath($doc);
861
    }
862
}
863