Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

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

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