Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

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