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