Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
// This file is part of Moodle - http://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
 
17
/**
18
 * Code for exporting questions as Moodle XML.
19
 *
20
 * @package    qformat_xml
21
 * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
22
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
 
25
 
26
defined('MOODLE_INTERNAL') || die();
27
 
28
require_once($CFG->libdir . '/xmlize.php');
29
if (!class_exists('qformat_default')) {
30
    // This is ugly, but this class is also (ab)used by mod/lesson, which defines
31
    // a different base class in mod/lesson/format.php. Thefore, we can only
32
    // include the proper base class conditionally like this. (We have to include
33
    // the base class like this, otherwise it breaks third-party question types.)
34
    // This may be reviewd, and a better fix found one day.
35
    require_once($CFG->dirroot . '/question/format.php');
36
}
37
 
38
 
39
/**
40
 * Importer for Moodle XML question format.
41
 *
42
 * See http://docs.moodle.org/en/Moodle_XML_format for a description of the format.
43
 *
44
 * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
45
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
46
 */
47
class qformat_xml extends qformat_default {
48
 
49
    /** @var array Array of files for question answers. */
50
    protected $answerfiles = [];
51
 
52
    /** @var array Array of files for feedback to question answers. */
53
    protected $feedbackfiles = [];
54
 
55
    public function provide_import() {
56
        return true;
57
    }
58
 
59
    public function provide_export() {
60
        return true;
61
    }
62
 
63
    public function mime_type() {
64
        return 'application/xml';
65
    }
66
 
67
    /**
68
     * Validate the given file.
69
     *
70
     * For more expensive or detailed integrity checks.
71
     *
72
     * @param stored_file $file the file to check
73
     * @return string the error message that occurred while validating the given file
74
     */
75
    public function validate_file(stored_file $file): string {
76
        return $this->validate_is_utf8_file($file);
77
    }
78
 
79
    // IMPORT FUNCTIONS START HERE.
80
 
81
    /**
82
     * Translate human readable format name
83
     * into internal Moodle code number
84
     * Note the reverse function is called get_format.
85
     * @param string name format name from xml file
86
     * @return int Moodle format code
87
     */
88
    public function trans_format($name) {
89
        $name = trim($name);
90
 
91
        if ($name == 'moodle_auto_format') {
92
            return FORMAT_MOODLE;
93
        } else if ($name == 'html') {
94
            return FORMAT_HTML;
95
        } else if ($name == 'plain_text') {
96
            return FORMAT_PLAIN;
97
        } else if ($name == 'wiki_like') {
98
            return FORMAT_WIKI;
99
        } else if ($name == 'markdown') {
100
            return FORMAT_MARKDOWN;
101
        } else {
102
            debugging("Unrecognised text format '{$name}' in the import file. Assuming 'html'.");
103
            return FORMAT_HTML;
104
        }
105
    }
106
 
107
    /**
108
     * Translate human readable single answer option
109
     * to internal code number
110
     * @param string name true/false
111
     * @return int internal code number
112
     */
113
    public function trans_single($name) {
114
        $name = trim($name);
115
        if ($name == "false" || !$name) {
116
            return 0;
117
        } else {
118
            return 1;
119
        }
120
    }
121
 
122
    /**
123
     * process text string from xml file
124
     * @param array $text bit of xml tree after ['text']
125
     * @return string processed text.
126
     */
127
    public function import_text($text) {
128
        // Quick sanity check.
129
        if (empty($text)) {
130
            return '';
131
        }
132
        $data = $text[0]['#'];
133
        return trim($data);
134
    }
135
 
136
    /**
137
     * return the value of a node, given a path to the node
138
     * if it doesn't exist return the default value
139
     * @param array xml data to read
140
     * @param array path path to node expressed as array
141
     * @param mixed default
142
     * @param bool istext process as text
143
     * @param string error if set value must exist, return false and issue message if not
144
     * @return mixed value
145
     */
146
    public function getpath($xml, $path, $default, $istext=false, $error='') {
147
        foreach ($path as $index) {
148
            if (!isset($xml[$index])) {
149
                if (!empty($error)) {
150
                    $this->error($error);
151
                    return false;
152
                } else {
153
                    return $default;
154
                }
155
            }
156
 
157
            $xml = $xml[$index];
158
        }
159
 
160
        if ($istext) {
161
            if (!is_string($xml)) {
162
                $this->error(get_string('invalidxml', 'qformat_xml'));
163
                return false;
164
            }
165
            $xml = trim($xml);
166
        }
167
 
168
        return $xml;
169
    }
170
 
171
    public function import_text_with_files($data, $path, $defaultvalue = '', $defaultformat = 'html') {
172
        $field  = array();
173
        $field['text'] = $this->getpath($data,
174
                array_merge($path, array('#', 'text', 0, '#')), $defaultvalue, true);
175
        $field['format'] = $this->trans_format($this->getpath($data,
176
                array_merge($path, array('@', 'format')), $defaultformat));
177
        $itemid = $this->import_files_as_draft($this->getpath($data,
178
                array_merge($path, array('#', 'file')), array(), false));
179
        if (!empty($itemid)) {
180
            $field['itemid'] = $itemid;
181
        }
182
        return $field;
183
    }
184
 
185
    public function import_files_as_draft($xml) {
186
        global $USER;
187
        if (empty($xml)) {
188
            return null;
189
        }
190
        $fs = get_file_storage();
191
        $itemid = file_get_unused_draft_itemid();
192
        $filepaths = array();
193
        foreach ($xml as $file) {
194
            $filename = $this->getpath($file, array('@', 'name'), '', true);
195
            $filepath = $this->getpath($file, array('@', 'path'), '/', true);
196
            $fullpath = $filepath . $filename;
197
            if (in_array($fullpath, $filepaths)) {
198
                debugging('Duplicate file in XML: ' . $fullpath, DEBUG_DEVELOPER);
199
                continue;
200
            }
201
            $filerecord = array(
202
                'contextid' => context_user::instance($USER->id)->id,
203
                'component' => 'user',
204
                'filearea'  => 'draft',
205
                'itemid'    => $itemid,
206
                'filepath'  => $filepath,
207
                'filename'  => $filename,
208
            );
209
            $fs->create_file_from_string($filerecord, base64_decode($file['#']));
210
            $filepaths[] = $fullpath;
211
        }
212
        return $itemid;
213
    }
214
 
215
    /**
216
     * import parts of question common to all types
217
     * @param $question array question question array from xml tree
218
     * @return object question object
219
     */
220
    public function import_headers($question) {
221
        global $USER;
222
 
223
        // This routine initialises the question object.
224
        $qo = $this->defaultquestion();
225
 
226
        // Question name.
227
        $qo->name = $this->clean_question_name($this->getpath($question,
228
                array('#', 'name', 0, '#', 'text', 0, '#'), '', true,
229
                get_string('xmlimportnoname', 'qformat_xml')));
230
        $questiontext = $this->import_text_with_files($question,
231
                array('#', 'questiontext', 0));
232
        $qo->questiontext = $questiontext['text'];
233
        $qo->questiontextformat = $questiontext['format'];
234
        if (!empty($questiontext['itemid'])) {
235
            $qo->questiontextitemid = $questiontext['itemid'];
236
        }
237
        // Backwards compatibility, deal with the old image tag.
238
        $filedata = $this->getpath($question, array('#', 'image_base64', '0', '#'), null, false);
239
        $filename = $this->getpath($question, array('#', 'image', '0', '#'), null, false);
240
        if ($filedata && $filename) {
241
            $fs = get_file_storage();
242
            if (empty($qo->questiontextitemid)) {
243
                $qo->questiontextitemid = file_get_unused_draft_itemid();
244
            }
245
            $filename = clean_param(str_replace('/', '_', $filename), PARAM_FILE);
246
            $filerecord = array(
247
                'contextid' => context_user::instance($USER->id)->id,
248
                'component' => 'user',
249
                'filearea'  => 'draft',
250
                'itemid'    => $qo->questiontextitemid,
251
                'filepath'  => '/',
252
                'filename'  => $filename,
253
            );
254
            $fs->create_file_from_string($filerecord, base64_decode($filedata));
255
            $qo->questiontext .= ' <img src="@@PLUGINFILE@@/' . $filename . '" />';
256
        }
257
 
258
        $qo->idnumber = $this->getpath($question, ['#', 'idnumber', 0, '#'], null);
259
 
260
        // Restore files in generalfeedback.
261
        $generalfeedback = $this->import_text_with_files($question,
262
                array('#', 'generalfeedback', 0), '', $this->get_format($qo->questiontextformat));
263
        $qo->generalfeedback = $generalfeedback['text'];
264
        $qo->generalfeedbackformat = $generalfeedback['format'];
265
        if (!empty($generalfeedback['itemid'])) {
266
            $qo->generalfeedbackitemid = $generalfeedback['itemid'];
267
        }
268
 
269
        $qo->defaultmark = $this->getpath($question,
270
                array('#', 'defaultgrade', 0, '#'), $qo->defaultmark);
271
        $qo->penalty = $this->getpath($question,
272
                array('#', 'penalty', 0, '#'), $qo->penalty);
273
 
274
        // Fix problematic rounding from old files.
275
        if (abs($qo->penalty - 0.3333333) < 0.005) {
276
            $qo->penalty = 0.3333333;
277
        }
278
 
279
        // Read the question tags.
280
        $this->import_question_tags($qo, $question);
281
 
282
        return $qo;
283
    }
284
 
285
    /**
286
     * Import the common parts of a single answer
287
     * @param array answer xml tree for single answer
288
     * @param bool $withanswerfiles if true, the answers are HTML (or $defaultformat)
289
     *      and so may contain files, otherwise the answers are plain text.
290
     * @param array Default text format for the feedback, and the answers if $withanswerfiles
291
     *      is true.
292
     * @return object answer object
293
     */
294
    public function import_answer($answer, $withanswerfiles = false, $defaultformat = 'html') {
295
        $ans = new stdClass();
296
 
297
        if ($withanswerfiles) {
298
            $ans->answer = $this->import_text_with_files($answer, array(), '', $defaultformat);
299
        } else {
300
            $ans->answer = array();
301
            $ans->answer['text']   = $this->getpath($answer, array('#', 'text', 0, '#'), '', true);
302
            $ans->answer['format'] = FORMAT_PLAIN;
303
        }
304
 
305
        $ans->feedback = $this->import_text_with_files($answer, array('#', 'feedback', 0), '', $defaultformat);
306
 
307
        $ans->fraction = $this->getpath($answer, array('@', 'fraction'), 0) / 100;
308
 
309
        return $ans;
310
    }
311
 
312
    /**
313
     * Import the common overall feedback fields.
314
     * @param object $question the part of the XML relating to this question.
315
     * @param object $qo the question data to add the fields to.
316
     * @param bool $withshownumpartscorrect include the shownumcorrect field.
317
     */
318
    public function import_combined_feedback($qo, $questionxml, $withshownumpartscorrect = false) {
319
        $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback');
320
        foreach ($fields as $field) {
321
            $qo->$field = $this->import_text_with_files($questionxml,
322
                    array('#', $field, 0), '', $this->get_format($qo->questiontextformat));
323
        }
324
 
325
        if ($withshownumpartscorrect) {
326
            $qo->shownumcorrect = array_key_exists('shownumcorrect', $questionxml['#']);
327
 
328
            // Backwards compatibility.
329
            if (array_key_exists('correctresponsesfeedback', $questionxml['#'])) {
330
                $qo->shownumcorrect = $this->trans_single($this->getpath($questionxml,
331
                        array('#', 'correctresponsesfeedback', 0, '#'), 1));
332
            }
333
        }
334
    }
335
 
336
    /**
337
     * Import a question hint
338
     * @param array $hintxml hint xml fragment.
339
     * @param string $defaultformat the text format to assume for hints that do not specify.
340
     * @return object hint for storing in the database.
341
     */
342
    public function import_hint($hintxml, $defaultformat) {
343
        $hint = new stdClass();
344
        if (array_key_exists('hintcontent', $hintxml['#'])) {
345
            // Backwards compatibility.
346
 
347
            $hint->hint = $this->import_text_with_files($hintxml,
348
                    array('#', 'hintcontent', 0), '', $defaultformat);
349
 
350
            $hint->shownumcorrect = $this->getpath($hintxml,
351
                    array('#', 'statenumberofcorrectresponses', 0, '#'), 0);
352
            $hint->clearwrong = $this->getpath($hintxml,
353
                    array('#', 'clearincorrectresponses', 0, '#'), 0);
354
            $hint->options = $this->getpath($hintxml,
355
                    array('#', 'showfeedbacktoresponses', 0, '#'), 0);
356
 
357
            return $hint;
358
        }
359
        $hint->hint = $this->import_text_with_files($hintxml, array(), '', $defaultformat);
360
        $hint->shownumcorrect = array_key_exists('shownumcorrect', $hintxml['#']);
361
        $hint->clearwrong = array_key_exists('clearwrong', $hintxml['#']);
362
        $hint->options = $this->getpath($hintxml, array('#', 'options', 0, '#'), '', true);
363
 
364
        return $hint;
365
    }
366
 
367
    /**
368
     * Import all the question hints
369
     *
370
     * @param object $qo the question data that is being constructed.
371
     * @param array $questionxml The xml representing the question.
372
     * @param bool $withparts whether the extra fields relating to parts should be imported.
373
     * @param bool $withoptions whether the extra options field should be imported.
374
     * @param string $defaultformat the text format to assume for hints that do not specify.
375
     * @return array of objects representing the hints in the file.
376
     */
377
    public function import_hints($qo, $questionxml, $withparts = false,
378
            $withoptions = false, $defaultformat = 'html') {
379
        if (!isset($questionxml['#']['hint'])) {
380
            return;
381
        }
382
 
383
        foreach ($questionxml['#']['hint'] as $hintxml) {
384
            $hint = $this->import_hint($hintxml, $defaultformat);
385
            $qo->hint[] = $hint->hint;
386
 
387
            if ($withparts) {
388
                $qo->hintshownumcorrect[] = $hint->shownumcorrect;
389
                $qo->hintclearwrong[] = $hint->clearwrong;
390
            }
391
 
392
            if ($withoptions) {
393
                $qo->hintoptions[] = $hint->options;
394
            }
395
        }
396
    }
397
 
398
    /**
399
     * Import all the question tags
400
     *
401
     * @param object $qo the question data that is being constructed.
402
     * @param array $questionxml The xml representing the question.
403
     * @return array of objects representing the tags in the file.
404
     */
405
    public function import_question_tags($qo, $questionxml) {
406
        global $CFG;
407
 
408
        if (core_tag_tag::is_enabled('core_question', 'question')) {
409
 
410
            $qo->tags = [];
411
            if (!empty($questionxml['#']['tags'][0]['#']['tag'])) {
412
                foreach ($questionxml['#']['tags'][0]['#']['tag'] as $tagdata) {
413
                    $qo->tags[] = $this->getpath($tagdata, array('#', 'text', 0, '#'), '', true);
414
                }
415
            }
416
 
417
            $qo->coursetags = [];
418
            if (!empty($questionxml['#']['coursetags'][0]['#']['tag'])) {
419
                foreach ($questionxml['#']['coursetags'][0]['#']['tag'] as $tagdata) {
420
                    $qo->coursetags[] = $this->getpath($tagdata, array('#', 'text', 0, '#'), '', true);
421
                }
422
            }
423
        }
424
    }
425
 
426
    /**
427
     * Import files from a node in the XML.
428
     * @param array $xml an array of <file> nodes from the the parsed XML.
429
     * @return array of things representing files - in the form that save_question expects.
430
     */
431
    public function import_files($xml) {
432
        $files = array();
433
        foreach ($xml as $file) {
434
            $data = new stdClass();
435
            $data->content = $file['#'];
436
            $data->encoding = $file['@']['encoding'];
437
            $data->name = $file['@']['name'];
438
            $files[] = $data;
439
        }
440
        return $files;
441
    }
442
 
443
    /**
444
     * import multiple choice question
445
     * @param array question question array from xml tree
446
     * @return object question object
447
     */
448
    public function import_multichoice($question) {
449
        // Get common parts.
450
        $qo = $this->import_headers($question);
451
 
452
        // Header parts particular to multichoice.
453
        $qo->qtype = 'multichoice';
454
        $single = $this->getpath($question, array('#', 'single', 0, '#'), 'true');
455
        $qo->single = $this->trans_single($single);
456
        $shuffleanswers = $this->getpath($question,
457
                array('#', 'shuffleanswers', 0, '#'), 'false');
458
        $qo->answernumbering = $this->getpath($question,
459
                array('#', 'answernumbering', 0, '#'), 'abc');
460
        $qo->shuffleanswers = $this->trans_single($shuffleanswers);
461
        $qo->showstandardinstruction = $this->getpath($question,
462
            array('#', 'showstandardinstruction', 0, '#'), '1');
463
 
464
        // There was a time on the 1.8 branch when it could output an empty
465
        // answernumbering tag, so fix up any found.
466
        if (empty($qo->answernumbering)) {
467
            $qo->answernumbering = 'abc';
468
        }
469
 
470
        // Run through the answers.
471
        $answers = $question['#']['answer'];
472
        $acount = 0;
473
        foreach ($answers as $answer) {
474
            $ans = $this->import_answer($answer, true, $this->get_format($qo->questiontextformat));
475
            $qo->answer[$acount] = $ans->answer;
476
            $qo->fraction[$acount] = $ans->fraction;
477
            $qo->feedback[$acount] = $ans->feedback;
478
            ++$acount;
479
        }
480
 
481
        $this->import_combined_feedback($qo, $question, true);
482
        $this->import_hints($qo, $question, true, false, $this->get_format($qo->questiontextformat));
483
 
484
        return $qo;
485
    }
486
 
487
    /**
488
     * Import cloze type question
489
     * @param array question question array from xml tree
490
     * @return object question object
491
     */
492
    public function import_multianswer($question) {
493
        global $USER;
494
        question_bank::get_qtype('multianswer');
495
 
496
        $questiontext = $this->import_text_with_files($question,
497
                array('#', 'questiontext', 0));
498
        $qo = qtype_multianswer_extract_question($questiontext);
499
        $errors = qtype_multianswer_validate_question($qo);
500
        if ($errors) {
501
            $this->error(get_string('invalidmultianswerquestion', 'qtype_multianswer', implode(' ', $errors)));
502
            return null;
503
        }
504
 
505
        // Header parts particular to multianswer.
506
        $qo->qtype = 'multianswer';
507
 
508
        // Only set the course if the data is available.
509
        if (isset($this->course)) {
510
            $qo->course = $this->course;
511
        }
512
        if (isset($question['#']['name'])) {
513
            $qo->name = $this->clean_question_name($this->import_text($question['#']['name'][0]['#']['text']));
514
        } else {
515
            $qo->name = $this->create_default_question_name($qo->questiontext['text'],
516
                    get_string('questionname', 'question'));
517
        }
518
        $qo->questiontextformat = $questiontext['format'];
519
        $qo->questiontext = $qo->questiontext['text'];
520
        if (!empty($questiontext['itemid'])) {
521
            $qo->questiontextitemid = $questiontext['itemid'];
522
        }
523
 
524
        // Backwards compatibility, deal with the old image tag.
525
        $filedata = $this->getpath($question, array('#', 'image_base64', '0', '#'), null, false);
526
        $filename = $this->getpath($question, array('#', 'image', '0', '#'), null, false);
527
        if ($filedata && $filename) {
528
            $fs = get_file_storage();
529
            if (empty($qo->questiontextitemid)) {
530
                $qo->questiontextitemid = file_get_unused_draft_itemid();
531
            }
532
            $filename = clean_param(str_replace('/', '_', $filename), PARAM_FILE);
533
            $filerecord = array(
534
                'contextid' => context_user::instance($USER->id)->id,
535
                'component' => 'user',
536
                'filearea'  => 'draft',
537
                'itemid'    => $qo->questiontextitemid,
538
                'filepath'  => '/',
539
                'filename'  => $filename,
540
            );
541
            $fs->create_file_from_string($filerecord, base64_decode($filedata));
542
            $qo->questiontext .= ' <img src="@@PLUGINFILE@@/' . $filename . '" />';
543
        }
544
 
545
        $qo->idnumber = $this->getpath($question, ['#', 'idnumber', 0, '#'], null);
546
 
547
        // Restore files in generalfeedback.
548
        $generalfeedback = $this->import_text_with_files($question,
549
                array('#', 'generalfeedback', 0), '', $this->get_format($qo->questiontextformat));
550
        $qo->generalfeedback = $generalfeedback['text'];
551
        $qo->generalfeedbackformat = $generalfeedback['format'];
552
        if (!empty($generalfeedback['itemid'])) {
553
            $qo->generalfeedbackitemid = $generalfeedback['itemid'];
554
        }
555
 
556
        $qo->penalty = $this->getpath($question,
557
                array('#', 'penalty', 0, '#'), $this->defaultquestion()->penalty);
558
        // Fix problematic rounding from old files.
559
        if (abs($qo->penalty - 0.3333333) < 0.005) {
560
            $qo->penalty = 0.3333333;
561
        }
562
 
563
        $this->import_hints($qo, $question, true, false, $this->get_format($qo->questiontextformat));
564
        $this->import_question_tags($qo, $question);
565
 
566
        return $qo;
567
    }
568
 
569
    /**
570
     * Import true/false type question
571
     * @param array question question array from xml tree
572
     * @return object question object
573
     */
574
    public function import_truefalse($question) {
575
        // Get common parts.
576
        global $OUTPUT;
577
        $qo = $this->import_headers($question);
578
 
579
        // Header parts particular to true/false.
580
        $qo->qtype = 'truefalse';
581
 
582
        // In the past, it used to be assumed that the two answers were in the file
583
        // true first, then false. Howevever that was not always true. Now, we
584
        // try to match on the answer text, but in old exports, this will be a localised
585
        // string, so if we don't find true or false, we fall back to the old system.
586
        $first = true;
587
        $warning = false;
588
        foreach ($question['#']['answer'] as $answer) {
589
            $answertext = $this->getpath($answer,
590
                    array('#', 'text', 0, '#'), '', true);
591
            $feedback = $this->import_text_with_files($answer,
592
                    array('#', 'feedback', 0), '', $this->get_format($qo->questiontextformat));
593
 
594
            if ($answertext != 'true' && $answertext != 'false') {
595
                // Old style file, assume order is true/false.
596
                $warning = true;
597
                if ($first) {
598
                    $answertext = 'true';
599
                } else {
600
                    $answertext = 'false';
601
                }
602
            }
603
 
604
            if ($answertext == 'true') {
605
                $qo->answer = ($answer['@']['fraction'] == 100);
606
                $qo->correctanswer = $qo->answer;
607
                $qo->feedbacktrue = $feedback;
608
            } else {
609
                $qo->answer = ($answer['@']['fraction'] != 100);
610
                $qo->correctanswer = $qo->answer;
611
                $qo->feedbackfalse = $feedback;
612
            }
613
            $first = false;
614
        }
615
 
616
        if ($warning) {
617
            $a = new stdClass();
618
            $a->questiontext = $qo->questiontext;
619
            $a->answer = get_string($qo->correctanswer ? 'true' : 'false', 'qtype_truefalse');
620
            echo $OUTPUT->notification(get_string('truefalseimporterror', 'qformat_xml', $a));
621
        }
622
 
623
        $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));
