Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Code for exporting questions as Moodle XML.
 *
 * @package    qformat_xml
 * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */


defined('MOODLE_INTERNAL') || die();

require_once($CFG->libdir . '/xmlize.php');
if (!class_exists('qformat_default')) {
    // This is ugly, but this class is also (ab)used by mod/lesson, which defines
    // a different base class in mod/lesson/format.php. Thefore, we can only
    // include the proper base class conditionally like this. (We have to include
    // the base class like this, otherwise it breaks third-party question types.)
    // This may be reviewd, and a better fix found one day.
    require_once($CFG->dirroot . '/question/format.php');
}


/**
 * Importer for Moodle XML question format.
 *
 * See http://docs.moodle.org/en/Moodle_XML_format for a description of the format.
 *
 * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class qformat_xml extends qformat_default {

    public function provide_import() {
        return true;
    }

    public function provide_export() {
        return true;
    }

    public function mime_type() {
        return 'application/xml';
    }

    // IMPORT FUNCTIONS START HERE.

    /**
     * Translate human readable format name
     * into internal Moodle code number
     * Note the reverse function is called get_format.
     * @param string name format name from xml file
     * @return int Moodle format code
     */
    public function trans_format($name) {
        $name = trim($name);

        if ($name == 'moodle_auto_format') {
            return FORMAT_MOODLE;
        } else if ($name == 'html') {
            return FORMAT_HTML;
        } else if ($name == 'plain_text') {
            return FORMAT_PLAIN;
        } else if ($name == 'wiki_like') {
            return FORMAT_WIKI;
        } else if ($name == 'markdown') {
            return FORMAT_MARKDOWN;
        } else {
            debugging("Unrecognised text format '{$name}' in the import file. Assuming 'html'.");
            return FORMAT_HTML;
        }
    }

    /**
     * Translate human readable single answer option
     * to internal code number
     * @param string name true/false
     * @return int internal code number
     */
    public function trans_single($name) {
        $name = trim($name);
        if ($name == "false" || !$name) {
            return 0;
        } else {
            return 1;
        }
    }

    /**
     * process text string from xml file
     * @param array $text bit of xml tree after ['text']
     * @return string processed text.
     */
    public function import_text($text) {
        // Quick sanity check.
        if (empty($text)) {
            return '';
        }
        $data = $text[0]['#'];
        return trim($data);
    }

    /**
     * return the value of a node, given a path to the node
     * if it doesn't exist return the default value
     * @param array xml data to read
     * @param array path path to node expressed as array
     * @param mixed default
     * @param bool istext process as text
     * @param string error if set value must exist, return false and issue message if not
     * @return mixed value
     */
    public function getpath($xml, $path, $default, $istext=false, $error='') {
        foreach ($path as $index) {
            if (!isset($xml[$index])) {
                if (!empty($error)) {
                    $this->error($error);
                    return false;
                } else {
                    return $default;
                }
            }

            $xml = $xml[$index];
        }

        if ($istext) {
            if (!is_string($xml)) {
                $this->error(get_string('invalidxml', 'qformat_xml'));
            }
            $xml = trim($xml);
        }

        return $xml;
    }

    public function import_text_with_files($data, $path, $defaultvalue = '', $defaultformat = 'html') {
        $field  = array();
        $field['text'] = $this->getpath($data,
                array_merge($path, array('#', 'text', 0, '#')), $defaultvalue, true);
        $field['format'] = $this->trans_format($this->getpath($data,
                array_merge($path, array('@', 'format')), $defaultformat));
        $itemid = $this->import_files_as_draft($this->getpath($data,
                array_merge($path, array('#', 'file')), array(), false));
        if (!empty($itemid)) {
            $field['itemid'] = $itemid;
        }
        return $field;
    }

    public function import_files_as_draft($xml) {
        global $USER;
        if (empty($xml)) {
            return null;
        }
        $fs = get_file_storage();
        $itemid = file_get_unused_draft_itemid();
        $filepaths = array();
        foreach ($xml as $file) {
            $filename = $this->getpath($file, array('@', 'name'), '', true);
            $filepath = $this->getpath($file, array('@', 'path'), '/', true);
            $fullpath = $filepath . $filename;
            if (in_array($fullpath, $filepaths)) {
                debugging('Duplicate file in XML: ' . $fullpath, DEBUG_DEVELOPER);
                continue;
            }
            $filerecord = array(
                'contextid' => context_user::instance($USER->id)->id,
                'component' => 'user',
                'filearea'  => 'draft',
                'itemid'    => $itemid,
                'filepath'  => $filepath,
                'filename'  => $filename,
            );
            $fs->create_file_from_string($filerecord, base64_decode($file['#']));
            $filepaths[] = $fullpath;
        }
        return $itemid;
    }

    /**
     * import parts of question common to all types
     * @param $question array question question array from xml tree
     * @return object question object
     */
    public function import_headers($question) {
        global $USER;

        // This routine initialises the question object.
        $qo = $this->defaultquestion();

        // Question name.
        $qo->name = $this->clean_question_name($this->getpath($question,
                array('#', 'name', 0, '#', 'text', 0, '#'), '', true,
                get_string('xmlimportnoname', 'qformat_xml')));
        $questiontext = $this->import_text_with_files($question,
                array('#', 'questiontext', 0));
        $qo->questiontext = $questiontext['text'];
        $qo->questiontextformat = $questiontext['format'];
        if (!empty($questiontext['itemid'])) {
            $qo->questiontextitemid = $questiontext['itemid'];
        }
        // Backwards compatibility, deal with the old image tag.
        $filedata = $this->getpath($question, array('#', 'image_base64', '0', '#'), null, false);
        $filename = $this->getpath($question, array('#', 'image', '0', '#'), null, false);
        if ($filedata && $filename) {
            $fs = get_file_storage();
            if (empty($qo->questiontextitemid)) {
                $qo->questiontextitemid = file_get_unused_draft_itemid();
            }
            $filename = clean_param(str_replace('/', '_', $filename), PARAM_FILE);
            $filerecord = array(
                'contextid' => context_user::instance($USER->id)->id,
                'component' => 'user',
                'filearea'  => 'draft',
                'itemid'    => $qo->questiontextitemid,
                'filepath'  => '/',
                'filename'  => $filename,
            );
            $fs->create_file_from_string($filerecord, base64_decode($filedata));
            $qo->questiontext .= ' <img src="@@PLUGINFILE@@/' . $filename . '" />';
        }

        $qo->idnumber = $this->getpath($question, ['#', 'idnumber', 0, '#'], null);

        // Restore files in generalfeedback.
        $generalfeedback = $this->import_text_with_files($question,
                array('#', 'generalfeedback', 0), '', $this->get_format($qo->questiontextformat));
        $qo->generalfeedback = $generalfeedback['text'];
        $qo->generalfeedbackformat = $generalfeedback['format'];
        if (!empty($generalfeedback['itemid'])) {
            $qo->generalfeedbackitemid = $generalfeedback['itemid'];
        }

        $qo->defaultmark = $this->getpath($question,
                array('#', 'defaultgrade', 0, '#'), $qo->defaultmark);
        $qo->penalty = $this->getpath($question,
                array('#', 'penalty', 0, '#'), $qo->penalty);

        // Fix problematic rounding from old files.
        if (abs($qo->penalty - 0.3333333) < 0.005) {
            $qo->penalty = 0.3333333;
        }

        // Read the question tags.
        $this->import_question_tags($qo, $question);

        return $qo;
    }

    /**
     * Import the common parts of a single answer
     * @param array answer xml tree for single answer
     * @param bool $withanswerfiles if true, the answers are HTML (or $defaultformat)
     *      and so may contain files, otherwise the answers are plain text.
     * @param array Default text format for the feedback, and the answers if $withanswerfiles
     *      is true.
     * @return object answer object
     */
    public function import_answer($answer, $withanswerfiles = false, $defaultformat = 'html') {
        $ans = new stdClass();

        if ($withanswerfiles) {
            $ans->answer = $this->import_text_with_files($answer, array(), '', $defaultformat);
        } else {
            $ans->answer = array();
            $ans->answer['text']   = $this->getpath($answer, array('#', 'text', 0, '#'), '', true);
            $ans->answer['format'] = FORMAT_PLAIN;
        }

        $ans->feedback = $this->import_text_with_files($answer, array('#', 'feedback', 0), '', $defaultformat);

        $ans->fraction = $this->getpath($answer, array('@', 'fraction'), 0) / 100;

        return $ans;
    }

    /**
     * Import the common overall feedback fields.
     * @param object $question the part of the XML relating to this question.
     * @param object $qo the question data to add the fields to.
     * @param bool $withshownumpartscorrect include the shownumcorrect field.
     */
    public function import_combined_feedback($qo, $questionxml, $withshownumpartscorrect = false) {
        $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback');
        foreach ($fields as $field) {
            $qo->$field = $this->import_text_with_files($questionxml,
                    array('#', $field, 0), '', $this->get_format($qo->questiontextformat));
        }

        if ($withshownumpartscorrect) {
            $qo->shownumcorrect = array_key_exists('shownumcorrect', $questionxml['#']);

            // Backwards compatibility.
            if (array_key_exists('correctresponsesfeedback', $questionxml['#'])) {
                $qo->shownumcorrect = $this->trans_single($this->getpath($questionxml,
                        array('#', 'correctresponsesfeedback', 0, '#'), 1));
            }
        }
    }

    /**
     * Import a question hint
     * @param array $hintxml hint xml fragment.
     * @param string $defaultformat the text format to assume for hints that do not specify.
     * @return object hint for storing in the database.
     */
    public function import_hint($hintxml, $defaultformat) {
        $hint = new stdClass();
        if (array_key_exists('hintcontent', $hintxml['#'])) {
            // Backwards compatibility.

            $hint->hint = $this->import_text_with_files($hintxml,
                    array('#', 'hintcontent', 0), '', $defaultformat);

            $hint->shownumcorrect = $this->getpath($hintxml,
                    array('#', 'statenumberofcorrectresponses', 0, '#'), 0);
            $hint->clearwrong = $this->getpath($hintxml,
                    array('#', 'clearincorrectresponses', 0, '#'), 0);
            $hint->options = $this->getpath($hintxml,
                    array('#', 'showfeedbacktoresponses', 0, '#'), 0);

            return $hint;
        }
        $hint->hint = $this->import_text_with_files($hintxml, array(), '', $defaultformat);
        $hint->shownumcorrect = array_key_exists('shownumcorrect', $hintxml['#']);
        $hint->clearwrong = array_key_exists('clearwrong', $hintxml['#']);
        $hint->options = $this->getpath($hintxml, array('#', 'options', 0, '#'), '', true);

        return $hint;
    }

    /**
     * Import all the question hints
     *
     * @param object $qo the question data that is being constructed.
     * @param array $questionxml The xml representing the question.
     * @param bool $withparts whether the extra fields relating to parts should be imported.
     * @param bool $withoptions whether the extra options field should be imported.
     * @param string $defaultformat the text format to assume for hints that do not specify.
     * @return array of objects representing the hints in the file.
     */
    public function import_hints($qo, $questionxml, $withparts = false,
            $withoptions = false, $defaultformat = 'html') {
        if (!isset($questionxml['#']['hint'])) {
            return;
        }

        foreach ($questionxml['#']['hint'] as $hintxml) {
            $hint = $this->import_hint($hintxml, $defaultformat);
            $qo->hint[] = $hint->hint;

            if ($withparts) {
                $qo->hintshownumcorrect[] = $hint->shownumcorrect;
                $qo->hintclearwrong[] = $hint->clearwrong;
            }

            if ($withoptions) {
                $qo->hintoptions[] = $hint->options;
            }
        }
    }

    /**
     * Import all the question tags
     *
     * @param object $qo the question data that is being constructed.
     * @param array $questionxml The xml representing the question.
     * @return array of objects representing the tags in the file.
     */
    public function import_question_tags($qo, $questionxml) {
        global $CFG;

        if (core_tag_tag::is_enabled('core_question', 'question')) {

            $qo->tags = [];
            if (!empty($questionxml['#']['tags'][0]['#']['tag'])) {
                foreach ($questionxml['#']['tags'][0]['#']['tag'] as $tagdata) {
                    $qo->tags[] = $this->getpath($tagdata, array('#', 'text', 0, '#'), '', true);
                }
            }

            $qo->coursetags = [];
            if (!empty($questionxml['#']['coursetags'][0]['#']['tag'])) {
                foreach ($questionxml['#']['coursetags'][0]['#']['tag'] as $tagdata) {
                    $qo->coursetags[] = $this->getpath($tagdata, array('#', 'text', 0, '#'), '', true);
                }
            }
        }
    }

    /**
     * Import files from a node in the XML.
     * @param array $xml an array of <file> nodes from the the parsed XML.
     * @return array of things representing files - in the form that save_question expects.
     */
    public function import_files($xml) {
        $files = array();
        foreach ($xml as $file) {
            $data = new stdClass();
            $data->content = $file['#'];
            $data->encoding = $file['@']['encoding'];
            $data->name = $file['@']['name'];
            $files[] = $data;
        }
        return $files;
    }

    /**
     * import multiple choice question
     * @param array question question array from xml tree
     * @return object question object
     */
    public function import_multichoice($question) {
        // Get common parts.
        $qo = $this->import_headers($question);

        // Header parts particular to multichoice.
        $qo->qtype = 'multichoice';
        $single = $this->getpath($question, array('#', 'single', 0, '#'), 'true');
        $qo->single = $this->trans_single($single);
        $shuffleanswers = $this->getpath($question,
                array('#', 'shuffleanswers', 0, '#'), 'false');
        $qo->answernumbering = $this->getpath($question,
                array('#', 'answernumbering', 0, '#'), 'abc');
        $qo->shuffleanswers = $this->trans_single($shuffleanswers);
        $qo->showstandardinstruction = $this->getpath($question,
            array('#', 'showstandardinstruction', 0, '#'), '1');

        // There was a time on the 1.8 branch when it could output an empty
        // answernumbering tag, so fix up any found.
        if (empty($qo->answernumbering)) {
            $qo->answernumbering = 'abc';
        }

        // Run through the answers.
        $answers = $question['#']['answer'];
        $acount = 0;
        foreach ($answers as $answer) {
            $ans = $this->import_answer($answer, true, $this->get_format($qo->questiontextformat));
            $qo->answer[$acount] = $ans->answer;
            $qo->fraction[$acount] = $ans->fraction;
            $qo->feedback[$acount] = $ans->feedback;
            ++$acount;
        }

        $this->import_combined_feedback($qo, $question, true);
        $this->import_hints($qo, $question, true, false, $this->get_format($qo->questiontextformat));

        return $qo;
    }

    /**
     * Import cloze type question
     * @param array question question array from xml tree
     * @return object question object
     */
    public function import_multianswer($question) {
        global $USER;
        question_bank::get_qtype('multianswer');

        $questiontext = $this->import_text_with_files($question,
                array('#', 'questiontext', 0));
        $qo = qtype_multianswer_extract_question($questiontext);
        $errors = qtype_multianswer_validate_question($qo);
        if ($errors) {
            $this->error(get_string('invalidmultianswerquestion', 'qtype_multianswer', implode(' ', $errors)));
            return null;
        }

        // Header parts particular to multianswer.
        $qo->qtype = 'multianswer';

        // Only set the course if the data is available.
        if (isset($this->course)) {
            $qo->course = $this->course;
        }
        if (isset($question['#']['name'])) {
            $qo->name = $this->clean_question_name($this->import_text($question['#']['name'][0]['#']['text']));
        } else {
            $qo->name = $this->create_default_question_name($qo->questiontext['text'],
                    get_string('questionname', 'question'));
        }
        $qo->questiontextformat = $questiontext['format'];
        $qo->questiontext = $qo->questiontext['text'];
        if (!empty($questiontext['itemid'])) {
            $qo->questiontextitemid = $questiontext['itemid'];
        }

        // Backwards compatibility, deal with the old image tag.
        $filedata = $this->getpath($question, array('#', 'image_base64', '0', '#'), null, false);
        $filename = $this->getpath($question, array('#', 'image', '0', '#'), null, false);
        if ($filedata && $filename) {
            $fs = get_file_storage();
            if (empty($qo->questiontextitemid)) {
                $qo->questiontextitemid = file_get_unused_draft_itemid();
            }
            $filename = clean_param(str_replace('/', '_', $filename), PARAM_FILE);
            $filerecord = array(
                'contextid' => context_user::instance($USER->id)->id,
                'component' => 'user',
                'filearea'  => 'draft',
                'itemid'    => $qo->questiontextitemid,
                'filepath'  => '/',
                'filename'  => $filename,
            );
            $fs->create_file_from_string($filerecord, base64_decode($filedata));
            $qo->questiontext .= ' <img src="@@PLUGINFILE@@/' . $filename . '" />';
        }

        // Restore files in generalfeedback.
        $generalfeedback = $this->import_text_with_files($question,
                array('#', 'generalfeedback', 0), '', $this->get_format($qo->questiontextformat));
        $qo->generalfeedback = $generalfeedback['text'];
        $qo->generalfeedbackformat = $generalfeedback['format'];
        if (!empty($generalfeedback['itemid'])) {
            $qo->generalfeedbackitemid = $generalfeedback['itemid'];
        }

        $qo->penalty = $this->getpath($question,
                array('#', 'penalty', 0, '#'), $this->defaultquestion()->penalty);
        // Fix problematic rounding from old files.
        if (abs($qo->penalty - 0.3333333) < 0.005) {
            $qo->penalty = 0.3333333;
        }

        $this->import_hints($qo, $question, true, false, $this->get_format($qo->questiontextformat));
        $this->import_question_tags($qo, $question);

        return $qo;
    }

    /**
     * Import true/false type question
     * @param array question question array from xml tree
     * @return object question object
     */
    public function import_truefalse($question) {
        // Get common parts.
        global $OUTPUT;
        $qo = $this->import_headers($question);

        // Header parts particular to true/false.
        $qo->qtype = 'truefalse';

        // In the past, it used to be assumed that the two answers were in the file
        // true first, then false. Howevever that was not always true. Now, we
        // try to match on the answer text, but in old exports, this will be a localised
        // string, so if we don't find true or false, we fall back to the old system.
        $first = true;
        $warning = false;
        foreach ($question['#']['answer'] as $answer) {
            $answertext = $this->getpath($answer,
                    array('#', 'text', 0, '#'), '', true);
            $feedback = $this->import_text_with_files($answer,
                    array('#', 'feedback', 0), '', $this->get_format($qo->questiontextformat));

            if ($answertext != 'true' && $answertext != 'false') {
                // Old style file, assume order is true/false.
                $warning = true;
                if ($first) {
                    $answertext = 'true';
                } else {
                    $answertext = 'false';
                }
            }

            if ($answertext == 'true') {
                $qo->answer = ($answer['@']['fraction'] == 100);
                $qo->correctanswer = $qo->answer;
                $qo->feedbacktrue = $feedback;
            } else {
                $qo->answer = ($answer['@']['fraction'] != 100);
                $qo->correctanswer = $qo->answer;
                $qo->feedbackfalse = $feedback;
            }
            $first = false;
        }

        if ($warning) {
            $a = new stdClass();
            $a->questiontext = $qo->questiontext;
            $a->answer = get_string($qo->correctanswer ? 'true' : 'false', 'qtype_truefalse');
            echo $OUTPUT->notification(get_string('truefalseimporterror', 'qformat_xml', $a));
        }

        $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));

        return $qo;
    }

    /**
     * Import short answer type question
     * @param array question question array from xml tree
     * @return object question object
     */
    public function import_shortanswer($question) {
        // Get common parts.
        $qo = $this->import_headers($question);

        // Header parts particular to shortanswer.
        $qo->qtype = 'shortanswer';

        // Get usecase.
        $qo->usecase = $this->getpath($question, array('#', 'usecase', 0, '#'), $qo->usecase);

        // Run through the answers.
        $answers = $question['#']['answer'];
        $acount = 0;
        foreach ($answers as $answer) {
            $ans = $this->import_answer($answer, false, $this->get_format($qo->questiontextformat));
            $qo->answer[$acount] = $ans->answer['text'];
            $qo->fraction[$acount] = $ans->fraction;
            $qo->feedback[$acount] = $ans->feedback;
            ++$acount;
        }

        $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));

        return $qo;
    }

    /**
     * Import description type question
     * @param array question question array from xml tree
     * @return object question object
     */
    public function import_description($question) {
        // Get common parts.
        $qo = $this->import_headers($question);
        // Header parts particular to shortanswer.
        $qo->qtype = 'description';
        $qo->defaultmark = 0;
        $qo->length = 0;
        return $qo;
    }

    /**
     * Import numerical type question
     * @param array question question array from xml tree
     * @return object question object
     */
    public function import_numerical($question) {
        // Get common parts.
        $qo = $this->import_headers($question);

        // Header parts particular to numerical.
        $qo->qtype = 'numerical';

        // Get answers array.
        $answers = $question['#']['answer'];
        $qo->answer = array();
        $qo->feedback = array();
        $qo->fraction = array();
        $qo->tolerance = array();
        foreach ($answers as $answer) {
            // Answer outside of <text> is deprecated.
            $obj = $this->import_answer($answer, false, $this->get_format($qo->questiontextformat));
            $qo->answer[] = $obj->answer['text'];
            if (empty($qo->answer)) {
                $qo->answer = '*';
            }
            $qo->feedback[]  = $obj->feedback;
            $qo->tolerance[] = $this->getpath($answer, array('#', 'tolerance', 0, '#'), 0);

            // Fraction as a tag is deprecated.
            $fraction = $this->getpath($answer, array('@', 'fraction'), 0) / 100;
            $qo->fraction[] = $this->getpath($answer,
                    array('#', 'fraction', 0, '#'), $fraction); // Deprecated.
        }

        // Get the units array.
        $qo->unit = array();
        $units = $this->getpath($question, array('#', 'units', 0, '#', 'unit'), array());
        if (!empty($units)) {
            $qo->multiplier = array();
            foreach ($units as $unit) {
                $qo->multiplier[] = $this->getpath($unit, array('#', 'multiplier', 0, '#'), 1);
                $qo->unit[] = $this->getpath($unit, array('#', 'unit_name', 0, '#'), '', true);
            }
        }
        $qo->unitgradingtype = $this->getpath($question, array('#', 'unitgradingtype', 0, '#'), 0);
        $qo->unitpenalty = $this->getpath($question, array('#', 'unitpenalty', 0, '#'), 0.1);
        $qo->showunits = $this->getpath($question, array('#', 'showunits', 0, '#'), null);
        $qo->unitsleft = $this->getpath($question, array('#', 'unitsleft', 0, '#'), 0);
        $qo->instructions['text'] = '';
        $qo->instructions['format'] = FORMAT_HTML;
        $instructions = $this->getpath($question, array('#', 'instructions'), array());
        if (!empty($instructions)) {
            $qo->instructions = $this->import_text_with_files($instructions,
                    array('0'), '', $this->get_format($qo->questiontextformat));
        }

        if (is_null($qo->showunits)) {
            // Set a good default, depending on whether there are any units defined.
            if (empty($qo->unit)) {
                $qo->showunits = 3; // This is qtype_numerical::UNITNONE, but we cannot refer to that constant here.
            } else {
                $qo->showunits = 0; // This is qtype_numerical::UNITOPTIONAL, but we cannot refer to that constant here.
            }
        }

        $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));

        return $qo;
    }

    /**
     * Import matching type question
     * @param array question question array from xml tree
     * @return object question object
     */
    public function import_match($question) {
        // Get common parts.
        $qo = $this->import_headers($question);

        // Header parts particular to matching.
        $qo->qtype = 'match';
        $qo->shuffleanswers = $this->trans_single($this->getpath($question,
                array('#', 'shuffleanswers', 0, '#'), 1));

        // Run through subquestions.
        $qo->subquestions = array();
        $qo->subanswers = array();
        foreach ($question['#']['subquestion'] as $subqxml) {
            $qo->subquestions[] = $this->import_text_with_files($subqxml,
                    array(), '', $this->get_format($qo->questiontextformat));

            $answers = $this->getpath($subqxml, array('#', 'answer'), array());
            $qo->subanswers[] = $this->getpath($subqxml,
                    array('#', 'answer', 0, '#', 'text', 0, '#'), '', true);
        }

        $this->import_combined_feedback($qo, $question, true);
        $this->import_hints($qo, $question, true, false, $this->get_format($qo->questiontextformat));

        return $qo;
    }

    /**
     * Import essay type question
     * @param array question question array from xml tree
     * @return object question object
     */
    public function import_essay($question) {
        // Get common parts.
        $qo = $this->import_headers($question);

        // Header parts particular to essay.
        $qo->qtype = 'essay';

        $qo->responseformat = $this->getpath($question,
                array('#', 'responseformat', 0, '#'), 'editor');
        $qo->responsefieldlines = $this->getpath($question,
                array('#', 'responsefieldlines', 0, '#'), 15);
        $qo->responserequired = $this->getpath($question,
                array('#', 'responserequired', 0, '#'), 1);
        $qo->attachments = $this->getpath($question,
                array('#', 'attachments', 0, '#'), 0);
        $qo->attachmentsrequired = $this->getpath($question,
                array('#', 'attachmentsrequired', 0, '#'), 0);
        $qo->filetypeslist = $this->getpath($question,
                array('#', 'filetypeslist', 0, '#'), null);
> $qo->maxbytes = $this->getpath($question, $qo->graderinfo = $this->import_text_with_files($question, > array('#', 'maxbytes', 0, '#'), null);
array('#', 'graderinfo', 0), '', $this->get_format($qo->questiontextformat)); $qo->responsetemplate['text'] = $this->getpath($question, array('#', 'responsetemplate', 0, '#', 'text', 0, '#'), '', true); $qo->responsetemplate['format'] = $this->trans_format($this->getpath($question, array('#', 'responsetemplate', 0, '@', 'format'), $this->get_format($qo->questiontextformat))); return $qo; } /** * Import a calculated question * @param object $question the imported XML data. */ public function import_calculated($question) { // Get common parts. $qo = $this->import_headers($question); // Header parts particular to calculated. $qo->qtype = 'calculated'; $qo->synchronize = $this->getpath($question, array('#', 'synchronize', 0, '#'), 0); $single = $this->getpath($question, array('#', 'single', 0, '#'), 'true'); $qo->single = $this->trans_single($single); $shuffleanswers = $this->getpath($question, array('#', 'shuffleanswers', 0, '#'), 'false'); $qo->answernumbering = $this->getpath($question, array('#', 'answernumbering', 0, '#'), 'abc'); $qo->shuffleanswers = $this->trans_single($shuffleanswers); $this->import_combined_feedback($qo, $question); $qo->unitgradingtype = $this->getpath($question, array('#', 'unitgradingtype', 0, '#'), 0); $qo->unitpenalty = $this->getpath($question, array('#', 'unitpenalty', 0, '#'), null); $qo->showunits = $this->getpath($question, array('#', 'showunits', 0, '#'), 0); $qo->unitsleft = $this->getpath($question, array('#', 'unitsleft', 0, '#'), 0); $qo->instructions = $this->getpath($question, array('#', 'instructions', 0, '#', 'text', 0, '#'), '', true); if (!empty($instructions)) { $qo->instructions = $this->import_text_with_files($instructions, array('0'), '', $this->get_format($qo->questiontextformat)); } // Get answers array. $answers = $question['#']['answer']; $qo->answer = array(); $qo->feedback = array(); $qo->fraction = array(); $qo->tolerance = array(); $qo->tolerancetype = array(); $qo->correctanswerformat = array(); $qo->correctanswerlength = array(); $qo->feedback = array(); foreach ($answers as $answer) { $ans = $this->import_answer($answer, true, $this->get_format($qo->questiontextformat)); // Answer outside of <text> is deprecated. if (empty($ans->answer['text'])) { $ans->answer['text'] = '*'; } $qo->answer[] = $ans->answer['text']; $qo->feedback[] = $ans->feedback; $qo->tolerance[] = $answer['#']['tolerance'][0]['#']; // Fraction as a tag is deprecated. if (!empty($answer['#']['fraction'][0]['#'])) { $qo->fraction[] = $answer['#']['fraction'][0]['#']; } else { $qo->fraction[] = $answer['@']['fraction'] / 100; } $qo->tolerancetype[] = $answer['#']['tolerancetype'][0]['#']; $qo->correctanswerformat[] = $answer['#']['correctanswerformat'][0]['#']; $qo->correctanswerlength[] = $answer['#']['correctanswerlength'][0]['#']; } // Get units array. $qo->unit = array(); if (isset($question['#']['units'][0]['#']['unit'])) { $units = $question['#']['units'][0]['#']['unit']; $qo->multiplier = array(); foreach ($units as $unit) { $qo->multiplier[] = $unit['#']['multiplier'][0]['#']; $qo->unit[] = $unit['#']['unit_name'][0]['#']; } } $instructions = $this->getpath($question, array('#', 'instructions'), array()); if (!empty($instructions)) { $qo->instructions = $this->import_text_with_files($instructions, array('0'), '', $this->get_format($qo->questiontextformat)); } if (is_null($qo->unitpenalty)) { // Set a good default, depending on whether there are any units defined. if (empty($qo->unit)) { $qo->showunits = 3; // This is qtype_numerical::UNITNONE, but we cannot refer to that constant here. } else { $qo->showunits = 0; // This is qtype_numerical::UNITOPTIONAL, but we cannot refer to that constant here. } } $datasets = $question['#']['dataset_definitions'][0]['#']['dataset_definition']; $qo->dataset = array(); $qo->datasetindex= 0; foreach ($datasets as $dataset) { $qo->datasetindex++; $qo->dataset[$qo->datasetindex] = new stdClass(); $qo->dataset[$qo->datasetindex]->status = $this->import_text($dataset['#']['status'][0]['#']['text']); $qo->dataset[$qo->datasetindex]->name = $this->import_text($dataset['#']['name'][0]['#']['text']); $qo->dataset[$qo->datasetindex]->type = $dataset['#']['type'][0]['#']; $qo->dataset[$qo->datasetindex]->distribution = $this->import_text($dataset['#']['distribution'][0]['#']['text']); $qo->dataset[$qo->datasetindex]->max = $this->import_text($dataset['#']['maximum'][0]['#']['text']); $qo->dataset[$qo->datasetindex]->min = $this->import_text($dataset['#']['minimum'][0]['#']['text']); $qo->dataset[$qo->datasetindex]->length = $this->import_text($dataset['#']['decimals'][0]['#']['text']); $qo->dataset[$qo->datasetindex]->distribution = $this->import_text($dataset['#']['distribution'][0]['#']['text']); $qo->dataset[$qo->datasetindex]->itemcount = $dataset['#']['itemcount'][0]['#']; $qo->dataset[$qo->datasetindex]->datasetitem = array(); $qo->dataset[$qo->datasetindex]->itemindex = 0; $qo->dataset[$qo->datasetindex]->number_of_items = $this->getpath($dataset, array('#', 'number_of_items', 0, '#'), 0); $datasetitems = $this->getpath($dataset, array('#', 'dataset_items', 0, '#', 'dataset_item'), array()); foreach ($datasetitems as $datasetitem) { $qo->dataset[$qo->datasetindex]->itemindex++; $qo->dataset[$qo->datasetindex]->datasetitem[ $qo->dataset[$qo->datasetindex]->itemindex] = new stdClass(); $qo->dataset[$qo->datasetindex]->datasetitem[ $qo->dataset[$qo->datasetindex]->itemindex]->itemnumber = $datasetitem['#']['number'][0]['#']; $qo->dataset[$qo->datasetindex]->datasetitem[ $qo->dataset[$qo->datasetindex]->itemindex]->value = $datasetitem['#']['value'][0]['#']; } } $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat)); return $qo; } /** * This is not a real question type. It's a dummy type used to specify the * import category. The format is: * <question type="category"> * <category>tom/dick/harry</category> * <info format="moodle_auto_format"><text>Category description</text></info> * </question> */ protected function import_category($question) { $qo = new stdClass(); $qo->qtype = 'category'; $qo->category = $this->import_text($question['#']['category'][0]['#']['text']); $qo->info = ''; $qo->infoformat = FORMAT_MOODLE; if (array_key_exists('info', $question['#'])) { $qo->info = $this->import_text($question['#']['info'][0]['#']['text']); // The import should have the format in human readable form, so translate to machine readable format. $qo->infoformat = $this->trans_format($question['#']['info'][0]['@']['format']); } $qo->idnumber = $this->getpath($question, array('#', 'idnumber', 0, '#'), null); return $qo; } /** * Parse the array of lines into an array of questions * this *could* burn memory - but it won't happen that much * so fingers crossed! * @param array of lines from the input file. * @param stdClass $context * @return array (of objects) question objects. */ public function readquestions($lines) { // We just need it as one big string. $lines = implode('', $lines); // This converts xml to big nasty data structure // the 0 means keep white space as it is (important for markdown format). try { $xml = xmlize($lines, 0, 'UTF-8', true); } catch (xml_format_exception $e) { $this->error($e->getMessage(), ''); return false; } unset($lines); // No need to keep this in memory. return $this->import_questions($xml['quiz']['#']['question']); } /** * @param array $xml the xmlized xml * @return stdClass[] question objects to pass to question type save_question_options */ public function import_questions($xml) { $questions = array(); // Iterate through questions. foreach ($xml as $questionxml) { $qo = $this->import_question($questionxml); // Stick the result in the $questions array. if ($qo) { $questions[] = $qo; } } return $questions; } /** * @param array $questionxml xml describing the question * @return null|stdClass an object with data to be fed to question type save_question_options */ protected function import_question($questionxml) { $questiontype = $questionxml['@']['type']; if ($questiontype == 'multichoice') { return $this->import_multichoice($questionxml); } else if ($questiontype == 'truefalse') { return $this->import_truefalse($questionxml); } else if ($questiontype == 'shortanswer') { return $this->import_shortanswer($questionxml); } else if ($questiontype == 'numerical') { return $this->import_numerical($questionxml); } else if ($questiontype == 'description') { return $this->import_description($questionxml); } else if ($questiontype == 'matching' || $questiontype == 'match') { return $this->import_match($questionxml); } else if ($questiontype == 'cloze' || $questiontype == 'multianswer') { return $this->import_multianswer($questionxml); } else if ($questiontype == 'essay') { return $this->import_essay($questionxml); } else if ($questiontype == 'calculated') { return $this->import_calculated($questionxml); } else if ($questiontype == 'calculatedsimple') { $qo = $this->import_calculated($questionxml); $qo->qtype = 'calculatedsimple'; return $qo; } else if ($questiontype == 'calculatedmulti') { $qo = $this->import_calculated($questionxml); $qo->qtype = 'calculatedmulti'; return $qo; } else if ($questiontype == 'category') { return $this->import_category($questionxml); } else { // Not a type we handle ourselves. See if the question type wants // to handle it. if (!$qo = $this->try_importing_using_qtypes($questionxml, null, null, $questiontype)) { $this->error(get_string('xmltypeunsupported', 'qformat_xml', $questiontype)); return null; } return $qo; } } // EXPORT FUNCTIONS START HERE. public function export_file_extension() { return '.xml'; } /** * Turn the internal question type name into a human readable form. * (In the past, the code used to use integers internally. Now, it uses * strings, so there is less need for this, but to maintain * backwards-compatibility we change two of the type names.) * @param string $qtype question type plugin name. * @return string $qtype string to use in the file. */ protected function get_qtype($qtype) { switch($qtype) { case 'match': return 'matching'; case 'multianswer': return 'cloze'; default: return $qtype; } } /** * Convert internal Moodle text format code into * human readable form * @param int id internal code * @return string format text */ public function get_format($id) { switch($id) { case FORMAT_MOODLE: return 'moodle_auto_format'; case FORMAT_HTML: return 'html'; case FORMAT_PLAIN: return 'plain_text'; case FORMAT_WIKI: return 'wiki_like'; case FORMAT_MARKDOWN: return 'markdown'; default: return 'unknown'; } } /** * Convert internal single question code into * human readable form * @param int id single question code * @return string single question string */ public function get_single($id) { switch($id) { case 0: return 'false'; case 1: return 'true'; default: return 'unknown'; } } /** * Take a string, and wrap it in a CDATA secion, if that is required to make * the output XML valid. * @param string $string a string * @return string the string, wrapped in CDATA if necessary. */ public function xml_escape($string) { if (!empty($string) && htmlspecialchars($string) != $string) { // If the string contains something that looks like the end // of a CDATA section, then we need to avoid errors by splitting // the string between two CDATA sections. $string = str_replace(']]>', ']]]]><![CDATA[>', $string); return "<![CDATA[{$string}]]>"; } else { return $string; } } /** * Generates <text></text> tags, processing raw text therein * @param string $raw the content to output. * @param int $indent the current indent level. * @param bool $short stick it on one line. * @return string formatted text. */ public function writetext($raw, $indent = 0, $short = true) { $indent = str_repeat(' ', $indent); $raw = $this->xml_escape($raw); if ($short) { $xml = "{$indent}<text>{$raw}</text>\n"; } else { $xml = "{$indent}<text>\n{$raw}\n{$indent}</text>\n"; } return $xml; } /** * Generte the XML to represent some files. * @param array of store array of stored_file objects. * @return string $string the XML. */ public function write_files($files) { if (empty($files)) { return ''; } $string = ''; foreach ($files as $file) { if ($file->is_directory()) { continue; } $string .= '<file name="' . $file->get_filename() . '" path="' . $file->get_filepath() . '" encoding="base64">'; $string .= base64_encode($file->get_content()); $string .= "</file>\n"; } return $string; } protected function presave_process($content) { // Override to allow us to add xml headers and footers. return '<?xml version="1.0" encoding="UTF-8"?> <quiz> ' . $content . '</quiz>'; } /** * Turns question into an xml segment * @param object $question the question data. * @return string xml segment */ public function writequestion($question) { $invalidquestion = false; $fs = get_file_storage(); $contextid = $question->contextid; // Get files used by the questiontext. $question->questiontextfiles = $fs->get_area_files( $contextid, 'question', 'questiontext', $question->id); // Get files used by the generalfeedback. $question->generalfeedbackfiles = $fs->get_area_files( $contextid, 'question', 'generalfeedback', $question->id); if (!empty($question->options->answers)) { foreach ($question->options->answers as $answer) { $answer->answerfiles = $fs->get_area_files( $contextid, 'question', 'answer', $answer->id); $answer->feedbackfiles = $fs->get_area_files( $contextid, 'question', 'answerfeedback', $answer->id); } } $expout = ''; // Add a comment linking this to the original question id. $expout .= "<!-- question: {$question->id} -->\n"; // Check question type. $questiontype = $this->get_qtype($question->qtype); $idnumber = htmlspecialchars($question->idnumber); // Categories are a special case. if ($question->qtype == 'category') { $categorypath = $this->writetext($question->category); $categoryinfo = $this->writetext($question->info); $infoformat = $this->format($question->infoformat); $expout .= " <question type=\"category\">\n"; $expout .= " <category>\n"; $expout .= " {$categorypath}"; $expout .= " </category>\n"; $expout .= " <info {$infoformat}>\n"; $expout .= " {$categoryinfo}"; $expout .= " </info>\n"; $expout .= " <idnumber>{$idnumber}</idnumber>\n"; $expout .= " </question>\n"; return $expout; } // Now we know we are are handing a real question. // Output the generic information. $expout .= " <question type=\"{$questiontype}\">\n"; $expout .= " <name>\n"; $expout .= $this->writetext($question->name, 3); $expout .= " </name>\n"; $expout .= " <questiontext {$this->format($question->questiontextformat)}>\n"; $expout .= $this->writetext($question->questiontext, 3); $expout .= $this->write_files($question->questiontextfiles); $expout .= " </questiontext>\n"; $expout .= " <generalfeedback {$this->format($question->generalfeedbackformat)}>\n"; $expout .= $this->writetext($question->generalfeedback, 3); $expout .= $this->write_files($question->generalfeedbackfiles); $expout .= " </generalfeedback>\n"; if ($question->qtype != 'multianswer') { $expout .= " <defaultgrade>{$question->defaultmark}</defaultgrade>\n"; } $expout .= " <penalty>{$question->penalty}</penalty>\n"; $expout .= " <hidden>{$question->hidden}</hidden>\n"; $expout .= " <idnumber>{$idnumber}</idnumber>\n"; // The rest of the output depends on question type. switch($question->qtype) { case 'category': // Not a qtype really - dummy used for category switching. break; case 'truefalse': $trueanswer = $question->options->answers[$question->options->trueanswer]; $trueanswer->answer = 'true'; $expout .= $this->write_answer($trueanswer); $falseanswer = $question->options->answers[$question->options->falseanswer]; $falseanswer->answer = 'false'; $expout .= $this->write_answer($falseanswer); break; case 'multichoice': $expout .= " <single>" . $this->get_single($question->options->single) . "</single>\n"; $expout .= " <shuffleanswers>" . $this->get_single($question->options->shuffleanswers) . "</shuffleanswers>\n"; $expout .= " <answernumbering>" . $question->options->answernumbering . "</answernumbering>\n"; $expout .= " <showstandardinstruction>" . $question->options->showstandardinstruction . "</showstandardinstruction>\n"; $expout .= $this->write_combined_feedback($question->options, $question->id, $question->contextid); $expout .= $this->write_answers($question->options->answers); break; case 'shortanswer': $expout .= " <usecase>{$question->options->usecase}</usecase>\n"; $expout .= $this->write_answers($question->options->answers); break; case 'numerical': foreach ($question->options->answers as $answer) { $expout .= $this->write_answer($answer, " <tolerance>{$answer->tolerance}</tolerance>\n"); } $units = $question->options->units; if (count($units)) { $expout .= "<units>\n"; foreach ($units as $unit) { $expout .= " <unit>\n"; $expout .= " <multiplier>{$unit->multiplier}</multiplier>\n"; $expout .= " <unit_name>{$unit->unit}</unit_name>\n"; $expout .= " </unit>\n"; } $expout .= "</units>\n"; } if (isset($question->options->unitgradingtype)) { $expout .= " <unitgradingtype>" . $question->options->unitgradingtype . "</unitgradingtype>\n"; } if (isset($question->options->unitpenalty)) { $expout .= " <unitpenalty>{$question->options->unitpenalty}</unitpenalty>\n"; } if (isset($question->options->showunits)) { $expout .= " <showunits>{$question->options->showunits}</showunits>\n"; } if (isset($question->options->unitsleft)) { $expout .= " <unitsleft>{$question->options->unitsleft}</unitsleft>\n"; } if (!empty($question->options->instructionsformat)) { $files = $fs->get_area_files($contextid, 'qtype_numerical', 'instruction', $question->id); $expout .= " <instructions " . $this->format($question->options->instructionsformat) . ">\n"; $expout .= $this->writetext($question->options->instructions, 3); $expout .= $this->write_files($files); $expout .= " </instructions>\n"; } break; case 'match': $expout .= " <shuffleanswers>" . $this->get_single($question->options->shuffleanswers) . "</shuffleanswers>\n"; $expout .= $this->write_combined_feedback($question->options, $question->id, $question->contextid); foreach ($question->options->subquestions as $subquestion) { $files = $fs->get_area_files($contextid, 'qtype_match', 'subquestion', $subquestion->id); $expout .= " <subquestion " . $this->format($subquestion->questiontextformat) . ">\n"; $expout .= $this->writetext($subquestion->questiontext, 3); $expout .= $this->write_files($files); $expout .= " <answer>\n"; $expout .= $this->writetext($subquestion->answertext, 4); $expout .= " </answer>\n"; $expout .= " </subquestion>\n"; } break; case 'description': // Nothing else to do. break; case 'multianswer': foreach ($question->options->questions as $index => $subq) { $expout = str_replace('{#' . $index . '}', $subq->questiontext, $expout); } break; case 'essay': $expout .= " <responseformat>" . $question->options->responseformat . "</responseformat>\n"; $expout .= " <responserequired>" . $question->options->responserequired . "</responserequired>\n"; $expout .= " <responsefieldlines>" . $question->options->responsefieldlines . "</responsefieldlines>\n"; $expout .= " <attachments>" . $question->options->attachments . "</attachments>\n"; $expout .= " <attachmentsrequired>" . $question->options->attachmentsrequired . "</attachmentsrequired>\n";
> $expout .= " <maxbytes>" . $question->options->maxbytes . $expout .= " <filetypeslist>" . $question->options->filetypeslist . > "</maxbytes>\n";
"</filetypeslist>\n"; $expout .= " <graderinfo " . $this->format($question->options->graderinfoformat) . ">\n"; $expout .= $this->writetext($question->options->graderinfo, 3); $expout .= $this->write_files($fs->get_area_files($contextid, 'qtype_essay', 'graderinfo', $question->id)); $expout .= " </graderinfo>\n"; $expout .= " <responsetemplate " . $this->format($question->options->responsetemplateformat) . ">\n"; $expout .= $this->writetext($question->options->responsetemplate, 3); $expout .= " </responsetemplate>\n"; break; case 'calculated': case 'calculatedsimple': case 'calculatedmulti': $expout .= " <synchronize>{$question->options->synchronize}</synchronize>\n"; $expout .= " <single>{$question->options->single}</single>\n"; $expout .= " <answernumbering>" . $question->options->answernumbering . "</answernumbering>\n"; $expout .= " <shuffleanswers>" . $question->options->shuffleanswers . "</shuffleanswers>\n"; $component = 'qtype_' . $question->qtype; $files = $fs->get_area_files($contextid, $component, 'correctfeedback', $question->id); $expout .= " <correctfeedback>\n"; $expout .= $this->writetext($question->options->correctfeedback, 3); $expout .= $this->write_files($files); $expout .= " </correctfeedback>\n"; $files = $fs->get_area_files($contextid, $component, 'partiallycorrectfeedback', $question->id); $expout .= " <partiallycorrectfeedback>\n"; $expout .= $this->writetext($question->options->partiallycorrectfeedback, 3); $expout .= $this->write_files($files); $expout .= " </partiallycorrectfeedback>\n"; $files = $fs->get_area_files($contextid, $component, 'incorrectfeedback', $question->id); $expout .= " <incorrectfeedback>\n"; $expout .= $this->writetext($question->options->incorrectfeedback, 3); $expout .= $this->write_files($files); $expout .= " </incorrectfeedback>\n"; foreach ($question->options->answers as $answer) { $percent = 100 * $answer->fraction; $expout .= "<answer fraction=\"{$percent}\">\n"; // The "<text/>" tags are an added feature, old files won't have them. $expout .= " <text>{$answer->answer}</text>\n"; $expout .= " <tolerance>{$answer->tolerance}</tolerance>\n"; $expout .= " <tolerancetype>{$answer->tolerancetype}</tolerancetype>\n"; $expout .= " <correctanswerformat>" . $answer->correctanswerformat . "</correctanswerformat>\n"; $expout .= " <correctanswerlength>" . $answer->correctanswerlength . "</correctanswerlength>\n"; $expout .= " <feedback {$this->format($answer->feedbackformat)}>\n"; $files = $fs->get_area_files($contextid, $component, 'instruction', $question->id); $expout .= $this->writetext($answer->feedback); $expout .= $this->write_files($answer->feedbackfiles); $expout .= " </feedback>\n"; $expout .= "</answer>\n"; } if (isset($question->options->unitgradingtype)) { $expout .= " <unitgradingtype>" . $question->options->unitgradingtype . "</unitgradingtype>\n"; } if (isset($question->options->unitpenalty)) { $expout .= " <unitpenalty>" . $question->options->unitpenalty . "</unitpenalty>\n"; } if (isset($question->options->showunits)) { $expout .= " <showunits>{$question->options->showunits}</showunits>\n"; } if (isset($question->options->unitsleft)) { $expout .= " <unitsleft>{$question->options->unitsleft}</unitsleft>\n"; } if (isset($question->options->instructionsformat)) { $files = $fs->get_area_files($contextid, $component, 'instruction', $question->id); $expout .= " <instructions " . $this->format($question->options->instructionsformat) . ">\n"; $expout .= $this->writetext($question->options->instructions, 3); $expout .= $this->write_files($files); $expout .= " </instructions>\n"; } if (isset($question->options->units)) { $units = $question->options->units; if (count($units)) { $expout .= "<units>\n"; foreach ($units as $unit) { $expout .= " <unit>\n"; $expout .= " <multiplier>{$unit->multiplier}</multiplier>\n"; $expout .= " <unit_name>{$unit->unit}</unit_name>\n"; $expout .= " </unit>\n"; } $expout .= "</units>\n"; } } // The tag $question->export_process has been set so we get all the // data items in the database from the function // qtype_calculated::get_question_options calculatedsimple defaults // to calculated. if (isset($question->options->datasets) && count($question->options->datasets)) { $expout .= "<dataset_definitions>\n"; foreach ($question->options->datasets as $def) { $expout .= "<dataset_definition>\n"; $expout .= " <status>".$this->writetext($def->status)."</status>\n"; $expout .= " <name>".$this->writetext($def->name)."</name>\n"; if ($question->qtype == 'calculated') { $expout .= " <type>calculated</type>\n"; } else { $expout .= " <type>calculatedsimple</type>\n"; } $expout .= " <distribution>" . $this->writetext($def->distribution) . "</distribution>\n"; $expout .= " <minimum>" . $this->writetext($def->minimum) . "</minimum>\n"; $expout .= " <maximum>" . $this->writetext($def->maximum) . "</maximum>\n"; $expout .= " <decimals>" . $this->writetext($def->decimals) . "</decimals>\n"; $expout .= " <itemcount>{$def->itemcount}</itemcount>\n"; if ($def->itemcount > 0) { $expout .= " <dataset_items>\n"; foreach ($def->items as $item) { $expout .= " <dataset_item>\n"; $expout .= " <number>".$item->itemnumber."</number>\n"; $expout .= " <value>".$item->value."</value>\n"; $expout .= " </dataset_item>\n"; } $expout .= " </dataset_items>\n"; $expout .= " <number_of_items>" . $def->number_of_items . "</number_of_items>\n"; } $expout .= "</dataset_definition>\n"; } $expout .= "</dataset_definitions>\n"; } break; default: // Try support by optional plugin. if (!$data = $this->try_exporting_using_qtypes($question->qtype, $question)) { $invalidquestion = true; } else { $expout .= $data; } } // Output any hints. $expout .= $this->write_hints($question); // Write the question tags. if (core_tag_tag::is_enabled('core_question', 'question')) { $tagobjects = core_tag_tag::get_item_tags('core_question', 'question', $question->id); if (!empty($tagobjects)) { $context = context::instance_by_id($contextid); $sortedtagobjects = question_sort_tags($tagobjects, $context, [$this->course]); if (!empty($sortedtagobjects->coursetags)) { // Set them on the form to be rendered as existing tags. $expout .= " <coursetags>\n"; foreach ($sortedtagobjects->coursetags as $coursetag) { $expout .= " <tag>" . $this->writetext($coursetag, 0, true) . "</tag>\n"; } $expout .= " </coursetags>\n"; } if (!empty($sortedtagobjects->tags)) { $expout .= " <tags>\n"; foreach ($sortedtagobjects->tags as $tag) { $expout .= " <tag>" . $this->writetext($tag, 0, true) . "</tag>\n"; } $expout .= " </tags>\n"; } } } // Close the question tag. $expout .= " </question>\n"; if ($invalidquestion) { return ''; } else { return $expout; } } public function write_answers($answers) { if (empty($answers)) { return; } $output = ''; foreach ($answers as $answer) { $output .= $this->write_answer($answer); } return $output; } public function write_answer($answer, $extra = '') { $percent = $answer->fraction * 100; $output = ''; $output .= " <answer fraction=\"{$percent}\" {$this->format($answer->answerformat)}>\n"; $output .= $this->writetext($answer->answer, 3); $output .= $this->write_files($answer->answerfiles); $output .= " <feedback {$this->format($answer->feedbackformat)}>\n"; $output .= $this->writetext($answer->feedback, 4); $output .= $this->write_files($answer->feedbackfiles); $output .= " </feedback>\n"; $output .= $extra; $output .= " </answer>\n"; return $output; } /** * Write out the hints. * @param object $question the question definition data. * @return string XML to output. */ public function write_hints($question) { if (empty($question->hints)) { return ''; } $output = ''; foreach ($question->hints as $hint) { $output .= $this->write_hint($hint, $question->contextid); } return $output; } /** * @param int $format a FORMAT_... constant. * @return string the attribute to add to an XML tag. */ public function format($format) { return 'format="' . $this->get_format($format) . '"'; } public function write_hint($hint, $contextid) { $fs = get_file_storage(); $files = $fs->get_area_files($contextid, 'question', 'hint', $hint->id); $output = ''; $output .= " <hint {$this->format($hint->hintformat)}>\n"; $output .= ' ' . $this->writetext($hint->hint); if (!empty($hint->shownumcorrect)) { $output .= " <shownumcorrect/>\n"; } if (!empty($hint->clearwrong)) { $output .= " <clearwrong/>\n"; } if (!empty($hint->options)) { $output .= ' <options>' . $this->xml_escape($hint->options) . "</options>\n"; } $output .= $this->write_files($files); $output .= " </hint>\n"; return $output; } /** * Output the combined feedback fields. * @param object $questionoptions the question definition data. * @param int $questionid the question id. * @param int $contextid the question context id. * @return string XML to output. */ public function write_combined_feedback($questionoptions, $questionid, $contextid) { $fs = get_file_storage(); $output = ''; $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'); foreach ($fields as $field) { $formatfield = $field . 'format'; $files = $fs->get_area_files($contextid, 'question', $field, $questionid); $output .= " <{$field} {$this->format($questionoptions->$formatfield)}>\n"; $output .= ' ' . $this->writetext($questionoptions->$field); $output .= $this->write_files($files); $output .= " </{$field}>\n"; } if (!empty($questionoptions->shownumcorrect)) { $output .= " <shownumcorrect/>\n"; } return $output; } }