b2b71ffd1e5cce99ab0d36da1c065caa4290a034
[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         $qo->idnumber = $this->getpath($question, ['#', 'idnumber', 0, '#'], null);
241         // Restore files in generalfeedback.
242         $generalfeedback = $this->import_text_with_files($question,
243                 array('#', 'generalfeedback', 0), '', $this->get_format($qo->questiontextformat));
244         $qo->generalfeedback = $generalfeedback['text'];
245         $qo->generalfeedbackformat = $generalfeedback['format'];
246         if (!empty($generalfeedback['itemid'])) {
247             $qo->generalfeedbackitemid = $generalfeedback['itemid'];
248         }
250         $qo->defaultmark = $this->getpath($question,
251                 array('#', 'defaultgrade', 0, '#'), $qo->defaultmark);
252         $qo->penalty = $this->getpath($question,
253                 array('#', 'penalty', 0, '#'), $qo->penalty);
255         // Fix problematic rounding from old files.
256         if (abs($qo->penalty - 0.3333333) < 0.005) {
257             $qo->penalty = 0.3333333;
258         }
260         // Read the question tags.
261         $this->import_question_tags($qo, $question);
263         return $qo;
264     }
266     /**
267      * Import the common parts of a single answer
268      * @param array answer xml tree for single answer
269      * @param bool $withanswerfiles if true, the answers are HTML (or $defaultformat)
270      *      and so may contain files, otherwise the answers are plain text.
271      * @param array Default text format for the feedback, and the answers if $withanswerfiles
272      *      is true.
273      * @return object answer object
274      */
275     public function import_answer($answer, $withanswerfiles = false, $defaultformat = 'html') {
276         $ans = new stdClass();
278         if ($withanswerfiles) {
279             $ans->answer = $this->import_text_with_files($answer, array(), '', $defaultformat);
280         } else {
281             $ans->answer = array();
282             $ans->answer['text']   = $this->getpath($answer, array('#', 'text', 0, '#'), '', true);
283             $ans->answer['format'] = FORMAT_PLAIN;
284         }
286         $ans->feedback = $this->import_text_with_files($answer, array('#', 'feedback', 0), '', $defaultformat);
288         $ans->fraction = $this->getpath($answer, array('@', 'fraction'), 0) / 100;
290         return $ans;
291     }
293     /**
294      * Import the common overall feedback fields.
295      * @param object $question the part of the XML relating to this question.
296      * @param object $qo the question data to add the fields to.
297      * @param bool $withshownumpartscorrect include the shownumcorrect field.
298      */
299     public function import_combined_feedback($qo, $questionxml, $withshownumpartscorrect = false) {
300         $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback');
301         foreach ($fields as $field) {
302             $qo->$field = $this->import_text_with_files($questionxml,
303                     array('#', $field, 0), '', $this->get_format($qo->questiontextformat));
304         }
306         if ($withshownumpartscorrect) {
307             $qo->shownumcorrect = array_key_exists('shownumcorrect', $questionxml['#']);
309             // Backwards compatibility.
310             if (array_key_exists('correctresponsesfeedback', $questionxml['#'])) {
311                 $qo->shownumcorrect = $this->trans_single($this->getpath($questionxml,
312                         array('#', 'correctresponsesfeedback', 0, '#'), 1));
313             }
314         }
315     }
317     /**
318      * Import a question hint
319      * @param array $hintxml hint xml fragment.
320      * @param string $defaultformat the text format to assume for hints that do not specify.
321      * @return object hint for storing in the database.
322      */
323     public function import_hint($hintxml, $defaultformat) {
324         $hint = new stdClass();
325         if (array_key_exists('hintcontent', $hintxml['#'])) {
326             // Backwards compatibility.
328             $hint->hint = $this->import_text_with_files($hintxml,
329                     array('#', 'hintcontent', 0), '', $defaultformat);
331             $hint->shownumcorrect = $this->getpath($hintxml,
332                     array('#', 'statenumberofcorrectresponses', 0, '#'), 0);
333             $hint->clearwrong = $this->getpath($hintxml,
334                     array('#', 'clearincorrectresponses', 0, '#'), 0);
335             $hint->options = $this->getpath($hintxml,
336                     array('#', 'showfeedbacktoresponses', 0, '#'), 0);
338             return $hint;
339         }
340         $hint->hint = $this->import_text_with_files($hintxml, array(), '', $defaultformat);
341         $hint->shownumcorrect = array_key_exists('shownumcorrect', $hintxml['#']);
342         $hint->clearwrong = array_key_exists('clearwrong', $hintxml['#']);
343         $hint->options = $this->getpath($hintxml, array('#', 'options', 0, '#'), '', true);
345         return $hint;
346     }
348     /**
349      * Import all the question hints
350      *
351      * @param object $qo the question data that is being constructed.
352      * @param array $questionxml The xml representing the question.
353      * @param bool $withparts whether the extra fields relating to parts should be imported.
354      * @param bool $withoptions whether the extra options field should be imported.
355      * @param string $defaultformat the text format to assume for hints that do not specify.
356      * @return array of objects representing the hints in the file.
357      */
358     public function import_hints($qo, $questionxml, $withparts = false,
359             $withoptions = false, $defaultformat = 'html') {
360         if (!isset($questionxml['#']['hint'])) {
361             return;
362         }
364         foreach ($questionxml['#']['hint'] as $hintxml) {
365             $hint = $this->import_hint($hintxml, $defaultformat);
366             $qo->hint[] = $hint->hint;
368             if ($withparts) {
369                 $qo->hintshownumcorrect[] = $hint->shownumcorrect;
370                 $qo->hintclearwrong[] = $hint->clearwrong;
371             }
373             if ($withoptions) {
374                 $qo->hintoptions[] = $hint->options;
375             }
376         }
377     }
379     /**
380      * Import all the question tags
381      *
382      * @param object $qo the question data that is being constructed.
383      * @param array $questionxml The xml representing the question.
384      * @return array of objects representing the tags in the file.
385      */
386     public function import_question_tags($qo, $questionxml) {
387         global $CFG;
389         if (core_tag_tag::is_enabled('core_question', 'question')) {
391             $qo->tags = [];
392             if (!empty($questionxml['#']['tags'][0]['#']['tag'])) {
393                 foreach ($questionxml['#']['tags'][0]['#']['tag'] as $tagdata) {
394                     $qo->tags[] = $this->getpath($tagdata, array('#', 'text', 0, '#'), '', true);
395                 }
396             }
398             $qo->coursetags = [];
399             if (!empty($questionxml['#']['coursetags'][0]['#']['tag'])) {
400                 foreach ($questionxml['#']['coursetags'][0]['#']['tag'] as $tagdata) {
401                     $qo->coursetags[] = $this->getpath($tagdata, array('#', 'text', 0, '#'), '', true);
402                 }
403             }
404         }
405     }
407     /**
408      * Import files from a node in the XML.
409      * @param array $xml an array of <file> nodes from the the parsed XML.
410      * @return array of things representing files - in the form that save_question expects.
411      */
412     public function import_files($xml) {
413         $files = array();
414         foreach ($xml as $file) {
415             $data = new stdClass();
416             $data->content = $file['#'];
417             $data->encoding = $file['@']['encoding'];
418             $data->name = $file['@']['name'];
419             $files[] = $data;
420         }
421         return $files;
422     }
424     /**
425      * import multiple choice question
426      * @param array question question array from xml tree
427      * @return object question object
428      */
429     public function import_multichoice($question) {
430         // Get common parts.
431         $qo = $this->import_headers($question);
433         // Header parts particular to multichoice.
434         $qo->qtype = 'multichoice';
435         $single = $this->getpath($question, array('#', 'single', 0, '#'), 'true');
436         $qo->single = $this->trans_single($single);
437         $shuffleanswers = $this->getpath($question,
438                 array('#', 'shuffleanswers', 0, '#'), 'false');
439         $qo->answernumbering = $this->getpath($question,
440                 array('#', 'answernumbering', 0, '#'), 'abc');
441         $qo->shuffleanswers = $this->trans_single($shuffleanswers);
442         $qo->showstandardinstruction = $this->getpath($question,
443             array('#', 'showstandardinstruction', 0, '#'), '1');
445         // There was a time on the 1.8 branch when it could output an empty
446         // answernumbering tag, so fix up any found.
447         if (empty($qo->answernumbering)) {
448             $qo->answernumbering = 'abc';
449         }
451         // Run through the answers.
452         $answers = $question['#']['answer'];
453         $acount = 0;
454         foreach ($answers as $answer) {
455             $ans = $this->import_answer($answer, true, $this->get_format($qo->questiontextformat));
456             $qo->answer[$acount] = $ans->answer;
457             $qo->fraction[$acount] = $ans->fraction;
458             $qo->feedback[$acount] = $ans->feedback;
459             ++$acount;
460         }
462         $this->import_combined_feedback($qo, $question, true);
463         $this->import_hints($qo, $question, true, false, $this->get_format($qo->questiontextformat));
465         return $qo;
466     }
468     /**
469      * Import cloze type question
470      * @param array question question array from xml tree
471      * @return object question object
472      */
473     public function import_multianswer($question) {
474         global $USER;
475         question_bank::get_qtype('multianswer');
477         $questiontext = $this->import_text_with_files($question,
478                 array('#', 'questiontext', 0));
479         $qo = qtype_multianswer_extract_question($questiontext);
480         $errors = qtype_multianswer_validate_question($qo);
481         if ($errors) {
482             $this->error(get_string('invalidmultianswerquestion', 'qtype_multianswer', implode(' ', $errors)));
483             return null;
484         }
486         // Header parts particular to multianswer.
487         $qo->qtype = 'multianswer';
489         // Only set the course if the data is available.
490         if (isset($this->course)) {
491             $qo->course = $this->course;
492         }
493         if (isset($question['#']['name'])) {
494             $qo->name = $this->clean_question_name($this->import_text($question['#']['name'][0]['#']['text']));
495         } else {
496             $qo->name = $this->create_default_question_name($qo->questiontext['text'],
497                     get_string('questionname', 'question'));
498         }
499         $qo->questiontextformat = $questiontext['format'];
500         $qo->questiontext = $qo->questiontext['text'];
501         if (!empty($questiontext['itemid'])) {
502             $qo->questiontextitemid = $questiontext['itemid'];
503         }
505         // Backwards compatibility, deal with the old image tag.
506         $filedata = $this->getpath($question, array('#', 'image_base64', '0', '#'), null, false);
507         $filename = $this->getpath($question, array('#', 'image', '0', '#'), null, false);
508         if ($filedata && $filename) {
509             $fs = get_file_storage();
510             if (empty($qo->questiontextitemid)) {
511                 $qo->questiontextitemid = file_get_unused_draft_itemid();
512             }
513             $filename = clean_param(str_replace('/', '_', $filename), PARAM_FILE);
514             $filerecord = array(
515                 'contextid' => context_user::instance($USER->id)->id,
516                 'component' => 'user',
517                 'filearea'  => 'draft',
518                 'itemid'    => $qo->questiontextitemid,
519                 'filepath'  => '/',
520                 'filename'  => $filename,
521             );
522             $fs->create_file_from_string($filerecord, base64_decode($filedata));
523             $qo->questiontext .= ' <img src="@@PLUGINFILE@@/' . $filename . '" />';
524         }
526         // Restore files in generalfeedback.
527         $generalfeedback = $this->import_text_with_files($question,
528                 array('#', 'generalfeedback', 0), '', $this->get_format($qo->questiontextformat));
529         $qo->generalfeedback = $generalfeedback['text'];
530         $qo->generalfeedbackformat = $generalfeedback['format'];
531         if (!empty($generalfeedback['itemid'])) {
532             $qo->generalfeedbackitemid = $generalfeedback['itemid'];
533         }
535         $qo->penalty = $this->getpath($question,
536                 array('#', 'penalty', 0, '#'), $this->defaultquestion()->penalty);
537         // Fix problematic rounding from old files.
538         if (abs($qo->penalty - 0.3333333) < 0.005) {
539             $qo->penalty = 0.3333333;
540         }
542         $this->import_hints($qo, $question, true, false, $this->get_format($qo->questiontextformat));
543         $this->import_question_tags($qo, $question);
545         return $qo;
546     }
548     /**
549      * Import true/false type question
550      * @param array question question array from xml tree
551      * @return object question object
552      */
553     public function import_truefalse($question) {
554         // Get common parts.
555         global $OUTPUT;
556         $qo = $this->import_headers($question);
558         // Header parts particular to true/false.
559         $qo->qtype = 'truefalse';
561         // In the past, it used to be assumed that the two answers were in the file
562         // true first, then false. Howevever that was not always true. Now, we
563         // try to match on the answer text, but in old exports, this will be a localised
564         // string, so if we don't find true or false, we fall back to the old system.
565         $first = true;
566         $warning = false;
567         foreach ($question['#']['answer'] as $answer) {
568             $answertext = $this->getpath($answer,
569                     array('#', 'text', 0, '#'), '', true);
570             $feedback = $this->import_text_with_files($answer,
571                     array('#', 'feedback', 0), '', $this->get_format($qo->questiontextformat));
573             if ($answertext != 'true' && $answertext != 'false') {
574                 // Old style file, assume order is true/false.
575                 $warning = true;
576                 if ($first) {
577                     $answertext = 'true';
578                 } else {
579                     $answertext = 'false';
580                 }
581             }
583             if ($answertext == 'true') {
584                 $qo->answer = ($answer['@']['fraction'] == 100);
585                 $qo->correctanswer = $qo->answer;
586                 $qo->feedbacktrue = $feedback;
587             } else {
588                 $qo->answer = ($answer['@']['fraction'] != 100);
589                 $qo->correctanswer = $qo->answer;
590                 $qo->feedbackfalse = $feedback;
591             }
592             $first = false;
593         }
595         if ($warning) {
596             $a = new stdClass();
597             $a->questiontext = $qo->questiontext;
598             $a->answer = get_string($qo->correctanswer ? 'true' : 'false', 'qtype_truefalse');
599             echo $OUTPUT->notification(get_string('truefalseimporterror', 'qformat_xml', $a));
600         }
602         $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));
604         return $qo;
605     }
607     /**
608      * Import short answer type question
609      * @param array question question array from xml tree
610      * @return object question object
611      */
612     public function import_shortanswer($question) {
613         // Get common parts.
614         $qo = $this->import_headers($question);
616         // Header parts particular to shortanswer.
617         $qo->qtype = 'shortanswer';
619         // Get usecase.
620         $qo->usecase = $this->getpath($question, array('#', 'usecase', 0, '#'), $qo->usecase);
622         // Run through the answers.
623         $answers = $question['#']['answer'];
624         $acount = 0;
625         foreach ($answers as $answer) {
626             $ans = $this->import_answer($answer, false, $this->get_format($qo->questiontextformat));
627             $qo->answer[$acount] = $ans->answer['text'];
628             $qo->fraction[$acount] = $ans->fraction;
629             $qo->feedback[$acount] = $ans->feedback;
630             ++$acount;
631         }
633         $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));
635         return $qo;
636     }
638     /**
639      * Import description type question
640      * @param array question question array from xml tree
641      * @return object question object
642      */
643     public function import_description($question) {
644         // Get common parts.
645         $qo = $this->import_headers($question);
646         // Header parts particular to shortanswer.
647         $qo->qtype = 'description';
648         $qo->defaultmark = 0;
649         $qo->length = 0;
650         return $qo;
651     }
653     /**
654      * Import numerical type question
655      * @param array question question array from xml tree
656      * @return object question object
657      */
658     public function import_numerical($question) {
659         // Get common parts.
660         $qo = $this->import_headers($question);
662         // Header parts particular to numerical.
663         $qo->qtype = 'numerical';
665         // Get answers array.
666         $answers = $question['#']['answer'];
667         $qo->answer = array();
668         $qo->feedback = array();
669         $qo->fraction = array();
670         $qo->tolerance = array();
671         foreach ($answers as $answer) {
672             // Answer outside of <text> is deprecated.
673             $obj = $this->import_answer($answer, false, $this->get_format($qo->questiontextformat));
674             $qo->answer[] = $obj->answer['text'];
675             if (empty($qo->answer)) {
676                 $qo->answer = '*';
677             }
678             $qo->feedback[]  = $obj->feedback;
679             $qo->tolerance[] = $this->getpath($answer, array('#', 'tolerance', 0, '#'), 0);
681             // Fraction as a tag is deprecated.
682             $fraction = $this->getpath($answer, array('@', 'fraction'), 0) / 100;
683             $qo->fraction[] = $this->getpath($answer,
684                     array('#', 'fraction', 0, '#'), $fraction); // Deprecated.
685         }
687         // Get the units array.
688         $qo->unit = array();
689         $units = $this->getpath($question, array('#', 'units', 0, '#', 'unit'), array());
690         if (!empty($units)) {
691             $qo->multiplier = array();
692             foreach ($units as $unit) {
693                 $qo->multiplier[] = $this->getpath($unit, array('#', 'multiplier', 0, '#'), 1);
694                 $qo->unit[] = $this->getpath($unit, array('#', 'unit_name', 0, '#'), '', true);
695             }
696         }
697         $qo->unitgradingtype = $this->getpath($question, array('#', 'unitgradingtype', 0, '#'), 0);
698         $qo->unitpenalty = $this->getpath($question, array('#', 'unitpenalty', 0, '#'), 0.1);
699         $qo->showunits = $this->getpath($question, array('#', 'showunits', 0, '#'), null);
700         $qo->unitsleft = $this->getpath($question, array('#', 'unitsleft', 0, '#'), 0);
701         $qo->instructions['text'] = '';
702         $qo->instructions['format'] = FORMAT_HTML;
703         $instructions = $this->getpath($question, array('#', 'instructions'), array());
704         if (!empty($instructions)) {
705             $qo->instructions = $this->import_text_with_files($instructions,
706                     array('0'), '', $this->get_format($qo->questiontextformat));
707         }
709         if (is_null($qo->showunits)) {
710             // Set a good default, depending on whether there are any units defined.
711             if (empty($qo->unit)) {
712                 $qo->showunits = 3; // This is qtype_numerical::UNITNONE, but we cannot refer to that constant here.
713             } else {
714                 $qo->showunits = 0; // This is qtype_numerical::UNITOPTIONAL, but we cannot refer to that constant here.
715             }
716         }
718         $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));
720         return $qo;
721     }
723     /**
724      * Import matching type question
725      * @param array question question array from xml tree
726      * @return object question object
727      */
728     public function import_match($question) {
729         // Get common parts.
730         $qo = $this->import_headers($question);
732         // Header parts particular to matching.
733         $qo->qtype = 'match';
734         $qo->shuffleanswers = $this->trans_single($this->getpath($question,
735                 array('#', 'shuffleanswers', 0, '#'), 1));
737         // Run through subquestions.
738         $qo->subquestions = array();
739         $qo->subanswers = array();
740         foreach ($question['#']['subquestion'] as $subqxml) {
741             $qo->subquestions[] = $this->import_text_with_files($subqxml,
742                     array(), '', $this->get_format($qo->questiontextformat));
744             $answers = $this->getpath($subqxml, array('#', 'answer'), array());
745             $qo->subanswers[] = $this->getpath($subqxml,
746                     array('#', 'answer', 0, '#', 'text', 0, '#'), '', true);
747         }
749         $this->import_combined_feedback($qo, $question, true);
750         $this->import_hints($qo, $question, true, false, $this->get_format($qo->questiontextformat));
752         return $qo;
753     }
755     /**
756      * Import essay type question
757      * @param array question question array from xml tree
758      * @return object question object
759      */
760     public function import_essay($question) {
761         // Get common parts.
762         $qo = $this->import_headers($question);
764         // Header parts particular to essay.
765         $qo->qtype = 'essay';
767         $qo->responseformat = $this->getpath($question,
768                 array('#', 'responseformat', 0, '#'), 'editor');
769         $qo->responsefieldlines = $this->getpath($question,
770                 array('#', 'responsefieldlines', 0, '#'), 15);
771         $qo->responserequired = $this->getpath($question,
772                 array('#', 'responserequired', 0, '#'), 1);
773         $qo->attachments = $this->getpath($question,
774                 array('#', 'attachments', 0, '#'), 0);
775         $qo->attachmentsrequired = $this->getpath($question,
776                 array('#', 'attachmentsrequired', 0, '#'), 0);
777         $qo->graderinfo = $this->import_text_with_files($question,
778                 array('#', 'graderinfo', 0), '', $this->get_format($qo->questiontextformat));
779         $qo->responsetemplate['text'] = $this->getpath($question,
780                 array('#', 'responsetemplate', 0, '#', 'text', 0, '#'), '', true);
781         $qo->responsetemplate['format'] = $this->trans_format($this->getpath($question,
782                 array('#', 'responsetemplate', 0, '@', 'format'), $this->get_format($qo->questiontextformat)));
784         return $qo;
785     }
787     /**
788      * Import a calculated question
789      * @param object $question the imported XML data.
790      */
791     public function import_calculated($question) {
793         // Get common parts.
794         $qo = $this->import_headers($question);
796         // Header parts particular to calculated.
797         $qo->qtype = 'calculated';
798         $qo->synchronize = $this->getpath($question, array('#', 'synchronize', 0, '#'), 0);
799         $single = $this->getpath($question, array('#', 'single', 0, '#'), 'true');
800         $qo->single = $this->trans_single($single);
801         $shuffleanswers = $this->getpath($question, array('#', 'shuffleanswers', 0, '#'), 'false');
802         $qo->answernumbering = $this->getpath($question,
803                 array('#', 'answernumbering', 0, '#'), 'abc');
804         $qo->shuffleanswers = $this->trans_single($shuffleanswers);
806         $this->import_combined_feedback($qo, $question);
808         $qo->unitgradingtype = $this->getpath($question,
809                 array('#', 'unitgradingtype', 0, '#'), 0);
810         $qo->unitpenalty = $this->getpath($question, array('#', 'unitpenalty', 0, '#'), null);
811         $qo->showunits = $this->getpath($question, array('#', 'showunits', 0, '#'), 0);
812         $qo->unitsleft = $this->getpath($question, array('#', 'unitsleft', 0, '#'), 0);
813         $qo->instructions = $this->getpath($question,
814                 array('#', 'instructions', 0, '#', 'text', 0, '#'), '', true);
815         if (!empty($instructions)) {
816             $qo->instructions = $this->import_text_with_files($instructions,
817                     array('0'), '', $this->get_format($qo->questiontextformat));
818         }
820         // Get answers array.
821         $answers = $question['#']['answer'];
822         $qo->answer = array();
823         $qo->feedback = array();
824         $qo->fraction = array();
825         $qo->tolerance = array();
826         $qo->tolerancetype = array();
827         $qo->correctanswerformat = array();
828         $qo->correctanswerlength = array();
829         $qo->feedback = array();
830         foreach ($answers as $answer) {
831             $ans = $this->import_answer($answer, true, $this->get_format($qo->questiontextformat));
832             // Answer outside of <text> is deprecated.
833             if (empty($ans->answer['text'])) {
834                 $ans->answer['text'] = '*';
835             }
836             $qo->answer[] = $ans->answer['text'];
837             $qo->feedback[] = $ans->feedback;
838             $qo->tolerance[] = $answer['#']['tolerance'][0]['#'];
839             // Fraction as a tag is deprecated.
840             if (!empty($answer['#']['fraction'][0]['#'])) {
841                 $qo->fraction[] = $answer['#']['fraction'][0]['#'];
842             } else {
843                 $qo->fraction[] = $answer['@']['fraction'] / 100;
844             }
845             $qo->tolerancetype[] = $answer['#']['tolerancetype'][0]['#'];
846             $qo->correctanswerformat[] = $answer['#']['correctanswerformat'][0]['#'];
847             $qo->correctanswerlength[] = $answer['#']['correctanswerlength'][0]['#'];
848         }
849         // Get units array.
850         $qo->unit = array();
851         if (isset($question['#']['units'][0]['#']['unit'])) {
852             $units = $question['#']['units'][0]['#']['unit'];
853             $qo->multiplier = array();
854             foreach ($units as $unit) {
855                 $qo->multiplier[] = $unit['#']['multiplier'][0]['#'];
856                 $qo->unit[] = $unit['#']['unit_name'][0]['#'];
857             }
858         }
859         $instructions = $this->getpath($question, array('#', 'instructions'), array());
860         if (!empty($instructions)) {
861             $qo->instructions = $this->import_text_with_files($instructions,
862                     array('0'), '', $this->get_format($qo->questiontextformat));
863         }
865         if (is_null($qo->unitpenalty)) {
866             // Set a good default, depending on whether there are any units defined.
867             if (empty($qo->unit)) {
868                 $qo->showunits = 3; // This is qtype_numerical::UNITNONE, but we cannot refer to that constant here.
869             } else {
870                 $qo->showunits = 0; // This is qtype_numerical::UNITOPTIONAL, but we cannot refer to that constant here.
871             }
872         }
874         $datasets = $question['#']['dataset_definitions'][0]['#']['dataset_definition'];
875         $qo->dataset = array();
876         $qo->datasetindex= 0;
877         foreach ($datasets as $dataset) {
878             $qo->datasetindex++;
879             $qo->dataset[$qo->datasetindex] = new stdClass();
880             $qo->dataset[$qo->datasetindex]->status =
881                     $this->import_text($dataset['#']['status'][0]['#']['text']);
882             $qo->dataset[$qo->datasetindex]->name =
883                     $this->import_text($dataset['#']['name'][0]['#']['text']);
884             $qo->dataset[$qo->datasetindex]->type =
885                     $dataset['#']['type'][0]['#'];
886             $qo->dataset[$qo->datasetindex]->distribution =
887                     $this->import_text($dataset['#']['distribution'][0]['#']['text']);
888             $qo->dataset[$qo->datasetindex]->max =
889                     $this->import_text($dataset['#']['maximum'][0]['#']['text']);
890             $qo->dataset[$qo->datasetindex]->min =
891                     $this->import_text($dataset['#']['minimum'][0]['#']['text']);
892             $qo->dataset[$qo->datasetindex]->length =
893                     $this->import_text($dataset['#']['decimals'][0]['#']['text']);
894             $qo->dataset[$qo->datasetindex]->distribution =
895                     $this->import_text($dataset['#']['distribution'][0]['#']['text']);
896             $qo->dataset[$qo->datasetindex]->itemcount = $dataset['#']['itemcount'][0]['#'];
897             $qo->dataset[$qo->datasetindex]->datasetitem = array();
898             $qo->dataset[$qo->datasetindex]->itemindex = 0;
899             $qo->dataset[$qo->datasetindex]->number_of_items = $this->getpath($dataset,
900                     array('#', 'number_of_items', 0, '#'), 0);
901             $datasetitems = $this->getpath($dataset,
902                     array('#', 'dataset_items', 0, '#', 'dataset_item'), array());
903             foreach ($datasetitems as $datasetitem) {
904                 $qo->dataset[$qo->datasetindex]->itemindex++;
905                 $qo->dataset[$qo->datasetindex]->datasetitem[
906                         $qo->dataset[$qo->datasetindex]->itemindex] = new stdClass();
907                 $qo->dataset[$qo->datasetindex]->datasetitem[
908                         $qo->dataset[$qo->datasetindex]->itemindex]->itemnumber =
909                                 $datasetitem['#']['number'][0]['#'];
910                 $qo->dataset[$qo->datasetindex]->datasetitem[
911                         $qo->dataset[$qo->datasetindex]->itemindex]->value =
912                                 $datasetitem['#']['value'][0]['#'];
913             }
914         }
916         $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));
918         return $qo;
919     }
921     /**
922      * This is not a real question type. It's a dummy type used to specify the
923      * import category. The format is:
924      * <question type="category">
925      *     <category>tom/dick/harry</category>
926      *     <info format="moodle_auto_format"><text>Category description</text></info>
927      * </question>
928      */
929     protected function import_category($question) {
930         $qo = new stdClass();
931         $qo->qtype = 'category';
932         $qo->category = $this->import_text($question['#']['category'][0]['#']['text']);
933         $qo->info = '';
934         $qo->infoformat = FORMAT_MOODLE;
935         if (array_key_exists('info', $question['#'])) {
936             $qo->info = $this->import_text($question['#']['info'][0]['#']['text']);
937             // The import should have the format in human readable form, so translate to machine readable format.
938             $qo->infoformat = $this->trans_format($question['#']['info'][0]['@']['format']);
939         }
940         $qo->idnumber = $this->getpath($question, array('#', 'idnumber', 0, '#'), null);
941         return $qo;
942     }
944     /**
945      * Parse the array of lines into an array of questions
946      * this *could* burn memory - but it won't happen that much
947      * so fingers crossed!
948      * @param array of lines from the input file.
949      * @param stdClass $context
950      * @return array (of objects) question objects.
951      */
952     public function readquestions($lines) {
953         // We just need it as one big string.
954         $lines = implode('', $lines);
956         // This converts xml to big nasty data structure
957         // the 0 means keep white space as it is (important for markdown format).
958         try {
959             $xml = xmlize($lines, 0, 'UTF-8', true);
960         } catch (xml_format_exception $e) {
961             $this->error($e->getMessage(), '');
962             return false;
963         }
964         unset($lines); // No need to keep this in memory.
965         return $this->import_questions($xml['quiz']['#']['question']);
966     }
968     /**
969      * @param array $xml the xmlized xml
970      * @return stdClass[] question objects to pass to question type save_question_options
971      */
972     public function import_questions($xml) {
973         $questions = array();
975         // Iterate through questions.
976         foreach ($xml as $questionxml) {
977             $qo = $this->import_question($questionxml);
979             // Stick the result in the $questions array.
980             if ($qo) {
981                 $questions[] = $qo;
982             }
983         }
984         return $questions;
985     }
987     /**
988      * @param array $questionxml xml describing the question
989      * @return null|stdClass an object with data to be fed to question type save_question_options
990      */
991     protected function import_question($questionxml) {
992         $questiontype = $questionxml['@']['type'];
994         if ($questiontype == 'multichoice') {
995             return $this->import_multichoice($questionxml);
996         } else if ($questiontype == 'truefalse') {
997             return $this->import_truefalse($questionxml);
998         } else if ($questiontype == 'shortanswer') {
999             return $this->import_shortanswer($questionxml);
1000         } else if ($questiontype == 'numerical') {
1001             return $this->import_numerical($questionxml);
1002         } else if ($questiontype == 'description') {
1003             return $this->import_description($questionxml);
1004         } else if ($questiontype == 'matching' || $questiontype == 'match') {
1005             return $this->import_match($questionxml);
1006         } else if ($questiontype == 'cloze' || $questiontype == 'multianswer') {
1007             return $this->import_multianswer($questionxml);
1008         } else if ($questiontype == 'essay') {
1009             return $this->import_essay($questionxml);
1010         } else if ($questiontype == 'calculated') {
1011             return $this->import_calculated($questionxml);
1012         } else if ($questiontype == 'calculatedsimple') {
1013             $qo = $this->import_calculated($questionxml);
1014             $qo->qtype = 'calculatedsimple';
1015             return $qo;
1016         } else if ($questiontype == 'calculatedmulti') {
1017             $qo = $this->import_calculated($questionxml);
1018             $qo->qtype = 'calculatedmulti';
1019             return $qo;
1020         } else if ($questiontype == 'category') {
1021             return $this->import_category($questionxml);
1023         } else {
1024             // Not a type we handle ourselves. See if the question type wants
1025             // to handle it.
1026             if (!$qo = $this->try_importing_using_qtypes($questionxml, null, null, $questiontype)) {
1027                 $this->error(get_string('xmltypeunsupported', 'qformat_xml', $questiontype));
1028                 return null;
1029             }
1030             return $qo;
1031         }
1032     }
1034     // EXPORT FUNCTIONS START HERE.
1036     public function export_file_extension() {
1037         return '.xml';
1038     }
1040     /**
1041      * Turn the internal question type name into a human readable form.
1042      * (In the past, the code used to use integers internally. Now, it uses
1043      * strings, so there is less need for this, but to maintain
1044      * backwards-compatibility we change two of the type names.)
1045      * @param string $qtype question type plugin name.
1046      * @return string $qtype string to use in the file.
1047      */
1048     protected function get_qtype($qtype) {
1049         switch($qtype) {
1050             case 'match':
1051                 return 'matching';
1052             case 'multianswer':
1053                 return 'cloze';
1054             default:
1055                 return $qtype;
1056         }
1057     }
1059     /**
1060      * Convert internal Moodle text format code into
1061      * human readable form
1062      * @param int id internal code
1063      * @return string format text
1064      */
1065     public function get_format($id) {
1066         switch($id) {
1067             case FORMAT_MOODLE:
1068                 return 'moodle_auto_format';
1069             case FORMAT_HTML:
1070                 return 'html';
1071             case FORMAT_PLAIN:
1072                 return 'plain_text';
1073             case FORMAT_WIKI:
1074                 return 'wiki_like';
1075             case FORMAT_MARKDOWN:
1076                 return 'markdown';
1077             default:
1078                 return 'unknown';
1079         }
1080     }
1082     /**
1083      * Convert internal single question code into
1084      * human readable form
1085      * @param int id single question code
1086      * @return string single question string
1087      */
1088     public function get_single($id) {
1089         switch($id) {
1090             case 0:
1091                 return 'false';
1092             case 1:
1093                 return 'true';
1094             default:
1095                 return 'unknown';
1096         }
1097     }
1099     /**
1100      * Take a string, and wrap it in a CDATA secion, if that is required to make
1101      * the output XML valid.
1102      * @param string $string a string
1103      * @return string the string, wrapped in CDATA if necessary.
1104      */
1105     public function xml_escape($string) {
1106         if (!empty($string) && htmlspecialchars($string) != $string) {
1107             // If the string contains something that looks like the end
1108             // of a CDATA section, then we need to avoid errors by splitting
1109             // the string between two CDATA sections.
1110             $string = str_replace(']]>', ']]]]><![CDATA[>', $string);
1111             return "<![CDATA[{$string}]]>";
1112         } else {
1113             return $string;
1114         }
1115     }
1117     /**
1118      * Generates <text></text> tags, processing raw text therein
1119      * @param string $raw the content to output.
1120      * @param int $indent the current indent level.
1121      * @param bool $short stick it on one line.
1122      * @return string formatted text.
1123      */
1124     public function writetext($raw, $indent = 0, $short = true) {
1125         $indent = str_repeat('  ', $indent);
1126         $raw = $this->xml_escape($raw);
1128         if ($short) {
1129             $xml = "{$indent}<text>{$raw}</text>\n";
1130         } else {
1131             $xml = "{$indent}<text>\n{$raw}\n{$indent}</text>\n";
1132         }
1134         return $xml;
1135     }
1137     /**
1138      * Generte the XML to represent some files.
1139      * @param array of store array of stored_file objects.
1140      * @return string $string the XML.
1141      */
1142     public function write_files($files) {
1143         if (empty($files)) {
1144             return '';
1145         }
1146         $string = '';
1147         foreach ($files as $file) {
1148             if ($file->is_directory()) {
1149                 continue;
1150             }
1151             $string .= '<file name="' . $file->get_filename() . '" path="' . $file->get_filepath() . '" encoding="base64">';
1152             $string .= base64_encode($file->get_content());
1153             $string .= "</file>\n";
1154         }
1155         return $string;
1156     }
1158     protected function presave_process($content) {
1159         // Override to allow us to add xml headers and footers.
1160         return '<?xml version="1.0" encoding="UTF-8"?>
1161 <quiz>
1162 ' . $content . '</quiz>';
1163     }
1165     /**
1166      * Turns question into an xml segment
1167      * @param object $question the question data.
1168      * @return string xml segment
1169      */
1170     public function writequestion($question) {
1171         global $CFG, $OUTPUT;
1173         $invalidquestion = false;
1174         $fs = get_file_storage();
1175         $contextid = $question->contextid;
1176         // Get files used by the questiontext.
1177         $question->questiontextfiles = $fs->get_area_files(
1178                 $contextid, 'question', 'questiontext', $question->id);
1179         // Get files used by the generalfeedback.
1180         $question->generalfeedbackfiles = $fs->get_area_files(
1181                 $contextid, 'question', 'generalfeedback', $question->id);
1182         if (!empty($question->options->answers)) {
1183             foreach ($question->options->answers as $answer) {
1184                 $answer->answerfiles = $fs->get_area_files(
1185                         $contextid, 'question', 'answer', $answer->id);
1186                 $answer->feedbackfiles = $fs->get_area_files(
1187                         $contextid, 'question', 'answerfeedback', $answer->id);
1188             }
1189         }
1191         $expout = '';
1193         // Add a comment linking this to the original question id.
1194         $expout .= "<!-- question: {$question->id}  -->\n";
1196         // Check question type.
1197         $questiontype = $this->get_qtype($question->qtype);
1199         // Categories are a special case.
1200         if ($question->qtype == 'category') {
1201             $categorypath = $this->writetext($question->category);
1202             $categoryinfo = $this->writetext($question->info);
1203             $infoformat = $this->format($question->infoformat);
1204             $expout .= "  <question type=\"category\">\n";
1205             $expout .= "    <category>\n";
1206             $expout .= "      {$categorypath}";
1207             $expout .= "    </category>\n";
1208             $expout .= "    <info {$infoformat}>\n";
1209             $expout .= "      {$categoryinfo}";
1210             $expout .= "    </info>\n";
1211             $expout .= "    <idnumber>{$question->idnumber}</idnumber>\n";
1212             $expout .= "  </question>\n";
1213             return $expout;
1214         }
1216         // Now we know we are are handing a real question.
1217         // Output the generic information.
1218         $expout .= "  <question type=\"{$questiontype}\">\n";
1219         $expout .= "    <name>\n";
1220         $expout .= $this->writetext($question->name, 3);
1221         $expout .= "    </name>\n";
1222         $expout .= "    <questiontext {$this->format($question->questiontextformat)}>\n";
1223         $expout .= $this->writetext($question->questiontext, 3);
1224         $expout .= $this->write_files($question->questiontextfiles);
1225         $expout .= "    </questiontext>\n";
1226         $expout .= "    <generalfeedback {$this->format($question->generalfeedbackformat)}>\n";
1227         $expout .= $this->writetext($question->generalfeedback, 3);
1228         $expout .= $this->write_files($question->generalfeedbackfiles);
1229         $expout .= "    </generalfeedback>\n";
1230         if ($question->qtype != 'multianswer') {
1231             $expout .= "    <defaultgrade>{$question->defaultmark}</defaultgrade>\n";
1232         }
1233         $expout .= "    <penalty>{$question->penalty}</penalty>\n";
1234         $expout .= "    <hidden>{$question->hidden}</hidden>\n";
1235         $expout .= "    <idnumber>{$question->idnumber}</idnumber>\n";
1237         // The rest of the output depends on question type.
1238         switch($question->qtype) {
1239             case 'category':
1240                 // Not a qtype really - dummy used for category switching.
1241                 break;
1243             case 'truefalse':
1244                 $trueanswer = $question->options->answers[$question->options->trueanswer];
1245                 $trueanswer->answer = 'true';
1246                 $expout .= $this->write_answer($trueanswer);
1248                 $falseanswer = $question->options->answers[$question->options->falseanswer];
1249                 $falseanswer->answer = 'false';
1250                 $expout .= $this->write_answer($falseanswer);
1251                 break;
1253             case 'multichoice':
1254                 $expout .= "    <single>" . $this->get_single($question->options->single) .
1255                         "</single>\n";
1256                 $expout .= "    <shuffleanswers>" .
1257                         $this->get_single($question->options->shuffleanswers) .
1258                         "</shuffleanswers>\n";
1259                 $expout .= "    <answernumbering>" . $question->options->answernumbering .
1260                     "</answernumbering>\n";
1261                 $expout .= "    <showstandardinstruction>" . $question->options->showstandardinstruction .
1262                     "</showstandardinstruction>\n";
1263                 $expout .= $this->write_combined_feedback($question->options, $question->id, $question->contextid);
1264                 $expout .= $this->write_answers($question->options->answers);
1265                 break;
1267             case 'shortanswer':
1268                 $expout .= "    <usecase>{$question->options->usecase}</usecase>\n";
1269                 $expout .= $this->write_answers($question->options->answers);
1270                 break;
1272             case 'numerical':
1273                 foreach ($question->options->answers as $answer) {
1274                     $expout .= $this->write_answer($answer,
1275                             "      <tolerance>{$answer->tolerance}</tolerance>\n");
1276                 }
1278                 $units = $question->options->units;
1279                 if (count($units)) {
1280                     $expout .= "<units>\n";
1281                     foreach ($units as $unit) {
1282                         $expout .= "  <unit>\n";
1283                         $expout .= "    <multiplier>{$unit->multiplier}</multiplier>\n";
1284                         $expout .= "    <unit_name>{$unit->unit}</unit_name>\n";
1285                         $expout .= "  </unit>\n";
1286                     }
1287                     $expout .= "</units>\n";
1288                 }
1289                 if (isset($question->options->unitgradingtype)) {
1290                     $expout .= "    <unitgradingtype>" . $question->options->unitgradingtype .
1291                             "</unitgradingtype>\n";
1292                 }
1293                 if (isset($question->options->unitpenalty)) {
1294                     $expout .= "    <unitpenalty>{$question->options->unitpenalty}</unitpenalty>\n";
1295                 }
1296                 if (isset($question->options->showunits)) {
1297                     $expout .= "    <showunits>{$question->options->showunits}</showunits>\n";
1298                 }
1299                 if (isset($question->options->unitsleft)) {
1300                     $expout .= "    <unitsleft>{$question->options->unitsleft}</unitsleft>\n";
1301                 }
1302                 if (!empty($question->options->instructionsformat)) {
1303                     $files = $fs->get_area_files($contextid, 'qtype_numerical',
1304                             'instruction', $question->id);
1305                     $expout .= "    <instructions " .
1306                             $this->format($question->options->instructionsformat) . ">\n";
1307                     $expout .= $this->writetext($question->options->instructions, 3);
1308                     $expout .= $this->write_files($files);
1309                     $expout .= "    </instructions>\n";
1310                 }
1311                 break;
1313             case 'match':
1314                 $expout .= "    <shuffleanswers>" .
1315                         $this->get_single($question->options->shuffleanswers) .
1316                         "</shuffleanswers>\n";
1317                 $expout .= $this->write_combined_feedback($question->options, $question->id, $question->contextid);
1318                 foreach ($question->options->subquestions as $subquestion) {
1319                     $files = $fs->get_area_files($contextid, 'qtype_match',
1320                             'subquestion', $subquestion->id);
1321                     $expout .= "    <subquestion " .
1322                             $this->format($subquestion->questiontextformat) . ">\n";
1323                     $expout .= $this->writetext($subquestion->questiontext, 3);
1324                     $expout .= $this->write_files($files);
1325                     $expout .= "      <answer>\n";
1326                     $expout .= $this->writetext($subquestion->answertext, 4);
1327                     $expout .= "      </answer>\n";
1328                     $expout .= "    </subquestion>\n";
1329                 }
1330                 break;
1332             case 'description':
1333                 // Nothing else to do.
1334                 break;
1336             case 'multianswer':
1337                 foreach ($question->options->questions as $index => $subq) {
1338                     $expout = str_replace('{#' . $index . '}', $subq->questiontext, $expout);
1339                 }
1340                 break;
1342             case 'essay':
1343                 $expout .= "    <responseformat>" . $question->options->responseformat .
1344                         "</responseformat>\n";
1345                 $expout .= "    <responserequired>" . $question->options->responserequired .
1346                         "</responserequired>\n";
1347                 $expout .= "    <responsefieldlines>" . $question->options->responsefieldlines .
1348                         "</responsefieldlines>\n";
1349                 $expout .= "    <attachments>" . $question->options->attachments .
1350                         "</attachments>\n";
1351                 $expout .= "    <attachmentsrequired>" . $question->options->attachmentsrequired .
1352                         "</attachmentsrequired>\n";
1353                 $expout .= "    <graderinfo " .
1354                         $this->format($question->options->graderinfoformat) . ">\n";
1355                 $expout .= $this->writetext($question->options->graderinfo, 3);
1356                 $expout .= $this->write_files($fs->get_area_files($contextid, 'qtype_essay',
1357                         'graderinfo', $question->id));
1358                 $expout .= "    </graderinfo>\n";
1359                 $expout .= "    <responsetemplate " .
1360                         $this->format($question->options->responsetemplateformat) . ">\n";
1361                 $expout .= $this->writetext($question->options->responsetemplate, 3);
1362                 $expout .= "    </responsetemplate>\n";
1363                 break;
1365             case 'calculated':
1366             case 'calculatedsimple':
1367             case 'calculatedmulti':
1368                 $expout .= "    <synchronize>{$question->options->synchronize}</synchronize>\n";
1369                 $expout .= "    <single>{$question->options->single}</single>\n";
1370                 $expout .= "    <answernumbering>" . $question->options->answernumbering .
1371                         "</answernumbering>\n";
1372                 $expout .= "    <shuffleanswers>" . $question->options->shuffleanswers .
1373                         "</shuffleanswers>\n";
1375                 $component = 'qtype_' . $question->qtype;
1376                 $files = $fs->get_area_files($contextid, $component,
1377                         'correctfeedback', $question->id);
1378                 $expout .= "    <correctfeedback>\n";
1379                 $expout .= $this->writetext($question->options->correctfeedback, 3);
1380                 $expout .= $this->write_files($files);
1381                 $expout .= "    </correctfeedback>\n";
1383                 $files = $fs->get_area_files($contextid, $component,
1384                         'partiallycorrectfeedback', $question->id);
1385                 $expout .= "    <partiallycorrectfeedback>\n";
1386                 $expout .= $this->writetext($question->options->partiallycorrectfeedback, 3);
1387                 $expout .= $this->write_files($files);
1388                 $expout .= "    </partiallycorrectfeedback>\n";
1390                 $files = $fs->get_area_files($contextid, $component,
1391                         'incorrectfeedback', $question->id);
1392                 $expout .= "    <incorrectfeedback>\n";
1393                 $expout .= $this->writetext($question->options->incorrectfeedback, 3);
1394                 $expout .= $this->write_files($files);
1395                 $expout .= "    </incorrectfeedback>\n";
1397                 foreach ($question->options->answers as $answer) {
1398                     $percent = 100 * $answer->fraction;
1399                     $expout .= "<answer fraction=\"{$percent}\">\n";
1400                     // The "<text/>" tags are an added feature, old files won't have them.
1401                     $expout .= "    <text>{$answer->answer}</text>\n";
1402                     $expout .= "    <tolerance>{$answer->tolerance}</tolerance>\n";
1403                     $expout .= "    <tolerancetype>{$answer->tolerancetype}</tolerancetype>\n";
1404                     $expout .= "    <correctanswerformat>" .
1405                             $answer->correctanswerformat . "</correctanswerformat>\n";
1406                     $expout .= "    <correctanswerlength>" .
1407                             $answer->correctanswerlength . "</correctanswerlength>\n";
1408                     $expout .= "    <feedback {$this->format($answer->feedbackformat)}>\n";
1409                     $files = $fs->get_area_files($contextid, $component,
1410                             'instruction', $question->id);
1411                     $expout .= $this->writetext($answer->feedback);
1412                     $expout .= $this->write_files($answer->feedbackfiles);
1413                     $expout .= "    </feedback>\n";
1414                     $expout .= "</answer>\n";
1415                 }
1416                 if (isset($question->options->unitgradingtype)) {
1417                     $expout .= "    <unitgradingtype>" .
1418                             $question->options->unitgradingtype . "</unitgradingtype>\n";
1419                 }
1420                 if (isset($question->options->unitpenalty)) {
1421                     $expout .= "    <unitpenalty>" .
1422                             $question->options->unitpenalty . "</unitpenalty>\n";
1423                 }
1424                 if (isset($question->options->showunits)) {
1425                     $expout .= "    <showunits>{$question->options->showunits}</showunits>\n";
1426                 }
1427                 if (isset($question->options->unitsleft)) {
1428                     $expout .= "    <unitsleft>{$question->options->unitsleft}</unitsleft>\n";
1429                 }
1431                 if (isset($question->options->instructionsformat)) {
1432                     $files = $fs->get_area_files($contextid, $component,
1433                             'instruction', $question->id);
1434                     $expout .= "    <instructions " .
1435                             $this->format($question->options->instructionsformat) . ">\n";
1436                     $expout .= $this->writetext($question->options->instructions, 3);
1437                     $expout .= $this->write_files($files);
1438                     $expout .= "    </instructions>\n";
1439                 }
1441                 if (isset($question->options->units)) {
1442                     $units = $question->options->units;
1443                     if (count($units)) {
1444                         $expout .= "<units>\n";
1445                         foreach ($units as $unit) {
1446                             $expout .= "  <unit>\n";
1447                             $expout .= "    <multiplier>{$unit->multiplier}</multiplier>\n";
1448                             $expout .= "    <unit_name>{$unit->unit}</unit_name>\n";
1449                             $expout .= "  </unit>\n";
1450                         }
1451                         $expout .= "</units>\n";
1452                     }
1453                 }
1455                 // The tag $question->export_process has been set so we get all the
1456                 // data items in the database from the function
1457                 // qtype_calculated::get_question_options calculatedsimple defaults
1458                 // to calculated.
1459                 if (isset($question->options->datasets) && count($question->options->datasets)) {
1460                     $expout .= "<dataset_definitions>\n";
1461                     foreach ($question->options->datasets as $def) {
1462                         $expout .= "<dataset_definition>\n";
1463                         $expout .= "    <status>".$this->writetext($def->status)."</status>\n";
1464                         $expout .= "    <name>".$this->writetext($def->name)."</name>\n";
1465                         if ($question->qtype == 'calculated') {
1466                             $expout .= "    <type>calculated</type>\n";
1467                         } else {
1468                             $expout .= "    <type>calculatedsimple</type>\n";
1469                         }
1470                         $expout .= "    <distribution>" . $this->writetext($def->distribution) .
1471                                 "</distribution>\n";
1472                         $expout .= "    <minimum>" . $this->writetext($def->minimum) .
1473                                 "</minimum>\n";
1474                         $expout .= "    <maximum>" . $this->writetext($def->maximum) .
1475                                 "</maximum>\n";
1476                         $expout .= "    <decimals>" . $this->writetext($def->decimals) .
1477                                 "</decimals>\n";
1478                         $expout .= "    <itemcount>{$def->itemcount}</itemcount>\n";
1479                         if ($def->itemcount > 0) {
1480                             $expout .= "    <dataset_items>\n";
1481                             foreach ($def->items as $item) {
1482                                   $expout .= "        <dataset_item>\n";
1483                                   $expout .= "           <number>".$item->itemnumber."</number>\n";
1484                                   $expout .= "           <value>".$item->value."</value>\n";
1485                                   $expout .= "        </dataset_item>\n";
1486                             }
1487                             $expout .= "    </dataset_items>\n";
1488                             $expout .= "    <number_of_items>" . $def->number_of_items .
1489                                     "</number_of_items>\n";
1490                         }
1491                         $expout .= "</dataset_definition>\n";
1492                     }
1493                     $expout .= "</dataset_definitions>\n";
1494                 }
1495                 break;
1497             default:
1498                 // Try support by optional plugin.
1499                 if (!$data = $this->try_exporting_using_qtypes($question->qtype, $question)) {
1500                     $invalidquestion = true;
1501                 } else {
1502                     $expout .= $data;
1503                 }
1504         }
1506         // Output any hints.
1507         $expout .= $this->write_hints($question);
1509         // Write the question tags.
1510         if (core_tag_tag::is_enabled('core_question', 'question')) {
1511             $tagobjects = core_tag_tag::get_item_tags('core_question', 'question', $question->id);
1513             if (!empty($tagobjects)) {
1514                 $context = context::instance_by_id($contextid);
1515                 $sortedtagobjects = question_sort_tags($tagobjects, $context, [$this->course]);
1517                 if (!empty($sortedtagobjects->coursetags)) {
1518                     // Set them on the form to be rendered as existing tags.
1519                     $expout .= "    <coursetags>\n";
1520                     foreach ($sortedtagobjects->coursetags as $coursetag) {
1521                         $expout .= "      <tag>" . $this->writetext($coursetag, 0, true) . "</tag>\n";
1522                     }
1523                     $expout .= "    </coursetags>\n";
1524                 }
1526                 if (!empty($sortedtagobjects->tags)) {
1527                     $expout .= "    <tags>\n";
1528                     foreach ($sortedtagobjects->tags as $tag) {
1529                         $expout .= "      <tag>" . $this->writetext($tag, 0, true) . "</tag>\n";
1530                     }
1531                     $expout .= "    </tags>\n";
1532                 }
1533             }
1534         }
1536         // Close the question tag.
1537         $expout .= "  </question>\n";
1538         if ($invalidquestion) {
1539             return '';
1540         } else {
1541             return $expout;
1542         }
1543     }
1545     public function write_answers($answers) {
1546         if (empty($answers)) {
1547             return;
1548         }
1549         $output = '';
1550         foreach ($answers as $answer) {
1551             $output .= $this->write_answer($answer);
1552         }
1553         return $output;
1554     }
1556     public function write_answer($answer, $extra = '') {
1557         $percent = $answer->fraction * 100;
1558         $output = '';
1559         $output .= "    <answer fraction=\"{$percent}\" {$this->format($answer->answerformat)}>\n";
1560         $output .= $this->writetext($answer->answer, 3);
1561         $output .= $this->write_files($answer->answerfiles);
1562         $output .= "      <feedback {$this->format($answer->feedbackformat)}>\n";
1563         $output .= $this->writetext($answer->feedback, 4);
1564         $output .= $this->write_files($answer->feedbackfiles);
1565         $output .= "      </feedback>\n";
1566         $output .= $extra;
1567         $output .= "    </answer>\n";
1568         return $output;
1569     }
1571     /**
1572      * Write out the hints.
1573      * @param object $question the question definition data.
1574      * @return string XML to output.
1575      */
1576     public function write_hints($question) {
1577         if (empty($question->hints)) {
1578             return '';
1579         }
1581         $output = '';
1582         foreach ($question->hints as $hint) {
1583             $output .= $this->write_hint($hint, $question->contextid);
1584         }
1585         return $output;
1586     }
1588     /**
1589      * @param int $format a FORMAT_... constant.
1590      * @return string the attribute to add to an XML tag.
1591      */
1592     public function format($format) {
1593         return 'format="' . $this->get_format($format) . '"';
1594     }
1596     public function write_hint($hint, $contextid) {
1597         $fs = get_file_storage();
1598         $files = $fs->get_area_files($contextid, 'question', 'hint', $hint->id);
1600         $output = '';
1601         $output .= "    <hint {$this->format($hint->hintformat)}>\n";
1602         $output .= '      ' . $this->writetext($hint->hint);
1604         if (!empty($hint->shownumcorrect)) {
1605             $output .= "      <shownumcorrect/>\n";
1606         }
1607         if (!empty($hint->clearwrong)) {
1608             $output .= "      <clearwrong/>\n";
1609         }
1611         if (!empty($hint->options)) {
1612             $output .= '      <options>' . $this->xml_escape($hint->options) . "</options>\n";
1613         }
1614         $output .= $this->write_files($files);
1615         $output .= "    </hint>\n";
1616         return $output;
1617     }
1619     /**
1620      * Output the combined feedback fields.
1621      * @param object $questionoptions the question definition data.
1622      * @param int $questionid the question id.
1623      * @param int $contextid the question context id.
1624      * @return string XML to output.
1625      */
1626     public function write_combined_feedback($questionoptions, $questionid, $contextid) {
1627         $fs = get_file_storage();
1628         $output = '';
1630         $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback');
1631         foreach ($fields as $field) {
1632             $formatfield = $field . 'format';
1633             $files = $fs->get_area_files($contextid, 'question', $field, $questionid);
1635             $output .= "    <{$field} {$this->format($questionoptions->$formatfield)}>\n";
1636             $output .= '      ' . $this->writetext($questionoptions->$field);
1637             $output .= $this->write_files($files);
1638             $output .= "    </{$field}>\n";
1639         }
1641         if (!empty($questionoptions->shownumcorrect)) {
1642             $output .= "    <shownumcorrect/>\n";
1643         }
1644         return $output;
1645     }