624
 
625
        return $qo;
626
    }
627
 
628
    /**
629
     * Import short answer type question
630
     * @param array question question array from xml tree
631
     * @return object question object
632
     */
633
    public function import_shortanswer($question) {
634
        // Get common parts.
635
        $qo = $this->import_headers($question);
636
 
637
        // Header parts particular to shortanswer.
638
        $qo->qtype = 'shortanswer';
639
 
640
        // Get usecase.
641
        $qo->usecase = $this->getpath($question, array('#', 'usecase', 0, '#'), $qo->usecase);
642
 
643
        // Run through the answers.
644
        $answers = $question['#']['answer'];
645
        $acount = 0;
646
        foreach ($answers as $answer) {
647
            $ans = $this->import_answer($answer, false, $this->get_format($qo->questiontextformat));
648
            $qo->answer[$acount] = $ans->answer['text'];
649
            $qo->fraction[$acount] = $ans->fraction;
650
            $qo->feedback[$acount] = $ans->feedback;
651
            ++$acount;
652
        }
653
 
654
        $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));
655
 
656
        return $qo;
657
    }
658
 
659
    /**
660
     * Import description type question
661
     * @param array question question array from xml tree
662
     * @return object question object
663
     */
664
    public function import_description($question) {
665
        // Get common parts.
666
        $qo = $this->import_headers($question);
667
        // Header parts particular to shortanswer.
668
        $qo->qtype = 'description';
669
        $qo->defaultmark = 0;
670
        $qo->length = 0;
671
        return $qo;
672
    }
