Issues (2037)

main/exercise/export/qti2/qti2_export.php (1 issue)

1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
/**
6
 * @author Claro Team <[email protected]>
7
 * @author Yannick Warnier <[email protected]>
8
 */
9
require __DIR__.'/qti2_classes.php';
10
11
/**
12
 * An IMS/QTI item. It corresponds to a single question.
13
 * This class allows export from Claroline to IMS/QTI2.0 XML format of a single question.
14
 * It is not usable as-is, but must be subclassed, to support different kinds of questions.
15
 *
16
 * Every start_*() and corresponding end_*(), as well as export_*() methods return a string.
17
 *
18
 * note: Attached files are NOT exported.
19
 */
20
class ImsAssessmentItem
21
{
22
    /**
23
     * @var Ims2Question
24
     */
25
    public $question;
26
    /**
27
     * @var string
28
     */
29
    public $questionIdent;
30
    /**
31
     * @var ImsAnswerInterface
32
     */
33
    public $answer;
34
35
    /**
36
     * Constructor.
37
     *
38
     * @param Ims2Question $question ims2Question object we want to export
39
     */
40
    public function __construct($question)
41
    {
42
        $this->question = $question;
43
        $this->answer = $this->question->setAnswer();
44
        $this->questionIdent = 'QST_'.$question->iid;
45
    }
46
47
    /**
48
     * Start the XML flow.
49
     *
50
     * This opens the <item> block, with correct attributes.
51
     */
52
    public function start_item()
53
    {
54
        $categoryTitle = '';
55
        if (!empty($this->question->category)) {
56
            $category = new TestCategory();
57
            $category = $category->getCategory($this->question->category);
58
            if ($category) {
59
                $categoryTitle = htmlspecialchars(formatExerciseQtiText($category->name));
60
            }
61
        }
62
63
        return '<assessmentItem xmlns="http://www.imsglobal.org/xsd/imsqti_v2p1"
64
                xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
65
                xsi:schemaLocation="http://www.imsglobal.org/xsd/imsqti_v2p1 imsqti_v2p1.xsd"
66
                identifier="'.$this->questionIdent.'"
67
                title = "'.htmlspecialchars(formatExerciseQtiText($this->question->selectTitle())).'"
68
                category = "'.$categoryTitle.'"
69
        >'."\n";
70
    }
71
72
    /**
73
     * End the XML flow, closing the </item> tag.
74
     */
75
    public function end_item()
76
    {
77
        return "</assessmentItem>\n";
78
    }
79
80
    /**
81
     * Start the itemBody.
82
     */
83
    public function start_item_body()
84
    {
85
        return '  <itemBody>'."\n";
86
    }
87
88
    /**
89
     * End the itemBody part.
90
     */
91
    public function end_item_body()
92
    {
93
        return "  </itemBody>\n";
94
    }
95
96
    /**
97
     * add the response processing template used.
98
     */
99
    public function add_response_processing()
100
    {
101
        return '  <responseProcessing template="http://www.imsglobal.org/question/qti_v2p1/rptemplates/map_correct"/>'."\n";
102
    }
103
104
    /**
105
     * Export the question as an IMS/QTI Item.
106
     *
107
     * This is a default behaviour, some classes may want to override this.
108
     *
109
     * @return string string, the XML flow for an Item
110
     */
111
    public function export($standalone = false)
112
    {
113
        $head = $foot = '';
114
        if ($standalone) {
115
            $head = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>'."\n";
116
        }
117
118
        //TODO understand why answer might be a non-object sometimes
119
        if (!is_object($this->answer)) {
120
            return $head;
121
        }
122
123
        return $head
124
            .$this->start_item()
125
            .$this->answer->imsExportResponsesDeclaration($this->questionIdent, $this->question)
126
            .$this->start_item_body()
127
            .$this->answer->imsExportResponses(
128
                $this->questionIdent,
129
                $this->question->question,
130
                $this->question->description,
131
                $this->question->getPictureFilename()
132
            )
133
            .$this->end_item_body()
134
            .$this->add_response_processing()
135
            .$this->end_item()
136
            .$foot;
137
    }
138
}
139
140
/**
141
 * This class represents an entire exercise to be exported in IMS/QTI.
142
 * It will be represented by a single <section> containing several <item>.
143
 *
144
 * Some properties cannot be exported, as IMS does not support them :
145
 *   - type (one page or multiple pages)
146
 *   - start_date and end_date
147
 *   - max_attempts
148
 *   - show_answer
149
 *   - anonymous_attempts
150
 *
151
 * @author Amand Tihon <[email protected]>
152
 */
