Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401] [Versions 401 and 402] [Versions 401 and 403]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * Code for exporting questions as Moodle XML.
  19   *
  20   * @package    qformat_xml
  21   * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
  22   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  
  26  defined('MOODLE_INTERNAL') || die();
  27  
  28  require_once($CFG->libdir . '/xmlize.php');
  29  if (!class_exists('qformat_default')) {
  30      // This is ugly, but this class is also (ab)used by mod/lesson, which defines
  31      // a different base class in mod/lesson/format.php. Thefore, we can only
  32      // include the proper base class conditionally like this. (We have to include
  33      // the base class like this, otherwise it breaks third-party question types.)
  34      // This may be reviewd, and a better fix found one day.
  35      require_once($CFG->dirroot . '/question/format.php');
  36  }
  37  
  38  
  39  /**
  40   * Importer for Moodle XML question format.
  41   *
  42   * See http://docs.moodle.org/en/Moodle_XML_format for a description of the format.
  43   *
  44   * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
  45   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  46   */
  47  class qformat_xml extends qformat_default {
  48  
  49      public function provide_import() {
  50          return true;
  51      }
  52  
  53      public function provide_export() {
  54          return true;
  55      }
  56  
  57      public function mime_type() {
  58          return 'application/xml';
  59      }
  60  
  61      /**
  62       * Validate the given file.
  63       *
  64       * For more expensive or detailed integrity checks.
  65       *
  66       * @param stored_file $file the file to check
  67       * @return string the error message that occurred while validating the given file
  68       */
  69      public function validate_file(stored_file $file): string {
  70          return $this->validate_is_utf8_file($file);
  71      }
  72  
  73      // IMPORT FUNCTIONS START HERE.
  74  
  75      /**
  76       * Translate human readable format name
  77       * into internal Moodle code number
  78       * Note the reverse function is called get_format.
  79       * @param string name format name from xml file
  80       * @return int Moodle format code
  81       */
  82      public function trans_format($name) {
  83          $name = trim($name);
  84  
  85          if ($name == 'moodle_auto_format') {
  86              return FORMAT_MOODLE;
  87          } else if ($name == 'html') {
  88              return FORMAT_HTML;
  89          } else if ($name == 'plain_text') {
  90              return FORMAT_PLAIN;
  91          } else if ($name == 'wiki_like') {
  92              return FORMAT_WIKI;
  93          } else if ($name == 'markdown') {
  94              return FORMAT_MARKDOWN;
  95          } else {
  96              debugging("Unrecognised text format '{$name}' in the import file. Assuming 'html'.");
  97              return FORMAT_HTML;
  98          }
  99      }
 100  
 101      /**
 102       * Translate human readable single answer option
 103       * to internal code number
 104       * @param string name true/false
 105       * @return int internal code number
 106       */
 107      public function trans_single($name) {
 108          $name = trim($name);
 109          if ($name == "false" || !$name) {
 110              return 0;
 111          } else {
 112              return 1;
 113          }
 114      }
 115  
 116      /**
 117       * process text string from xml file
 118       * @param array $text bit of xml tree after ['text']
 119       * @return string processed text.
 120       */
 121      public function import_text($text) {
 122          // Quick sanity check.
 123          if (empty($text)) {
 124              return '';
 125          }
 126          $data = $text[0]['#'];
 127          return trim($data);
 128      }
 129  
 130      /**
 131       * return the value of a node, given a path to the node
 132       * if it doesn't exist return the default value
 133       * @param array xml data to read
 134       * @param array path path to node expressed as array
 135       * @param mixed default
 136       * @param bool istext process as text
 137       * @param string error if set value must exist, return false and issue message if not
 138       * @return mixed value
 139       */
 140      public function getpath($xml, $path, $default, $istext=false, $error='') {
 141          foreach ($path as $index) {
 142              if (!isset($xml[$index])) {
 143                  if (!empty($error)) {
 144                      $this->error($error);
 145                      return false;
 146                  } else {
 147                      return $default;
 148                  }
 149              }
 150  
 151              $xml = $xml[$index];
 152          }
 153  
 154          if ($istext) {
 155              if (!is_string($xml)) {
 156                  $this->error(get_string('invalidxml', 'qformat_xml'));
 157              }
 158              $xml = trim($xml);
 159          }
 160  
 161          return $xml;
 162      }
 163  
 164      public function import_text_with_files($data, $path, $defaultvalue = '', $defaultformat = 'html') {
 165          $field  = array();
 166          $field['text'] = $this->getpath($data,
 167                  array_merge($path, array('#', 'text', 0, '#')), $defaultvalue, true);
 168          $field['format'] = $this->trans_format($this->getpath($data,
 169                  array_merge($path, array('@', 'format')), $defaultformat));
 170          $itemid = $this->import_files_as_draft($this->getpath($data,
 171                  array_merge($path, array('#', 'file')), array(), false));
 172          if (!empty($itemid)) {
 173              $field['itemid'] = $itemid;
 174          }
 175          return $field;
 176      }
 177  
 178      public function import_files_as_draft($xml) {
 179          global $USER;
 180          if (empty($xml)) {
 181              return null;
 182          }
 183          $fs = get_file_storage();
 184          $itemid = file_get_unused_draft_itemid();
 185          $filepaths = array();
 186          foreach ($xml as $file) {
 187              $filename = $this->getpath($file, array('@', 'name'), '', true);
 188              $filepath = $this->getpath($file, array('@', 'path'), '/', true);
 189              $fullpath = $filepath . $filename;
 190              if (in_array($fullpath, $filepaths)) {
 191                  debugging('Duplicate file in XML: ' . $fullpath, DEBUG_DEVELOPER);
 192                  continue;
 193              }
 194              $filerecord = array(
 195                  'contextid' => context_user::instance($USER->id)->id,
 196                  'component' => 'user',
 197                  'filearea'  => 'draft',
 198                  'itemid'    => $itemid,
 199                  'filepath'  => $filepath,
 200                  'filename'  => $filename,
 201              );
 202              $fs->create_file_from_string($filerecord, base64_decode($file['#']));
 203              $filepaths[] = $fullpath;
 204          }
 205          return $itemid;
 206      }
 207  
 208      /**
 209       * import parts of question common to all types
 210       * @param $question array question question array from xml tree
 211       * @return object question object
 212       */
 213      public function import_headers($question) {
 214          global $USER;
 215  
 216          // This routine initialises the question object.
 217          $qo = $this->defaultquestion();
 218  
 219          // Question name.
 220          $qo->name = $this->clean_question_name($this->getpath($question,
 221                  array('#', 'name', 0, '#', 'text', 0, '#'), '', true,
 222                  get_string('xmlimportnoname', 'qformat_xml')));
 223          $questiontext = $this->import_text_with_files($question,
 224                  array('#', 'questiontext', 0));
 225          $qo->questiontext = $questiontext['text'];
 226          $qo->questiontextformat = $questiontext['format'];
 227          if (!empty($questiontext['itemid'])) {
 228              $qo->questiontextitemid = $questiontext['itemid'];
 229          }
 230          // Backwards compatibility, deal with the old image tag.
 231          $filedata = $this->getpath($question, array('#', 'image_base64', '0', '#'), null, false);
 232          $filename = $this->getpath($question, array('#', 'image', '0', '#'), null, false);
 233          if ($filedata && $filename) {
 234              $fs = get_file_storage();
 235              if (empty($qo->questiontextitemid)) {
 236                  $qo->questiontextitemid = file_get_unused_draft_itemid();
 237              }
 238              $filename = clean_param(str_replace('/', '_', $filename), PARAM_FILE);
 239              $filerecord = array(
 240                  'contextid' => context_user::instance($USER->id)->id,
 241                  'component' => 'user',
 242                  'filearea'  => 'draft',
 243                  'itemid'    => $qo->questiontextitemid,
 244                  'filepath'  => '/',
 245                  'filename'  => $filename,
 246              );
 247              $fs->create_file_from_string($filerecord, base64_decode($filedata));
 248              $qo->questiontext .= ' <img src="@@PLUGINFILE@@/' . $filename . '" />';
 249          }
 250  
 251          $qo->idnumber = $this->getpath($question, ['#', 'idnumber', 0, '#'], null);
 252  
 253          // Restore files in generalfeedback.
 254          $generalfeedback = $this->import_text_with_files($question,
 255                  array('#', 'generalfeedback', 0), '', $this->get_format($qo->questiontextformat));
 256          $qo->generalfeedback = $generalfeedback['text'];
 257          $qo->generalfeedbackformat = $generalfeedback['format'];
 258          if (!empty($generalfeedback['itemid'])) {
 259              $qo->generalfeedbackitemid = $generalfeedback['itemid'];
 260          }
 261  
 262          $qo->defaultmark = $this->getpath($question,
 263                  array('#', 'defaultgrade', 0, '#'), $qo->defaultmark);
 264          $qo->penalty = $this->getpath($question,
 265                  array('#', 'penalty', 0, '#'), $qo->penalty);
 266  
 267          // Fix problematic rounding from old files.
 268          if (abs($qo->penalty - 0.3333333) < 0.005) {
 269              $qo->penalty = 0.3333333;
 270          }
 271  
 272          // Read the question tags.
 273          $this->import_question_tags($qo, $question);
 274  
 275          return $qo;
 276      }
 277  
 278      /**
 279       * Import the common parts of a single answer
 280       * @param array answer xml tree for single answer
 281       * @param bool $withanswerfiles if true, the answers are HTML (or $defaultformat)
 282       *      and so may contain files, otherwise the answers are plain text.
 283       * @param array Default text format for the feedback, and the answers if $withanswerfiles
 284       *      is true.
 285       * @return object answer object
 286       */
 287      public function import_answer($answer, $withanswerfiles = false, $defaultformat = 'html') {
 288          $ans = new stdClass();
 289  
 290          if ($withanswerfiles) {
 291              $ans->answer = $this->import_text_with_files($answer, array(), '', $defaultformat);
 292          } else {
 293              $ans->answer = array();
 294              $ans->answer['text']   = $this->getpath($answer, array('#', 'text', 0, '#'), '', true);
 295              $ans->answer['format'] = FORMAT_PLAIN;
 296          }
 297  
 298          $ans->feedback = $this->import_text_with_files($answer, array('#', 'feedback', 0), '', $defaultformat);
 299  
 300          $ans->fraction = $this->getpath($answer, array('@', 'fraction'), 0) / 100;
 301  
 302          return $ans;
 303      }
 304  
 305      /**
 306       * Import the common overall feedback fields.
 307       * @param object $question the part of the XML relating to this question.
 308       * @param object $qo the question data to add the fields to.
 309       * @param bool $withshownumpartscorrect include the shownumcorrect field.
 310       */
 311      public function import_combined_feedback($qo, $questionxml, $withshownumpartscorrect = false) {
 312          $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback');
 313          foreach ($fields as $field) {
 314              $qo->$field = $this->import_text_with_files($questionxml,
 315                      array('#', $field, 0), '', $this->get_format($qo->questiontextformat));
 316          }
 317  
 318          if ($withshownumpartscorrect) {
 319              $qo->shownumcorrect = array_key_exists('shownumcorrect', $questionxml['#']);
 320  
 321              // Backwards compatibility.
 322              if (array_key_exists('correctresponsesfeedback', $questionxml['#'])) {
 323                  $qo->shownumcorrect = $this->trans_single($this->getpath($questionxml,
 324                          array('#', 'correctresponsesfeedback', 0, '#'), 1));
 325              }
 326          }
 327      }
 328  
 329      /**
 330       * Import a question hint
 331       * @param array $hintxml hint xml fragment.
 332       * @param string $defaultformat the text format to assume for hints that do not specify.
 333       * @return object hint for storing in the database.
 334       */
 335      public function import_hint($hintxml, $defaultformat) {
 336          $hint = new stdClass();
 337          if (array_key_exists('hintcontent', $hintxml['#'])) {
 338              // Backwards compatibility.
 339  
 340              $hint->hint = $this->import_text_with_files($hintxml,
 341                      array('#', 'hintcontent', 0), '', $defaultformat);
 342  
 343              $hint->shownumcorrect = $this->getpath($hintxml,
 344                      array('#', 'statenumberofcorrectresponses', 0, '#'), 0);
 345              $hint->clearwrong = $this->getpath($hintxml,
 346                      array('#', 'clearincorrectresponses', 0, '#'), 0);
 347              $hint->options = $this->getpath($hintxml,
 348                      array('#', 'showfeedbacktoresponses', 0, '#'), 0);
 349  
 350              return $hint;
 351          }
 352          $hint->hint = $this->import_text_with_files($hintxml, array(), '', $defaultformat);
 353          $hint->shownumcorrect = array_key_exists('shownumcorrect', $hintxml['#']);
 354          $hint->clearwrong = array_key_exists('clearwrong', $hintxml['#']);
 355          $hint->options = $this->getpath($hintxml, array('#', 'options', 0, '#'), '', true);
 356  
 357          return $hint;
 358      }
 359  
 360      /**
 361       * Import all the question hints
 362       *
 363       * @param object $qo the question data that is being constructed.
 364       * @param array $questionxml The xml representing the question.
 365       * @param bool $withparts whether the extra fields relating to parts should be imported.
 366       * @param bool $withoptions whether the extra options field should be imported.
 367       * @param string $defaultformat the text format to assume for hints that do not specify.
 368       * @return array of objects representing the hints in the file.
 369       */
 370      public function import_hints($qo, $questionxml, $withparts = false,
 371              $withoptions = false, $defaultformat = 'html') {
 372          if (!isset($questionxml['#']['hint'])) {
 373              return;
 374          }
 375  
 376          foreach ($questionxml['#']['hint'] as $hintxml) {
 377              $hint = $this->import_hint($hintxml, $defaultformat);
 378              $qo->hint[] = $hint->hint;
 379  
 380              if ($withparts) {
 381                  $qo->hintshownumcorrect[] = $hint->shownumcorrect;
 382                  $qo->hintclearwrong[] = $hint->clearwrong;
 383              }
 384  
 385              if ($withoptions) {
 386                  $qo->hintoptions[] = $hint->options;
 387              }
 388          }
 389      }
 390  
 391      /**
 392       * Import all the question tags
 393       *
 394       * @param object $qo the question data that is being constructed.
 395       * @param array $questionxml The xml representing the question.
 396       * @return array of objects representing the tags in the file.
 397       */
 398      public function import_question_tags($qo, $questionxml) {
 399          global $CFG;
 400  
 401          if (core_tag_tag::is_enabled('core_question', 'question')) {
 402  
 403              $qo->tags = [];
 404              if (!empty($questionxml['#']['tags'][0]['#']['tag'])) {
 405                  foreach ($questionxml['#']['tags'][0]['#']['tag'] as $tagdata) {
 406                      $qo->tags[] = $this->getpath($tagdata, array('#', 'text', 0, '#'), '', true);
 407                  }
 408              }
 409  
 410              $qo->coursetags = [];
 411              if (!empty($questionxml['#']['coursetags'][0]['#']['tag'])) {
 412                  foreach ($questionxml['#']['coursetags'][0]['#']['tag'] as $tagdata) {
 413                      $qo->coursetags[] = $this->getpath($tagdata, array('#', 'text', 0, '#'), '', true);
 414                  }
 415              }
 416          }
 417      }
 418  
 419      /**
 420       * Import files from a node in the XML.
 421       * @param array $xml an array of <file> nodes from the the parsed XML.
 422       * @return array of things representing files - in the form that save_question expects.
 423       */
 424      public function import_files($xml) {
 425          $files = array();
 426          foreach ($xml as $file) {
 427              $data = new stdClass();
 428              $data->content = $file['#'];
 429              $data->encoding = $file['@']['encoding'];
 430              $data->name = $file['@']['name'];
 431              $files[] = $data;
 432          }
 433          return $files;
 434      }
 435  
 436      /**
 437       * import multiple choice question
 438       * @param array question question array from xml tree
 439       * @return object question object
 440       */
 441      public function import_multichoice($question) {
 442          // Get common parts.
 443          $qo = $this->import_headers($question);
 444  
 445          // Header parts particular to multichoice.
 446          $qo->qtype = 'multichoice';
 447          $single = $this->getpath($question, array('#', 'single', 0, '#'), 'true');
 448          $qo->single = $this->trans_single($single);
 449          $shuffleanswers = $this->getpath($question,
 450                  array('#', 'shuffleanswers', 0, '#'), 'false');
 451          $qo->answernumbering = $this->getpath($question,
 452                  array('#', 'answernumbering', 0, '#'), 'abc');
 453          $qo->shuffleanswers = $this->trans_single($shuffleanswers);
 454          $qo->showstandardinstruction = $this->getpath($question,
 455              array('#', 'showstandardinstruction', 0, '#'), '1');
 456  
 457          // There was a time on the 1.8 branch when it could output an empty
 458          // answernumbering tag, so fix up any found.
 459          if (empty($qo->answernumbering)) {
 460              $qo->answernumbering = 'abc';
 461          }
 462  
 463          // Run through the answers.
 464          $answers = $question['#']['answer'];
 465          $acount = 0;
 466          foreach ($answers as $answer) {
 467              $ans = $this->import_answer($answer, true, $this->get_format($qo->questiontextformat));
 468              $qo->answer[$acount] = $ans->answer;
 469              $qo->fraction[$acount] = $ans->fraction;
 470              $qo->feedback[$acount] = $ans->feedback;
 471              ++$acount;
 472          }
 473  
 474          $this->import_combined_feedback($qo, $question, true);
 475          $this->import_hints($qo, $question, true, false, $this->get_format($qo->questiontextformat));
 476  
 477          return $qo;
 478      }
 479  
 480      /**
 481       * Import cloze type question
 482       * @param array question question array from xml tree
 483       * @return object question object
 484       */
 485      public function import_multianswer($question) {
 486          global $USER;
 487          question_bank::get_qtype('multianswer');
 488  
 489          $questiontext = $this->import_text_with_files($question,
 490                  array('#', 'questiontext', 0));
 491          $qo = qtype_multianswer_extract_question($questiontext);
 492          $errors = qtype_multianswer_validate_question($qo);
 493          if ($errors) {
 494              $this->error(get_string('invalidmultianswerquestion', 'qtype_multianswer', implode(' ', $errors)));
 495              return null;
 496          }
 497  
 498          // Header parts particular to multianswer.
 499          $qo->qtype = 'multianswer';
 500  
 501          // Only set the course if the data is available.
 502          if (isset($this->course)) {
 503              $qo->course = $this->course;
 504          }
 505          if (isset($question['#']['name'])) {
 506              $qo->name = $this->clean_question_name($this->import_text($question['#']['name'][0]['#']['text']));
 507          } else {
 508              $qo->name = $this->create_default_question_name($qo->questiontext['text'],
 509                      get_string('questionname', 'question'));
 510          }
 511          $qo->questiontextformat = $questiontext['format'];
 512          $qo->questiontext = $qo->questiontext['text'];
 513          if (!empty($questiontext['itemid'])) {
 514              $qo->questiontextitemid = $questiontext['itemid'];
 515          }
 516  
 517          // Backwards compatibility, deal with the old image tag.
 518          $filedata = $this->getpath($question, array('#', 'image_base64', '0', '#'), null, false);
 519          $filename = $this->getpath($question, array('#', 'image', '0', '#'), null, false);
 520          if ($filedata && $filename) {
 521              $fs = get_file_storage();
 522              if (empty($qo->questiontextitemid)) {
 523                  $qo->questiontextitemid = file_get_unused_draft_itemid();
 524              }
 525              $filename = clean_param(str_replace('/', '_', $filename), PARAM_FILE);
 526              $filerecord = array(
 527                  'contextid' => context_user::instance($USER->id)->id,
 528                  'component' => 'user',
 529                  'filearea'  => 'draft',
 530                  'itemid'    => $qo->questiontextitemid,
 531                  'filepath'  => '/',
 532                  'filename'  => $filename,
 533              );
 534              $fs->create_file_from_string($filerecord, base64_decode($filedata));
 535              $qo->questiontext .= ' <img src="@@PLUGINFILE@@/' . $filename . '" />';
 536          }
 537  
 538          $qo->idnumber = $this->getpath($question, ['#', 'idnumber', 0, '#'], null);
 539  
 540          // Restore files in generalfeedback.
 541          $generalfeedback = $this->import_text_with_files($question,
 542                  array('#', 'generalfeedback', 0), '', $this->get_format($qo->questiontextformat));
 543          $qo->generalfeedback = $generalfeedback['text'];
 544          $qo->generalfeedbackformat = $generalfeedback['format'];
 545          if (!empty($generalfeedback['itemid'])) {
 546              $qo->generalfeedbackitemid = $generalfeedback['itemid'];
 547          }
 548  
 549          $qo->penalty = $this->getpath($question,
 550                  array('#', 'penalty', 0, '#'), $this->defaultquestion()->penalty);
 551          // Fix problematic rounding from old files.
 552          if (abs($qo->penalty - 0.3333333) < 0.005) {
 553              $qo->penalty = 0.3333333;
 554          }
 555  
 556          $this->import_hints($qo, $question, true, false, $this->get_format($qo->questiontextformat));
 557          $this->import_question_tags($qo, $question);
 558  
 559          return $qo;
 560      }
 561  
 562      /**
 563       * Import true/false type question
 564       * @param array question question array from xml tree
 565       * @return object question object
 566       */
 567      public function import_truefalse($question) {
 568          // Get common parts.
 569          global $OUTPUT;
 570          $qo = $this->import_headers($question);
 571  
 572          // Header parts particular to true/false.
 573          $qo->qtype = 'truefalse';
 574  
 575          // In the past, it used to be assumed that the two answers were in the file
 576          // true first, then false. Howevever that was not always true. Now, we
 577          // try to match on the answer text, but in old exports, this will be a localised
 578          // string, so if we don't find true or false, we fall back to the old system.
 579          $first = true;
 580          $warning = false;
 581          foreach ($question['#']['answer'] as $answer) {
 582              $answertext = $this->getpath($answer,
 583                      array('#', 'text', 0, '#'), '', true);
 584              $feedback = $this->import_text_with_files($answer,
 585                      array('#', 'feedback', 0), '', $this->get_format($qo->questiontextformat));
 586  
 587              if ($answertext != 'true' && $answertext != 'false') {
 588                  // Old style file, assume order is true/false.
 589                  $warning = true;
 590                  if ($first) {
 591                      $answertext = 'true';
 592                  } else {
 593                      $answertext = 'false';
 594                  }
 595              }
 596  
 597              if ($answertext == 'true') {
 598                  $qo->answer = ($answer['@']['fraction'] == 100);
 599                  $qo->correctanswer = $qo->answer;
 600                  $qo->feedbacktrue = $feedback;
 601              } else {
 602                  $qo->answer = ($answer['@']['fraction'] != 100);
 603                  $qo->correctanswer = $qo->answer;
 604                  $qo->feedbackfalse = $feedback;
 605              }
 606              $first = false;
 607          }
 608  
 609          if ($warning) {
 610              $a = new stdClass();
 611              $a->questiontext = $qo->questiontext;
 612              $a->answer = get_string($qo->correctanswer ? 'true' : 'false', 'qtype_truefalse');
 613              echo $OUTPUT->notification(get_string('truefalseimporterror', 'qformat_xml', $a));
 614          }
 615  
 616          $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));
 617  
 618          return $qo;
 619      }
 620  
 621      /**
 622       * Import short answer type question
 623       * @param array question question array from xml tree
 624       * @return object question object
 625       */
 626      public function import_shortanswer($question) {
 627          // Get common parts.
 628          $qo = $this->import_headers($question);
 629  
 630          // Header parts particular to shortanswer.
 631          $qo->qtype = 'shortanswer';
 632  
 633          // Get usecase.
 634          $qo->usecase = $this->getpath($question, array('#', 'usecase', 0, '#'), $qo->usecase);
 635  
 636          // Run through the answers.
 637          $answers = $question['#']['answer'];
 638          $acount = 0;
 639          foreach ($answers as $answer) {
 640              $ans = $this->import_answer($answer, false, $this->get_format($qo->questiontextformat));
 641              $qo->answer[$acount] = $ans->answer['text'];
 642              $qo->fraction[$acount] = $ans->fraction;
 643              $qo->feedback[$acount] = $ans->feedback;
 644              ++$acount;
 645          }
 646  
 647          $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));
 648  
 649          return $qo;
 650      }
 651  
 652      /**
 653       * Import description type question
 654       * @param array question question array from xml tree
 655       * @return object question object
 656       */
 657      public function import_description($question) {
 658          // Get common parts.
 659          $qo = $this->import_headers($question);
 660          // Header parts particular to shortanswer.
 661          $qo->qtype = 'description';
 662          $qo->defaultmark = 0;
 663          $qo->length = 0;
 664          return $qo;
 665      }
 666  
 667      /**
 668       * Import numerical type question
 669       * @param array question question array from xml tree
 670       * @return object question object
 671       */
 672      public function import_numerical($question) {
 673          // Get common parts.
 674          $qo = $this->import_headers($question);
 675  
 676          // Header parts particular to numerical.
 677          $qo->qtype = 'numerical';
 678  
 679          // Get answers array.
 680          $answers = $question['#']['answer'];
 681          $qo->answer = array();
 682          $qo->feedback = array();
 683          $qo->fraction = array();
 684          $qo->tolerance = array();
 685          foreach ($answers as $answer) {
 686              // Answer outside of <text> is deprecated.
 687              $obj = $this->import_answer($answer, false, $this->get_format($qo->questiontextformat));
 688              $qo->answer[] = $obj->answer['text'];
 689              if (empty($qo->answer)) {
 690                  $qo->answer = '*';
 691              }
 692              $qo->feedback[]  = $obj->feedback;
 693              $qo->tolerance[] = $this->getpath($answer, array('#', 'tolerance', 0, '#'), 0);
 694  
 695              // Fraction as a tag is deprecated.
 696              $fraction = $this->getpath($answer, array('@', 'fraction'), 0) / 100;
 697              $qo->fraction[] = $this->getpath($answer,
 698                      array('#', 'fraction', 0, '#'), $fraction); // Deprecated.
 699          }
 700  
 701          // Get the units array.
 702          $qo->unit = array();
 703          $units = $this->getpath($question, array('#', 'units', 0, '#', 'unit'), array());
 704          if (!empty($units)) {
 705              $qo->multiplier = array();
 706              foreach ($units as $unit) {
 707                  $qo->multiplier[] = $this->getpath($unit, array('#', 'multiplier', 0, '#'), 1);
 708                  $qo->unit[] = $this->getpath($unit, array('#', 'unit_name', 0, '#'), '', true);
 709              }
 710          }
 711          $qo->unitgradingtype = $this->getpath($question, array('#', 'unitgradingtype', 0, '#'), 0);
 712          $qo->unitpenalty = $this->getpath($question, array('#', 'unitpenalty', 0, '#'), 0.1);
 713          $qo->showunits = $this->getpath($question, array('#', 'showunits', 0, '#'), null);
 714          $qo->unitsleft = $this->getpath($question, array('#', 'unitsleft', 0, '#'), 0);
 715          $qo->instructions['text'] = '';
 716          $qo->instructions['format'] = FORMAT_HTML;
 717          $instructions = $this->getpath($question, array('#', 'instructions'), array());
 718          if (!empty($instructions)) {
 719              $qo->instructions = $this->import_text_with_files($instructions,
 720                      array('0'), '', $this->get_format($qo->questiontextformat));
 721          }
 722  
 723          if (is_null($qo->showunits)) {
 724              // Set a good default, depending on whether there are any units defined.
 725              if (empty($qo->unit)) {
 726                  $qo->showunits = 3; // This is qtype_numerical::UNITNONE, but we cannot refer to that constant here.
 727              } else {
 728                  $qo->showunits = 0; // This is qtype_numerical::UNITOPTIONAL, but we cannot refer to that constant here.
 729              }
 730          }
 731  
 732          $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));
 733  
 734          return $qo;
 735      }
 736  
 737      /**
 738       * Import matching type question
 739       * @param array question question array from xml tree
 740       * @return object question object
 741       */
 742      public function import_match($question) {
 743          // Get common parts.
 744          $qo = $this->import_headers($question);
 745  
 746          // Header parts particular to matching.
 747          $qo->qtype = 'match';
 748          $qo->shuffleanswers = $this->trans_single($this->getpath($question,
 749                  array('#', 'shuffleanswers', 0, '#'), 1));
 750  
 751          // Run through subquestions.
 752          $qo->subquestions = array();
 753          $qo->subanswers = array();
 754          foreach ($question['#']['subquestion'] as $subqxml) {
 755              $qo->subquestions[] = $this->import_text_with_files($subqxml,
 756                      array(), '', $this->get_format($qo->questiontextformat));
 757  
 758              $answers = $this->getpath($subqxml, array('#', 'answer'), array());
 759              $qo->subanswers[] = $this->getpath($subqxml,
 760                      array('#', 'answer', 0, '#', 'text', 0, '#'), '', true);
 761          }
 762  
 763          $this->import_combined_feedback($qo, $question, true);
 764          $this->import_hints($qo, $question, true, false, $this->get_format($qo->questiontextformat));
 765  
 766          return $qo;
 767      }
 768  
 769      /**
 770       * Import essay type question
 771       * @param array question question array from xml tree
 772       * @return object question object
 773       */
 774      public function import_essay($question) {
 775          // Get common parts.
 776          $qo = $this->import_headers($question);
 777  
 778          // Header parts particular to essay.
 779          $qo->qtype = 'essay';
 780  
 781          $qo->responseformat = $this->getpath($question,
 782                  array('#', 'responseformat', 0, '#'), 'editor');
 783          $qo->responsefieldlines = $this->getpath($question,
 784                  array('#', 'responsefieldlines', 0, '#'), 15);
 785          $qo->responserequired = $this->getpath($question,
 786                  array('#', 'responserequired', 0, '#'), 1);
 787          $qo->minwordlimit = $this->getpath($question,
 788                  array('#', 'minwordlimit', 0, '#'), null);
 789          $qo->minwordenabled = !empty($qo->minwordlimit);
 790          $qo->maxwordlimit = $this->getpath($question,
 791                  array('#', 'maxwordlimit', 0, '#'), null);
 792          $qo->maxwordenabled = !empty($qo->maxwordlimit);
 793          $qo->attachments = $this->getpath($question,
 794                  array('#', 'attachments', 0, '#'), 0);
 795          $qo->attachmentsrequired = $this->getpath($question,
 796                  array('#', 'attachmentsrequired', 0, '#'), 0);
 797          $qo->filetypeslist = $this->getpath($question,
 798                  array('#', 'filetypeslist', 0, '#'), null);
 799          $qo->maxbytes = $this->getpath($question,
 800                  array('#', 'maxbytes', 0, '#'), null);
 801          $qo->graderinfo = $this->import_text_with_files($question,
 802                  array('#', 'graderinfo', 0), '', $this->get_format($qo->questiontextformat));
 803          $qo->responsetemplate['text'] = $this->getpath($question,
 804                  array('#', 'responsetemplate', 0, '#', 'text', 0, '#'), '', true);
 805          $qo->responsetemplate['format'] = $this->trans_format($this->getpath($question,
 806                  array('#', 'responsetemplate', 0, '@', 'format'), $this->get_format($qo->questiontextformat)));
 807  
 808          return $qo;
 809      }
 810  
 811      /**
 812       * Import a calculated question
 813       * @param object $question the imported XML data.
 814       */
 815      public function import_calculated($question) {
 816  
 817          // Get common parts.
 818          $qo = $this->import_headers($question);
 819  
 820          // Header parts particular to calculated.
 821          $qo->qtype = 'calculated';
 822          $qo->synchronize = $this->getpath($question, array('#', 'synchronize', 0, '#'), 0);
 823          $single = $this->getpath($question, array('#', 'single', 0, '#'), 'true');
 824          $qo->single = $this->trans_single($single);
 825          $shuffleanswers = $this->getpath($question, array('#', 'shuffleanswers', 0, '#'), 'false');
 826          $qo->answernumbering = $this->getpath($question,
 827                  array('#', 'answernumbering', 0, '#'), 'abc');
 828          $qo->shuffleanswers = $this->trans_single($shuffleanswers);
 829  
 830          $this->import_combined_feedback($qo, $question);
 831  
 832          $qo->unitgradingtype = $this->getpath($question,
 833                  array('#', 'unitgradingtype', 0, '#'), 0);
 834          $qo->unitpenalty = $this->getpath($question, array('#', 'unitpenalty', 0, '#'), null);
 835          $qo->showunits = $this->getpath($question, array('#', 'showunits', 0, '#'), 0);
 836          $qo->unitsleft = $this->getpath($question, array('#', 'unitsleft', 0, '#'), 0);
 837          $qo->instructions = $this->getpath($question,
 838                  array('#', 'instructions', 0, '#', 'text', 0, '#'), '', true);
 839          if (!empty($instructions)) {
 840              $qo->instructions = $this->import_text_with_files($instructions,
 841                      array('0'), '', $this->get_format($qo->questiontextformat));
 842          }
 843  
 844          // Get answers array.
 845          $answers = $question['#']['answer'];
 846          $qo->answer = array();
 847          $qo->feedback = array();
 848          $qo->fraction = array();
 849          $qo->tolerance = array();
 850          $qo->tolerancetype = array();
 851          $qo->correctanswerformat = array();
 852          $qo->correctanswerlength = array();
 853          $qo->feedback = array();
 854          foreach ($answers as $answer) {
 855              $ans = $this->import_answer($answer, true, $this->get_format($qo->questiontextformat));
 856              // Answer outside of <text> is deprecated.
 857              if (empty($ans->answer['text'])) {
 858                  $ans->answer['text'] = '*';
 859              }
 860              $qo->answer[] = $ans->answer['text'];
 861              $qo->feedback[] = $ans->feedback;
 862              $qo->tolerance[] = $answer['#']['tolerance'][0]['#'];
 863              // Fraction as a tag is deprecated.
 864              if (!empty($answer['#']['fraction'][0]['#'])) {
 865                  $qo->fraction[] = $answer['#']['fraction'][0]['#'];
 866              } else {
 867                  $qo->fraction[] = $answer['@']['fraction'] / 100;
 868              }
 869              $qo->tolerancetype[] = $answer['#']['tolerancetype'][0]['#'];
 870              $qo->correctanswerformat[] = $answer['#']['correctanswerformat'][0]['#'];
 871              $qo->correctanswerlength[] = $answer['#']['correctanswerlength'][0]['#'];
 872          }
 873          // Get units array.
 874          $qo->unit = array();
 875          if (isset($question['#']['units'][0]['#']['unit'])) {
 876              $units = $question['#']['units'][0]['#']['unit'];
 877              $qo->multiplier = array();
 878              foreach ($units as $unit) {
 879                  $qo->multiplier[] = $unit['#']['multiplier'][0]['#'];
 880                  $qo->unit[] = $unit['#']['unit_name'][0]['#'];
 881              }
 882          }
 883          $instructions = $this->getpath($question, array('#', 'instructions'), array());
 884          if (!empty($instructions)) {
 885              $qo->instructions = $this->import_text_with_files($instructions,
 886                      array('0'), '', $this->get_format($qo->questiontextformat));
 887          }
 888  
 889          if (is_null($qo->unitpenalty)) {
 890              // Set a good default, depending on whether there are any units defined.
 891              if (empty($qo->unit)) {
 892                  $qo->showunits = 3; // This is qtype_numerical::UNITNONE, but we cannot refer to that constant here.
 893              } else {
 894                  $qo->showunits = 0; // This is qtype_numerical::UNITOPTIONAL, but we cannot refer to that constant here.
 895              }
 896          }
 897  
 898          $datasets = $question['#']['dataset_definitions'][0]['#']['dataset_definition'];
 899          $qo->dataset = array();
 900          $qo->datasetindex= 0;
 901          foreach ($datasets as $dataset) {
 902              $qo->datasetindex++;
 903              $qo->dataset[$qo->datasetindex] = new stdClass();
 904              $qo->dataset[$qo->datasetindex]->status =
 905                      $this->import_text($dataset['#']['status'][0]['#']['text']);
 906              $qo->dataset[$qo->datasetindex]->name =
 907                      $this->import_text($dataset['#']['name'][0]['#']['text']);
 908              $qo->dataset[$qo->datasetindex]->type =
 909                      $dataset['#']['type'][0]['#'];
 910              $qo->dataset[$qo->datasetindex]->distribution =
 911                      $this->import_text($dataset['#']['distribution'][0]['#']['text']);
 912              $qo->dataset[$qo->datasetindex]->max =
 913                      $this->import_text($dataset['#']['maximum'][0]['#']['text']);
 914              $qo->dataset[$qo->datasetindex]->min =
 915                      $this->import_text($dataset['#']['minimum'][0]['#']['text']);
 916              $qo->dataset[$qo->datasetindex]->length =
 917                      $this->import_text($dataset['#']['decimals'][0]['#']['text']);
 918              $qo->dataset[$qo->datasetindex]->distribution =
 919                      $this->import_text($dataset['#']['distribution'][0]['#']['text']);
 920              $qo->dataset[$qo->datasetindex]->itemcount = $dataset['#']['itemcount'][0]['#'];
 921              $qo->dataset[$qo->datasetindex]->datasetitem = array();
 922              $qo->dataset[$qo->datasetindex]->itemindex = 0;
 923              $qo->dataset[$qo->datasetindex]->number_of_items = $this->getpath($dataset,
 924                      array('#', 'number_of_items', 0, '#'), 0);
 925              $datasetitems = $this->getpath($dataset,
 926                      array('#', 'dataset_items', 0, '#', 'dataset_item'), array());
 927              foreach ($datasetitems as $datasetitem) {
 928                  $qo->dataset[$qo->datasetindex]->itemindex++;
 929                  $qo->dataset[$qo->datasetindex]->datasetitem[
 930                          $qo->dataset[$qo->datasetindex]->itemindex] = new stdClass();
 931                  $qo->dataset[$qo->datasetindex]->datasetitem[
 932                          $qo->dataset[$qo->datasetindex]->itemindex]->itemnumber =
 933                                  $datasetitem['#']['number'][0]['#'];
 934                  $qo->dataset[$qo->datasetindex]->datasetitem[
 935                          $qo->dataset[$qo->datasetindex]->itemindex]->value =
 936                                  $datasetitem['#']['value'][0]['#'];
 937              }
 938          }
 939  
 940          $this->import_hints($qo, $question, false, false, $this->get_format($qo->questiontextformat));
 941  
 942          return $qo;
 943      }
 944  
 945      /**
 946       * This is not a real question type. It's a dummy type used to specify the
 947       * import category. The format is:
 948       * <question type="category">
 949       *     <category>tom/dick/harry</category>
 950       *     <info format="moodle_auto_format"><text>Category description</text></info>
 951       * </question>
 952       */
 953      protected function import_category($question) {
 954          $qo = new stdClass();
 955          $qo->qtype = 'category';
 956          $qo->category = $this->import_text($question['#']['category'][0]['#']['text']);
 957          $qo->info = '';
 958          $qo->infoformat = FORMAT_MOODLE;
 959          if (array_key_exists('info', $question['#'])) {
 960              $qo->info = $this->import_text($question['#']['info'][0]['#']['text']);
 961              // The import should have the format in human readable form, so translate to machine readable format.
 962              $qo->infoformat = $this->trans_format($question['#']['info'][0]['@']['format']);
 963          }
 964          $qo->idnumber = $this->getpath($question, array('#', 'idnumber', 0, '#'), null);
 965          return $qo;
 966      }
 967  
 968      /**
 969       * Parse the array of lines into an array of questions
 970       * this *could* burn memory - but it won't happen that much
 971       * so fingers crossed!
 972       * @param array of lines from the input file.
 973       * @param stdClass $context
 974       * @return array (of objects) question objects.
 975       */
 976      public function readquestions($lines) {
 977          // We just need it as one big string.
 978          $lines = implode('', $lines);
 979  
 980          // This converts xml to big nasty data structure
 981          // the 0 means keep white space as it is (important for markdown format).
 982          try {
 983              $xml = xmlize($lines, 0, 'UTF-8', true);
 984          } catch (xml_format_exception $e) {
 985              $this->error($e->getMessage(), '');
 986              return false;
 987          }
 988          unset($lines); // No need to keep this in memory.
 989          return $this->import_questions($xml['quiz']['#']['question']);
 990      }
 991  
 992      /**
 993       * @param array $xml the xmlized xml
 994       * @return stdClass[] question objects to pass to question type save_question_options
 995       */
 996      public function import_questions($xml) {
 997          $questions = array();
 998  
 999          // Iterate through questions.
1000          foreach ($xml as $questionxml) {
1001              $qo = $this->import_question($questionxml);
1002  
1003              // Stick the result in the $questions array.
1004              if ($qo) {
1005                  $questions[] = $qo;
1006              }
1007          }
1008          return $questions;
1009      }
1010  
1011      /**
1012       * @param array $questionxml xml describing the question
1013       * @return null|stdClass an object with data to be fed to question type save_question_options
1014       */
1015      protected function import_question($questionxml) {
1016          $questiontype = $questionxml['@']['type'];
1017  
1018          if ($questiontype == 'multichoice') {
1019              return $this->import_multichoice($questionxml);
1020          } else if ($questiontype == 'truefalse') {
1021              return $this->import_truefalse($questionxml);
1022          } else if ($questiontype == 'shortanswer') {
1023              return $this->import_shortanswer($questionxml);
1024          } else if ($questiontype == 'numerical') {
1025              return $this->import_numerical($questionxml);
1026          } else if ($questiontype == 'description') {
1027              return $this->import_description($questionxml);
1028          } else if ($questiontype == 'matching' || $questiontype == 'match') {
1029              return $this->import_match($questionxml);
1030          } else if ($questiontype == 'cloze' || $questiontype == 'multianswer') {
1031              return $this->import_multianswer($questionxml);
1032          } else if ($questiontype == 'essay') {
1033              return $this->import_essay($questionxml);
1034          } else if ($questiontype == 'calculated') {
1035              return $this->import_calculated($questionxml);
1036          } else if ($questiontype == 'calculatedsimple') {
1037              $qo = $this->import_calculated($questionxml);
1038              $qo->qtype = 'calculatedsimple';
1039              return $qo;
1040          } else if ($questiontype == 'calculatedmulti') {
1041              $qo = $this->import_calculated($questionxml);
1042              $qo->qtype = 'calculatedmulti';
1043              return $qo;
1044          } else if ($questiontype == 'category') {
1045              return $this->import_category($questionxml);
1046  
1047          } else {
1048              // Not a type we handle ourselves. See if the question type wants
1049              // to handle it.
1050              if (!$qo = $this->try_importing_using_qtypes($questionxml, null, null, $questiontype)) {
1051                  $this->error(get_string('xmltypeunsupported', 'qformat_xml', $questiontype));
1052                  return null;
1053              }
1054              return $qo;
1055          }
1056      }
1057  
1058      // EXPORT FUNCTIONS START HERE.
1059  
1060      public function export_file_extension() {
1061          return '.xml';
1062      }
1063  
1064      /**
1065       * Turn the internal question type name into a human readable form.
1066       * (In the past, the code used to use integers internally. Now, it uses
1067       * strings, so there is less need for this, but to maintain
1068       * backwards-compatibility we change two of the type names.)
1069       * @param string $qtype question type plugin name.
1070       * @return string $qtype string to use in the file.
1071       */
1072      protected function get_qtype($qtype) {
1073          switch($qtype) {
1074              case 'match':
1075                  return 'matching';
1076              case 'multianswer':
1077                  return 'cloze';
1078              default:
1079                  return $qtype;
1080          }
1081      }
1082  
1083      /**
1084       * Convert internal Moodle text format code into
1085       * human readable form
1086       * @param int id internal code
1087       * @return string format text
1088       */
1089      public function get_format($id) {
1090          switch($id) {
1091              case FORMAT_MOODLE:
1092                  return 'moodle_auto_format';
1093              case FORMAT_HTML:
1094                  return 'html';
1095              case FORMAT_PLAIN:
1096                  return 'plain_text';
1097              case FORMAT_WIKI:
1098                  return 'wiki_like';
1099              case FORMAT_MARKDOWN:
1100                  return 'markdown';
1101              default:
1102                  return 'unknown';
1103          }
1104      }
1105  
1106      /**
1107       * Convert internal single question code into
1108       * human readable form
1109       * @param int id single question code
1110       * @return string single question string
1111       */
1112      public function get_single($id) {
1113          switch($id) {
1114              case 0:
1115                  return 'false';
1116              case 1:
1117                  return 'true';
1118              default:
1119                  return 'unknown';
1120          }
1121      }
1122  
1123      /**
1124       * Take a string, and wrap it in a CDATA secion, if that is required to make
1125       * the output XML valid.
1126       * @param string $string a string
1127       * @return string the string, wrapped in CDATA if necessary.
1128       */
1129      public function xml_escape($string) {
1130          if (!empty($string) && htmlspecialchars($string, ENT_COMPAT) != $string) {
1131              // If the string contains something that looks like the end
1132              // of a CDATA section, then we need to avoid errors by splitting
1133              // the string between two CDATA sections.
1134              $string = str_replace(']]>', ']]]]><![CDATA[>', $string);
1135              return "<![CDATA[{$string}]]>";
1136          } else {
1137              return $string;
1138          }
1139      }
1140  
1141      /**
1142       * Generates <text></text> tags, processing raw text therein
1143       * @param string $raw the content to output.
1144       * @param int $indent the current indent level.
1145       * @param bool $short stick it on one line.
1146       * @return string formatted text.
1147       */
1148      public function writetext($raw, $indent = 0, $short = true) {
1149          $indent = str_repeat('  ', $indent);
1150          $raw = $this->xml_escape($raw);
1151  
1152          if ($short) {
1153              $xml = "{$indent}<text>{$raw}</text>\n";
1154          } else {
1155              $xml = "{$indent}<text>\n{$raw}\n{$indent}</text>\n";
1156          }
1157  
1158          return $xml;
1159      }
1160  
1161      /**
1162       * Generte the XML to represent some files.
1163       * @param array of store array of stored_file objects.
1164       * @return string $string the XML.
1165       */
1166      public function write_files($files) {
1167          if (empty($files)) {
1168              return '';
1169          }
1170          $string = '';
1171          foreach ($files as $file) {
1172              if ($file->is_directory()) {
1173                  continue;
1174              }
1175              $string .= '<file name="' . $file->get_filename() . '" path="' . $file->get_filepath() . '" encoding="base64">';
1176              $string .= base64_encode($file->get_content());
1177              $string .= "</file>\n";
1178          }
1179          return $string;
1180      }
1181  
1182      protected function presave_process($content) {
1183          // Override to allow us to add xml headers and footers.
1184          return '<?xml version="1.0" encoding="UTF-8"?>
1185  <quiz>
1186  ' . $content . '</quiz>';
1187      }
1188  
1189      /**
1190       * Turns question into an xml segment
1191       * @param object $question the question data.
1192       * @return string xml segment
1193       */
1194      public function writequestion($question) {
1195  
1196          $invalidquestion = false;
1197          $fs = get_file_storage();
1198          $contextid = $question->contextid;
1199          $question->status = 0;
1200          // Get files used by the questiontext.
1201          $question->questiontextfiles = $fs->get_area_files(
1202                  $contextid, 'question', 'questiontext', $question->id);
1203          // Get files used by the generalfeedback.
1204          $question->generalfeedbackfiles = $fs->get_area_files(
1205                  $contextid, 'question', 'generalfeedback', $question->id);
1206          if (!empty($question->options->answers)) {
1207              foreach ($question->options->answers as $answer) {
1208                  $answer->answerfiles = $fs->get_area_files(
1209                          $contextid, 'question', 'answer', $answer->id);
1210                  $answer->feedbackfiles = $fs->get_area_files(
1211                          $contextid, 'question', 'answerfeedback', $answer->id);
1212              }
1213          }
1214  
1215          $expout = '';
1216  
1217          // Add a comment linking this to the original question id.
1218          $expout .= "<!-- question: {$question->id}  -->\n";
1219  
1220          // Check question type.
1221          $questiontype = $this->get_qtype($question->qtype);
1222  
1223          $idnumber = '';
1224          if (isset($question->idnumber)) {
1225              $idnumber = htmlspecialchars($question->idnumber, ENT_COMPAT);
1226          }
1227  
1228          // Categories are a special case.
1229          if ($question->qtype == 'category') {
1230              $categorypath = $this->writetext($question->category);
1231              $categoryinfo = $this->writetext($question->info);
1232              $infoformat = $this->format($question->infoformat);
1233              $expout .= "  <question type=\"category\">\n";
1234              $expout .= "    <category>\n";
1235              $expout .= "      {$categorypath}";
1236              $expout .= "    </category>\n";
1237              $expout .= "    <info {$infoformat}>\n";
1238              $expout .= "      {$categoryinfo}";
1239              $expout .= "    </info>\n";
1240              $expout .= "    <idnumber>{$idnumber}</idnumber>\n";
1241              $expout .= "  </question>\n";
1242              return $expout;
1243          }
1244  
1245          // Now we know we are are handing a real question.
1246          // Output the generic information.
1247          $expout .= "  <question type=\"{$questiontype}\">\n";
1248          $expout .= "    <name>\n";
1249          $expout .= $this->writetext($question->name, 3);
1250          $expout .= "    </name>\n";
1251          $expout .= "    <questiontext {$this->format($question->questiontextformat)}>\n";
1252          $expout .= $this->writetext($question->questiontext, 3);
1253          $expout .= $this->write_files($question->questiontextfiles);
1254          $expout .= "    </questiontext>\n";
1255          $expout .= "    <generalfeedback {$this->format($question->generalfeedbackformat)}>\n";
1256          $expout .= $this->writetext($question->generalfeedback, 3);
1257          $expout .= $this->write_files($question->generalfeedbackfiles);
1258          $expout .= "    </generalfeedback>\n";
1259          if ($question->qtype != 'multianswer') {
1260              $expout .= "    <defaultgrade>{$question->defaultmark}</defaultgrade>\n";
1261          }
1262          $expout .= "    <penalty>{$question->penalty}</penalty>\n";
1263          $expout .= "    <hidden>{$question->status}</hidden>\n";
1264          $expout .= "    <idnumber>{$idnumber}</idnumber>\n";
1265  
1266          // The rest of the output depends on question type.
1267          switch($question->qtype) {
1268              case 'category':
1269                  // Not a qtype really - dummy used for category switching.
1270                  break;
1271  
1272              case 'truefalse':
1273                  $trueanswer = $question->options->answers[$question->options->trueanswer];
1274                  $trueanswer->answer = 'true';
1275                  $expout .= $this->write_answer($trueanswer);
1276  
1277                  $falseanswer = $question->options->answers[$question->options->falseanswer];
1278                  $falseanswer->answer = 'false';
1279                  $expout .= $this->write_answer($falseanswer);
1280                  break;
1281  
1282              case 'multichoice':
1283                  $expout .= "    <single>" . $this->get_single($question->options->single) .
1284                          "</single>\n";
1285                  $expout .= "    <shuffleanswers>" .
1286                          $this->get_single($question->options->shuffleanswers) .
1287                          "</shuffleanswers>\n";
1288                  $expout .= "    <answernumbering>" . $question->options->answernumbering .
1289                      "</answernumbering>\n";
1290                  $expout .= "    <showstandardinstruction>" . $question->options->showstandardinstruction .
1291                      "</showstandardinstruction>\n";
1292                  $expout .= $this->write_combined_feedback($question->options, $question->id, $question->contextid);
1293                  $expout .= $this->write_answers($question->options->answers);
1294                  break;
1295  
1296              case 'shortanswer':
1297                  $expout .= "    <usecase>{$question->options->usecase}</usecase>\n";
1298                  $expout .= $this->write_answers($question->options->answers);
1299                  break;
1300  
1301              case 'numerical':
1302                  foreach ($question->options->answers as $answer) {
1303                      $expout .= $this->write_answer($answer,
1304                              "      <tolerance>{$answer->tolerance}</tolerance>\n");
1305                  }
1306  
1307                  $units = $question->options->units;
1308                  if (count($units)) {
1309                      $expout .= "<units>\n";
1310                      foreach ($units as $unit) {
1311                          $expout .= "  <unit>\n";
1312                          $expout .= "    <multiplier>{$unit->multiplier}</multiplier>\n";
1313                          $expout .= "    <unit_name>{$unit->unit}</unit_name>\n";
1314                          $expout .= "  </unit>\n";
1315                      }
1316                      $expout .= "</units>\n";
1317                  }
1318                  if (isset($question->options->unitgradingtype)) {
1319                      $expout .= "    <unitgradingtype>" . $question->options->unitgradingtype .
1320                              "</unitgradingtype>\n";
1321                  }
1322                  if (isset($question->options->unitpenalty)) {
1323                      $expout .= "    <unitpenalty>{$question->options->unitpenalty}</unitpenalty>\n";
1324                  }
1325                  if (isset($question->options->showunits)) {
1326                      $expout .= "    <showunits>{$question->options->showunits}</showunits>\n";
1327                  }
1328                  if (isset($question->options->unitsleft)) {
1329                      $expout .= "    <unitsleft>{$question->options->unitsleft}</unitsleft>\n";
1330                  }
1331                  if (!empty($question->options->instructionsformat)) {
1332                      $files = $fs->get_area_files($contextid, 'qtype_numerical',
1333                              'instruction', $question->id);
1334                      $expout .= "    <instructions " .
1335                              $this->format($question->options->instructionsformat) . ">\n";
1336                      $expout .= $this->writetext($question->options->instructions, 3);
1337                      $expout .= $this->write_files($files);
1338                      $expout .= "    </instructions>\n";
1339                  }
1340                  break;
1341  
1342              case 'match':
1343                  $expout .= "    <shuffleanswers>" .
1344                          $this->get_single($question->options->shuffleanswers) .
1345                          "</shuffleanswers>\n";
1346                  $expout .= $this->write_combined_feedback($question->options, $question->id, $question->contextid);
1347                  foreach ($question->options->subquestions as $subquestion) {
1348                      $files = $fs->get_area_files($contextid, 'qtype_match',
1349                              'subquestion', $subquestion->id);
1350                      $expout .= "    <subquestion " .
1351                              $this->format($subquestion->questiontextformat) . ">\n";
1352                      $expout .= $this->writetext($subquestion->questiontext, 3);
1353                      $expout .= $this->write_files($files);
1354                      $expout .= "      <answer>\n";
1355                      $expout .= $this->writetext($subquestion->answertext, 4);
1356                      $expout .= "      </answer>\n";
1357                      $expout .= "    </subquestion>\n";
1358                  }
1359                  break;
1360  
1361              case 'description':
1362                  // Nothing else to do.
1363                  break;
1364  
1365              case 'multianswer':
1366                  foreach ($question->options->questions as $index => $subq) {
1367                      $expout = str_replace('{#' . $index . '}', $subq->questiontext, $expout);
1368                  }
1369                  break;
1370  
1371              case 'essay':
1372                  $expout .= "    <responseformat>" . $question->options->responseformat .
1373                          "</responseformat>\n";
1374                  $expout .= "    <responserequired>" . $question->options->responserequired .
1375                          "</responserequired>\n";
1376                  $expout .= "    <responsefieldlines>" . $question->options->responsefieldlines .
1377                          "</responsefieldlines>\n";
1378                  $expout .= "    <minwordlimit>" . $question->options->minwordlimit .
1379                          "</minwordlimit>\n";
1380                  $expout .= "    <maxwordlimit>" . $question->options->maxwordlimit .
1381                          "</maxwordlimit>\n";
1382                  $expout .= "    <attachments>" . $question->options->attachments .
1383                          "</attachments>\n";
1384                  $expout .= "    <attachmentsrequired>" . $question->options->attachmentsrequired .
1385                          "</attachmentsrequired>\n";
1386                  $expout .= "    <maxbytes>" . $question->options->maxbytes .
1387                          "</maxbytes>\n";
1388                  $expout .= "    <filetypeslist>" . $question->options->filetypeslist .
1389                          "</filetypeslist>\n";
1390                  $expout .= "    <graderinfo " .
1391                          $this->format($question->options->graderinfoformat) . ">\n";
1392                  $expout .= $this->writetext($question->options->graderinfo, 3);
1393                  $expout .= $this->write_files($fs->get_area_files($contextid, 'qtype_essay',
1394                          'graderinfo', $question->id));
1395                  $expout .= "    </graderinfo>\n";
1396                  $expout .= "    <responsetemplate " .
1397                          $this->format($question->options->responsetemplateformat) . ">\n";
1398                  $expout .= $this->writetext($question->options->responsetemplate, 3);
1399                  $expout .= "    </responsetemplate>\n";
1400                  break;
1401  
1402              case 'calculated':
1403              case 'calculatedsimple':
1404              case 'calculatedmulti':
1405                  $expout .= "    <synchronize>{$question->options->synchronize}</synchronize>\n";
1406                  $expout .= "    <single>{$question->options->single}</single>\n";
1407                  $expout .= "    <answernumbering>" . $question->options->answernumbering .
1408                          "</answernumbering>\n";
1409                  $expout .= "    <shuffleanswers>" . $question->options->shuffleanswers .
1410                          "</shuffleanswers>\n";
1411  
1412                  $component = 'qtype_' . $question->qtype;
1413                  $files = $fs->get_area_files($contextid, $component,
1414                          'correctfeedback', $question->id);
1415                  $expout .= "    <correctfeedback>\n";
1416                  $expout .= $this->writetext($question->options->correctfeedback, 3);
1417                  $expout .= $this->write_files($files);
1418                  $expout .= "    </correctfeedback>\n";
1419  
1420                  $files = $fs->get_area_files($contextid, $component,
1421                          'partiallycorrectfeedback', $question->id);
1422                  $expout .= "    <partiallycorrectfeedback>\n";
1423                  $expout .= $this->writetext($question->options->partiallycorrectfeedback, 3);
1424                  $expout .= $this->write_files($files);
1425                  $expout .= "    </partiallycorrectfeedback>\n";
1426  
1427                  $files = $fs->get_area_files($contextid, $component,
1428                          'incorrectfeedback', $question->id);
1429                  $expout .= "    <incorrectfeedback>\n";
1430                  $expout .= $this->writetext($question->options->incorrectfeedback, 3);
1431                  $expout .= $this->write_files($files);
1432                  $expout .= "    </incorrectfeedback>\n";
1433  
1434                  foreach ($question->options->answers as $answer) {
1435                      $percent = 100 * $answer->fraction;
1436                      $expout .= "    <answer fraction=\"{$percent}\">\n";
1437                      // The "<text/>" tags are an added feature, old files won't have them.
1438                      $expout .= $this->writetext($answer->answer, 3);
1439                      $expout .= "      <tolerance>{$answer->tolerance}</tolerance>\n";
1440                      $expout .= "      <tolerancetype>{$answer->tolerancetype}</tolerancetype>\n";
1441                      $expout .= "      <correctanswerformat>" .
1442                              $answer->correctanswerformat . "</correctanswerformat>\n";
1443                      $expout .= "      <correctanswerlength>" .
1444                              $answer->correctanswerlength . "</correctanswerlength>\n";
1445                      $expout .= "      <feedback {$this->format($answer->feedbackformat)}>\n";
1446                      $files = $fs->get_area_files($contextid, $component,
1447                              'instruction', $question->id);
1448                      $expout .= $this->writetext($answer->feedback, 4);
1449                      $expout .= $this->write_files($answer->feedbackfiles);
1450                      $expout .= "      </feedback>\n";
1451                      $expout .= "    </answer>\n";
1452                  }
1453                  if (isset($question->options->unitgradingtype)) {
1454                      $expout .= "    <unitgradingtype>" .
1455                              $question->options->unitgradingtype . "</unitgradingtype>\n";
1456                  }
1457                  if (isset($question->options->unitpenalty)) {
1458                      $expout .= "    <unitpenalty>" .
1459                              $question->options->unitpenalty . "</unitpenalty>\n";
1460                  }
1461                  if (isset($question->options->showunits)) {
1462                      $expout .= "    <showunits>{$question->options->showunits}</showunits>\n";
1463                  }
1464                  if (isset($question->options->unitsleft)) {
1465                      $expout .= "    <unitsleft>{$question->options->unitsleft}</unitsleft>\n";
1466                  }
1467  
1468                  if (isset($question->options->instructionsformat)) {
1469                      $files = $fs->get_area_files($contextid, $component,
1470                              'instruction', $question->id);
1471                      $expout .= "    <instructions " .
1472                              $this->format($question->options->instructionsformat) . ">\n";
1473                      $expout .= $this->writetext($question->options->instructions, 3);
1474                      $expout .= $this->write_files($files);
1475                      $expout .= "    </instructions>\n";
1476                  }
1477  
1478                  if (isset($question->options->units)) {
1479                      $units = $question->options->units;
1480                      if (count($units)) {
1481                          $expout .= "<units>\n";
1482                          foreach ($units as $unit) {
1483                              $expout .= "  <unit>\n";
1484                              $expout .= "    <multiplier>{$unit->multiplier}</multiplier>\n";
1485                              $expout .= "    <unit_name>{$unit->unit}</unit_name>\n";
1486                              $expout .= "  </unit>\n";
1487                          }
1488                          $expout .= "</units>\n";
1489                      }
1490                  }
1491  
1492                  // The tag $question->export_process has been set so we get all the
1493                  // data items in the database from the function
1494                  // qtype_calculated::get_question_options calculatedsimple defaults
1495                  // to calculated.
1496                  if (isset($question->options->datasets) && count($question->options->datasets)) {
1497                      $expout .= "<dataset_definitions>\n";
1498                      foreach ($question->options->datasets as $def) {
1499                          $expout .= "<dataset_definition>\n";
1500                          $expout .= "    <status>".$this->writetext($def->status)."</status>\n";
1501                          $expout .= "    <name>".$this->writetext($def->name)."</name>\n";
1502                          if ($question->qtype == 'calculated') {
1503                              $expout .= "    <type>calculated</type>\n";
1504                          } else {
1505                              $expout .= "    <type>calculatedsimple</type>\n";
1506                          }
1507                          $expout .= "    <distribution>" . $this->writetext($def->distribution) .
1508                                  "</distribution>\n";
1509                          $expout .= "    <minimum>" . $this->writetext($def->minimum) .
1510                                  "</minimum>\n";
1511                          $expout .= "    <maximum>" . $this->writetext($def->maximum) .
1512                                  "</maximum>\n";
1513                          $expout .= "    <decimals>" . $this->writetext($def->decimals) .
1514                                  "</decimals>\n";
1515                          $expout .= "    <itemcount>{$def->itemcount}</itemcount>\n";
1516                          if ($def->itemcount > 0) {
1517                              $expout .= "    <dataset_items>\n";
1518                              foreach ($def->items as $item) {
1519                                    $expout .= "        <dataset_item>\n";
1520                                    $expout .= "           <number>".$item->itemnumber."</number>\n";
1521                                    $expout .= "           <value>".$item->value."</value>\n";
1522                                    $expout .= "        </dataset_item>\n";
1523                              }
1524                              $expout .= "    </dataset_items>\n";
1525                              $expout .= "    <number_of_items>" . $def->number_of_items .
1526                                      "</number_of_items>\n";
1527                          }
1528                          $expout .= "</dataset_definition>\n";
1529                      }
1530                      $expout .= "</dataset_definitions>\n";
1531                  }
1532                  break;
1533  
1534              default:
1535                  // Try support by optional plugin.
1536                  if (!$data = $this->try_exporting_using_qtypes($question->qtype, $question)) {
1537                      $invalidquestion = true;
1538                  } else {
1539                      $expout .= $data;
1540                  }
1541          }
1542  
1543          // Output any hints.
1544          $expout .= $this->write_hints($question);
1545  
1546          // Write the question tags.
1547          if (core_tag_tag::is_enabled('core_question', 'question')) {
1548              $tagobjects = core_tag_tag::get_item_tags('core_question', 'question', $question->id);
1549  
1550              if (!empty($tagobjects)) {
1551                  $context = context::instance_by_id($contextid);
1552                  $sortedtagobjects = question_sort_tags($tagobjects, $context, [$this->course]);
1553  
1554                  if (!empty($sortedtagobjects->coursetags)) {
1555                      // Set them on the form to be rendered as existing tags.
1556                      $expout .= "    <coursetags>\n";
1557                      foreach ($sortedtagobjects->coursetags as $coursetag) {
1558                          $expout .= "      <tag>" . $this->writetext($coursetag, 0, true) . "</tag>\n";
1559                      }
1560                      $expout .= "    </coursetags>\n";
1561                  }
1562  
1563                  if (!empty($sortedtagobjects->tags)) {
1564                      $expout .= "    <tags>\n";
1565                      foreach ($sortedtagobjects->tags as $tag) {
1566                          $expout .= "      <tag>" . $this->writetext($tag, 0, true) . "</tag>\n";
1567                      }
1568                      $expout .= "    </tags>\n";
1569                  }
1570              }
1571          }
1572  
1573          // Close the question tag.
1574          $expout .= "  </question>\n";
1575          if ($invalidquestion) {
1576              return '';
1577          } else {
1578              return $expout;
1579          }
1580      }
1581  
1582      public function write_answers($answers) {
1583          if (empty($answers)) {
1584              return;
1585          }
1586          $output = '';
1587          foreach ($answers as $answer) {
1588              $output .= $this->write_answer($answer);
1589          }
1590          return $output;
1591      }
1592  
1593      public function write_answer($answer, $extra = '') {
1594          $percent = $answer->fraction * 100;
1595          $output = '';
1596          $output .= "    <answer fraction=\"{$percent}\" {$this->format($answer->answerformat)}>\n";
1597          $output .= $this->writetext($answer->answer, 3);
1598          $output .= $this->write_files($answer->answerfiles);
1599          $output .= "      <feedback {$this->format($answer->feedbackformat)}>\n";
1600          $output .= $this->writetext($answer->feedback, 4);
1601          $output .= $this->write_files($answer->feedbackfiles);
1602          $output .= "      </feedback>\n";
1603          $output .= $extra;
1604          $output .= "    </answer>\n";
1605          return $output;
1606      }
1607  
1608      /**
1609       * Write out the hints.
1610       * @param object $question the question definition data.
1611       * @return string XML to output.
1612       */
1613      public function write_hints($question) {
1614          if (empty($question->hints)) {
1615              return '';
1616          }
1617  
1618          $output = '';
1619          foreach ($question->hints as $hint) {
1620              $output .= $this->write_hint($hint, $question->contextid);
1621          }
1622          return $output;
1623      }
1624  
1625      /**
1626       * @param int $format a FORMAT_... constant.
1627       * @return string the attribute to add to an XML tag.
1628       */
1629      public function format($format) {
1630          return 'format="' . $this->get_format($format) . '"';
1631      }
1632  
1633      public function write_hint($hint, $contextid) {
1634          $fs = get_file_storage();
1635          $files = $fs->get_area_files($contextid, 'question', 'hint', $hint->id);
1636  
1637          $output = '';
1638          $output .= "    <hint {$this->format($hint->hintformat)}>\n";
1639          $output .= '      ' . $this->writetext($hint->hint);
1640  
1641          if (!empty($hint->shownumcorrect)) {
1642              $output .= "      <shownumcorrect/>\n";
1643          }
1644          if (!empty($hint->clearwrong)) {
1645              $output .= "      <clearwrong/>\n";
1646          }
1647  
1648          if (!empty($hint->options)) {
1649              $output .= '      <options>' . $this->xml_escape($hint->options) . "</options>\n";
1650          }
1651          $output .= $this->write_files($files);
1652          $output .= "    </hint>\n";
1653          return $output;
1654      }
1655  
1656      /**
1657       * Output the combined feedback fields.
1658       * @param object $questionoptions the question definition data.
1659       * @param int $questionid the question id.
1660       * @param int $contextid the question context id.
1661       * @return string XML to output.
1662       */
1663      public function write_combined_feedback($questionoptions, $questionid, $contextid) {
1664          $fs = get_file_storage();
1665          $output = '';
1666  
1667          $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback');
1668          foreach ($fields as $field) {
1669              $formatfield = $field . 'format';
1670              $files = $fs->get_area_files($contextid, 'question', $field, $questionid);
1671  
1672              $output .= "    <{$field} {$this->format($questionoptions->$formatfield)}>\n";
1673              $output .= '      ' . $this->writetext($questionoptions->$field);
1674              $output .= $this->write_files($files);
1675              $output .= "    </{$field}>\n";
1676          }
1677  
1678          if (!empty($questionoptions->shownumcorrect)) {
1679              $output .= "    <shownumcorrect/>\n";
1680          }
1681          return $output;
1682      }
1683  }