673
 
674
    /**
675
     * Import numerical type question
676
     * @param array question question array from xml tree
677
     * @return object question object
678
     */
679
    public function import_numerical($question) {
680
        // Get common parts.
681
        $qo = $this->import_headers($question);
682
 
683
        // Header parts particular to numerical.
684
        $qo->qtype = 'numerical';
685
 
686
        // Get answers array.
687
        $answers = $question['#']['answer'];
688
        $qo->answer = array();
689
        $qo->feedback = array();
690
        $qo->fraction = array();
691
        $qo->tolerance = array();
692
        foreach ($answers as $answer) {
693
            // Answer outside of <text> is deprecated.
694
            $obj = $this->import_answer($answer, false, $this->get_format($qo->questiontextformat));
695
            $qo->answer[] = $obj->answer['text'];
696
            if (empty($qo->answer)) {
697
                $qo->answer = '*';
698
            }
699
            $qo->feedback[]  = $obj->feedback;
700
            $qo->tolerance[] = $this->getpath($answer, array('#', 'tolerance', 0, '#'), 0);
701
 
702
            // Fraction as a tag is deprecated.
703
            $fraction = $this->getpath($answer, array('@', 'fraction'), 0) / 100;
704
            $qo->fraction[] = $this->getpath($answer,
705
                    array('#', 'fraction', 0, '#'), $fraction); // Deprecated.
706
        }
707
 
708
        // Get the units array.
709
        $qo->unit = array();
710
        $units = $this->getpath($question, array('#', 'units', 0, '#', 'unit'), array());
711
        if (!empty($units)) {
712
            $qo->multiplier = array();
713
            foreach ($units as $unit) {
714
                $qo->multiplier[] = $this->getpath($unit, array('#', 'multiplier', 0, '#'), 1);
715
                $qo->unit[] = $this->getpath($unit, array('#', 'unit_name', 0, '#'), '', true);
716
            }
717
        }
718
        $qo->unitgradingtype = $this->getpath($question, array('#', 'unitgradingtype', 0, '#'), 0);
719
        $qo->unitpenalty = $this->getpath($question, array('#', 'unitpenalty', 0, '#'), 0.1);
720
        $qo->showunits = $this->getpath($question, array('#', 'showunits', 0, '#'), null);
721
        $qo->unitsleft = $this->getpath($question, array('#', 'unitsleft', 0, '#'), 0);
722
        $qo->instructions['text'] = '';
723
        $qo->instructions['format'] = FORMAT_HTML;
724
        $instructions = $this->getpath($question, array('#', 'instructions'), array());
725
        if (!empty($instructions)) {
726
            $qo->instructions = $this->import_text_with_files($instructions,
727
                    array('0'), '', $this->get_format($qo->questiontextformat));
728
        }
729
 
730
        if (is_null($qo->showunits)) {
731
            // Set a good default, depending on whether there are any units defined.
732
            if (empty($qo->unit)) {
733
                $qo->showunits = 3; // This is qtype_numerical::UNITNONE, but we cannot refer to that constant here.
734
            } else {
735
                $qo->showunits = 0; // This is qtype_numerical::UNITOPTIONAL, but we cannot refer to that constant here.
736
            }
737
        }
738
 
739
        $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));
