Merge branch 'MDL-37374_23' of git://github.com/timhunt/moodle into MOODLE_23_STABLE
[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
21  * @subpackage xml
22  * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
27 defined('MOODLE_INTERNAL') || die();
29 require_once($CFG->libdir . '/xmlize.php');
30 if (!class_exists('qformat_default')) {
31     // This is ugly, but this class is also (ab)used by mod/lesson, which defines
32     // a different base class in mod/lesson/format.php. Thefore, we can only
33     // include the proper base class conditionally like this. (We have to include
34     // the base class like this, otherwise it breaks third-party question types.)
35     // This may be reviewd, and a better fix found one day.
36     require_once($CFG->dirroot . '/question/format.php');
37 }
40 /**
41  * Importer for Moodle XML question format.
42  *
43  * See http://docs.moodle.org/en/Moodle_XML_format for a description of the format.
44  *
45  * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
46  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
47  */
48 class qformat_xml extends qformat_default {
50     public function provide_import() {
51         return true;
52     }
54     public function provide_export() {
55         return true;
56     }
58     public function mime_type() {
59         return 'application/xml';
60     }
62     // IMPORT FUNCTIONS START HERE
64     /**
65      * Translate human readable format name
66      * into internal Moodle code number
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         foreach ($xml as $file) {
174             $filerecord = array(
175                 'contextid' => context_user::instance($USER->id)->id,
176                 'component' => 'user',
177                 'filearea'  => 'draft',
178                 'itemid'    => $itemid,
179                 'filepath'  => '/',
180                 'filename'  => $file['@']['name'],
181             );
182             $fs->create_file_from_string($filerecord, base64_decode($file['#']));
183         }
184         return $itemid;
185     }
187     /**
188      * import parts of question common to all types
189      * @param $question array question question array from xml tree
190      * @return object question object
191      */
192     public function import_headers($question) {
193         global $CFG, $USER;
195         // get some error strings
196         $error_noname = get_string('xmlimportnoname', 'qformat_xml');
197         $error_noquestion = get_string('xmlimportnoquestion', 'qformat_xml');
199         // this routine initialises the question object
200         $qo = $this->defaultquestion();
202         // Question name
203         $qo->name = $this->clean_question_name($this->getpath($question,
204                 array('#', 'name', 0, '#', 'text', 0, '#'), '', true,
205                 get_string('xmlimportnoname', 'qformat_xml')));
206         $questiontext = $this->import_text_with_files($question,
207                 array('#', 'questiontext', 0));
208         $qo->questiontext = $questiontext['text'];
209         $qo->questiontextformat = $questiontext['format'];
210         if (!empty($questiontext['itemid'])) {
211             $qo->questiontextitemid = $questiontext['itemid'];
212         }
213         // Backwards compatibility, deal with the old image tag.
214         $filedata = $this->getpath($question, array('#', 'image_base64', '0', '#'), null, false);
215         $filename = $this->getpath($question, array('#', 'image', '0', '#'), null, false);
216         if ($filedata && $filename) {
217             $fs = get_file_storage();
218             if (empty($qo->questiontextitemid)) {
219                 $qo->questiontextitemid = file_get_unused_draft_itemid();
220             }
221             $filename = clean_param(str_replace('/', '_', $filename), PARAM_FILE);
222             $filerecord = array(
223                 'contextid' => context_user::instance($USER->id)->id,
224                 'component' => 'user',
225                 'filearea'  => 'draft',
226                 'itemid'    => $qo->questiontextitemid,
227                 'filepath'  => '/',
228                 'filename'  => $filename,
229             );
230             $fs->create_file_from_string($filerecord, base64_decode($filedata));
231             $qo->questiontext .= ' <img src="@@PLUGINFILE@@/' . $filename . '" />';
232         }
234         // restore files in generalfeedback
235         $generalfeedback = $this->import_text_with_files($question,
236                 array('#', 'generalfeedback', 0), $qo->generalfeedback, $this->get_format($qo->questiontextformat));
237         $qo->generalfeedback = $generalfeedback['text'];
238         $qo->generalfeedbackformat = $generalfeedback['format'];
239         if (!empty($generalfeedback['itemid'])) {
240             $qo->generalfeedbackitemid = $generalfeedback['itemid'];
241         }
243         $qo->defaultmark = $this->getpath($question,
244                 array('#', 'defaultgrade', 0, '#'), $qo->defaultmark);
245         $qo->penalty = $this->getpath($question,
246                 array('#', 'penalty', 0, '#'), $qo->penalty);
248         // Fix problematic rounding from old files:
249         if (abs($qo->penalty - 0.3333333) < 0.005) {
250             $qo->penalty = 0.3333333;
251         }
253         // Read the question tags.
254         if (!empty($CFG->usetags) && array_key_exists('tags', $question['#'])
255                 && !empty($question['#']['tags'][0]['#']['tag'])) {
256             require_once($CFG->dirroot.'/tag/lib.php');
257             $qo->tags = array();
258             foreach ($question['#']['tags'][0]['#']['tag'] as $tagdata) {
259                 $qo->tags[] = $this->getpath($tagdata, array('#', 'text', 0, '#'), '', true);
260             }
261         }
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 files from a node in the XML.
381      * @param array $xml an array of <file> nodes from the the parsed XML.
382      * @return array of things representing files - in the form that save_question expects.
383      */
384     public function import_files($xml) {
385         $files = array();
386         foreach ($xml as $file) {
387             $data = new stdClass();
388             $data->content = $file['#'];
389             $data->encoding = $file['@']['encoding'];
390             $data->name = $file['@']['name'];
391             $files[] = $data;
392         }
393         return $files;
394     }
396     /**
397      * import multiple choice question
398      * @param array question question array from xml tree
399      * @return object question object
400      */
401     public function import_multichoice($question) {
402         // get common parts
403         $qo = $this->import_headers($question);
405         // 'header' parts particular to multichoice
406         $qo->qtype = MULTICHOICE;
407         $single = $this->getpath($question, array('#', 'single', 0, '#'), 'true');
408         $qo->single = $this->trans_single($single);
409         $shuffleanswers = $this->getpath($question,
410                 array('#', 'shuffleanswers', 0, '#'), 'false');
411         $qo->answernumbering = $this->getpath($question,
412                 array('#', 'answernumbering', 0, '#'), 'abc');
413         $qo->shuffleanswers = $this->trans_single($shuffleanswers);
415         // There was a time on the 1.8 branch when it could output an empty
416         // answernumbering tag, so fix up any found.
417         if (empty($qo->answernumbering)) {
418             $qo->answernumbering = 'abc';
419         }
421         // Run through the answers
422         $answers = $question['#']['answer'];
423         $acount = 0;
424         foreach ($answers as $answer) {
425             $ans = $this->import_answer($answer, true, $this->get_format($qo->questiontextformat));
426             $qo->answer[$acount] = $ans->answer;
427             $qo->fraction[$acount] = $ans->fraction;
428             $qo->feedback[$acount] = $ans->feedback;
429             ++$acount;
430         }
432         $this->import_combined_feedback($qo, $question, true);
433         $this->import_hints($qo, $question, true, false, $this->get_format($qo->questiontextformat));
435         return $qo;
436     }
438     /**
439      * Import cloze type question
440      * @param array question question array from xml tree
441      * @return object question object
442      */
443     public function import_multianswer($question) {
444         question_bank::get_qtype('multianswer');
446         $questiontext['text'] = $this->import_text($question['#']['questiontext'][0]['#']['text']);
447         $questiontext['format'] = FORMAT_HTML;
448         $questiontext['itemid'] = '';
449         $qo = qtype_multianswer_extract_question($questiontext);
451         // 'header' parts particular to multianswer
452         $qo->qtype = 'multianswer';
453         $qo->course = $this->course;
454         $qo->generalfeedback = '';
456         $qo->name = $this->clean_question_name($this->import_text($question['#']['name'][0]['#']['text']));
457         $qo->questiontextformat = $questiontext['format'];
458         $qo->questiontext = $qo->questiontext['text'];
459         $itemid = $this->import_files($this->getpath($question,
460                 array('#', 'questiontext', 0, '#', 'file'), array(), false));
461         if (!empty($itemid)) {
462             $qo->questiontextitemid = $itemid;
463         }
464         // Backwards compatibility, deal with the old image tag.
465         $filedata = $this->getpath($question, array('#', 'image_base64', '0', '#'), null, false);
466         $filename = $this->getpath($question, array('#', 'image', '0', '#'), null, false);
467         if ($filedata && $filename) {
468             $data = new stdClass();
469             $data->content = $filedata;
470             $data->encoding = 'base64';
471             // Question file areas don't support subdirs, so convert path to filename if necessary.
472             $data->name = clean_param(str_replace('/', '_', $filename), PARAM_FILE);
473             $qo->questiontextfiles[] = $data;
474             $qo->questiontext .= ' <img src="@@PLUGINFILE@@/' . $data->name . '" />';
475         }
477         // restore files in generalfeedback
478         $generalfeedback = $this->import_text_with_files($question,
479                 array('#', 'generalfeedback', 0), $qo->generalfeedback, $this->get_format($qo->questiontextformat));
480         $qo->generalfeedback = $generalfeedback['text'];
481         $qo->generalfeedbackformat = $generalfeedback['format'];
482         if (!empty($generalfeedback['itemid'])) {
483             $qo->generalfeedbackitemid = $generalfeedback['itemid'];
484         }
486         $qo->penalty = $this->getpath($question,
487                 array('#', 'penalty', 0, '#'), $this->defaultquestion()->penalty);
488         // Fix problematic rounding from old files:
489         if (abs($qo->penalty - 0.3333333) < 0.005) {
490             $qo->penalty = 0.3333333;
491         }
493         $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));
495         return $qo;
496     }
498     /**
499      * Import true/false type question
500      * @param array question question array from xml tree
501      * @return object question object
502      */
503     public function import_truefalse($question) {
504         // get common parts
505         global $OUTPUT;
506         $qo = $this->import_headers($question);
508         // 'header' parts particular to true/false
509         $qo->qtype = TRUEFALSE;
511         // In the past, it used to be assumed that the two answers were in the file
512         // true first, then false. Howevever that was not always true. Now, we
513         // try to match on the answer text, but in old exports, this will be a localised
514         // string, so if we don't find true or false, we fall back to the old system.
515         $first = true;
516         $warning = false;
517         foreach ($question['#']['answer'] as $answer) {
518             $answertext = $this->getpath($answer,
519                     array('#', 'text', 0, '#'), '', true);
520             $feedback = $this->import_text_with_files($answer,
521                     array('#', 'feedback', 0), '', $this->get_format($qo->questiontextformat));
523             if ($answertext != 'true' && $answertext != 'false') {
524                 // Old style file, assume order is true/false.
525                 $warning = true;
526                 if ($first) {
527                     $answertext = 'true';
528                 } else {
529                     $answertext = 'false';
530                 }
531             }
533             if ($answertext == 'true') {
534                 $qo->answer = ($answer['@']['fraction'] == 100);
535                 $qo->correctanswer = $qo->answer;
536                 $qo->feedbacktrue = $feedback;
537             } else {
538                 $qo->answer = ($answer['@']['fraction'] != 100);
539                 $qo->correctanswer = $qo->answer;
540                 $qo->feedbackfalse = $feedback;
541             }
542             $first = false;
543         }
545         if ($warning) {
546             $a = new stdClass();
547             $a->questiontext = $qo->questiontext;
548             $a->answer = get_string($qo->correctanswer ? 'true' : 'false', 'qtype_truefalse');
549             echo $OUTPUT->notification(get_string('truefalseimporterror', 'qformat_xml', $a));
550         }
552         $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));
554         return $qo;
555     }
557     /**
558      * Import short answer type question
559      * @param array question question array from xml tree
560      * @return object question object
561      */
562     public function import_shortanswer($question) {
563         // get common parts
564         $qo = $this->import_headers($question);
566         // header parts particular to shortanswer
567         $qo->qtype = SHORTANSWER;
569         // get usecase
570         $qo->usecase = $this->getpath($question, array('#', 'usecase', 0, '#'), $qo->usecase);
572         // Run through the answers
573         $answers = $question['#']['answer'];
574         $acount = 0;
575         foreach ($answers as $answer) {
576             $ans = $this->import_answer($answer, false, $this->get_format($qo->questiontextformat));
577             $qo->answer[$acount] = $ans->answer['text'];
578             $qo->fraction[$acount] = $ans->fraction;
579             $qo->feedback[$acount] = $ans->feedback;
580             ++$acount;
581         }
583         $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));
585         return $qo;
586     }
588     /**
589      * Import description type question
590      * @param array question question array from xml tree
591      * @return object question object
592      */
593     public function import_description($question) {
594         // get common parts
595         $qo = $this->import_headers($question);
596         // header parts particular to shortanswer
597         $qo->qtype = DESCRIPTION;
598         $qo->defaultmark = 0;
599         $qo->length = 0;
600         return $qo;
601     }
603     /**
604      * Import numerical type question
605      * @param array question question array from xml tree
606      * @return object question object
607      */
608     public function import_numerical($question) {
609         // get common parts
610         $qo = $this->import_headers($question);
612         // header parts particular to numerical
613         $qo->qtype = NUMERICAL;
615         // get answers array
616         $answers = $question['#']['answer'];
617         $qo->answer = array();
618         $qo->feedback = array();
619         $qo->fraction = array();
620         $qo->tolerance = array();
621         foreach ($answers as $answer) {
622             // answer outside of <text> is deprecated
623             $obj = $this->import_answer($answer, false, $this->get_format($qo->questiontextformat));
624             $qo->answer[] = $obj->answer['text'];
625             if (empty($qo->answer)) {
626                 $qo->answer = '*';
627             }
628             $qo->feedback[]  = $obj->feedback;
629             $qo->tolerance[] = $this->getpath($answer, array('#', 'tolerance', 0, '#'), 0);
631             // fraction as a tag is deprecated
632             $fraction = $this->getpath($answer, array('@', 'fraction'), 0) / 100;
633             $qo->fraction[] = $this->getpath($answer,
634                     array('#', 'fraction', 0, '#'), $fraction); // deprecated
635         }
637         // Get the units array
638         $qo->unit = array();
639         $units = $this->getpath($question, array('#', 'units', 0, '#', 'unit'), array());
640         if (!empty($units)) {
641             $qo->multiplier = array();
642             foreach ($units as $unit) {
643                 $qo->multiplier[] = $this->getpath($unit, array('#', 'multiplier', 0, '#'), 1);
644                 $qo->unit[] = $this->getpath($unit, array('#', 'unit_name', 0, '#'), '', true);
645             }
646         }
647         $qo->unitgradingtype = $this->getpath($question, array('#', 'unitgradingtype', 0, '#'), 0);
648         $qo->unitpenalty = $this->getpath($question, array('#', 'unitpenalty', 0, '#'), 0.1);
649         $qo->showunits = $this->getpath($question, array('#', 'showunits', 0, '#'), null);
650         $qo->unitsleft = $this->getpath($question, array('#', 'unitsleft', 0, '#'), 0);
651         $qo->instructions['text'] = '';
652         $qo->instructions['format'] = FORMAT_HTML;
653         $instructions = $this->getpath($question, array('#', 'instructions'), array());
654         if (!empty($instructions)) {
655             $qo->instructions = $this->import_text_with_files($instructions,
656                     array('0'), '', $this->get_format($qo->questiontextformat));
657         }
659         if (is_null($qo->showunits)) {
660             // Set a good default, depending on whether there are any units defined.
661             if (empty($qo->unit)) {
662                 $qo->showunits = 3; // qtype_numerical::UNITNONE;
663             } else {
664                 $qo->showunits = 0; // qtype_numerical::UNITOPTIONAL;
665             }
666         }
668         $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));
670         return $qo;
671     }
673     /**
674      * Import matching type question
675      * @param array question question array from xml tree
676      * @return object question object
677      */
678     public function import_match($question) {
679         // get common parts
680         $qo = $this->import_headers($question);
682         // header parts particular to matching
683         $qo->qtype = 'match';
684         $qo->shuffleanswers = $this->trans_single($this->getpath($question,
685                 array('#', 'shuffleanswers', 0, '#'), 1));
687         // run through subquestions
688         $qo->subquestions = array();
689         $qo->subanswers = array();
690         foreach ($question['#']['subquestion'] as $subqxml) {
691             $qo->subquestions[] = $this->import_text_with_files($subqxml,
692                     array(), '', $this->get_format($qo->questiontextformat));
694             $answers = $this->getpath($subqxml, array('#', 'answer'), array());
695             $qo->subanswers[] = $this->getpath($subqxml,
696                     array('#', 'answer', 0, '#', 'text', 0, '#'), '', true);
697         }
699         $this->import_combined_feedback($qo, $question, true);
700         $this->import_hints($qo, $question, true, false, $this->get_format($qo->questiontextformat));
702         return $qo;
703     }
705     /**
706      * Import essay type question
707      * @param array question question array from xml tree
708      * @return object question object
709      */
710     public function import_essay($question) {
711         // get common parts
712         $qo = $this->import_headers($question);
714         // header parts particular to essay
715         $qo->qtype = ESSAY;
717         $qo->responseformat = $this->getpath($question,
718                 array('#', 'responseformat', 0, '#'), 'editor');
719         $qo->responsefieldlines = $this->getpath($question,
720                 array('#', 'responsefieldlines', 0, '#'), 15);
721         $qo->attachments = $this->getpath($question,
722                 array('#', 'attachments', 0, '#'), 0);
723         $qo->graderinfo = $this->import_text_with_files($question,
724                 array('#', 'graderinfo', 0), '', $this->get_format($qo->questiontextformat));
726         return $qo;
727     }
729     /**
730      * Import a calculated question
731      * @param object $question the imported XML data.
732      */
733     public function import_calculated($question) {
735         // get common parts
736         $qo = $this->import_headers($question);
738         // header parts particular to calculated
739         $qo->qtype = CALCULATED;
740         $qo->synchronize = $this->getpath($question, array('#', 'synchronize', 0, '#'), 0);
741         $single = $this->getpath($question, array('#', 'single', 0, '#'), 'true');
742         $qo->single = $this->trans_single($single);
743         $shuffleanswers = $this->getpath($question, array('#', 'shuffleanswers', 0, '#'), 'false');
744         $qo->answernumbering = $this->getpath($question,
745                 array('#', 'answernumbering', 0, '#'), 'abc');
746         $qo->shuffleanswers = $this->trans_single($shuffleanswers);
748         $this->import_combined_feedback($qo, $question);
750         $qo->unitgradingtype = $this->getpath($question,
751                 array('#', 'unitgradingtype', 0, '#'), 0);
752         $qo->unitpenalty = $this->getpath($question, array('#', 'unitpenalty', 0, '#'), null);
753         $qo->showunits = $this->getpath($question, array('#', 'showunits', 0, '#'), 0);
754         $qo->unitsleft = $this->getpath($question, array('#', 'unitsleft', 0, '#'), 0);
755         $qo->instructions = $this->getpath($question,
756                 array('#', 'instructions', 0, '#', 'text', 0, '#'), '', true);
757         if (!empty($instructions)) {
758             $qo->instructions = $this->import_text_with_files($instructions,
759                     array('0'), '', $this->get_format($qo->questiontextformat));
760         }
762         // get answers array
763         $answers = $question['#']['answer'];
764         $qo->answers = array();
765         $qo->feedback = array();
766         $qo->fraction = array();
767         $qo->tolerance = array();
768         $qo->tolerancetype = array();
769         $qo->correctanswerformat = array();
770         $qo->correctanswerlength = array();
771         $qo->feedback = array();
772         foreach ($answers as $answer) {
773             $ans = $this->import_answer($answer, true, $this->get_format($qo->questiontextformat));
774             // answer outside of <text> is deprecated
775             if (empty($ans->answer['text'])) {
776                 $ans->answer['text'] = '*';
777             }
778             $qo->answers[] = $ans->answer;
779             $qo->feedback[] = $ans->feedback;
780             $qo->tolerance[] = $answer['#']['tolerance'][0]['#'];
781             // fraction as a tag is deprecated
782             if (!empty($answer['#']['fraction'][0]['#'])) {
783                 $qo->fraction[] = $answer['#']['fraction'][0]['#'];
784             } else {
785                 $qo->fraction[] = $answer['@']['fraction'] / 100;
786             }
787             $qo->tolerancetype[] = $answer['#']['tolerancetype'][0]['#'];
788             $qo->correctanswerformat[] = $answer['#']['correctanswerformat'][0]['#'];
789             $qo->correctanswerlength[] = $answer['#']['correctanswerlength'][0]['#'];
790         }
791         // get units array
792         $qo->unit = array();
793         if (isset($question['#']['units'][0]['#']['unit'])) {
794             $units = $question['#']['units'][0]['#']['unit'];
795             $qo->multiplier = array();
796             foreach ($units as $unit) {
797                 $qo->multiplier[] = $unit['#']['multiplier'][0]['#'];
798                 $qo->unit[] = $unit['#']['unit_name'][0]['#'];
799             }
800         }
801         $instructions = $this->getpath($question, array('#', 'instructions'), array());
802         if (!empty($instructions)) {
803             $qo->instructions = $this->import_text_with_files($instructions,
804                     array('0'), '', $this->get_format($qo->questiontextformat));
805         }
807         if (is_null($qo->unitpenalty)) {
808             // Set a good default, depending on whether there are any units defined.
809             if (empty($qo->unit)) {
810                 $qo->showunits = 3; // qtype_numerical::UNITNONE;
811             } else {
812                 $qo->showunits = 0; // qtype_numerical::UNITOPTIONAL;
813             }
814         }
816         $datasets = $question['#']['dataset_definitions'][0]['#']['dataset_definition'];
817         $qo->dataset = array();
818         $qo->datasetindex= 0;
819         foreach ($datasets as $dataset) {
820             $qo->datasetindex++;
821             $qo->dataset[$qo->datasetindex] = new stdClass();
822             $qo->dataset[$qo->datasetindex]->status =
823                     $this->import_text($dataset['#']['status'][0]['#']['text']);
824             $qo->dataset[$qo->datasetindex]->name =
825                     $this->import_text($dataset['#']['name'][0]['#']['text']);
826             $qo->dataset[$qo->datasetindex]->type =
827                     $dataset['#']['type'][0]['#'];
828             $qo->dataset[$qo->datasetindex]->distribution =
829                     $this->import_text($dataset['#']['distribution'][0]['#']['text']);
830             $qo->dataset[$qo->datasetindex]->max =
831                     $this->import_text($dataset['#']['maximum'][0]['#']['text']);
832             $qo->dataset[$qo->datasetindex]->min =
833                     $this->import_text($dataset['#']['minimum'][0]['#']['text']);
834             $qo->dataset[$qo->datasetindex]->length =
835                     $this->import_text($dataset['#']['decimals'][0]['#']['text']);
836             $qo->dataset[$qo->datasetindex]->distribution =
837                     $this->import_text($dataset['#']['distribution'][0]['#']['text']);
838             $qo->dataset[$qo->datasetindex]->itemcount = $dataset['#']['itemcount'][0]['#'];
839             $qo->dataset[$qo->datasetindex]->datasetitem = array();
840             $qo->dataset[$qo->datasetindex]->itemindex = 0;
841             $qo->dataset[$qo->datasetindex]->number_of_items =
842                     $dataset['#']['number_of_items'][0]['#'];
843             $datasetitems = $dataset['#']['dataset_items'][0]['#']['dataset_item'];
844             foreach ($datasetitems as $datasetitem) {
845                 $qo->dataset[$qo->datasetindex]->itemindex++;
846                 $qo->dataset[$qo->datasetindex]->datasetitem[
847                         $qo->dataset[$qo->datasetindex]->itemindex] = new stdClass();
848                 $qo->dataset[$qo->datasetindex]->datasetitem[
849                         $qo->dataset[$qo->datasetindex]->itemindex]->itemnumber =
850                                 $datasetitem['#']['number'][0]['#'];
851                 $qo->dataset[$qo->datasetindex]->datasetitem[
852                         $qo->dataset[$qo->datasetindex]->itemindex]->value =
853                                 $datasetitem['#']['value'][0]['#'];
854             }
855         }
857         $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));
859         return $qo;
860     }
862     /**
863      * This is not a real question type. It's a dummy type used to specify the
864      * import category. The format is:
865      * <question type="category">
866      *     <category>tom/dick/harry</category>
867      * </question>
868      */
869     protected function import_category($question) {
870         $qo = new stdClass();
871         $qo->qtype = 'category';
872         $qo->category = $this->import_text($question['#']['category'][0]['#']['text']);
873         return $qo;
874     }
876     /**
877      * Parse the array of lines into an array of questions
878      * this *could* burn memory - but it won't happen that much
879      * so fingers crossed!
880      * @param array of lines from the input file.
881      * @param stdClass $context
882      * @return array (of objects) question objects.
883      */
884     protected function readquestions($lines) {
885         // We just need it as one big string
886         $lines = implode('', $lines);
888         // This converts xml to big nasty data structure
889         // the 0 means keep white space as it is (important for markdown format)
890         try {
891             $xml = xmlize($lines, 0, 'UTF-8', true);
892         } catch (xml_format_exception $e) {
893             $this->error($e->getMessage(), '');
894             return false;
895         }
896         unset($lines); // No need to keep this in memory.
898         // Set up array to hold all our questions
899         $questions = array();
901         // Iterate through questions
902         foreach ($xml['quiz']['#']['question'] as $question) {
903             $questiontype = $question['@']['type'];
905             if ($questiontype == 'multichoice') {
906                 $qo = $this->import_multichoice($question);
907             } else if ($questiontype == 'truefalse') {
908                 $qo = $this->import_truefalse($question);
909             } else if ($questiontype == 'shortanswer') {
910                 $qo = $this->import_shortanswer($question);
911             } else if ($questiontype == 'numerical') {
912                 $qo = $this->import_numerical($question);
913             } else if ($questiontype == 'description') {
914                 $qo = $this->import_description($question);
915             } else if ($questiontype == 'matching' || $questiontype == 'match') {
916                 $qo = $this->import_match($question);
917             } else if ($questiontype == 'cloze' || $questiontype == 'multianswer') {
918                 $qo = $this->import_multianswer($question);
919             } else if ($questiontype == 'essay') {
920                 $qo = $this->import_essay($question);
921             } else if ($questiontype == 'calculated') {
922                 $qo = $this->import_calculated($question);
923             } else if ($questiontype == 'calculatedsimple') {
924                 $qo = $this->import_calculated($question);
925                 $qo->qtype = 'calculatedsimple';
926             } else if ($questiontype == 'calculatedmulti') {
927                 $qo = $this->import_calculated($question);
928                 $qo->qtype = 'calculatedmulti';
929             } else if ($questiontype == 'category') {
930                 $qo = $this->import_category($question);
932             } else {
933                 // Not a type we handle ourselves. See if the question type wants
934                 // to handle it.
935                 if (!$qo = $this->try_importing_using_qtypes(
936                         $question, null, null, $questiontype)) {
937                     $this->error(get_string('xmltypeunsupported', 'qformat_xml', $questiontype));
938                     $qo = null;
939                 }
940             }
942             // Stick the result in the $questions array
943             if ($qo) {
944                 $questions[] = $qo;
945             }
946         }
947         return $questions;
948     }
950     // EXPORT FUNCTIONS START HERE
952     public function export_file_extension() {
953         return '.xml';
954     }
956     /**
957      * Turn the internal question type name into a human readable form.
958      * (In the past, the code used to use integers internally. Now, it uses
959      * strings, so there is less need for this, but to maintain
960      * backwards-compatibility we change two of the type names.)
961      * @param string $qtype question type plugin name.
962      * @return string $qtype string to use in the file.
963      */
964     protected function get_qtype($qtype) {
965         switch($qtype) {
966             case 'match':
967                 return 'matching';
968             case 'multianswer':
969                 return 'cloze';
970             default:
971                 return $qtype;
972         }
973     }
975     /**
976      * Convert internal Moodle text format code into
977      * human readable form
978      * @param int id internal code
979      * @return string format text
980      */
981     public function get_format($id) {
982         switch($id) {
983             case FORMAT_MOODLE:
984                 return 'moodle_auto_format';
985             case FORMAT_HTML:
986                 return 'html';
987             case FORMAT_PLAIN:
988                 return 'plain_text';
989             case FORMAT_WIKI:
990                 return 'wiki_like';
991             case FORMAT_MARKDOWN:
992                 return 'markdown';
993             default:
994                 return 'unknown';
995         }
996     }
998     /**
999      * Convert internal single question code into
1000      * human readable form
1001      * @param int id single question code
1002      * @return string single question string
1003      */
1004     public function get_single($id) {
1005         switch($id) {
1006             case 0:
1007                 return 'false';
1008             case 1:
1009                 return 'true';
1010             default:
1011                 return 'unknown';
1012         }
1013     }
1015     /**
1016      * Take a string, and wrap it in a CDATA secion, if that is required to make
1017      * the output XML valid.
1018      * @param string $string a string
1019      * @return string the string, wrapped in CDATA if necessary.
1020      */
1021     public function xml_escape($string) {
1022         if (!empty($string) && htmlspecialchars($string) != $string) {
1023             return "<![CDATA[{$string}]]>";
1024         } else {
1025             return $string;
1026         }
1027     }
1029     /**
1030      * Generates <text></text> tags, processing raw text therein
1031      * @param string $raw the content to output.
1032      * @param int $indent the current indent level.
1033      * @param bool $short stick it on one line.
1034      * @return string formatted text.
1035      */
1036     public function writetext($raw, $indent = 0, $short = true) {
1037         $indent = str_repeat('  ', $indent);
1038         $raw = $this->xml_escape($raw);
1040         if ($short) {
1041             $xml = "$indent<text>$raw</text>\n";
1042         } else {
1043             $xml = "$indent<text>\n$raw\n$indent</text>\n";
1044         }
1046         return $xml;
1047     }
1049     /**
1050      * Generte the XML to represent some files.
1051      * @param array of store array of stored_file objects.
1052      * @return string $string the XML.
1053      */
1054     public function write_files($files) {
1055         if (empty($files)) {
1056             return '';
1057         }
1058         $string = '';
1059         foreach ($files as $file) {
1060             if ($file->is_directory()) {
1061                 continue;
1062             }
1063             $string .= '<file name="' . $file->get_filename() . '" encoding="base64">';
1064             $string .= base64_encode($file->get_content());
1065             $string .= '</file>';
1066         }
1067         return $string;
1068     }
1070     protected function presave_process($content) {
1071         // Override to allow us to add xml headers and footers
1072         return '<?xml version="1.0" encoding="UTF-8"?>
1073 <quiz>
1074 ' . $content . '</quiz>';
1075     }
1077     /**
1078      * Turns question into an xml segment
1079      * @param object $question the question data.
1080      * @return string xml segment
1081      */
1082     public function writequestion($question) {
1083         global $CFG, $OUTPUT;
1085         $fs = get_file_storage();
1086         $contextid = $question->contextid;
1087         // Get files used by the questiontext.
1088         $question->questiontextfiles = $fs->get_area_files(
1089                 $contextid, 'question', 'questiontext', $question->id);
1090         // Get files used by the generalfeedback.
1091         $question->generalfeedbackfiles = $fs->get_area_files(
1092                 $contextid, 'question', 'generalfeedback', $question->id);
1093         if (!empty($question->options->answers)) {
1094             foreach ($question->options->answers as $answer) {
1095                 $answer->answerfiles = $fs->get_area_files(
1096                         $contextid, 'question', 'answer', $answer->id);
1097                 $answer->feedbackfiles = $fs->get_area_files(
1098                         $contextid, 'question', 'answerfeedback', $answer->id);
1099             }
1100         }
1102         $expout = '';
1104         // Add a comment linking this to the original question id.
1105         $expout .= "<!-- question: $question->id  -->\n";
1107         // Check question type
1108         $questiontype = $this->get_qtype($question->qtype);
1110         // Categories are a special case.
1111         if ($question->qtype == 'category') {
1112             $categorypath = $this->writetext($question->category);
1113             $expout .= "  <question type=\"category\">\n";
1114             $expout .= "    <category>\n";
1115             $expout .= "        $categorypath\n";
1116             $expout .= "    </category>\n";
1117             $expout .= "  </question>\n";
1118             return $expout;
1119         }
1121         // Now we know we are are handing a real question.
1122         // Output the generic information.
1123         $expout .= "  <question type=\"$questiontype\">\n";
1124         $expout .= "    <name>\n";
1125         $expout .= $this->writetext($question->name, 3);
1126         $expout .= "    </name>\n";
1127         $expout .= "    <questiontext {$this->format($question->questiontextformat)}>\n";
1128         $expout .= $this->writetext($question->questiontext, 3);
1129         $expout .= $this->write_files($question->questiontextfiles);
1130         $expout .= "    </questiontext>\n";
1131         $expout .= "    <generalfeedback {$this->format($question->generalfeedbackformat)}>\n";
1132         $expout .= $this->writetext($question->generalfeedback, 3);
1133         $expout .= $this->write_files($question->generalfeedbackfiles);
1134         $expout .= "    </generalfeedback>\n";
1135         if ($question->qtype != 'multianswer') {
1136             $expout .= "    <defaultgrade>{$question->defaultmark}</defaultgrade>\n";
1137         }
1138         $expout .= "    <penalty>{$question->penalty}</penalty>\n";
1139         $expout .= "    <hidden>{$question->hidden}</hidden>\n";
1141         // The rest of the output depends on question type.
1142         switch($question->qtype) {
1143             case 'category':
1144                 // not a qtype really - dummy used for category switching
1145                 break;
1147             case 'truefalse':
1148                 $trueanswer = $question->options->answers[$question->options->trueanswer];
1149                 $trueanswer->answer = 'true';
1150                 $expout .= $this->write_answer($trueanswer);
1152                 $falseanswer = $question->options->answers[$question->options->falseanswer];
1153                 $falseanswer->answer = 'false';
1154                 $expout .= $this->write_answer($falseanswer);
1155                 break;
1157             case 'multichoice':
1158                 $expout .= "    <single>" . $this->get_single($question->options->single) .
1159                         "</single>\n";
1160                 $expout .= "    <shuffleanswers>" .
1161                         $this->get_single($question->options->shuffleanswers) .
1162                         "</shuffleanswers>\n";
1163                 $expout .= "    <answernumbering>" . $question->options->answernumbering .
1164                         "</answernumbering>\n";
1165                 $expout .= $this->write_combined_feedback($question->options, $question->id, $question->contextid);
1166                 $expout .= $this->write_answers($question->options->answers);
1167                 break;
1169             case 'shortanswer':
1170                 $expout .= "    <usecase>{$question->options->usecase}</usecase>\n";
1171                 $expout .= $this->write_answers($question->options->answers);
1172                 break;
1174             case 'numerical':
1175                 foreach ($question->options->answers as $answer) {
1176                     $expout .= $this->write_answer($answer,
1177                             "      <tolerance>$answer->tolerance</tolerance>\n");
1178                 }
1180                 $units = $question->options->units;
1181                 if (count($units)) {
1182                     $expout .= "<units>\n";
1183                     foreach ($units as $unit) {
1184                         $expout .= "  <unit>\n";
1185                         $expout .= "    <multiplier>{$unit->multiplier}</multiplier>\n";
1186                         $expout .= "    <unit_name>{$unit->unit}</unit_name>\n";
1187                         $expout .= "  </unit>\n";
1188                     }
1189                     $expout .= "</units>\n";
1190                 }
1191                 if (isset($question->options->unitgradingtype)) {
1192                     $expout .= "    <unitgradingtype>" . $question->options->unitgradingtype .
1193                             "</unitgradingtype>\n";
1194                 }
1195                 if (isset($question->options->unitpenalty)) {
1196                     $expout .= "    <unitpenalty>{$question->options->unitpenalty}</unitpenalty>\n";
1197                 }
1198                 if (isset($question->options->showunits)) {
1199                     $expout .= "    <showunits>{$question->options->showunits}</showunits>\n";
1200                 }
1201                 if (isset($question->options->unitsleft)) {
1202                     $expout .= "    <unitsleft>{$question->options->unitsleft}</unitsleft>\n";
1203                 }
1204                 if (!empty($question->options->instructionsformat)) {
1205                     $files = $fs->get_area_files($contextid, 'qtype_numerical',
1206                             'instruction', $question->id);
1207                     $expout .= "    <instructions " .
1208                             $this->format($question->options->instructionsformat) . ">\n";
1209                     $expout .= $this->writetext($question->options->instructions, 3);
1210                     $expout .= $this->write_files($files);
1211                     $expout .= "    </instructions>\n";
1212                 }
1213                 break;
1215             case 'match':
1216                 $expout .= "    <shuffleanswers>" .
1217                         $this->get_single($question->options->shuffleanswers) .
1218                         "</shuffleanswers>\n";
1219                 $expout .= $this->write_combined_feedback($question->options, $question->id, $question->contextid);
1220                 foreach ($question->options->subquestions as $subquestion) {
1221                     $files = $fs->get_area_files($contextid, 'qtype_match',
1222                             'subquestion', $subquestion->id);
1223                     $expout .= "    <subquestion " .
1224                             $this->format($subquestion->questiontextformat) . ">\n";
1225                     $expout .= $this->writetext($subquestion->questiontext, 3);
1226                     $expout .= $this->write_files($files);
1227                     $expout .= "      <answer>\n";
1228                     $expout .= $this->writetext($subquestion->answertext, 4);
1229                     $expout .= "      </answer>\n";
1230                     $expout .= "    </subquestion>\n";
1231                 }
1232                 break;
1234             case 'description':
1235                 // Nothing else to do.
1236                 break;
1238             case 'multianswer':
1239                 foreach ($question->options->questions as $index => $subq) {
1240                     $expout = preg_replace('~{#' . $index . '}~', $subq->questiontext, $expout);
1241                 }
1242                 break;
1244             case 'essay':
1245                 $expout .= "    <responseformat>" . $question->options->responseformat .
1246                         "</responseformat>\n";
1247                 $expout .= "    <responsefieldlines>" . $question->options->responsefieldlines .
1248                         "</responsefieldlines>\n";
1249                 $expout .= "    <attachments>" . $question->options->attachments .
1250                         "</attachments>\n";
1251                 $expout .= "    <graderinfo " .
1252                         $this->format($question->options->graderinfoformat) . ">\n";
1253                 $expout .= $this->writetext($question->options->graderinfo, 3);
1254                 $expout .= $this->write_files($fs->get_area_files($contextid, 'qtype_essay',
1255                         'graderinfo', $question->id));
1256                 $expout .= "    </graderinfo>\n";
1257                 break;
1259             case 'calculated':
1260             case 'calculatedsimple':
1261             case 'calculatedmulti':
1262                 $expout .= "    <synchronize>{$question->options->synchronize}</synchronize>\n";
1263                 $expout .= "    <single>{$question->options->single}</single>\n";
1264                 $expout .= "    <answernumbering>" . $question->options->answernumbering .
1265                         "</answernumbering>\n";
1266                 $expout .= "    <shuffleanswers>" . $question->options->shuffleanswers .
1267                         "</shuffleanswers>\n";
1269                 $component = 'qtype_' . $question->qtype;
1270                 $files = $fs->get_area_files($contextid, $component,
1271                         'correctfeedback', $question->id);
1272                 $expout .= "    <correctfeedback>\n";
1273                 $expout .= $this->writetext($question->options->correctfeedback, 3);
1274                 $expout .= $this->write_files($files);
1275                 $expout .= "    </correctfeedback>\n";
1277                 $files = $fs->get_area_files($contextid, $component,
1278                         'partiallycorrectfeedback', $question->id);
1279                 $expout .= "    <partiallycorrectfeedback>\n";
1280                 $expout .= $this->writetext($question->options->partiallycorrectfeedback, 3);
1281                 $expout .= $this->write_files($files);
1282                 $expout .= "    </partiallycorrectfeedback>\n";
1284                 $files = $fs->get_area_files($contextid, $component,
1285                         'incorrectfeedback', $question->id);
1286                 $expout .= "    <incorrectfeedback>\n";
1287                 $expout .= $this->writetext($question->options->incorrectfeedback, 3);
1288                 $expout .= $this->write_files($files);
1289                 $expout .= "    </incorrectfeedback>\n";
1291                 foreach ($question->options->answers as $answer) {
1292                     $percent = 100 * $answer->fraction;
1293                     $expout .= "<answer fraction=\"$percent\">\n";
1294                     // "<text/>" tags are an added feature, old files won't have them
1295                     $expout .= "    <text>{$answer->answer}</text>\n";
1296                     $expout .= "    <tolerance>{$answer->tolerance}</tolerance>\n";
1297                     $expout .= "    <tolerancetype>{$answer->tolerancetype}</tolerancetype>\n";
1298                     $expout .= "    <correctanswerformat>" .
1299                             $answer->correctanswerformat . "</correctanswerformat>\n";
1300                     $expout .= "    <correctanswerlength>" .
1301                             $answer->correctanswerlength . "</correctanswerlength>\n";
1302                     $expout .= "    <feedback {$this->format($answer->feedbackformat)}>\n";
1303                     $files = $fs->get_area_files($contextid, $component,
1304                             'instruction', $question->id);
1305                     $expout .= $this->writetext($answer->feedback);
1306                     $expout .= $this->write_files($answer->feedbackfiles);
1307                     $expout .= "    </feedback>\n";
1308                     $expout .= "</answer>\n";
1309                 }
1310                 if (isset($question->options->unitgradingtype)) {
1311                     $expout .= "    <unitgradingtype>" .
1312                             $question->options->unitgradingtype . "</unitgradingtype>\n";
1313                 }
1314                 if (isset($question->options->unitpenalty)) {
1315                     $expout .= "    <unitpenalty>" .
1316                             $question->options->unitpenalty . "</unitpenalty>\n";
1317                 }
1318                 if (isset($question->options->showunits)) {
1319                     $expout .= "    <showunits>{$question->options->showunits}</showunits>\n";
1320                 }
1321                 if (isset($question->options->unitsleft)) {
1322                     $expout .= "    <unitsleft>{$question->options->unitsleft}</unitsleft>\n";
1323                 }
1325                 if (isset($question->options->instructionsformat)) {
1326                     $files = $fs->get_area_files($contextid, $component,
1327                             'instruction', $question->id);
1328                     $expout .= "    <instructions " .
1329                             $this->format($question->options->instructionsformat) . ">\n";
1330                     $expout .= $this->writetext($question->options->instructions, 3);
1331                     $expout .= $this->write_files($files);
1332                     $expout .= "    </instructions>\n";
1333                 }
1335                 if (isset($question->options->units)) {
1336                     $units = $question->options->units;
1337                     if (count($units)) {
1338                         $expout .= "<units>\n";
1339                         foreach ($units as $unit) {
1340                             $expout .= "  <unit>\n";
1341                             $expout .= "    <multiplier>{$unit->multiplier}</multiplier>\n";
1342                             $expout .= "    <unit_name>{$unit->unit}</unit_name>\n";
1343                             $expout .= "  </unit>\n";
1344                         }
1345                         $expout .= "</units>\n";
1346                     }
1347                 }
1349                 // The tag $question->export_process has been set so we get all the
1350                 // data items in the database from the function
1351                 // qtype_calculated::get_question_options calculatedsimple defaults
1352                 // to calculated
1353                 if (isset($question->options->datasets) && count($question->options->datasets)) {
1354                     $expout .= "<dataset_definitions>\n";
1355                     foreach ($question->options->datasets as $def) {
1356                         $expout .= "<dataset_definition>\n";
1357                         $expout .= "    <status>".$this->writetext($def->status)."</status>\n";
1358                         $expout .= "    <name>".$this->writetext($def->name)."</name>\n";
1359                         if ($question->qtype == CALCULATED) {
1360                             $expout .= "    <type>calculated</type>\n";
1361                         } else {
1362                             $expout .= "    <type>calculatedsimple</type>\n";
1363                         }
1364                         $expout .= "    <distribution>" . $this->writetext($def->distribution) .
1365                                 "</distribution>\n";
1366                         $expout .= "    <minimum>" . $this->writetext($def->minimum) .
1367                                 "</minimum>\n";
1368                         $expout .= "    <maximum>" . $this->writetext($def->maximum) .
1369                                 "</maximum>\n";
1370                         $expout .= "    <decimals>" . $this->writetext($def->decimals) .
1371                                 "</decimals>\n";
1372                         $expout .= "    <itemcount>$def->itemcount</itemcount>\n";
1373                         if ($def->itemcount > 0) {
1374                             $expout .= "    <dataset_items>\n";
1375                             foreach ($def->items as $item) {
1376                                   $expout .= "        <dataset_item>\n";
1377                                   $expout .= "           <number>".$item->itemnumber."</number>\n";
1378                                   $expout .= "           <value>".$item->value."</value>\n";
1379                                   $expout .= "        </dataset_item>\n";
1380                             }
1381                             $expout .= "    </dataset_items>\n";
1382                             $expout .= "    <number_of_items>" . $def->number_of_items .
1383                                     "</number_of_items>\n";
1384                         }
1385                         $expout .= "</dataset_definition>\n";
1386                     }
1387                     $expout .= "</dataset_definitions>\n";
1388                 }
1389                 break;
1391             default:
1392                 // try support by optional plugin
1393                 if (!$data = $this->try_exporting_using_qtypes($question->qtype, $question)) {
1394                     notify(get_string('unsupportedexport', 'qformat_xml', $question->qtype));
1395                 }
1396                 $expout .= $data;
1397         }
1399         // Output any hints.
1400         $expout .= $this->write_hints($question);
1402         // Write the question tags.
1403         if (!empty($CFG->usetags)) {
1404             require_once($CFG->dirroot.'/tag/lib.php');
1405             $tags = tag_get_tags_array('question', $question->id);
1406             if (!empty($tags)) {
1407                 $expout .= "    <tags>\n";
1408                 foreach ($tags as $tag) {
1409                     $expout .= "      <tag>" . $this->writetext($tag, 0, true) . "</tag>\n";
1410                 }
1411                 $expout .= "    </tags>\n";
1412             }
1413         }
1415         // close the question tag
1416         $expout .= "  </question>\n";
1418         return $expout;
1419     }
1421     public function write_answers($answers) {
1422         if (empty($answers)) {
1423             return;
1424         }
1425         $output = '';
1426         foreach ($answers as $answer) {
1427             $output .= $this->write_answer($answer);
1428         }
1429         return $output;
1430     }
1432     public function write_answer($answer, $extra = '') {
1433         $percent = $answer->fraction * 100;
1434         $output = '';
1435         $output .= "    <answer fraction=\"$percent\" {$this->format($answer->answerformat)}>\n";
1436         $output .= $this->writetext($answer->answer, 3);
1437         $output .= $this->write_files($answer->answerfiles);
1438         $output .= "      <feedback {$this->format($answer->feedbackformat)}>\n";
1439         $output .= $this->writetext($answer->feedback, 4);
1440         $output .= $this->write_files($answer->feedbackfiles);
1441         $output .= "      </feedback>\n";
1442         $output .= $extra;
1443         $output .= "    </answer>\n";
1444         return $output;
1445     }
1447     /**
1448      * Write out the hints.
1449      * @param object $question the question definition data.
1450      * @return string XML to output.
1451      */
1452     public function write_hints($question) {
1453         if (empty($question->hints)) {
1454             return '';
1455         }
1457         $output = '';
1458         foreach ($question->hints as $hint) {
1459             $output .= $this->write_hint($hint, $question->contextid);
1460         }
1461         return $output;
1462     }
1464     /**
1465      * @param int $format a FORMAT_... constant.
1466      * @return string the attribute to add to an XML tag.
1467      */
1468     public function format($format) {
1469         return 'format="' . $this->get_format($format) . '"';
1470     }
1472     public function write_hint($hint, $contextid) {
1473         $fs = get_file_storage();
1474         $files = $fs->get_area_files($contextid, 'question', 'hint', $hint->id);
1476         $output = '';
1477         $output .= "    <hint {$this->format($hint->hintformat)}>\n";
1478         $output .= '      ' . $this->writetext($hint->hint);
1480         if (!empty($hint->shownumcorrect)) {
1481             $output .= "      <shownumcorrect/>\n";
1482         }
1483         if (!empty($hint->clearwrong)) {
1484             $output .= "      <clearwrong/>\n";
1485         }
1487         if (!empty($hint->options)) {
1488             $output .= '      <options>' . $this->xml_escape($hint->options) . "</options>\n";
1489         }
1490         $output .= $this->write_files($files);
1491         $output .= "    </hint>\n";
1492         return $output;
1493     }
1495     /**
1496      * Output the combined feedback fields.
1497      * @param object $questionoptions the question definition data.
1498      * @param int $questionid the question id.
1499      * @param int $contextid the question context id.
1500      * @return string XML to output.
1501      */
1502     public function write_combined_feedback($questionoptions, $questionid, $contextid) {
1503         $fs = get_file_storage();
1504         $output = '';
1506         $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback');
1507         foreach ($fields as $field) {
1508             $formatfield = $field . 'format';
1509             $files = $fs->get_area_files($contextid, 'question', $field, $questionid);
1511             $output .= "    <{$field} {$this->format($questionoptions->$formatfield)}>\n";
1512             $output .= '      ' . $this->writetext($questionoptions->$field);
1513             $output .= $this->write_files($files);
1514             $output .= "    </{$field}>\n";
1515         }
1517         if (!empty($questionoptions->shownumcorrect)) {
1518             $output .= "    <shownumcorrect/>\n";
1519         }
1520         return $output;
1521     }