153
class ImsSection
154
{
155
    public $exercise;
156
157
    /**
158
     * Constructor.
159
     *
160
     * @param Exercise $exe The Exercise instance to export
161
     *
162
     * @author Amand Tihon <[email protected]>
163
     */
164
    public function __construct($exe)
165
    {
166
        $this->exercise = $exe;
167
    }
168
169
    public function start_section()
170
    {
171
        return '<section
172
            ident = "EXO_'.$this->exercise->selectId().'"
173
            title = "'.cleanAttribute(formatExerciseQtiDescription($this->exercise->selectTitle())).'"
174
        >'."\n";
175
    }
176
177
    public function end_section()
178
    {
179
        return "</section>\n";
180
    }
181
182
    public function export_duration()
183
    {
184
        if ($max_time = $this->exercise->selectTimeLimit()) {
185
            // return exercise duration in ISO8601 format.
186
            $minutes = floor($max_time / 60);
187
            $seconds = $max_time % 60;
188
189
            return '<duration>PT'.$minutes.'M'.$seconds."S</duration>\n";
190
        } else {
191
            return '';
192
        }
193
    }
194
195
    /**
196
     * Export the presentation (Exercise's description).
197
     *
198
     * @author Amand Tihon <[email protected]>
199
     */
200
    public function export_presentation()
201
    {
202
        return "<presentation_material><flow_mat><material>\n"
203
             .'  <mattext><![CDATA['.formatExerciseQtiDescription($this->exercise->selectDescription())."]]></mattext>\n"
204
             ."</material></flow_mat></presentation_material>\n";
205
    }
206
207
    /**
208
     * Export the ordering information.
209
     * Either sequential, through all questions, or random, with a selected number of questions.
210
     *
211
     * @author Amand Tihon <[email protected]>
212
     */
213
    public function export_ordering()
214
    {
215
        $out = '';
216
        if ($n = $this->exercise->getShuffle()) {
217
            $out .= "<selection_ordering>"
218
                 ."  <selection>\n"
219
                 ."    <selection_number>".$n."</selection_number>\n"
220
                 ."  </selection>\n"
221
                 .'  <order order_type="Random" />'
222
                 ."\n</selection_ordering>\n";
223
        } else {
224
            $out .= '<selection_ordering sequence_type="Normal">'."\n"
225
                 ."  <selection />\n"
226
                 ."</selection_ordering>\n";
227
        }
228
229
        return $out;
230
    }
231
232
    /**
233
     * Export the questions, as a succession of <items>.
234
     *
235
     * @author Amand Tihon <[email protected]>
236
     */
237
    public function exportQuestions()
238
    {
239
        $out = '';
240
        foreach ($this->exercise->selectQuestionList() as $q) {
241
            $out .= export_question_qti($q, false);
242
        }
243
244
        return $out;
245
    }
246
247
    /**
248
     * Export the exercise in IMS/QTI.
249
     *
250
     * @param bool $standalone wether it should include XML tag and DTD line
251
     *
252
     * @return string string containing the XML flow
253
     *
254
     * @author Amand Tihon <[email protected]>
255
     */
256
    public function export($standalone)
257
    {
258
        $head = $foot = '';
259
        if ($standalone) {
260
            $head = '<?xml version = "1.0" encoding = "UTF-8" standalone = "no"?>'."\n"
261
                  .'<!DOCTYPE questestinterop SYSTEM "ims_qtiasiv2p1.dtd">'."\n"
262
                  ."<questestinterop>\n";
263
            $foot = "</questestinterop>\n";
264
        }
265
266
        return $head
267
             .$this->start_section()
268
             .$this->export_duration()
269
             .$this->export_presentation()
270
             .$this->export_ordering()
271
             .$this->exportQuestions()
272
             .$this->end_section()
273
             .$foot;
274
    }
275
}
276
277
/*
278
    Some quick notes on identifiers generation.
279
    The IMS format requires some blocks, like items, responses, feedbacks, to be uniquely
280
    identified.
281
    The unicity is mandatory in a single XML, of course, but it's prefered that the identifier stays
282
    coherent for an entire site.
283
284
    Here's the method used to generate those identifiers.
285
    Question identifier :: "QST_" + <Question Id from the DB> + "_" + <Question numeric type>
286
    Response identifier :: <Question identifier> + "_A_" + <Response Id from the DB>
287
    Condition identifier :: <Question identifier> + "_C_" + <Response Id from the DB>
288
    Feedback identifier :: <Question identifier> + "_F_" + <Response Id from the DB>
289
*/
290
/**
291
 * Class ImsItem.
292
 *
293
 * An IMS/QTI item. It corresponds to a single question.
294
 * This class allows export from Claroline to IMS/QTI XML format.
295
 * It is not usable as-is, but must be subclassed, to support different kinds of questions.
296
 *
297
 * Every start_*() and corresponding end_*(), as well as export_*() methods return a string.
298
 *
299
 * warning: Attached files are NOT exported.
300
 *
301
 * @author Amand Tihon <[email protected]>
302
 */