740
 
741
        return $qo;
742
    }
743
 
744
    /**
745
     * Import matching type question
746
     * @param array question question array from xml tree
747
     * @return object question object
748
     */
749
    public function import_match($question) {
750
        // Get common parts.
751
        $qo = $this->import_headers($question);
752
 
753
        // Header parts particular to matching.
754
        $qo->qtype = 'match';
755
        $qo->shuffleanswers = $this->trans_single($this->getpath($question,
756
                array('#', 'shuffleanswers', 0, '#'), 1));
757
 
758
        // Run through subquestions.
759
        $qo->subquestions = array();
760
        $qo->subanswers = array();
761
        foreach ($question['#']['subquestion'] as $subqxml) {
762
            $qo->subquestions[] = $this->import_text_with_files($subqxml,
763
                    array(), '', $this->get_format($qo->questiontextformat));
764
 
765
            $answers = $this->getpath($subqxml, array('#', 'answer'), array());
766
            $qo->subanswers[] = $this->getpath($subqxml,
767
                    array('#', 'answer', 0, '#', 'text', 0, '#'), '', true);
768
        }
769
 
770
        $this->import_combined_feedback($qo, $question, true);
771
        $this->import_hints($qo, $question, true, false, $this->get_format($qo->questiontextformat));
772
 
773
        return $qo;
774
    }
775
 
776
    /**
777
     * Import essay type question
778
     * @param array question question array from xml tree
779
     * @return object question object
780
     */
781
    public function import_essay($question) {
782
        // Get common parts.
783
        $qo = $this->import_headers($question);
784
 
785
        // Header parts particular to essay.
786
        $qo->qtype = 'essay';
787
 
788
        $qo->responseformat = $this->getpath($question,
789
                array('#', 'responseformat', 0, '#'), 'editor');
790
        $qo->responsefieldlines = $this->getpath($question,
791
                array('#', 'responsefieldlines', 0, '#'), 15);
792
        $qo->responserequired = $this->getpath($question,
793
                array('#', 'responserequired', 0, '#'), 1);
794
        $qo->minwordlimit = $this->getpath($question,
795
                array('#', 'minwordlimit', 0, '#'), null);
796
        $qo->minwordenabled = !empty($qo->minwordlimit);
797
        $qo->maxwordlimit = $this->getpath($question,
798
                array('#', 'maxwordlimit', 0, '#'), null);
799
        $qo->maxwordenabled = !empty($qo->maxwordlimit);
800
        $qo->attachments = $this->getpath($question,
801
                array('#', 'attachments', 0, '#'), 0);
802
        $qo->attachmentsrequired = $this->getpath($question,
803
                array('#', 'attachmentsrequired', 0, '#'), 0);
804
        $qo->filetypeslist = $this->getpath($question,
805
                array('#', 'filetypeslist', 0, '#'), null);
806
        $qo->maxbytes = $this->getpath($question,
807
                array('#', 'maxbytes', 0, '#'), null);
808
        $qo->graderinfo = $this->import_text_with_files($question,
809
                array('#', 'graderinfo', 0), '', $this->get_format($qo->questiontextformat));
810
        $qo->responsetemplate['text'] = $this->getpath($question,
811
                array('#', 'responsetemplate', 0, '#', 'text', 0, '#'), '', true);
812
        $qo->responsetemplate['format'] = $this->trans_format($this->getpath($question,
813
                array('#', 'responsetemplate', 0, '@', 'format'), $this->get_format($qo->questiontextformat)));
814
 
815
        return $qo;
816
    }
817
 
818
    /**
819
     * Import a calculated question
820
     * @param object $question the imported XML data.
821
     */
822
    public function import_calculated($question) {
823
 
824
        // Get common parts.
825
        $qo = $this->import_headers($question);
826
 
827
        // Header parts particular to calculated.
828
        $qo->qtype = 'calculated';
829
        $qo->synchronize = $this->getpath($question, array('#', 'synchronize', 0, '#'), 0);
830
        $single = $this->getpath($question, array('#', 'single', 0, '#'), 'true');
831
        $qo->single = $this->trans_single($single);
832
        $shuffleanswers = $this->getpath($question, array('#', 'shuffleanswers', 0, '#'), 'false');
833
        $qo->answernumbering = $this->getpath($question,
834
                array('#', 'answernumbering', 0, '#'), 'abc');
835
        $qo->shuffleanswers = $this->trans_single($shuffleanswers);
836
 
837
        $this->import_combined_feedback($qo, $question);
838
 
839
        $qo->unitgradingtype = $this->getpath($question,
840
                array('#', 'unitgradingtype', 0, '#'), 0);
841
        $qo->unitpenalty = $this->getpath($question, array('#', 'unitpenalty', 0, '#'), null);
842
        $qo->showunits = $this->getpath($question, array('#', 'showunits', 0, '#'), 0);
843
        $qo->unitsleft = $this->getpath($question, array('#', 'unitsleft', 0, '#'), 0);
844
        $qo->instructions = $this->getpath($question,
845
                array('#', 'instructions', 0, '#', 'text', 0, '#'), '', true);
846
        if (!empty($instructions)) {
847
            $qo->instructions = $this->import_text_with_files($instructions,
848
                    array('0'), '', $this->get_format($qo->questiontextformat));
849
        }
850
 
851
        // Get answers array.
852
        $answers = $question['#']['answer'];
853
        $qo->answer = array();
854
        $qo->feedback = array();
855
        $qo->fraction = array();
856
        $qo->tolerance = array();
857
        $qo->tolerancetype = array();
858
        $qo->correctanswerformat = array();
859
        $qo->correctanswerlength = array();
860
        $qo->feedback = array();
861
        foreach ($answers as $answer) {
862
            $ans = $this->import_answer($answer, true, $this->get_format($qo->questiontextformat));
863
            // Answer outside of <text> is deprecated.
864
            if (empty($ans->answer['text'])) {
865
                $ans->answer['text'] = '*';
866
            }
867
            // The qtype_calculatedmulti allows HTML in answer options.
868
            if ($question['@']['type'] == 'calculatedmulti') {
869
                // If the import file contains a "format" attribute for the answer text,
870
                // then use it. Otherwise, we must set the answerformat to FORMAT_PLAIN,
871
                // because the question has been exported from a Moodle version that
872
                // did not yet allow HTML answer options.
873
                if (array_key_exists('format', $answer['@'])) {
874
                    $ans->answer['format'] = $this->trans_format($answer['@']['format']);
875
                } else {
876
                    $ans->answer['format'] = FORMAT_PLAIN;
877
                }
878
                $qo->answer[] = $ans->answer;
879
            } else {
880
                $qo->answer[] = $ans->answer['text'];
881
            }
882
            $qo->feedback[] = $ans->feedback;
883
            $qo->tolerance[] = $answer['#']['tolerance'][0]['#'];
884
            // Fraction as a tag is deprecated.
885
            if (!empty($answer['#']['fraction'][0]['#'])) {
886
                $qo->fraction[] = $answer['#']['fraction'][0]['#'];
887
            } else {
888
                $qo->fraction[] = $answer['@']['fraction'] / 100;
889
            }
890
            $qo->tolerancetype[] = $answer['#']['tolerancetype'][0]['#'];
891
            $qo->correctanswerformat[] = $answer['#']['correctanswerformat'][0]['#'];
892
            $qo->correctanswerlength[] = $answer['#']['correctanswerlength'][0]['#'];
893
        }
894
        // Get units array.
895
        $qo->unit = array();
896
        if (isset($question['#']['units'][0]['#']['unit'])) {
897
            $units = $question['#']['units'][0]['#']['unit'];
898
            $qo->multiplier = array();
899
            foreach ($units as $unit) {
900
                $qo->multiplier[] = $unit['#']['multiplier'][0]['#'];
901
                $qo->unit[] = $unit['#']['unit_name'][0]['#'];
902
            }
903
        }
904
        $instructions = $this->getpath($question, array('#', 'instructions'), array());
905
        if (!empty($instructions)) {
906
            $qo->instructions = $this->import_text_with_files($instructions,
907
                    array('0'), '', $this->get_format($qo->questiontextformat));
908
        }
909
 
910
        if (is_null($qo->unitpenalty)) {
911
            // Set a good default, depending on whether there are any units defined.
912
            if (empty($qo->unit)) {
913
                $qo->showunits = 3; // This is qtype_numerical::UNITNONE, but we cannot refer to that constant here.
914
            } else {
915
                $qo->showunits = 0; // This is qtype_numerical::UNITOPTIONAL, but we cannot refer to that constant here.
916
            }
917
        }
918
 
919
        $datasets = $question['#']['dataset_definitions'][0]['#']['dataset_definition'] ?? [];
920
        $qo->dataset = array();
921
        $qo->datasetindex= 0;
922
        foreach ($datasets as $dataset) {
923
            $qo->datasetindex++;
924
            $qo->dataset[$qo->datasetindex] = new stdClass();
925
            $qo->dataset[$qo->datasetindex]->status =
926
                    $this->import_text($dataset['#']['status'][0]['#']['text']);
927
            $qo->dataset[$qo->datasetindex]->name =
928
                    $this->import_text($dataset['#']['name'][0]['#']['text']);
929
            $qo->dataset[$qo->datasetindex]->type =
930
                    $dataset['#']['type'][0]['#'];
931
            $qo->dataset[$qo->datasetindex]->distribution =
932
                    $this->import_text($dataset['#']['distribution'][0]['#']['text']);
933
            $qo->dataset[$qo->datasetindex]->max =
934
                    $this->import_text($dataset['#']['maximum'][0]['#']['text']);
935
            $qo->dataset[$qo->datasetindex]->min =
936
                    $this->import_text($dataset['#']['minimum'][0]['#']['text']);
937
            $qo->dataset[$qo->datasetindex]->length =
938
                    $this->import_text($dataset['#']['decimals'][0]['#']['text']);
939
            $qo->dataset[$qo->datasetindex]->distribution =
940
                    $this->import_text($dataset['#']['distribution'][0]['#']['text']);
941
            $qo->dataset[$qo->datasetindex]->itemcount = $dataset['#']['itemcount'][0]['#'];
942
            $qo->dataset[$qo->datasetindex]->datasetitem = array();
943
            $qo->dataset[$qo->datasetindex]->itemindex = 0;
944
            $qo->dataset[$qo->datasetindex]->number_of_items = $this->getpath($dataset,
945
                    array('#', 'number_of_items', 0, '#'), 0);
946
            $datasetitems = $this->getpath($dataset,
947
                    array('#', 'dataset_items', 0, '#', 'dataset_item'), array());
948
            foreach ($datasetitems as $datasetitem) {
949
                $qo->dataset[$qo->datasetindex]->itemindex++;
950
                $qo->dataset[$qo->datasetindex]->datasetitem[
951
                        $qo->dataset[$qo->datasetindex]->itemindex] = new stdClass();
952
                $qo->dataset[$qo->datasetindex]->datasetitem[
953
                        $qo->dataset[$qo->datasetindex]->itemindex]->itemnumber =
954
                                $datasetitem['#']['number'][0]['#'];
955
                $qo->dataset[$qo->datasetindex]->datasetitem[
956
                        $qo->dataset[$qo->datasetindex]->itemindex]->value =
957
                                $datasetitem['#']['value'][0]['#'];
958
            }
959
        }
960
 
961
        $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));
