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