303
class ImsItem
304
{
305
    public $question;
306
    public $questionIdent;
307
    public $answer;
308
309
    /**
310
     * Constructor.
311
     *
312
     * @param Question $question the Question object we want to export
313
     *
314
     * @author Anamd Tihon
315
     */
316
    public function __construct($question)
317
    {
318
        $this->question = $question;
319
        $this->answer = $question->answer;
320
        $this->questionIdent = 'QST_'.$question->selectId();
321
    }
322
323
    /**
324
     * Start the XML flow.
325
     *
326
     * This opens the <item> block, with correct attributes.
327
     *
328
     * @author Amand Tihon <[email protected]>
329
     */
330
    public function start_item()
331
    {
332
        return '<item title="'.cleanAttribute(formatExerciseQtiDescription($this->question->selectTitle())).'" ident="'.$this->questionIdent.'">'."\n";
333
    }
334
335
    /**
336
     * End the XML flow, closing the </item> tag.
337
     *
338
     * @author Amand Tihon <[email protected]>
339
     */
340
    public function end_item()
341
    {
342
        return "</item>\n";
343
    }
344
345
    /**
346
     * Create the opening, with the question itself.
347
     *
348
     * This means it opens the <presentation> but doesn't close it, as this is the role of end_presentation().
349
     * In between, the export_responses from the subclass should have been called.
350
     *
351
     * @author Amand Tihon <[email protected]>
352
     */
353
    public function start_presentation()
354
    {
355
        return '<presentation label="'.$this->questionIdent.'"><flow>'."\n"
356
            .'<material><mattext>'.formatExerciseQtiDescription($this->question->selectDescription())."</mattext></material>\n";
357
    }
358
359
    /**
360
     * End the </presentation> part, opened by export_header.
361
     *
362
     * @author Amand Tihon <[email protected]>
363
     */
364
    public function end_presentation()
365
    {
366
        return "</flow></presentation>\n";
367
    }
368
369
    /**
370
     * Start the response processing, and declare the default variable, SCORE, at 0 in the outcomes.
371
     *
372
     * @author Amand Tihon <[email protected]>
373
     */
374
    public function start_processing()
375
    {
376
        return '<resprocessing><outcomes><decvar vartype="Integer" defaultval="0" /></outcomes>'."\n";
377
    }
378
379
    /**
380
     * End the response processing part.
381
     *
382
     * @author Amand Tihon <[email protected]>
383
     */
384
    public function end_processing()
385
    {
386
        return "</resprocessing>\n";
387
    }
388
389
    /**
390
     * Export the question as an IMS/QTI Item.
391
     *
392
     * This is a default behaviour, some classes may want to override this.
393
     *
394
     * @param bool $standalone Boolean stating if it should be exported as a stand-alone question
395
     *
396
     * @return string string, the XML flow for an Item
397
     *
398
     * @author Amand Tihon <[email protected]>
399
     */
400
    public function export($standalone = false)
401
    {
402
        global $charset;
403
        $head = $foot = '';
404
405
        if ($standalone) {
406
            $head = '<?xml version = "1.0" encoding = "'.$charset.'" standalone = "no"?>'."\n"
407
                  .'<!DOCTYPE questestinterop SYSTEM "ims_qtiasiv2p1.dtd">'."\n"
408
                  ."<questestinterop>\n";
409
            $foot = "</questestinterop>\n";
410
        }
411
412
        return $head
413
            .$this->start_item()
414
            .$this->start_presentation()
415
            .$this->answer->imsExportResponses($this->questionIdent)
416
            .$this->end_presentation()
417
            .$this->start_processing()
418
            .$this->answer->imsExportProcessing($this->questionIdent)
419
            .$this->end_processing()
420
            .$this->answer->imsExportFeedback($this->questionIdent)
421
            .$this->end_item()
422
            .$foot;
423
    }
424
}
425
426
/**
427
 * Send a complete exercise in IMS/QTI format, from its ID.
428
 *
429
 * @param int  $exerciseId The exercise to export
430
 * @param bool $standalone wether it should include XML tag and DTD line
431
 *
432
 * @return string XML as a string, or an empty string if there's no exercise with given ID
433
 */