962
 
963
        return $qo;
964
    }
965
 
966
    /**
967
     * This is not a real question type. It's a dummy type used to specify the
968
     * import category. The format is:
969
     * <question type="category">
970
     *     <category>tom/dick/harry</category>
971
     *     <info format="moodle_auto_format"><text>Category description</text></info>
972
     * </question>
973
     */
974
    protected function import_category($question) {
975
        $qo = new stdClass();
976
        $qo->qtype = 'category';
977
        $qo->category = $this->import_text($question['#']['category'][0]['#']['text']);
978
        $qo->info = '';
979
        $qo->infoformat = FORMAT_MOODLE;
980
        if (array_key_exists('info', $question['#'])) {
981
            $qo->info = $this->import_text($question['#']['info'][0]['#']['text']);
982
            // The import should have the format in human readable form, so translate to machine readable format.
983
            $qo->infoformat = $this->trans_format($question['#']['info'][0]['@']['format']);
984
        }
985
        $qo->idnumber = $this->getpath($question, array('#', 'idnumber', 0, '#'), null);
986
        return $qo;
987
    }
988
 
989
    /**
990
     * Parse the array of lines into an array of questions
991
     * this *could* burn memory - but it won't happen that much
992
     * so fingers crossed!
993
     * @param array of lines from the input file.
994
     * @param stdClass $context
995
     * @return array (of objects) question objects.
996
     */
997
    public function readquestions($lines) {
998
        // We just need it as one big string.
999
        $lines = implode('', $lines);
1000
 
1001
        // This converts xml to big nasty data structure
1002
        // the 0 means keep white space as it is (important for markdown format).
1003
        try {
1004
            $xml = xmlize($lines, 0, 'UTF-8', true);
1005
        } catch (xml_format_exception $e) {
1006
            $this->error($e->getMessage(), '');
1007
            return false;
1008
        }
1009
        unset($lines); // No need to keep this in memory.
1010
        return $this->import_questions($xml['quiz']['#']['question']);
1011
    }
1012
 
1013
    /**
1014
     * @param array $xml the xmlized xml
1015
     * @return stdClass[] question objects to pass to question type save_question_options
1016
     */
1017
    public function import_questions($xml) {
1018
        $questions = array();
1019
 
1020
        // Iterate through questions.
1021
        foreach ($xml as $questionxml) {
1022
            $qo = $this->import_question($questionxml);
1023
 
1024
            // Stick the result in the $questions array.
1025
            if ($qo) {
1026
                $questions[] = $qo;
1027
            }
1028
        }
1029
        return $questions;
1030
    }
1031
 
1032
    /**
1033
     * @param array $questionxml xml describing the question
1034
     * @return null|stdClass an object with data to be fed to question type save_question_options
1035
     */
1036
    protected function import_question($questionxml) {
1037
        $questiontype = $questionxml['@']['type'];
1038
 
1039
        if ($questiontype == 'multichoice') {
1040
            return $this->import_multichoice($questionxml);
1041
        } else if ($questiontype == 'truefalse') {
1042
            return $this->import_truefalse($questionxml);
1043
        } else if ($questiontype == 'shortanswer') {
1044
            return $this->import_shortanswer($questionxml);
1045
        } else if ($questiontype == 'numerical') {
1046
            return $this->import_numerical($questionxml);
1047
        } else if ($questiontype == 'description') {
1048
            return $this->import_description($questionxml);
1049
        } else if ($questiontype == 'matching' || $questiontype == 'match') {
1050
            return $this->import_match($questionxml);
1051
        } else if ($questiontype == 'cloze' || $questiontype == 'multianswer') {
1052
            return $this->import_multianswer($questionxml);
1053
        } else if ($questiontype == 'essay') {
1054
            return $this->import_essay($questionxml);
1055
        } else if ($questiontype == 'calculated') {
1056
            return $this->import_calculated($questionxml);
1057
        } else if ($questiontype == 'calculatedsimple') {
1058
            $qo = $this->import_calculated($questionxml);
1059
            $qo->qtype = 'calculatedsimple';
1060
            return $qo;
1061
        } else if ($questiontype == 'calculatedmulti') {
1062
            $qo = $this->import_calculated($questionxml);
1063
            $qo->qtype = 'calculatedmulti';
1064
            return $qo;
1065
        } else if ($questiontype == 'category') {
1066
            return $this->import_category($questionxml);
1067
 
1068
        } else {
1069
            // Not a type we handle ourselves. See if the question type wants
1070
            // to handle it.
1071
            if (!$qo = $this->try_importing_using_qtypes($questionxml, null, null, $questiontype)) {
1072
                $this->error(get_string('xmltypeunsupported', 'qformat_xml', $questiontype));
1073
                return null;
1074
            }
1075
            return $qo;
1076
        }
1077
    }
1078
 
1079
    // EXPORT FUNCTIONS START HERE.
1080
 
1081
    public function export_file_extension() {
1082
        return '.xml';
1083
    }
1084
 
1085
    /**
1086
     * Turn the internal question type name into a human readable form.
1087
     * (In the past, the code used to use integers internally. Now, it uses
1088
     * strings, so there is less need for this, but to maintain
1089
     * backwards-compatibility we change two of the type names.)
1090
     * @param string $qtype question type plugin name.
1091
     * @return string $qtype string to use in the file.
1092
     */
1093
    protected function get_qtype($qtype) {
1094
        switch($qtype) {
1095
            case 'match':
1096
                return 'matching';
1097
            case 'multianswer':
1098
                return 'cloze';
1099
            default:
1100
                return $qtype;
1101
        }
1102
    }
1103
 
