.
/**
* 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
* @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 $CFG, $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 .= ' ';
}
// Restore files in generalfeedback.
$generalfeedback = $this->import_text_with_files($question,
array('#', 'generalfeedback', 0), $qo->generalfeedback, $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.
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();
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 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) {
global $USER;
question_bank::get_qtype('multianswer');
$questiontext = $this->import_text_with_files($question,
array('#', 'questiontext', 0));
$qo = qtype_multianswer_extract_question($questiontext);
// Header parts particular to multianswer.
$qo->qtype = 'multianswer';
$qo->course = $this->course;
$qo->name = $this->clean_question_name($this->import_text($question['#']['name'][0]['#']['text']));
$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 .= ' ';
}
// Restore files in generalfeedback.
$generalfeedback = $this->import_text_with_files($question,
array('#', 'generalfeedback', 0), $qo->generalfeedback, $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));
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 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->graderinfo = $this->import_text_with_files($question,
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 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:
*
* 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.
* @param stdClass $context
* @return array (of objects) question objects.
*/
protected 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) {
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 .= "\n";
}
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;
$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 .= "\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 = str_replace('{#' . $index . '}', $subq->questiontext, $expout);
}
break;
case 'essay':
$expout .= " " . $question->options->responseformat .
"\n";
$expout .= " " . $question->options->responserequired .
"\n";
$expout .= " " . $question->options->responsefieldlines .
"\n";
$expout .= " " . $question->options->attachments .
"\n";
$expout .= " " . $question->options->attachmentsrequired .
"\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";
$expout .= " format($question->options->responsetemplateformat) . ">\n";
$expout .= $this->writetext($question->options->responsetemplate, 3);
$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";
// The "" 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)) {
$invalidquestion = true;
} else {
$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";
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 .= " 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 .= " {$field}>\n";
}
if (!empty($questionoptions->shownumcorrect)) {
$output .= " \n";
}
return $output;
}
}