434
function export_exercise_to_qti($exerciseId, $standalone = true)
435
{
436
    $exercise = new Exercise();
437
    if (!$exercise->read($exerciseId)) {
438
        return '';
439
    }
440
    $ims = new ImsSection($exercise);
441
442
    return $ims->export($standalone);
443
}
444
445
/**
446
 * Returns the XML flow corresponding to one question.
447
 *
448
 * @param int  $questionId
449
 * @param bool $standalone (ie including XML tag, DTD declaration, etc)
450
 *
451
 * @return string
452
 */
453
function export_question_qti($questionId, $standalone = true)
454
{
455
    $question = new Ims2Question();
456
    $qst = $question->read($questionId);
457
    if (!$qst) {
458
        return '';
459
    }
460
461
    $isValid = $qst instanceof UniqueAnswer
462
        || $qst instanceof MultipleAnswer
463
        || $qst instanceof FreeAnswer
464
        || $qst instanceof MultipleAnswerDropdown
465
        || $qst instanceof MultipleAnswerDropdownCombination
0 ignored issues
show
$qst is never a sub-type of MultipleAnswerDropdownCombination.
Loading history...
466
    ;
467
468
    if (!$isValid) {
469
        return '';
470
    }
471
472
    $question->iid = $qst->iid;
473
    $question->type = $qst->type;
474
    $question->question = $qst->question;
475
    $question->description = $qst->description;
476
    $question->weighting = $qst->weighting;
477
    $question->position = $qst->position;
478
    $question->picture = $qst->picture;
479
    $question->category = $qst->category;
480
    $ims = new ImsAssessmentItem($question);
481
482
    return $ims->export($standalone);
483
}
484
485
/**
486
 * Clean text like a description.
487
 */
488
function formatExerciseQtiDescription($text)
489
{
490
    $entities = api_html_entity_decode($text);
491
492
    return htmlspecialchars($entities);
493
}
494
495
/**
496
 * Clean titles.
497
 *
498
 * @param $text
499
 *
500
 * @return string
501
 */
502
function formatExerciseQtiText($text)
503
{
504
    return htmlspecialchars($text);
505
}
506
507
/**
508
 * @param string $text
509
 *
510
 * @return string
511
 */
512
function cleanAttribute($text)
513
{
514
    return $text;
515
}
516