1104
    /**
1105
     * Convert internal Moodle text format code into
1106
     * human readable form
1107
     * @param int id internal code
1108
     * @return string format text
1109
     */
1110
    public function get_format($id) {
1111
        switch($id) {
1112
            case FORMAT_MOODLE:
1113
                return 'moodle_auto_format';
1114
            case FORMAT_HTML:
1115
                return 'html';
1116
            case FORMAT_PLAIN:
1117
                return 'plain_text';
1118
            case FORMAT_WIKI:
1119
                return 'wiki_like';
1120
            case FORMAT_MARKDOWN:
1121
                return 'markdown';
1122
            default:
1123
                return 'unknown';
1124
        }
1125
    }
1126
 
1127
    /**
1128
     * Convert internal single question code into
1129
     * human readable form
1130
     * @param int id single question code
1131
     * @return string single question string
1132
     */
1133
    public function get_single($id) {
1134
        switch($id) {
1135
            case 0:
1136
                return 'false';
1137
            case 1:
1138
                return 'true';
1139
            default:
1140
                return 'unknown';
1141
        }
1142
    }
1143
 
1144
    /**
1145
     * Take a string, and wrap it in a CDATA secion, if that is required to make
1146
     * the output XML valid.
1147
     * @param string $string a string
1148
     * @return string the string, wrapped in CDATA if necessary.
1149
     */
1150
    public function xml_escape($string) {
1151
        if (!empty($string) && htmlspecialchars($string, ENT_COMPAT) != $string) {
1152
            // If the string contains something that looks like the end
1153
            // of a CDATA section, then we need to avoid errors by splitting
1154
            // the string between two CDATA sections.
1155
            $string = str_replace(']]>', ']]]]><![CDATA[>', $string);
1156
            return "<![CDATA[{$string}]]>";
1157
        } else {
1158
            return $string;
1159
        }
1160
    }
1161
 
1162
    /**
1163
     * Generates <text></text> tags, processing raw text therein
1164
     * @param string $raw the content to output.
1165
     * @param int $indent the current indent level.
1166
     * @param bool $short stick it on one line.
1167
     * @return string formatted text.
1168
     */
1169
    public function writetext($raw, $indent = 0, $short = true) {
1170
        $indent = str_repeat('  ', $indent);
1171
        $raw = $this->xml_escape($raw);
1172
 
1173
        if ($short) {
1174
            $xml = "{$indent}<text>{$raw}</text>\n";
1175
        } else {
1176
            $xml = "{$indent}<text>\n{$raw}\n{$indent}</text>\n";
1177
        }
1178
 
1179
        return $xml;
1180
    }
1181
 
1182
    /**
1183
     * Generte the XML to represent some files.
1184
     * @param array of store array of stored_file objects.
1185
     * @return string $string the XML.
1186
     */
1187
    public function write_files($files) {
1188
        if (empty($files)) {
1189
            return '';
1190
        }
1191
        $string = '';
1192
        foreach ($files as $file) {
1193
            if ($file->is_directory()) {
1194
                continue;
1195
            }
1196
            $string .= '<file name="' . $file->get_filename() . '" path="' . $file->get_filepath() . '" encoding="base64">';
1197
            $string .= base64_encode($file->get_content());
1198
            $string .= "</file>\n";
1199
        }
1200
        return $string;
1201
    }
1202
 
1203
    protected function presave_process($content) {
1204
        // Override to allow us to add xml headers and footers.
1205
        return '<?xml version="1.0" encoding="UTF-8"?>
1206
<quiz>
1207
' . $content . '</quiz>';
1208
    }
1209
 
1210
    /**
1211
     * Turns question into an xml segment
1212
     * @param object $question the question data.
1213
     * @return string xml segment
1214
     */
