Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 3.9.x will end* 10 May 2021 (12 months).
  • Bug fixes for security issues in 3.9.x will end* 8 May 2023 (36 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 39 and 311] [Versions 39 and 400] [Versions 39 and 401] [Versions 39 and 402] [Versions 39 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   * Defines the base class for question import and export formats.
  19   *
  20   * @package    moodlecore
  21   * @subpackage questionbank
  22   * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
  23   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24   */
  25  
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  
  30  /**
  31   * Base class for question import and export formats.
  32   *
  33   * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
  34   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  35   */
  36  class qformat_default {
  37  
  38      public $displayerrors = true;
  39      public $category = null;
  40      public $questions = array();
  41      public $course = null;
  42      public $filename = '';
  43      public $realfilename = '';
  44      public $matchgrades = 'error';
  45      public $catfromfile = 0;
  46      public $contextfromfile = 0;
  47      public $cattofile = 0;
  48      public $contexttofile = 0;
  49      public $questionids = array();
  50      public $importerrors = 0;
  51      public $stoponerror = true;
  52      public $translator = null;
  53      public $canaccessbackupdata = true;
  54      protected $importcontext = null;
  55      /** @var bool $displayprogress Whether to display progress. */
  56      public $displayprogress = true;
  57  
  58      // functions to indicate import/export functionality
  59      // override to return true if implemented
  60  
  61      /** @return bool whether this plugin provides import functionality. */
  62      public function provide_import() {
  63          return false;
  64      }
  65  
  66      /** @return bool whether this plugin provides export functionality. */
  67      public function provide_export() {
  68          return false;
  69      }
  70  
  71      /** The string mime-type of the files that this plugin reads or writes. */
  72      public function mime_type() {
  73          return mimeinfo('type', $this->export_file_extension());
  74      }
  75  
  76      /**
  77       * @return string the file extension (including .) that is normally used for
  78       * files handled by this plugin.
  79       */
  80      public function export_file_extension() {
  81          return '.txt';
  82      }
  83  
  84      /**
  85       * Check if the given file is capable of being imported by this plugin.
  86       *
  87       * Note that expensive or detailed integrity checks on the file should
  88       * not be performed by this method. Simple file type or magic-number tests
  89       * would be suitable.
  90       *
  91       * @param stored_file $file the file to check
  92       * @return bool whether this plugin can import the file
  93       */
  94      public function can_import_file($file) {
  95          return ($file->get_mimetype() == $this->mime_type());
  96      }
  97  
  98      // Accessor methods
  99  
 100      /**
 101       * set the category
 102       * @param object category the category object
 103       */
 104      public function setCategory($category) {
 105          if (count($this->questions)) {
 106              debugging('You shouldn\'t call setCategory after setQuestions');
 107          }
 108          $this->category = $category;
 109          $this->importcontext = context::instance_by_id($this->category->contextid);
 110      }
 111  
 112      /**
 113       * Set the specific questions to export. Should not include questions with
 114       * parents (sub questions of cloze question type).
 115       * Only used for question export.
 116       * @param array of question objects
 117       */
 118      public function setQuestions($questions) {
 119          if ($this->category !== null) {
 120              debugging('You shouldn\'t call setQuestions after setCategory');
 121          }
 122          $this->questions = $questions;
 123      }
 124  
 125      /**
 126       * set the course class variable
 127       * @param course object Moodle course variable
 128       */
 129      public function setCourse($course) {
 130          $this->course = $course;
 131      }
 132  
 133      /**
 134       * set an array of contexts.
 135       * @param array $contexts Moodle course variable
 136       */
 137      public function setContexts($contexts) {
 138          $this->contexts = $contexts;
 139          $this->translator = new context_to_string_translator($this->contexts);
 140      }
 141  
 142      /**
 143       * set the filename
 144       * @param string filename name of file to import/export
 145       */
 146      public function setFilename($filename) {
 147          $this->filename = $filename;
 148      }
 149  
 150      /**
 151       * set the "real" filename
 152       * (this is what the user typed, regardless of wha happened next)
 153       * @param string realfilename name of file as typed by user
 154       */
 155      public function setRealfilename($realfilename) {
 156          $this->realfilename = $realfilename;
 157      }
 158  
 159      /**
 160       * set matchgrades
 161       * @param string matchgrades error or nearest for grades
 162       */
 163      public function setMatchgrades($matchgrades) {
 164          $this->matchgrades = $matchgrades;
 165      }
 166  
 167      /**
 168       * set catfromfile
 169       * @param bool catfromfile allow categories embedded in import file
 170       */
 171      public function setCatfromfile($catfromfile) {
 172          $this->catfromfile = $catfromfile;
 173      }
 174  
 175      /**
 176       * set contextfromfile
 177       * @param bool $contextfromfile allow contexts embedded in import file
 178       */
 179      public function setContextfromfile($contextfromfile) {
 180          $this->contextfromfile = $contextfromfile;
 181      }
 182  
 183      /**
 184       * set cattofile
 185       * @param bool cattofile exports categories within export file
 186       */
 187      public function setCattofile($cattofile) {
 188          $this->cattofile = $cattofile;
 189      }
 190  
 191      /**
 192       * set contexttofile
 193       * @param bool cattofile exports categories within export file
 194       */
 195      public function setContexttofile($contexttofile) {
 196          $this->contexttofile = $contexttofile;
 197      }
 198  
 199      /**
 200       * set stoponerror
 201       * @param bool stoponerror stops database write if any errors reported
 202       */
 203      public function setStoponerror($stoponerror) {
 204          $this->stoponerror = $stoponerror;
 205      }
 206  
 207      /**
 208       * @param bool $canaccess Whether the current use can access the backup data folder. Determines
 209       * where export files are saved.
 210       */
 211      public function set_can_access_backupdata($canaccess) {
 212          $this->canaccessbackupdata = $canaccess;
 213      }
 214  
 215      /**
 216       * Change whether to display progress messages.
 217       * There is normally no need to use this function as the
 218       * default for $displayprogress is true.
 219       * Set to false for unit tests.
 220       * @param bool $displayprogress
 221       */
 222      public function set_display_progress($displayprogress) {
 223          $this->displayprogress = $displayprogress;
 224      }
 225  
 226      /***********************
 227       * IMPORTING FUNCTIONS
 228       ***********************/
 229  
 230      /**
 231       * Handle parsing error
 232       */
 233      protected function error($message, $text='', $questionname='') {
 234          $importerrorquestion = get_string('importerrorquestion', 'question');
 235  
 236          echo "<div class=\"importerror\">\n";
 237          echo "<strong>{$importerrorquestion} {$questionname}</strong>";
 238          if (!empty($text)) {
 239              $text = s($text);
 240              echo "<blockquote>{$text}</blockquote>\n";
 241          }
 242          echo "<strong>{$message}</strong>\n";
 243          echo "</div>";
 244  
 245          $this->importerrors++;
 246      }
 247  
 248      /**
 249       * Import for questiontype plugins
 250       * Do not override.
 251       * @param data mixed The segment of data containing the question
 252       * @param question object processed (so far) by standard import code if appropriate
 253       * @param extra mixed any additional format specific data that may be passed by the format
 254       * @param qtypehint hint about a question type from format
 255       * @return object question object suitable for save_options() or false if cannot handle
 256       */
 257      public function try_importing_using_qtypes($data, $question = null, $extra = null,
 258              $qtypehint = '') {
 259  
 260          // work out what format we are using
 261          $formatname = substr(get_class($this), strlen('qformat_'));
 262          $methodname = "import_from_{$formatname}";
 263  
 264          //first try importing using a hint from format
 265          if (!empty($qtypehint)) {
 266              $qtype = question_bank::get_qtype($qtypehint, false);
 267              if (is_object($qtype) && method_exists($qtype, $methodname)) {
 268                  $question = $qtype->$methodname($data, $question, $this, $extra);
 269                  if ($question) {
 270                      return $question;
 271                  }
 272              }
 273          }
 274  
 275          // loop through installed questiontypes checking for
 276          // function to handle this question
 277          foreach (question_bank::get_all_qtypes() as $qtype) {
 278              if (method_exists($qtype, $methodname)) {
 279                  if ($question = $qtype->$methodname($data, $question, $this, $extra)) {
 280                      return $question;
 281                  }
 282              }
 283          }
 284          return false;
 285      }
 286  
 287      /**
 288       * Perform any required pre-processing
 289       * @return bool success
 290       */
 291      public function importpreprocess() {
 292          return true;
 293      }
 294  
 295      /**
 296       * Process the file
 297       * This method should not normally be overidden
 298       * @return bool success
 299       */
 300      public function importprocess() {
 301          global $USER, $DB, $OUTPUT;
 302  
 303          // Raise time and memory, as importing can be quite intensive.
 304          core_php_time_limit::raise();
 305          raise_memory_limit(MEMORY_EXTRA);
 306  
 307          // STAGE 1: Parse the file
 308          if ($this->displayprogress) {
 309              echo $OUTPUT->notification(get_string('parsingquestions', 'question'), 'notifysuccess');
 310          }
 311  
 312          if (! $lines = $this->readdata($this->filename)) {
 313              echo $OUTPUT->notification(get_string('cannotread', 'question'));
 314              return false;
 315          }
 316  
 317          if (!$questions = $this->readquestions($lines)) {   // Extract all the questions
 318              echo $OUTPUT->notification(get_string('noquestionsinfile', 'question'));
 319              return false;
 320          }
 321  
 322          // STAGE 2: Write data to database
 323          if ($this->displayprogress) {
 324              echo $OUTPUT->notification(get_string('importingquestions', 'question',
 325                      $this->count_questions($questions)), 'notifysuccess');
 326          }
 327  
 328          // check for errors before we continue
 329          if ($this->stoponerror and ($this->importerrors>0)) {
 330              echo $OUTPUT->notification(get_string('importparseerror', 'question'));
 331              return true;
 332          }
 333  
 334          // get list of valid answer grades
 335          $gradeoptionsfull = question_bank::fraction_options_full();
 336  
 337          // check answer grades are valid
 338          // (now need to do this here because of 'stop on error': MDL-10689)
 339          $gradeerrors = 0;
 340          $goodquestions = array();
 341          foreach ($questions as $question) {
 342              if (!empty($question->fraction) and (is_array($question->fraction))) {
 343                  $fractions = $question->fraction;
 344                  $invalidfractions = array();
 345                  foreach ($fractions as $key => $fraction) {
 346                      $newfraction = match_grade_options($gradeoptionsfull, $fraction,
 347                              $this->matchgrades);
 348                      if ($newfraction === false) {
 349                          $invalidfractions[] = $fraction;
 350                      } else {
 351                          $fractions[$key] = $newfraction;
 352                      }
 353                  }
 354                  if ($invalidfractions) {
 355                      echo $OUTPUT->notification(get_string('invalidgrade', 'question',
 356                              implode(', ', $invalidfractions)));
 357                      ++$gradeerrors;
 358                      continue;
 359                  } else {
 360                      $question->fraction = $fractions;
 361                  }
 362              }
 363              $goodquestions[] = $question;
 364          }
 365          $questions = $goodquestions;
 366  
 367          // check for errors before we continue
 368          if ($this->stoponerror && $gradeerrors > 0) {
 369              return false;
 370          }
 371  
 372          // count number of questions processed
 373          $count = 0;
 374  
 375          foreach ($questions as $question) {   // Process and store each question
 376              $transaction = $DB->start_delegated_transaction();
 377  
 378              // reset the php timeout
 379              core_php_time_limit::raise();
 380  
 381              // check for category modifiers
 382              if ($question->qtype == 'category') {
 383                  if ($this->catfromfile) {
 384                      // find/create category object
 385                      $catpath = $question->category;
 386                      $newcategory = $this->create_category_path($catpath, $question);
 387                      if (!empty($newcategory)) {
 388                          $this->category = $newcategory;
 389                      }
 390                  }
 391                  $transaction->allow_commit();
 392                  continue;
 393              }
 394              $question->context = $this->importcontext;
 395  
 396              $count++;
 397  
 398              if ($this->displayprogress) {
 399                  echo "<hr /><p><b>{$count}</b>. " . $this->format_question_text($question) . "</p>";
 400              }
 401  
 402              $question->category = $this->category->id;
 403              $question->stamp = make_unique_id_code();  // Set the unique code (not to be changed)
 404  
 405              $question->createdby = $USER->id;
 406              $question->timecreated = time();
 407              $question->modifiedby = $USER->id;
 408              $question->timemodified = time();
 409              if (isset($question->idnumber)) {
 410                  if ((string) $question->idnumber === '') {
 411                      // Id number not really set. Get rid of it.
 412                      unset($question->idnumber);
 413                  } else {
 414                      if ($DB->record_exists('question',
 415                              ['idnumber' => $question->idnumber, 'category' => $question->category])) {
 416                          // We cannot have duplicate idnumbers in a category. Just remove it.
 417                          unset($question->idnumber);
 418                      }
 419                  }
 420              }
 421  
 422              $fileoptions = array(
 423                      'subdirs' => true,
 424                      'maxfiles' => -1,
 425                      'maxbytes' => 0,
 426                  );
 427  
 428              $question->id = $DB->insert_record('question', $question);
 429              $event = \core\event\question_created::create_from_question_instance($question, $this->importcontext);
 430              $event->trigger();
 431  
 432              if (isset($question->questiontextitemid)) {
 433                  $question->questiontext = file_save_draft_area_files($question->questiontextitemid,
 434                          $this->importcontext->id, 'question', 'questiontext', $question->id,
 435                          $fileoptions, $question->questiontext);
 436              } else if (isset($question->questiontextfiles)) {
 437                  foreach ($question->questiontextfiles as $file) {
 438                      question_bank::get_qtype($question->qtype)->import_file(
 439                              $this->importcontext, 'question', 'questiontext', $question->id, $file);
 440                  }
 441              }
 442              if (isset($question->generalfeedbackitemid)) {
 443                  $question->generalfeedback = file_save_draft_area_files($question->generalfeedbackitemid,
 444                          $this->importcontext->id, 'question', 'generalfeedback', $question->id,
 445                          $fileoptions, $question->generalfeedback);
 446              } else if (isset($question->generalfeedbackfiles)) {
 447                  foreach ($question->generalfeedbackfiles as $file) {
 448                      question_bank::get_qtype($question->qtype)->import_file(
 449                              $this->importcontext, 'question', 'generalfeedback', $question->id, $file);
 450                  }
 451              }
 452              $DB->update_record('question', $question);
 453  
 454              $this->questionids[] = $question->id;
 455  
 456              // Now to save all the answers and type-specific options
 457  
 458              $result = question_bank::get_qtype($question->qtype)->save_question_options($question);
 459  
 460              if (core_tag_tag::is_enabled('core_question', 'question')) {
 461                  // Is the current context we're importing in a course context?
 462                  $importingcontext = $this->importcontext;
 463                  $importingcoursecontext = $importingcontext->get_course_context(false);
 464                  $isimportingcontextcourseoractivity = !empty($importingcoursecontext);
 465  
 466                  if (!empty($question->coursetags)) {
 467                      if ($isimportingcontextcourseoractivity) {
 468                          $mergedtags = array_merge($question->coursetags, $question->tags);
 469  
 470                          core_tag_tag::set_item_tags('core_question', 'question', $question->id,
 471                              $question->context, $mergedtags);
 472                      } else {
 473                          core_tag_tag::set_item_tags('core_question', 'question', $question->id,
 474                              context_course::instance($this->course->id), $question->coursetags);
 475  
 476                          if (!empty($question->tags)) {
 477                              core_tag_tag::set_item_tags('core_question', 'question', $question->id,
 478                                  $importingcontext, $question->tags);
 479                          }
 480                      }
 481                  } else if (!empty($question->tags)) {
 482                      core_tag_tag::set_item_tags('core_question', 'question', $question->id,
 483                          $question->context, $question->tags);
 484                  }
 485              }
 486  
 487              if (!empty($result->error)) {
 488                  echo $OUTPUT->notification($result->error);
 489                  // Can't use $transaction->rollback(); since it requires an exception,
 490                  // and I don't want to rewrite this code to change the error handling now.
 491                  $DB->force_transaction_rollback();
 492                  return false;
 493              }
 494  
 495              $transaction->allow_commit();
 496  
 497              if (!empty($result->notice)) {
 498                  echo $OUTPUT->notification($result->notice);
 499                  return true;
 500              }
 501  
 502              // Give the question a unique version stamp determined by question_hash()
 503              $DB->set_field('question', 'version', question_hash($question),
 504                      array('id' => $question->id));
 505          }
 506          return true;
 507      }
 508  
 509      /**
 510       * Count all non-category questions in the questions array.
 511       *
 512       * @param array questions An array of question objects.
 513       * @return int The count.
 514       *
 515       */
 516      protected function count_questions($questions) {
 517          $count = 0;
 518          if (!is_array($questions)) {
 519              return $count;
 520          }
 521          foreach ($questions as $question) {
 522              if (!is_object($question) || !isset($question->qtype) ||
 523                      ($question->qtype == 'category')) {
 524                  continue;
 525              }
 526              $count++;
 527          }
 528          return $count;
 529      }
 530  
 531      /**
 532       * find and/or create the category described by a delimited list
 533       * e.g. $course$/tom/dick/harry or tom/dick/harry
 534       *
 535       * removes any context string no matter whether $getcontext is set
 536       * but if $getcontext is set then ignore the context and use selected category context.
 537       *
 538       * @param string catpath delimited category path
 539       * @param object $lastcategoryinfo Contains category information
 540       * @return mixed category object or null if fails
 541       */
 542      protected function create_category_path($catpath, $lastcategoryinfo = null) {
 543          global $DB;
 544          $catnames = $this->split_category_path($catpath);
 545          $parent = 0;
 546          $category = null;
 547  
 548          // check for context id in path, it might not be there in pre 1.9 exports
 549          $matchcount = preg_match('/^\$([a-z]+)\$$/', $catnames[0], $matches);
 550          if ($matchcount == 1) {
 551              $contextid = $this->translator->string_to_context($matches[1]);
 552              array_shift($catnames);
 553          } else {
 554              $contextid = false;
 555          }
 556  
 557          // Before 3.5, question categories could be created at top level.
 558          // From 3.5 onwards, all question categories should be a child of a special category called the "top" category.
 559          if (isset($catnames[0]) && (($catnames[0] != 'top') || (count($catnames) < 3))) {
 560              array_unshift($catnames, 'top');
 561          }
 562  
 563          if ($this->contextfromfile && $contextid !== false) {
 564              $context = context::instance_by_id($contextid);
 565              require_capability('moodle/question:add', $context);
 566          } else {
 567              $context = context::instance_by_id($this->category->contextid);
 568          }
 569          $this->importcontext = $context;
 570  
 571          // Now create any categories that need to be created.
 572          foreach ($catnames as $key => $catname) {
 573              if ($parent == 0) {
 574                  $category = question_get_top_category($context->id, true);
 575                  $parent = $category->id;
 576              } else if ($category = $DB->get_record('question_categories',
 577                      array('name' => $catname, 'contextid' => $context->id, 'parent' => $parent))) {
 578                  // If this category is now the last one in the path we are processing ...
 579                  if ($key == (count($catnames) - 1) && $lastcategoryinfo) {
 580                      // Do nothing unless the child category appears before the parent category
 581                      // in the imported xml file. Because the parent was created without info being available
 582                      // at that time, this allows the info to be added from the xml data.
 583                      if (isset($lastcategoryinfo->info) && $lastcategoryinfo->info !== ''
 584                              && $category->info === '') {
 585                          $category->info = $lastcategoryinfo->info;
 586                          if (isset($lastcategoryinfo->infoformat) && $lastcategoryinfo->infoformat !== '') {
 587                              $category->infoformat = $lastcategoryinfo->infoformat;
 588                          }
 589                      }
 590                      // Same for idnumber.
 591                      if (isset($lastcategoryinfo->idnumber) && $lastcategoryinfo->idnumber !== ''
 592                              && $category->idnumber === '') {
 593                          $category->idnumber = $lastcategoryinfo->idnumber;
 594                      }
 595                      $DB->update_record('question_categories', $category);
 596                  }
 597                  $parent = $category->id;
 598              } else {
 599                  if ($catname == 'top') {
 600                      // Should not happen, but if it does just move on.
 601                      // Occurs when there has been some import/export that has created
 602                      // multiple nested 'top' categories (due to old bug solved by MDL-63165).
 603                      // This basically silently cleans up old errors. Not throwing an exception here.
 604                      continue;
 605                  }
 606                  require_capability('moodle/question:managecategory', $context);
 607                  // Create the new category. This will create all the categories in the catpath,
 608                  // though only the final category will have any info added if available.
 609                  $category = new stdClass();
 610                  $category->contextid = $context->id;
 611                  $category->name = $catname;
 612                  $category->info = '';
 613                  // Only add info (category description) for the final category in the catpath.
 614                  if ($key == (count($catnames) - 1) && $lastcategoryinfo) {
 615                      if (isset($lastcategoryinfo->info) && $lastcategoryinfo->info !== '') {
 616                          $category->info = $lastcategoryinfo->info;
 617                          if (isset($lastcategoryinfo->infoformat) && $lastcategoryinfo->infoformat !== '') {
 618                              $category->infoformat = $lastcategoryinfo->infoformat;
 619                          }
 620                      }
 621                      // Same for idnumber.
 622                      if (isset($lastcategoryinfo->idnumber) && $lastcategoryinfo->idnumber !== '') {
 623                          $category->idnumber = $lastcategoryinfo->idnumber;
 624                      }
 625                  }
 626                  $category->parent = $parent;
 627                  $category->sortorder = 999;
 628                  $category->stamp = make_unique_id_code();
 629                  $category->id = $DB->insert_record('question_categories', $category);
 630                  $parent = $category->id;
 631                  $event = \core\event\question_category_created::create_from_question_category_instance($category, $context);
 632                  $event->trigger();
 633              }
 634          }
 635          return $category;
 636      }
 637  
 638      /**
 639       * Return complete file within an array, one item per line
 640       * @param string filename name of file
 641       * @return mixed contents array or false on failure
 642       */
 643      protected function readdata($filename) {
 644          if (is_readable($filename)) {
 645              $filearray = file($filename);
 646  
 647              // If the first line of the file starts with a UTF-8 BOM, remove it.
 648              $filearray[0] = core_text::trim_utf8_bom($filearray[0]);
 649  
 650              // Check for Macintosh OS line returns (ie file on one line), and fix.
 651              if (preg_match("~\r~", $filearray[0]) AND !preg_match("~\n~", $filearray[0])) {
 652                  return explode("\r", $filearray[0]);
 653              } else {
 654                  return $filearray;
 655              }
 656          }
 657          return false;
 658      }
 659  
 660      /**
 661       * Parses an array of lines into an array of questions,
 662       * where each item is a question object as defined by
 663       * readquestion().   Questions are defined as anything
 664       * between blank lines.
 665       *
 666       * NOTE this method used to take $context as a second argument. However, at
 667       * the point where this method was called, it was impossible to know what
 668       * context the quetsions were going to be saved into, so the value could be
 669       * wrong. Also, none of the standard question formats were using this argument,
 670       * so it was removed. See MDL-32220.
 671       *
 672       * If your format does not use blank lines as a delimiter
 673       * then you will need to override this method. Even then
 674       * try to use readquestion for each question
 675       * @param array lines array of lines from readdata
 676       * @return array array of question objects
 677       */
 678      protected function readquestions($lines) {
 679  
 680          $questions = array();
 681          $currentquestion = array();
 682  
 683          foreach ($lines as $line) {
 684              $line = trim($line);
 685              if (empty($line)) {
 686                  if (!empty($currentquestion)) {
 687                      if ($question = $this->readquestion($currentquestion)) {
 688                          $questions[] = $question;
 689                      }
 690                      $currentquestion = array();
 691                  }
 692              } else {
 693                  $currentquestion[] = $line;
 694              }
 695          }
 696  
 697          if (!empty($currentquestion)) {  // There may be a final question
 698              if ($question = $this->readquestion($currentquestion)) {
 699                  $questions[] = $question;
 700              }
 701          }
 702  
 703          return $questions;
 704      }
 705  
 706      /**
 707       * return an "empty" question
 708       * Somewhere to specify question parameters that are not handled
 709       * by import but are required db fields.
 710       * This should not be overridden.
 711       * @return object default question
 712       */
 713      protected function defaultquestion() {
 714          global $CFG;
 715          static $defaultshuffleanswers = null;
 716          if (is_null($defaultshuffleanswers)) {
 717              $defaultshuffleanswers = get_config('quiz', 'shuffleanswers');
 718          }
 719  
 720          $question = new stdClass();
 721          $question->shuffleanswers = $defaultshuffleanswers;
 722          $question->defaultmark = 1;
 723          $question->image = '';
 724          $question->usecase = 0;
 725          $question->multiplier = array();
 726          $question->questiontextformat = FORMAT_MOODLE;
 727          $question->generalfeedback = '';
 728          $question->generalfeedbackformat = FORMAT_MOODLE;
 729          $question->answernumbering = 'abc';
 730          $question->penalty = 0.3333333;
 731          $question->length = 1;
 732  
 733          // this option in case the questiontypes class wants
 734          // to know where the data came from
 735          $question->export_process = true;
 736          $question->import_process = true;
 737  
 738          $this->add_blank_combined_feedback($question);
 739  
 740          return $question;
 741      }
 742  
 743      /**
 744       * Construct a reasonable default question name, based on the start of the question text.
 745       * @param string $questiontext the question text.
 746       * @param string $default default question name to use if the constructed one comes out blank.
 747       * @return string a reasonable question name.
 748       */
 749      public function create_default_question_name($questiontext, $default) {
 750          $name = $this->clean_question_name(shorten_text($questiontext, 80));
 751          if ($name) {
 752              return $name;
 753          } else {
 754              return $default;
 755          }
 756      }
 757  
 758      /**
 759       * Ensure that a question name does not contain anything nasty, and will fit in the DB field.
 760       * @param string $name the raw question name.
 761       * @return string a safe question name.
 762       */
 763      public function clean_question_name($name) {
 764          $name = clean_param($name, PARAM_TEXT); // Matches what the question editing form does.
 765          $name = trim($name);
 766          $trimlength = 251;
 767          while (core_text::strlen($name) > 255 && $trimlength > 0) {
 768              $name = shorten_text($name, $trimlength);
 769              $trimlength -= 10;
 770          }
 771          return $name;
 772      }
 773  
 774      /**
 775       * Add a blank combined feedback to a question object.
 776       * @param object question
 777       * @return object question
 778       */
 779      protected function add_blank_combined_feedback($question) {
 780          $question->correctfeedback = [
 781              'text' => '',
 782              'format' => $question->questiontextformat,
 783              'files' => []
 784          ];
 785          $question->partiallycorrectfeedback = [
 786              'text' => '',
 787              'format' => $question->questiontextformat,
 788              'files' => []
 789          ];
 790          $question->incorrectfeedback = [
 791              'text' => '',
 792              'format' => $question->questiontextformat,
 793              'files' => []
 794          ];
 795          return $question;
 796      }
 797  
 798      /**
 799       * Given the data known to define a question in
 800       * this format, this function converts it into a question
 801       * object suitable for processing and insertion into Moodle.
 802       *
 803       * If your format does not use blank lines to delimit questions
 804       * (e.g. an XML format) you must override 'readquestions' too
 805       * @param $lines mixed data that represents question
 806       * @return object question object
 807       */
 808      protected function readquestion($lines) {
 809          // We should never get there unless the qformat plugin is broken.
 810          throw new coding_exception('Question format plugin is missing important code: readquestion.');
 811  
 812          return null;
 813      }
 814  
 815      /**
 816       * Override if any post-processing is required
 817       * @return bool success
 818       */
 819      public function importpostprocess() {
 820          return true;
 821      }
 822  
 823      /*******************
 824       * EXPORT FUNCTIONS
 825       *******************/
 826  
 827      /**
 828       * Provide export functionality for plugin questiontypes
 829       * Do not override
 830       * @param name questiontype name
 831       * @param question object data to export
 832       * @param extra mixed any addition format specific data needed
 833       * @return string the data to append to export or false if error (or unhandled)
 834       */
 835      protected function try_exporting_using_qtypes($name, $question, $extra=null) {
 836          // work out the name of format in use
 837          $formatname = substr(get_class($this), strlen('qformat_'));
 838          $methodname = "export_to_{$formatname}";
 839  
 840          $qtype = question_bank::get_qtype($name, false);
 841          if (method_exists($qtype, $methodname)) {
 842              return $qtype->$methodname($question, $this, $extra);
 843          }
 844          return false;
 845      }
 846  
 847      /**
 848       * Do any pre-processing that may be required
 849       * @param bool success
 850       */
 851      public function exportpreprocess() {
 852          return true;
 853      }
 854  
 855      /**
 856       * Enable any processing to be done on the content
 857       * just prior to the file being saved
 858       * default is to do nothing
 859       * @param string output text
 860       * @param string processed output text
 861       */
 862      protected function presave_process($content) {
 863          return $content;
 864      }
 865  
 866      /**
 867       * Perform the export.
 868       * For most types this should not need to be overrided.
 869       *
 870       * @param   bool    $checkcapabilities Whether to check capabilities when exporting the questions.
 871       * @return  string  The content of the export.
 872       */
 873      public function exportprocess($checkcapabilities = true) {
 874          global $CFG, $DB;
 875  
 876          // Raise time and memory, as exporting can be quite intensive.
 877          core_php_time_limit::raise();
 878          raise_memory_limit(MEMORY_EXTRA);
 879  
 880          // Get the parents (from database) for this category.
 881          $parents = [];
 882          if ($this->category) {
 883              $parents = question_categorylist_parents($this->category->id);
 884          }
 885  
 886          // get the questions (from database) in this category
 887          // only get q's with no parents (no cloze subquestions specifically)
 888          if ($this->category) {
 889              $questions = get_questions_category($this->category, true);
 890          } else {
 891              $questions = $this->questions;
 892          }
 893  
 894          $count = 0;
 895  
 896          // results are first written into string (and then to a file)
 897          // so create/initialize the string here
 898          $expout = '';
 899  
 900          // track which category questions are in
 901          // if it changes we will record the category change in the output
 902          // file if selected. 0 means that it will get printed before the 1st question
 903          $trackcategory = 0;
 904  
 905          // Array of categories written to file.
 906          $writtencategories = [];
 907  
 908          foreach ($questions as $question) {
 909              // used by file api
 910              $contextid = $DB->get_field('question_categories', 'contextid',
 911                      array('id' => $question->category));
 912              $question->contextid = $contextid;
 913  
 914              // do not export hidden questions
 915              if (!empty($question->hidden)) {
 916                  continue;
 917              }
 918  
 919              // do not export random questions
 920              if ($question->qtype == 'random') {
 921                  continue;
 922              }
 923  
 924              // check if we need to record category change
 925              if ($this->cattofile) {
 926                  $addnewcat = false;
 927                  if ($question->category != $trackcategory) {
 928                      $addnewcat = true;
 929                      $trackcategory = $question->category;
 930                  }
 931                  $trackcategoryparents = question_categorylist_parents($trackcategory);
 932                  // Check if we need to record empty parents categories.
 933                  foreach ($trackcategoryparents as $trackcategoryparent) {
 934                      // If parent wasn't written.
 935                      if (!in_array($trackcategoryparent, $writtencategories)) {
 936                          // If parent is empty.
 937                          if (!count($DB->get_records('question', array('category' => $trackcategoryparent)))) {
 938                              $categoryname = $this->get_category_path($trackcategoryparent, $this->contexttofile);
 939                              $categoryinfo = $DB->get_record('question_categories', array('id' => $trackcategoryparent),
 940                                  'name, info, infoformat, idnumber', MUST_EXIST);
 941                              if ($categoryinfo->name != 'top') {
 942                                  // Create 'dummy' question for parent category.
 943                                  $dummyquestion = $this->create_dummy_question_representing_category($categoryname, $categoryinfo);
 944                                  $expout .= $this->writequestion($dummyquestion) . "\n";
 945                                  $writtencategories[] = $trackcategoryparent;
 946                              }
 947                          }
 948                      }
 949                  }
 950                  if ($addnewcat && !in_array($trackcategory, $writtencategories)) {
 951                      $categoryname = $this->get_category_path($trackcategory, $this->contexttofile);
 952                      $categoryinfo = $DB->get_record('question_categories', array('id' => $trackcategory),
 953                              'info, infoformat, idnumber', MUST_EXIST);
 954                      // Create 'dummy' question for category.
 955                      $dummyquestion = $this->create_dummy_question_representing_category($categoryname, $categoryinfo);
 956                      $expout .= $this->writequestion($dummyquestion) . "\n";
 957                      $writtencategories[] = $trackcategory;
 958                  }
 959              }
 960  
 961              // Add the question to result.
 962              if (!$checkcapabilities || question_has_capability_on($question, 'view')) {
 963                  $expquestion = $this->writequestion($question, $contextid);
 964                  // Don't add anything if witequestion returned nothing.
 965                  // This will permit qformat plugins to exclude some questions.
 966                  if ($expquestion !== null) {
 967                      $expout .= $expquestion . "\n";
 968                      $count++;
 969                  }
 970              }
 971          }
 972  
 973          // continue path for following error checks
 974          $course = $this->course;
 975          $continuepath = "{$CFG->wwwroot}/question/export.php?courseid={$course->id}";
 976  
 977          // did we actually process anything
 978          if ($count==0) {
 979              print_error('noquestions', 'question', $continuepath);
 980          }
 981  
 982          // final pre-process on exported data
 983          $expout = $this->presave_process($expout);
 984          return $expout;
 985      }
 986  
 987      /**
 988       * Create 'dummy' question for category export.
 989       * @param string $categoryname the name of the category
 990       * @param object $categoryinfo description of the category
 991       * @return stdClass 'dummy' question for category
 992       */
 993      protected function create_dummy_question_representing_category(string $categoryname, $categoryinfo) {
 994          $dummyquestion = new stdClass();
 995          $dummyquestion->qtype = 'category';
 996          $dummyquestion->category = $categoryname;
 997          $dummyquestion->id = 0;
 998          $dummyquestion->questiontextformat = '';
 999          $dummyquestion->contextid = 0;
1000          $dummyquestion->info = $categoryinfo->info;
1001          $dummyquestion->infoformat = $categoryinfo->infoformat;
1002          $dummyquestion->idnumber = $categoryinfo->idnumber;
1003          $dummyquestion->name = 'Switch category to ' . $categoryname;
1004          return $dummyquestion;
1005      }
1006  
1007      /**
1008       * get the category as a path (e.g., tom/dick/harry)
1009       * @param int id the id of the most nested catgory
1010       * @return string the path
1011       */
1012      protected function get_category_path($id, $includecontext = true) {
1013          global $DB;
1014  
1015          if (!$category = $DB->get_record('question_categories', array('id' => $id))) {
1016              print_error('cannotfindcategory', 'error', '', $id);
1017          }
1018          $contextstring = $this->translator->context_to_string($category->contextid);
1019  
1020          $pathsections = array();
1021          do {
1022              $pathsections[] = $category->name;
1023              $id = $category->parent;
1024          } while ($category = $DB->get_record('question_categories', array('id' => $id)));
1025  
1026          if ($includecontext) {
1027              $pathsections[] = '$' . $contextstring . '$';
1028          }
1029  
1030          $path = $this->assemble_category_path(array_reverse($pathsections));
1031  
1032          return $path;
1033      }
1034  
1035      /**
1036       * Convert a list of category names, possibly preceeded by one of the
1037       * context tokens like $course$, into a string representation of the
1038       * category path.
1039       *
1040       * Names are separated by / delimiters. And /s in the name are replaced by //.
1041       *
1042       * To reverse the process and split the paths into names, use
1043       * {@link split_category_path()}.
1044       *
1045       * @param array $names
1046       * @return string
1047       */
1048      protected function assemble_category_path($names) {
1049          $escapednames = array();
1050          foreach ($names as $name) {
1051              $escapedname = str_replace('/', '//', $name);
1052              if (substr($escapedname, 0, 1) == '/') {
1053                  $escapedname = ' ' . $escapedname;
1054              }
1055              if (substr($escapedname, -1) == '/') {
1056                  $escapedname = $escapedname . ' ';
1057              }
1058              $escapednames[] = $escapedname;
1059          }
1060          return implode('/', $escapednames);
1061      }
1062  
1063      /**
1064       * Convert a string, as returned by {@link assemble_category_path()},
1065       * back into an array of category names.
1066       *
1067       * Each category name is cleaned by a call to clean_param(, PARAM_TEXT),
1068       * which matches the cleaning in question/category_form.php.
1069       *
1070       * @param string $path
1071       * @return array of category names.
1072       */
1073      protected function split_category_path($path) {
1074          $rawnames = preg_split('~(?<!/)/(?!/)~', $path);
1075          $names = array();
1076          foreach ($rawnames as $rawname) {
1077              $names[] = clean_param(trim(str_replace('//', '/', $rawname)), PARAM_TEXT);
1078          }
1079          return $names;
1080      }
1081  
1082      /**
1083       * Do an post-processing that may be required
1084       * @return bool success
1085       */
1086      protected function exportpostprocess() {
1087          return true;
1088      }
1089  
1090      /**
1091       * convert a single question object into text output in the given
1092       * format.
1093       * This must be overriden
1094       * @param object question question object
1095       * @return mixed question export text or null if not implemented
1096       */
1097      protected function writequestion($question) {
1098          // if not overidden, then this is an error.
1099          throw new coding_exception('Question format plugin is missing important code: writequestion.');
1100          return null;
1101      }
1102  
1103      /**
1104       * Convert the question text to plain text, so it can safely be displayed
1105       * during import to let the user see roughly what is going on.
1106       */
1107      protected function format_question_text($question) {
1108          return s(question_utils::to_plain_text($question->questiontext,
1109                  $question->questiontextformat));
1110      }
1111  }
1112  
1113  class qformat_based_on_xml extends qformat_default {
1114  
1115      /**
1116       * A lot of imported files contain unwanted entities.
1117       * This method tries to clean up all known problems.
1118       * @param string str string to correct
1119       * @return string the corrected string
1120       */
1121      public function cleaninput($str) {
1122  
1123          $html_code_list = array(
1124              "&#039;" => "'",
1125              "&#8217;" => "'",
1126              "&#8220;" => "\"",
1127              "&#8221;" => "\"",
1128              "&#8211;" => "-",
1129              "&#8212;" => "-",
1130          );
1131          $str = strtr($str, $html_code_list);
1132          // Use core_text entities_to_utf8 function to convert only numerical entities.
1133          $str = core_text::entities_to_utf8($str, false);
1134          return $str;
1135      }
1136  
1137      /**
1138       * Return the array moodle is expecting
1139       * for an HTML text. No processing is done on $text.
1140       * qformat classes that want to process $text
1141       * for instance to import external images files
1142       * and recode urls in $text must overwrite this method.
1143       * @param array $text some HTML text string
1144       * @return array with keys text, format and files.
1145       */
1146      public function text_field($text) {
1147          return array(
1148              'text' => trim($text),
1149              'format' => FORMAT_HTML,
1150              'files' => array(),
1151          );
1152      }
1153  
1154      /**
1155       * Return the value of a node, given a path to the node
1156       * if it doesn't exist return the default value.
1157       * @param array xml data to read
1158       * @param array path path to node expressed as array
1159       * @param mixed default
1160       * @param bool istext process as text
1161       * @param string error if set value must exist, return false and issue message if not
1162       * @return mixed value
1163       */
1164      public function getpath($xml, $path, $default, $istext=false, $error='') {
1165          foreach ($path as $index) {
1166              if (!isset($xml[$index])) {
1167                  if (!empty($error)) {
1168                      $this->error($error);
1169                      return false;
1170                  } else {
1171                      return $default;
1172                  }
1173              }
1174  
1175              $xml = $xml[$index];
1176          }
1177  
1178          if ($istext) {
1179              if (!is_string($xml)) {
1180                  $this->error(get_string('invalidxml', 'qformat_xml'));
1181              }
1182              $xml = trim($xml);
1183          }
1184  
1185          return $xml;
1186      }
1187  }