. /** * Code for exporting questions as Moodle XML. * * @package qformat * @subpackage 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 * @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; } /** * 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 $CFG; // get some error strings $error_noname = get_string('xmlimportnoname', 'qformat_xml'); $error_noquestion = get_string('xmlimportnoquestion', 'qformat_xml'); // 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'))); $qo->questiontext = $this->getpath($question, array('#', 'questiontext', 0, '#', 'text', 0, '#'), '', true); $qo->questiontextformat = $this->trans_format($this->getpath( $question, array('#', 'questiontext', 0, '@', 'format'), 'html')); $qo->questiontextfiles = $this->import_files($this->getpath($question, array('#', 'questiontext', 0, '#', 'file'), array(), false)); // 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) { $data = new stdClass(); $data->content = $filedata; $data->encoding = 'base64'; // Question file areas don't support subdirs, so convert path to filename if necessary. $data->name = clean_param(str_replace('/', '_', $filename), PARAM_FILE); $qo->questiontextfiles[] = $data; $qo->questiontext .= ' '; } // restore files in generalfeedback $qo->generalfeedback = $this->getpath($question, array('#', 'generalfeedback', 0, '#', 'text', 0, '#'), $qo->generalfeedback, true); $qo->generalfeedbackfiles = array(); $qo->generalfeedbackformat = $this->trans_format($this->getpath($question, array('#', 'generalfeedback', 0, '@', 'format'), $this->get_format($qo->questiontextformat))); $qo->generalfeedbackfiles = $this->import_files($this->getpath($question, array('#', 'generalfeedback', 0, '#', 'file'), array(), false)); $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. if (!empty($CFG->usetags) && array_key_exists('tags', $question['#']) && !empty($question['#']['tags'][0]['#']['tag'])) { require_once($CFG->dirroot.'/tag/lib.php'); $qo->tags = array(); foreach ($question['#']['tags'][0]['#']['tag'] as $tagdata) { $qo->tags[] = $this->getpath($tagdata, array('#', 'text', 0, '#'), '', true); } } 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(); $ans->answer = array(); $ans->answer['text'] = $this->getpath($answer, array('#', 'text', 0, '#'), '', true); $ans->answer['format'] = $this->trans_format($this->getpath($answer, array('@', 'format'), $defaultformat)); if ($withanswerfiles) { $ans->answer['files'] = $this->import_files($this->getpath($answer, array('#', 'file'), array())); } else { $ans->answer['format'] = FORMAT_PLAIN; } $ans->feedback = array(); $ans->feedback['text'] = $this->getpath($answer, array('#', 'feedback', 0, '#', 'text', 0, '#'), '', true); $ans->feedback['format'] = $this->trans_format($this->getpath($answer, array('#', 'feedback', 0, '@', 'format'), $defaultformat)); $ans->feedback['files'] = $this->import_files($this->getpath($answer, array('#', 'feedback', 0, '#', 'file'), array())); $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) { $text = array(); $text['text'] = $this->getpath($questionxml, array('#', $field, 0, '#', 'text', 0, '#'), '', true); $text['format'] = $this->trans_format($this->getpath($questionxml, array('#', $field, 0, '@', 'format'), $this->get_format($qo->questiontextformat))); $text['files'] = $this->import_files($this->getpath($questionxml, array('#', $field, 0, '#', 'file'), array(), false)); $qo->$field = $text; } 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) { if (array_key_exists('hintcontent', $hintxml['#'])) { // Backwards compatibility: $hint = new stdClass(); $hint->hint = array('format' => FORMAT_HTML, 'files' => array()); $hint->hint['text'] = $this->getpath($hintxml, array('#', 'hintcontent', 0, '#', 'text', 0, '#'), '', true); $hint->hint['format'] = $this->trans_format($defaultformat); $hint->hint['files'] = array(); $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 = new stdClass(); $hint->hint['text'] = $this->getpath($hintxml, array('#', 'text', 0, '#'), '', true); $hint->hint['format'] = $this->trans_format($this->getpath($hintxml, array('@', 'format'), $defaultformat)); $hint->hint['files'] = $this->import_files($this->getpath($hintxml, array('#', 'file'), array(), false)); $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 files from a node in the XML. * @param array $xml an array of 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); // 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) { question_bank::get_qtype('multianswer'); $questiontext['text'] = $this->import_text($question['#']['questiontext'][0]['#']['text']); $questiontext['format'] = FORMAT_HTML; $questiontext['itemid'] = ''; $qo = qtype_multianswer_extract_question($questiontext); // 'header' parts particular to multianswer $qo->qtype = 'multianswer'; $qo->course = $this->course; $qo->generalfeedback = ''; $qo->name = $this->clean_question_name($this->import_text($question['#']['name'][0]['#']['text'])); $qo->questiontextformat = $questiontext['format']; $qo->questiontext = $qo->questiontext['text']; $qo->questiontextfiles = array(); // restore files in generalfeedback $qo->generalfeedback = $this->getpath($question, array('#', 'generalfeedback', 0, '#', 'text', 0, '#'), $qo->generalfeedback, true); $qo->generalfeedbackformat = $this->trans_format($this->getpath($question, array('#', 'generalfeedback', 0, '@', 'format'), $this->get_format($qo->questiontextformat))); $qo->generalfeedbackfiles = $this->import_files($this->getpath($question, array('#', 'generalfeedback', 0, '#', 'file'), array(), false)); $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, false, false, $this->get_format($qo->questiontextformat)); 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->getpath($answer, array('#', 'feedback', 0, '#', 'text', 0, '#'), '', true); $feedbackformat = $this->getpath($answer, array('#', 'feedback', 0, '@', 'format'), $this->get_format($qo->questiontextformat)); $feedbackfiles = $this->getpath($answer, array('#', 'feedback', 0, '#', 'file'), array()); $files = array(); foreach ($feedbackfiles as $file) { $data = new stdClass(); $data->content = $file['#']; $data->encoding = $file['@']['encoding']; $data->name = $file['@']['name']; $files[] = $data; } 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 = array(); $qo->feedbacktrue['text'] = $feedback; $qo->feedbacktrue['format'] = $this->trans_format($feedbackformat); $qo->feedbacktrue['files'] = $files; } else { $qo->answer = ($answer['@']['fraction'] != 100); $qo->correctanswer = $qo->answer; $qo->feedbackfalse = array(); $qo->feedbackfalse['text'] = $feedback; $qo->feedbackfalse['format'] = $this->trans_format($feedbackformat); $qo->feedbackfalse['files'] = $files; } $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 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 = array(); $qo->instructions['text'] = $this->getpath($instructions, array('0', '#', 'text', '0', '#'), '', true); $qo->instructions['format'] = $this->trans_format($this->getpath($instructions, array('0', '@', 'format'), $this->get_format($qo->questiontextformat))); $qo->instructions['files'] = $this->import_files($this->getpath( $instructions, array('0', '#', 'file'), array())); } if (is_null($qo->showunits)) { // Set a good default, depending on whether there are any units defined. if (empty($qo->unit)) { $qo->showunits = 3; // qtype_numerical::UNITNONE; } else { $qo->showunits = 0; // qtype_numerical::UNITOPTIONAL; } } $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) { $subquestion = array(); $subquestion['text'] = $this->getpath($subqxml, array('#', 'text', 0, '#'), '', true); $subquestion['format'] = $this->trans_format($this->getpath($subqxml, array('@', 'format'), $this->get_format($qo->questiontextformat))); $subquestion['files'] = $this->import_files($this->getpath($subqxml, array('#', 'file'), array())); $qo->subquestions[] = $subquestion; $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->attachments = $this->getpath($question, array('#', 'attachments', 0, '#'), 0); $qo->graderinfo['text'] = $this->getpath($question, array('#', 'graderinfo', 0, '#', 'text', 0, '#'), '', true); $qo->graderinfo['format'] = $this->trans_format($this->getpath($question, array('#', 'graderinfo', 0, '@', 'format'), $this->get_format($qo->questiontextformat))); $qo->graderinfo['files'] = $this->import_files($this->getpath($question, array('#', 'graderinfo', '0', '#', 'file'), array())); 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 = array(); $qo->instructions['text'] = $this->getpath($instructions, array('0', '#', 'text', '0', '#'), '', true); $qo->instructions['format'] = $this->trans_format($this->getpath($instructions, array('0', '@', 'format'), $this->get_format($qo->questiontextformat))); $qo->instructions['files'] = $this->import_files($this->getpath($instructions, array('0', '#', 'file'), array())); } // get answers array $answers = $question['#']['answer']; $qo->answers = 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 is deprecated if (empty($ans->answer['text'])) { $ans->answer['text'] = '*'; } $qo->answers[] = $ans->answer; $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 = array(); $qo->instructions['text'] = $this->getpath($instructions, array('0', '#', 'text', '0', '#'), '', true); $qo->instructions['format'] = $this->trans_format($this->getpath($instructions, array('0', '@', 'format'), $this->get_format($qo->questiontextformat))); $qo->instructions['files'] = $this->import_files($this->getpath($instructions, array('0', '#', 'file'), array())); } if (is_null($qo->unitpenalty)) { // Set a good default, depending on whether there are any units defined. if (empty($qo->unit)) { $qo->showunits = 3; // qtype_numerical::UNITNONE; } else { $qo->showunits = 0; // qtype_numerical::UNITOPTIONAL; } } $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 = $dataset['#']['number_of_items'][0]['#']; $datasetitems = $dataset['#']['dataset_items'][0]['#']['dataset_item']; 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: * * tom/dick/harry * */ protected function import_category($question) { $qo = new stdClass(); $qo->qtype = 'category'; $qo->category = $this->import_text($question['#']['category'][0]['#']['text']); 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. * @return array (of objects) question objects. */ protected function readquestions($lines) { // We just need it as one big string $text = implode($lines, ' '); unset($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($text, 0, 'UTF-8', true); } catch (xml_format_exception $e) { $this->error($e->getMessage(), ''); return false; } // Set up array to hold all our questions $questions = array(); // Iterate through questions foreach ($xml['quiz']['#']['question'] as $question) { $questiontype = $question['@']['type']; if ($questiontype == 'multichoice') { $qo = $this->import_multichoice($question); } else if ($questiontype == 'truefalse') { $qo = $this->import_truefalse($question); } else if ($questiontype == 'shortanswer') { $qo = $this->import_shortanswer($question); } else if ($questiontype == 'numerical') { $qo = $this->import_numerical($question); } else if ($questiontype == 'description') { $qo = $this->import_description($question); } else if ($questiontype == 'matching' || $questiontype == 'match') { $qo = $this->import_match($question); } else if ($questiontype == 'cloze' || $questiontype == 'multianswer') { $qo = $this->import_multianswer($question); } else if ($questiontype == 'essay') { $qo = $this->import_essay($question); } else if ($questiontype == 'calculated') { $qo = $this->import_calculated($question); } else if ($questiontype == 'calculatedsimple') { $qo = $this->import_calculated($question); $qo->qtype = 'calculatedsimple'; } else if ($questiontype == 'calculatedmulti') { $qo = $this->import_calculated($question); $qo->qtype = 'calculatedmulti'; } else if ($questiontype == 'category') { $qo = $this->import_category($question); } else { // Not a type we handle ourselves. See if the question type wants // to handle it. if (!$qo = $this->try_importing_using_qtypes( $question, null, null, $questiontype)) { $this->error(get_string('xmltypeunsupported', 'qformat_xml', $questiontype)); $qo = null; } } // Stick the result in the $questions array if ($qo) { $questions[] = $qo; } } return $questions; } // 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) { return ""; } else { return $string; } } /** * Generates 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$raw\n"; } else { $xml = "$indent\n$raw\n$indent\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 .= ''; $string .= base64_encode($file->get_content()); $string .= ''; } return $string; } protected function presave_process($content) { // Override to allow us to add xml headers and footers return ' ' . $content . ''; } /** * Turns question into an xml segment * @param object $question the question data. * @return string xml segment */ public function writequestion($question) { global $CFG, $OUTPUT; $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 .= "\n"; // Check question type $questiontype = $this->get_qtype($question->qtype); // Categories are a special case. if ($question->qtype == 'category') { $categorypath = $this->writetext($question->category); $expout .= " \n"; $expout .= " \n"; $expout .= " $categorypath\n"; $expout .= " \n"; $expout .= " \n"; return $expout; } // Now we know we are are handing a real question. // Output the generic information. $expout .= " \n"; $expout .= " \n"; $expout .= $this->writetext($question->name, 3); $expout .= " \n"; $expout .= " format($question->questiontextformat)}>\n"; $expout .= $this->writetext($question->questiontext, 3); $expout .= $this->write_files($question->questiontextfiles); $expout .= " \n"; $expout .= " format($question->generalfeedbackformat)}>\n"; $expout .= $this->writetext($question->generalfeedback, 3); $expout .= $this->write_files($question->generalfeedbackfiles); $expout .= " \n"; if ($question->qtype != 'multianswer') { $expout .= " {$question->defaultmark}\n"; } $expout .= " {$question->penalty}\n"; $expout .= " {$question->hidden}\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 .= " " . $this->get_single($question->options->single) . "\n"; $expout .= " " . $this->get_single($question->options->shuffleanswers) . "\n"; $expout .= " " . $question->options->answernumbering . "\n"; $expout .= $this->write_combined_feedback($question->options, $question->id, $question->contextid); $expout .= $this->write_answers($question->options->answers); break; case 'shortanswer': $expout .= " {$question->options->usecase}\n"; $expout .= $this->write_answers($question->options->answers); break; case 'numerical': foreach ($question->options->answers as $answer) { $expout .= $this->write_answer($answer, " $answer->tolerance\n"); } $units = $question->options->units; if (count($units)) { $expout .= "\n"; foreach ($units as $unit) { $expout .= " \n"; $expout .= " {$unit->multiplier}\n"; $expout .= " {$unit->unit}\n"; $expout .= " \n"; } $expout .= "\n"; } if (isset($question->options->unitgradingtype)) { $expout .= " " . $question->options->unitgradingtype . "\n"; } if (isset($question->options->unitpenalty)) { $expout .= " {$question->options->unitpenalty}\n"; } if (isset($question->options->showunits)) { $expout .= " {$question->options->showunits}\n"; } if (isset($question->options->unitsleft)) { $expout .= " {$question->options->unitsleft}\n"; } if (!empty($question->options->instructionsformat)) { $files = $fs->get_area_files($contextid, 'qtype_numerical', 'instruction', $question->id); $expout .= " format($question->options->instructionsformat) . ">\n"; $expout .= $this->writetext($question->options->instructions, 3); $expout .= $this->write_files($files); $expout .= " \n"; } break; case 'match': $expout .= " " . $this->get_single($question->options->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 .= " format($subquestion->questiontextformat) . ">\n"; $expout .= $this->writetext($subquestion->questiontext, 3); $expout .= $this->write_files($files); $expout .= " \n"; $expout .= $this->writetext($subquestion->answertext, 4); $expout .= " \n"; $expout .= " \n"; } break; case 'description': // Nothing else to do. break; case 'multianswer': foreach ($question->options->questions as $index => $subq) { $expout = preg_replace('~{#' . $index . '}~', $subq->questiontext, $expout); } break; case 'essay': $expout .= " " . $question->options->responseformat . "\n"; $expout .= " " . $question->options->responsefieldlines . "\n"; $expout .= " " . $question->options->attachments . "\n"; $expout .= " 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 .= " \n"; break; case 'calculated': case 'calculatedsimple': case 'calculatedmulti': $expout .= " {$question->options->synchronize}\n"; $expout .= " {$question->options->single}\n"; $expout .= " " . $question->options->answernumbering . "\n"; $expout .= " " . $question->options->shuffleanswers . "\n"; $component = 'qtype_' . $question->qtype; $files = $fs->get_area_files($contextid, $component, 'correctfeedback', $question->id); $expout .= " \n"; $expout .= $this->writetext($question->options->correctfeedback, 3); $expout .= $this->write_files($files); $expout .= " \n"; $files = $fs->get_area_files($contextid, $component, 'partiallycorrectfeedback', $question->id); $expout .= " \n"; $expout .= $this->writetext($question->options->partiallycorrectfeedback, 3); $expout .= $this->write_files($files); $expout .= " \n"; $files = $fs->get_area_files($contextid, $component, 'incorrectfeedback', $question->id); $expout .= " \n"; $expout .= $this->writetext($question->options->incorrectfeedback, 3); $expout .= $this->write_files($files); $expout .= " \n"; foreach ($question->options->answers as $answer) { $percent = 100 * $answer->fraction; $expout .= "\n"; // "" tags are an added feature, old files won't have them $expout .= " {$answer->answer}\n"; $expout .= " {$answer->tolerance}\n"; $expout .= " {$answer->tolerancetype}\n"; $expout .= " " . $answer->correctanswerformat . "\n"; $expout .= " " . $answer->correctanswerlength . "\n"; $expout .= " 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 .= " \n"; $expout .= "\n"; } if (isset($question->options->unitgradingtype)) { $expout .= " " . $question->options->unitgradingtype . "\n"; } if (isset($question->options->unitpenalty)) { $expout .= " " . $question->options->unitpenalty . "\n"; } if (isset($question->options->showunits)) { $expout .= " {$question->options->showunits}\n"; } if (isset($question->options->unitsleft)) { $expout .= " {$question->options->unitsleft}\n"; } if (isset($question->options->instructionsformat)) { $files = $fs->get_area_files($contextid, $component, 'instruction', $question->id); $expout .= " format($question->options->instructionsformat) . ">\n"; $expout .= $this->writetext($question->options->instructions, 3); $expout .= $this->write_files($files); $expout .= " \n"; } if (isset($question->options->units)) { $units = $question->options->units; if (count($units)) { $expout .= "\n"; foreach ($units as $unit) { $expout .= " \n"; $expout .= " {$unit->multiplier}\n"; $expout .= " {$unit->unit}\n"; $expout .= " \n"; } $expout .= "\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 .= "\n"; foreach ($question->options->datasets as $def) { $expout .= "\n"; $expout .= " ".$this->writetext($def->status)."\n"; $expout .= " ".$this->writetext($def->name)."\n"; if ($question->qtype == CALCULATED) { $expout .= " calculated\n"; } else { $expout .= " calculatedsimple\n"; } $expout .= " " . $this->writetext($def->distribution) . "\n"; $expout .= " " . $this->writetext($def->minimum) . "\n"; $expout .= " " . $this->writetext($def->maximum) . "\n"; $expout .= " " . $this->writetext($def->decimals) . "\n"; $expout .= " $def->itemcount\n"; if ($def->itemcount > 0) { $expout .= " \n"; foreach ($def->items as $item) { $expout .= " \n"; $expout .= " ".$item->itemnumber."\n"; $expout .= " ".$item->value."\n"; $expout .= " \n"; } $expout .= " \n"; $expout .= " " . $def->number_of_items . "\n"; } $expout .= "\n"; } $expout .= "\n"; } break; default: // try support by optional plugin if (!$data = $this->try_exporting_using_qtypes($question->qtype, $question)) { notify(get_string('unsupportedexport', 'qformat_xml', $question->qtype)); } $expout .= $data; } // Output any hints. $expout .= $this->write_hints($question); // Write the question tags. if (!empty($CFG->usetags)) { require_once($CFG->dirroot.'/tag/lib.php'); $tags = tag_get_tags_array('question', $question->id); if (!empty($tags)) { $expout .= " \n"; foreach ($tags as $tag) { $expout .= " " . $this->writetext($tag, 0, true) . "\n"; } $expout .= " \n"; } } // close the question tag $expout .= " \n"; 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 .= " format($answer->answerformat)}>\n"; $output .= $this->writetext($answer->answer, 3); $output .= $this->write_files($answer->answerfiles); $output .= " format($answer->feedbackformat)}>\n"; $output .= $this->writetext($answer->feedback, 4); $output .= $this->write_files($answer->feedbackfiles); $output .= " \n"; $output .= $extra; $output .= " \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 .= " format($hint->hintformat)}>\n"; $output .= ' ' . $this->writetext($hint->hint); if (!empty($hint->shownumcorrect)) { $output .= " \n"; } if (!empty($hint->clearwrong)) { $output .= " \n"; } if (!empty($hint->options)) { $output .= ' ' . $this->xml_escape($hint->options) . "\n"; } $output .= $this->write_files($files); $output .= " \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 .= " \n"; } if (!empty($questionoptions->shownumcorrect)) { $output .= " \n"; } return $output; } }