1215
    public function writequestion($question) {
1216
 
1217
        $invalidquestion = false;
1218
        $fs = get_file_storage();
1219
        $contextid = $question->contextid;
1220
        $question->status = 0;
1221
        // Get files used by the questiontext.
1222
        $question->questiontextfiles = $fs->get_area_files(
1223
                $contextid, 'question', 'questiontext', $question->id);
1224
        // Get files used by the generalfeedback.
1225
        $question->generalfeedbackfiles = $fs->get_area_files(
1226
                $contextid, 'question', 'generalfeedback', $question->id);
1227
        if (!empty($question->options->answers)) {
1228
            foreach ($question->options->answers as $answer) {
1229
                $this->answerfiles[$answer->id] = $fs->get_area_files(
1230
                        $contextid, 'question', 'answer', $answer->id);
1231
                $this->feedbackfiles[$answer->id] = $fs->get_area_files(
1232
                        $contextid, 'question', 'answerfeedback', $answer->id);
1233
            }
1234
        }
1235
 
1236
        $expout = '';
1237
 
1238
        // Add a comment linking this to the original question id.
1239
        $expout .= "<!-- question: {$question->id}  -->\n";
1240
 
1241
        // Check question type.
1242
        $questiontype = $this->get_qtype($question->qtype);
1243
 
1244
        $idnumber = '';
1245
        if (isset($question->idnumber)) {
1246
            $idnumber = htmlspecialchars($question->idnumber, ENT_COMPAT);
1247
        }
1248
 
1249
        // Categories are a special case.
1250
        if ($question->qtype == 'category') {
1251
            $categorypath = $this->writetext($question->category);
1252
            $categoryinfo = $this->writetext($question->info);
1253
            $infoformat = $this->format($question->infoformat);
1254
            $expout .= "  <question type=\"category\">\n";
1255
            $expout .= "    <category>\n";
1256
            $expout .= "      {$categorypath}";
1257
            $expout .= "    </category>\n";
1258
            $expout .= "    <info {$infoformat}>\n";
1259
            $expout .= "      {$categoryinfo}";
1260
            $expout .= "    </info>\n";
1261
            $expout .= "    <idnumber>{$idnumber}</idnumber>\n";
1262
            $expout .= "  </question>\n";
1263
            return $expout;
1264
        }
1265
 
1266
        // Now we know we are are handing a real question.
1267
        // Output the generic information.
1268
        $expout .= "  <question type=\"{$questiontype}\">\n";
1269
        $expout .= "    <name>\n";
1270
        $expout .= $this->writetext($question->name, 3);
1271
        $expout .= "    </name>\n";
1272
        $expout .= "    <questiontext {$this->format($question->questiontextformat)}>\n";
1273
        $expout .= $this->writetext($question->questiontext, 3);
1274
        $expout .= $this->write_files($question->questiontextfiles);
1275
        $expout .= "    </questiontext>\n";
1276
        $expout .= "    <generalfeedback {$this->format($question->generalfeedbackformat)}>\n";
1277
        $expout .= $this->writetext($question->generalfeedback, 3);
1278
        $expout .= $this->write_files($question->generalfeedbackfiles);
1279
        $expout .= "    </generalfeedback>\n";
1280
        if ($question->qtype != 'multianswer') {
1281
            $expout .= "    <defaultgrade>{$question->defaultmark}</defaultgrade>\n";
1282
        }
1283
        $expout .= "    <penalty>{$question->penalty}</penalty>\n";
1284
        $expout .= "    <hidden>{$question->status}</hidden>\n";
1285
        $expout .= "    <idnumber>{$idnumber}</idnumber>\n";
1286
 
1287
        // The rest of the output depends on question type.
1288
        switch($question->qtype) {
1289
            case 'category':
1290
                // Not a qtype really - dummy used for category switching.
1291
                break;
1292
 
1293
            case 'truefalse':
1294
                $trueanswer = $question->options->answers[$question->options->trueanswer];
1295
                $trueanswer->answer = 'true';
1296
                $expout .= $this->write_answer($trueanswer);
1297
 
1298
                $falseanswer = $question->options->answers[$question->options->falseanswer];
1299
                $falseanswer->answer = 'false';
1300
                $expout .= $this->write_answer($falseanswer);
1301
                break;
1302
 
1303
            case 'multichoice':
1304
                $expout .= "    <single>" . $this->get_single($question->options->single) .
1305
                        "</single>\n";
1306
                $expout .= "    <shuffleanswers>" .
1307
                        $this->get_single($question->options->shuffleanswers) .
1308
                        "</shuffleanswers>\n";
1309
                $expout .= "    <answernumbering>" . $question->options->answernumbering .
1310
                    "</answernumbering>\n";
1311
                $expout .= "    <showstandardinstruction>" . $question->options->showstandardinstruction .
1312
                    "</showstandardinstruction>\n";
1313
                $expout .= $this->write_combined_feedback($question->options, $question->id, $question->contextid);
1314
                $expout .= $this->write_answers($question->options->answers);
1315
                break;
1316
 
1317
            case 'shortanswer':
1318
                $expout .= "    <usecase>{$question->options->usecase}</usecase>\n";
1319
                $expout .= $this->write_answers($question->options->answers);
1320
                break;
1321
 
1322
            case 'numerical':
1323
                foreach ($question->options->answers as $answer) {
1324
                    $expout .= $this->write_answer($answer,
1325
                            "      <tolerance>{$answer->tolerance}</tolerance>\n");
1326
                }
1327
 
1328
                $units = $question->options->units;
1329
                if (count($units)) {
1330
                    $expout .= "<units>\n";
1331
                    foreach ($units as $unit) {
1332
                        $expout .= "  <unit>\n";
1333
                        $expout .= "    <multiplier>{$unit->multiplier}</multiplier>\n";
1334
                        $expout .= "    <unit_name>{$unit->unit}</unit_name>\n";
1335
                        $expout .= "  </unit>\n";
1336
                    }
1337
                    $expout .= "</units>\n";
1338
                }
1339
                if (isset($question->options->unitgradingtype)) {
1340
                    $expout .= "    <unitgradingtype>" . $question->options->unitgradingtype .
1341
                            "</unitgradingtype>\n";
1342
                }
1343
                if (isset($question->options->unitpenalty)) {
1344
                    $expout .= "    <unitpenalty>{$question->options->unitpenalty}</unitpenalty>\n";
1345
                }
1346
                if (isset($question->options->showunits)) {
1347
                    $expout .= "    <showunits>{$question->options->showunits}</showunits>\n";
1348
                }
1349
                if (isset($question->options->unitsleft)) {
1350
                    $expout .= "    <unitsleft>{$question->options->unitsleft}</unitsleft>\n";
1351
                }
1352
                if (!empty($question->options->instructionsformat)) {
1353
                    $files = $fs->get_area_files($contextid, 'qtype_numerical',
1354
                            'instruction', $question->id);
1355
                    $expout .= "    <instructions " .
1356
                            $this->format($question->options->instructionsformat) . ">\n";
1357
                    $expout .= $this->writetext($question->options->instructions, 3);
1358
                    $expout .= $this->write_files($files);
1359
                    $expout .= "    </instructions>\n";
1360
                }
1361
                break;
1362
 
1363
            case 'match':
1364
                $expout .= "    <shuffleanswers>" .
1365
                        $this->get_single($question->options->shuffleanswers) .
1366
                        "</shuffleanswers>\n";
1367
                $expout .= $this->write_combined_feedback($question->options, $question->id, $question->contextid);
1368
                foreach ($question->options->subquestions as $subquestion) {
1369
                    $files = $fs->get_area_files($contextid, 'qtype_match',
1370
                            'subquestion', $subquestion->id);
1371
                    $expout .= "    <subquestion " .
1372
                            $this->format($subquestion->questiontextformat) . ">\n";
1373
                    $expout .= $this->writetext($subquestion->questiontext, 3);
1374
                    $expout .= $this->write_files($files);
1375
                    $expout .= "      <answer>\n";
1376
                    $expout .= $this->writetext($subquestion->answertext, 4);
1377
                    $expout .= "      </answer>\n";
1378
                    $expout .= "    </subquestion>\n";
1379
                }
1380
                break;
1381
 
1382
            case 'description':
1383
                // Nothing else to do.
1384
                break;
1385
 
1386
            case 'multianswer':
1387
                foreach ($question->options->questions as $index => $subq) {
1388
                    $expout = str_replace('{#' . $index . '}', $subq->questiontext, $expout);
1389
                }
1390
                break;
1391
 
1392
            case 'essay':
1393
                $expout .= "    <responseformat>" . $question->options->responseformat .
1394
                        "</responseformat>\n";
1395
                $expout .= "    <responserequired>" . $question->options->responserequired .
1396
                        "</responserequired>\n";
1397
                $expout .= "    <responsefieldlines>" . $question->options->responsefieldlines .
1398
                        "</responsefieldlines>\n";
1399
                $expout .= "    <minwordlimit>" . $question->options->minwordlimit .
1400
                        "</minwordlimit>\n";
1401
                $expout .= "    <maxwordlimit>" . $question->options->maxwordlimit .
1402
                        "</maxwordlimit>\n";
1403
                $expout .= "    <attachments>" . $question->options->attachments .
1404
                        "</attachments>\n";
1405
                $expout .= "    <attachmentsrequired>" . $question->options->attachmentsrequired .
1406
                        "</attachmentsrequired>\n";
1407
                $expout .= "    <maxbytes>" . $question->options->maxbytes .
1408
                        "</maxbytes>\n";
1409
                $expout .= "    <filetypeslist>" . $question->options->filetypeslist .
1410
                        "</filetypeslist>\n";
1411
                $expout .= "    <graderinfo " .
1412
                        $this->format($question->options->graderinfoformat) . ">\n";
1413
                $expout .= $this->writetext($question->options->graderinfo, 3);
1414
                $expout .= $this->write_files($fs->get_area_files($contextid, 'qtype_essay',
1415
                        'graderinfo', $question->id));
1416
                $expout .= "    </graderinfo>\n";
1417
                $expout .= "    <responsetemplate " .
1418
                        $this->format($question->options->responsetemplateformat) . ">\n";
1419
                $expout .= $this->writetext($question->options->responsetemplate, 3);
1420
                $expout .= "    </responsetemplate>\n";
1421
                break;
1422
 
1423
            case 'calculated':
1424
            case 'calculatedsimple':
1425
            case 'calculatedmulti':
1426
                $expout .= "    <synchronize>{$question->options->synchronize}</synchronize>\n";
1427
                $expout .= "    <single>{$question->options->single}</single>\n";
1428
                $expout .= "    <answernumbering>" . $question->options->answernumbering .
1429
                        "</answernumbering>\n";
1430
                $expout .= "    <shuffleanswers>" . $question->options->shuffleanswers .
1431
                        "</shuffleanswers>\n";
1432
 
1433
                $component = 'qtype_' . $question->qtype;
1434
                $files = $fs->get_area_files($contextid, $component,
1435
                        'correctfeedback', $question->id);
1436
                $expout .= "    <correctfeedback>\n";
1437
                $expout .= $this->writetext($question->options->correctfeedback, 3);
1438
                $expout .= $this->write_files($files);
1439
                $expout .= "    </correctfeedback>\n";
1440
 
1441
                $files = $fs->get_area_files($contextid, $component,
1442
                        'partiallycorrectfeedback', $question->id);
1443
                $expout .= "    <partiallycorrectfeedback>\n";
1444
                $expout .= $this->writetext($question->options->partiallycorrectfeedback, 3);
1445
                $expout .= $this->write_files($files);
1446
                $expout .= "    </partiallycorrectfeedback>\n";
1447
 
1448
                $files = $fs->get_area_files($contextid, $component,
1449
                        'incorrectfeedback', $question->id);
1450
                $expout .= "    <incorrectfeedback>\n";
1451
                $expout .= $this->writetext($question->options->incorrectfeedback, 3);
1452
                $expout .= $this->write_files($files);
1453
                $expout .= "    </incorrectfeedback>\n";
1454
 
1455
                foreach ($question->options->answers as $answer) {
1456
                    $percent = 100 * $answer->fraction;
1457
                    // For qtype_calculatedmulti, answer options (choices) can be in plain text or in HTML
1458
                    // format, so we need to specify when exporting a question.
1459
                    if ($component == 'qtype_calculatedmulti') {
1460
                        $expout .= "<answer fraction=\"{$percent}\" {$this->format($answer->answerformat)}>\n";
1461
                    } else {
1462
                        $expout .= "<answer fraction=\"{$percent}\">\n";
1463
                    }
1464
                    // The "<text/>" tags are an added feature, old files won't have them.
1465
                    $expout .= $this->writetext($answer->answer);
1466
                    $expout .= $this->write_files($this->answerfiles[$answer->id]);
1467
                    $expout .= "    <tolerance>{$answer->tolerance}</tolerance>\n";
1468
                    $expout .= "    <tolerancetype>{$answer->tolerancetype}</tolerancetype>\n";
1469
                    $expout .= "    <correctanswerformat>" .
1470
                            $answer->correctanswerformat . "</correctanswerformat>\n";
1471
                    $expout .= "      <correctanswerlength>" .
1472
                            $answer->correctanswerlength . "</correctanswerlength>\n";
1473
                    $expout .= "      <feedback {$this->format($answer->feedbackformat)}>\n";
1474
                    $expout .= $this->writetext($answer->feedback, 4);
1475
                    $expout .= $this->write_files($this->feedbackfiles[$answer->id]);
1476
                    $expout .= "      </feedback>\n";
1477
                    $expout .= "    </answer>\n";
1478
                }
1479
                if (isset($question->options->unitgradingtype)) {
1480
                    $expout .= "    <unitgradingtype>" .
1481
                            $question->options->unitgradingtype . "</unitgradingtype>\n";
1482
                }
1483
                if (isset($question->options->unitpenalty)) {
1484
                    $expout .= "    <unitpenalty>" .
1485
                            $question->options->unitpenalty . "</unitpenalty>\n";
1486
                }
1487
                if (isset($question->options->showunits)) {
1488
                    $expout .= "    <showunits>{$question->options->showunits}</showunits>\n";
1489
                }
1490
                if (isset($question->options->unitsleft)) {
1491
                    $expout .= "    <unitsleft>{$question->options->unitsleft}</unitsleft>\n";
1492
                }
1493
 
1494
                if (isset($question->options->instructionsformat)) {
1495
                    $files = $fs->get_area_files($contextid, $component,
1496
                            'instruction', $question->id);
1497
                    $expout .= "    <instructions " .
1498
                            $this->format($question->options->instructionsformat) . ">\n";
1499
                    $expout .= $this->writetext($question->options->instructions, 3);
1500
                    $expout .= $this->write_files($files);
1501
                    $expout .= "    </instructions>\n";
1502
                }
1503
 
1504
                if (isset($question->options->units)) {
1505
                    $units = $question->options->units;
1506
                    if (count($units)) {
1507
                        $expout .= "<units>\n";
1508
                        foreach ($units as $unit) {
1509
                            $expout .= "  <unit>\n";
1510
                            $expout .= "    <multiplier>{$unit->multiplier}</multiplier>\n";
1511
                            $expout .= "    <unit_name>{$unit->unit}</unit_name>\n";
1512
                            $expout .= "  </unit>\n";
1513
                        }
1514
                        $expout .= "</units>\n";
1515
                    }
1516
                }
1517
 
1518
                // The tag $question->export_process has been set so we get all the
1519
                // data items in the database from the function
1520
                // qtype_calculated::get_question_options calculatedsimple defaults
1521
                // to calculated.
1522
                if (isset($question->options->datasets) && count($question->options->datasets)) {
1523
                    $expout .= "<dataset_definitions>\n";
1524
                    foreach ($question->options->datasets as $def) {
1525
                        $expout .= "<dataset_definition>\n";
1526
                        $expout .= "    <status>".$this->writetext($def->status)."</status>\n";
1527
                        $expout .= "    <name>".$this->writetext($def->name)."</name>\n";
1528
                        if ($question->qtype == 'calculated') {
1529
                            $expout .= "    <type>calculated</type>\n";
1530
                        } else {
1531
                            $expout .= "    <type>calculatedsimple</type>\n";
1532
                        }
1533
                        $expout .= "    <distribution>" . $this->writetext($def->distribution) .
1534
                                "</distribution>\n";
1535
                        $expout .= "    <minimum>" . $this->writetext($def->minimum) .
1536
                                "</minimum>\n";
1537
                        $expout .= "    <maximum>" . $this->writetext($def->maximum) .
1538
                                "</maximum>\n";
1539
                        $expout .= "    <decimals>" . $this->writetext($def->decimals) .
1540
                                "</decimals>\n";
1541
                        $expout .= "    <itemcount>{$def->itemcount}</itemcount>\n";
1542
                        if ($def->itemcount > 0) {
1543
                            $expout .= "    <dataset_items>\n";
1544
                            foreach ($def->items as $item) {
1545
                                  $expout .= "        <dataset_item>\n";
1546
                                  $expout .= "           <number>".$item->itemnumber."</number>\n";
1547
                                  $expout .= "           <value>".$item->value."</value>\n";
1548
                                  $expout .= "        </dataset_item>\n";
1549
                            }
1550
                            $expout .= "    </dataset_items>\n";
1551
                            $expout .= "    <number_of_items>" . $def->number_of_items .
1552
                                    "</number_of_items>\n";
1553
                        }
1554
                        $expout .= "</dataset_definition>\n";
1555
                    }
1556
                    $expout .= "</dataset_definitions>\n";
1557
                }
1558
                break;
1559
 
1560
            default:
1561
                // Try support by optional plugin.
1562
                if (!$data = $this->try_exporting_using_qtypes($question->qtype, $question)) {
1563
                    $invalidquestion = true;
1564
                } else {
1565
                    $expout .= $data;
1566
                }
1567
        }
1568
 
1569
        // Output any hints.
1570
        $expout .= $this->write_hints($question);
1571
 
1572
        // Write the question tags.
1573
        if (core_tag_tag::is_enabled('core_question', 'question')) {
1574
            $tagobjects = core_tag_tag::get_item_tags('core_question', 'question', $question->id);
1575
 
1576
            if (!empty($tagobjects)) {
1577
                $context = context::instance_by_id($contextid);
1578
                $sortedtagobjects = question_sort_tags($tagobjects, $context, [$this->course]);
1579
 
1580
                if (!empty($sortedtagobjects->coursetags)) {
1581
                    // Set them on the form to be rendered as existing tags.
1582
                    $expout .= "    <coursetags>\n";
1583
                    foreach ($sortedtagobjects->coursetags as $coursetag) {
1584
                        $expout .= "      <tag>" . $this->writetext($coursetag, 0, true) . "</tag>\n";
1585
                    }
1586
                    $expout .= "    </coursetags>\n";
1587
                }
1588
 
1589
                if (!empty($sortedtagobjects->tags)) {
1590
                    $expout .= "    <tags>\n";
1591
                    foreach ($sortedtagobjects->tags as $tag) {
1592
                        $expout .= "      <tag>" . $this->writetext($tag, 0, true) . "</tag>\n";
1593
                    }
1594
                    $expout .= "    </tags>\n";
1595
                }
1596
            }
1597
        }
1598
 
1599
        // Close the question tag.
1600
        $expout .= "  </question>\n";
1601
        if ($invalidquestion) {
1602
            return '';
1603
        } else {
1604
            return $expout;
1605
        }
1606
    }
1607
 
1608
    public function write_answers($answers) {
1609
        if (empty($answers)) {
1610
            return;
1611
        }
1612
        $output = '';
1613
        foreach ($answers as $answer) {
1614
            $output .= $this->write_answer($answer);
1615
        }
1616
        return $output;
1617
    }
1618
 
1619
    public function write_answer($answer, $extra = '') {
1620
        $percent = $answer->fraction * 100;
1621
        $output = '';
1622
        $output .= "    <answer fraction=\"{$percent}\" {$this->format($answer->answerformat)}>\n";
1623
        $output .= $this->writetext($answer->answer, 3);
1624
        $output .= $this->write_files($this->answerfiles[$answer->id]);
1625
        $output .= "      <feedback {$this->format($answer->feedbackformat)}>\n";
1626
        $output .= $this->writetext($answer->feedback, 4);
1627
        $output .= $this->write_files($this->feedbackfiles[$answer->id]);
1628
        $output .= "      </feedback>\n";
1629
        $output .= $extra;
1630
        $output .= "    </answer>\n";
1631
        return $output;
1632
    }
1633
 
1634
    /**
1635
     * Write out the hints.
1636
     * @param object $question the question definition data.
1637
     * @return string XML to output.
1638
     */
1639
    public function write_hints($question) {
1640
        if (empty($question->hints)) {
1641
            return '';
1642
        }
1643
 
1644
        $output = '';
1645
        foreach ($question->hints as $hint) {
1646
            $output .= $this->write_hint($hint, $question->contextid);
1647
        }
1648
        return $output;
1649
    }
1650
 
1651
    /**
1652
     * @param int $format a FORMAT_... constant.
1653
     * @return string the attribute to add to an XML tag.
1654
     */
1655
    public function format($format) {
1656
        return 'format="' . $this->get_format($format) . '"';
1657
    }
1658
 
1659
    public function write_hint($hint, $contextid) {
1660
        $fs = get_file_storage();
1661
        $files = $fs->get_area_files($contextid, 'question', 'hint', $hint->id);
1662
 
1663
        $output = '';
1664
        $output .= "    <hint {$this->format($hint->hintformat)}>\n";
1665
        $output .= '      ' . $this->writetext($hint->hint);
1666
 
1667
        if (!empty($hint->shownumcorrect)) {
1668
            $output .= "      <shownumcorrect/>\n";
1669
        }
1670
        if (!empty($hint->clearwrong)) {
1671
            $output .= "      <clearwrong/>\n";
1672
        }
1673
 
1674
        if (!empty($hint->options)) {
1675
            $output .= '      <options>' . $this->xml_escape($hint->options) . "</options>\n";
1676
        }
1677
        $output .= $this->write_files($files);
1678
        $output .= "    </hint>\n";
1679
        return $output;
1680
    }
1681
 
1682
    /**
1683
     * Output the combined feedback fields.
1684
     * @param object $questionoptions the question definition data.
1685
     * @param int $questionid the question id.
1686
     * @param int $contextid the question context id.
1687
     * @return string XML to output.
1688
     */
1689
    public function write_combined_feedback($questionoptions, $questionid, $contextid) {
1690
        $fs = get_file_storage();
1691
        $output = '';
1692
 
1693
        $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback');
1694
        foreach ($fields as $field) {
1695
            $formatfield = $field . 'format';
1696
            $files = $fs->get_area_files($contextid, 'question', $field, $questionid);
1697
 
1698
            $output .= "    <{$field} {$this->format($questionoptions->$formatfield)}>\n";
1699
            $output .= '      ' . $this->writetext($questionoptions->$field);
1700
            $output .= $this->write_files($files);
1701
            $output .= "    </{$field}>\n";
1702
        }
1703
 
1704
        if (!empty($questionoptions->shownumcorrect)) {
1705
            $output .= "    <shownumcorrect/>\n";
1706
        }
1707
        return $output;
1708
    }
1709
}