Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

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

Differences Between: [Versions 310 and 401] [Versions 39 and 401]

   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   * GIFT format question importer/exporter.
  19   *
  20   * @package    qformat_gift
  21   * @copyright  2003 Paul Tsuchido Shew
  22   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  
  26  defined('MOODLE_INTERNAL') || die();
  27  
  28  
  29  /**
  30   * The GIFT import filter was designed as an easy to use method
  31   * for teachers writing questions as a text file. It supports most
  32   * question types and the missing word format.
  33   *
  34   * Multiple Choice / Missing Word
  35   *     Who's buried in Grant's tomb?{~Grant ~Jefferson =no one}
  36   *     Grant is {~buried =entombed ~living} in Grant's tomb.
  37   * True-False:
  38   *     Grant is buried in Grant's tomb.{FALSE}
  39   * Short-Answer.
  40   *     Who's buried in Grant's tomb?{=no one =nobody}
  41   * Numerical
  42   *     When was Ulysses S. Grant born?{#1822:5}
  43   * Matching
  44   *     Match the following countries with their corresponding
  45   *     capitals.{=Canada->Ottawa =Italy->Rome =Japan->Tokyo}
  46   *
  47   * Comment lines start with a double backslash (//).
  48   * Optional question names are enclosed in double colon(::).
  49   * Answer feedback is indicated with hash mark (#).
  50   * Percentage answer weights immediately follow the tilde (for
  51   * multiple choice) or equal sign (for short answer and numerical),
  52   * and are enclosed in percent signs (% %). See docs and examples.txt for more.
  53   *
  54   * This filter was written through the collaboration of numerous
  55   * members of the Moodle community. It was originally based on
  56   * the missingword format, which included code from Thomas Robb
  57   * and others. Paul Tsuchido Shew wrote this filter in December 2003.
  58   *
  59   * @copyright  2003 Paul Tsuchido Shew
  60   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  61   */
  62  class qformat_gift extends qformat_default {
  63  
  64      public function provide_import() {
  65          return true;
  66      }
  67  
  68      public function provide_export() {
  69          return true;
  70      }
  71  
  72      public function export_file_extension() {
  73          return '.txt';
  74      }
  75  
  76      /**
  77       * Validate the given file.
  78       *
  79       * For more expensive or detailed integrity checks.
  80       *
  81       * @param stored_file $file the file to check
  82       * @return string the error message that occurred while validating the given file
  83       */
  84      public function validate_file(stored_file $file): string {
  85          return $this->validate_is_utf8_file($file);
  86      }
  87  
  88      protected function answerweightparser(&$answer) {
  89          $answer = substr($answer, 1);                        // Removes initial %.
  90          $endposition  = strpos($answer, "%");
  91          $answerweight = substr($answer, 0, $endposition);  // Gets weight as integer.
  92          $answerweight = $answerweight/100;                 // Converts to percent.
  93          $answer = substr($answer, $endposition+1);          // Removes comment from answer.
  94          return $answerweight;
  95      }
  96  
  97      protected function commentparser($answer, $defaultformat) {
  98          $bits = explode('#', $answer, 2);
  99          $ans = $this->parse_text_with_format(trim($bits[0]), $defaultformat);
 100          if (count($bits) > 1) {
 101              $feedback = $this->parse_text_with_format(trim($bits[1]), $defaultformat);
 102          } else {
 103              $feedback = array('text' => '', 'format' => $defaultformat, 'files' => array());
 104          }
 105          return array($ans, $feedback);
 106      }
 107  
 108      protected function split_truefalse_comment($answer, $defaultformat) {
 109          $bits = explode('#', $answer, 3);
 110          $ans = $this->parse_text_with_format(trim($bits[0]), $defaultformat);
 111          if (count($bits) > 1) {
 112              $wrongfeedback = $this->parse_text_with_format(trim($bits[1]), $defaultformat);
 113          } else {
 114              $wrongfeedback = array('text' => '', 'format' => $defaultformat, 'files' => array());
 115          }
 116          if (count($bits) > 2) {
 117              $rightfeedback = $this->parse_text_with_format(trim($bits[2]), $defaultformat);
 118          } else {
 119              $rightfeedback = array('text' => '', 'format' => $defaultformat, 'files' => array());
 120          }
 121          return array($ans, $wrongfeedback, $rightfeedback);
 122      }
 123  
 124      protected function escapedchar_pre($string) {
 125          // Replaces escaped control characters with a placeholder BEFORE processing.
 126  
 127          $escapedcharacters = array("\\:",    "\\#",    "\\=",    "\\{",    "\\}",    "\\~",    "\\n"  );
 128          $placeholders      = array("&&058;", "&&035;", "&&061;", "&&123;", "&&125;", "&&126;", "&&010");
 129  
 130          $string = str_replace("\\\\", "&&092;", $string);
 131          $string = str_replace($escapedcharacters, $placeholders, $string);
 132          $string = str_replace("&&092;", "\\", $string);
 133          return $string;
 134      }
 135  
 136      protected function escapedchar_post($string) {
 137          // Replaces placeholders with corresponding character AFTER processing is done.
 138          $placeholders = array("&&058;", "&&035;", "&&061;", "&&123;", "&&125;", "&&126;", "&&010");
 139          $characters   = array(":",     "#",      "=",      "{",      "}",      "~",      "\n"  );
 140          $string = str_replace($placeholders, $characters, $string);
 141          return $string;
 142      }
 143  
 144      protected function check_answer_count($min, $answers, $text) {
 145          $countanswers = count($answers);
 146          if ($countanswers < $min) {
 147              $this->error(get_string('importminerror', 'qformat_gift'), $text);
 148              return false;
 149          }
 150  
 151          return true;
 152      }
 153  
 154      protected function parse_text_with_format($text, $defaultformat = FORMAT_MOODLE) {
 155          $result = array(
 156              'text' => $text,
 157              'format' => $defaultformat,
 158              'files' => array(),
 159          );
 160          if (strpos($text, '[') === 0) {
 161              $formatend = strpos($text, ']');
 162              $result['format'] = $this->format_name_to_const(substr($text, 1, $formatend - 1));
 163              if ($result['format'] == -1) {
 164                  $result['format'] = $defaultformat;
 165              } else {
 166                  $result['text'] = substr($text, $formatend + 1);
 167              }
 168          }
 169          $result['text'] = trim($this->escapedchar_post($result['text']));
 170          return $result;
 171      }
 172  
 173      public function readquestion($lines) {
 174          // Given an array of lines known to define a question in this format, this function
 175          // converts it into a question object suitable for processing and insertion into Moodle.
 176  
 177          $question = $this->defaultquestion();
 178          // Define replaced by simple assignment, stop redefine notices.
 179          $giftanswerweightregex = '/^%\-*([0-9]{1,2})\.?([0-9]*)%/';
 180  
 181          // Separate comments and implode.
 182          $comments = '';
 183          foreach ($lines as $key => $line) {
 184              $line = trim($line);
 185              if (substr($line, 0, 2) == '//') {
 186                  $comments .= $line . "\n";
 187                  $lines[$key] = ' ';
 188              }
 189          }
 190          $text = trim(implode("\n", $lines));
 191  
 192          if ($text == '') {
 193              return false;
 194          }
 195  
 196          // Substitute escaped control characters with placeholders.
 197          $text = $this->escapedchar_pre($text);
 198  
 199          // Look for category modifier.
 200          if (preg_match('~^\$CATEGORY:~', $text)) {
 201              $newcategory = trim(substr($text, 10));
 202  
 203              // Build fake question to contain category.
 204              $question->qtype = 'category';
 205              $question->category = $newcategory;
 206              return $question;
 207          }
 208  
 209          // Question name parser.
 210          if (substr($text, 0, 2) == '::') {
 211              $text = substr($text, 2);
 212  
 213              $namefinish = strpos($text, '::');
 214              if ($namefinish === false) {
 215                  $question->name = false;
 216                  // Name will be assigned after processing question text below.
 217              } else {
 218                  $questionname = substr($text, 0, $namefinish);
 219                  $question->name = $this->clean_question_name($this->escapedchar_post($questionname));
 220                  $text = trim(substr($text, $namefinish+2)); // Remove name from text.
 221              }
 222          } else {
 223              $question->name = false;
 224          }
 225  
 226          // Find the answer section.
 227          $answerstart = strpos($text, '{');
 228          $answerfinish = strpos($text, '}');
 229  
 230          $description = false;
 231          if ($answerstart === false && $answerfinish === false) {
 232              // No answer means it's a description.
 233              $description = true;
 234              $answertext = '';
 235              $answerlength = 0;
 236  
 237          } else if ($answerstart === false || $answerfinish === false) {
 238              $this->error(get_string('braceerror', 'qformat_gift'), $text);
 239              return false;
 240  
 241          } else {
 242              $answerlength = $answerfinish - $answerstart;
 243              $answertext = trim(substr($text, $answerstart + 1, $answerlength - 1));
 244          }
 245  
 246          // Format the question text, without answer, inserting "_____" as necessary.
 247          if ($description) {
 248              $questiontext = $text;
 249          } else if (substr($text, -1) == "}") {
 250              // No blank line if answers follow question, outside of closing punctuation.
 251              $questiontext = substr_replace($text, "", $answerstart, $answerlength + 1);
 252          } else {
 253              // Inserts blank line for missing word format.
 254              $questiontext = substr_replace($text, "_____", $answerstart, $answerlength + 1);
 255          }
 256  
 257          // Look to see if there is any general feedback.
 258          $gfseparator = strrpos($answertext, '####');
 259          if ($gfseparator === false) {
 260              $generalfeedback = '';
 261          } else {
 262              $generalfeedback = substr($answertext, $gfseparator + 4);
 263              $answertext = trim(substr($answertext, 0, $gfseparator));
 264          }
 265  
 266          // Get questiontext format from questiontext.
 267          $text = $this->parse_text_with_format($questiontext);
 268          $question->questiontextformat = $text['format'];
 269          $question->questiontext = $text['text'];
 270  
 271          // Get generalfeedback format from questiontext.
 272          $text = $this->parse_text_with_format($generalfeedback, $question->questiontextformat);
 273          $question->generalfeedback = $text['text'];
 274          $question->generalfeedbackformat = $text['format'];
 275  
 276          // Set question name if not already set.
 277          if ($question->name === false) {
 278              $question->name = $this->create_default_question_name($question->questiontext, get_string('questionname', 'question'));
 279          }
 280  
 281          // Determine question type.
 282          $question->qtype = null;
 283  
 284          // Give plugins first try.
 285          // Plugins must promise not to intercept standard qtypes
 286          // MDL-12346, this could be called from lesson mod which has its own base class =(.
 287          if (method_exists($this, 'try_importing_using_qtypes')
 288                  && ($tryquestion = $this->try_importing_using_qtypes($lines, $question, $answertext))) {
 289              return $tryquestion;
 290          }
 291  
 292          if ($description) {
 293              $question->qtype = 'description';
 294  
 295          } else if ($answertext == '') {
 296              $question->qtype = 'essay';
 297  
 298          } else if ($answertext[0] == '#') {
 299              $question->qtype = 'numerical';
 300  
 301          } else if (strpos($answertext, '~') !== false) {
 302              // Only Multiplechoice questions contain tilde ~.
 303              $question->qtype = 'multichoice';
 304  
 305          } else if (strpos($answertext, '=')  !== false
 306                  && strpos($answertext, '->') !== false) {
 307              // Only Matching contains both = and ->.
 308              $question->qtype = 'match';
 309  
 310          } else { // Either truefalse or shortanswer.
 311  
 312              // Truefalse question check.
 313              $truefalsecheck = $answertext;
 314              if (strpos($answertext, '#') > 0) {
 315                  // Strip comments to check for TrueFalse question.
 316                  $truefalsecheck = trim(substr($answertext, 0, strpos($answertext, "#")));
 317              }
 318  
 319              $validtfanswers = array('T', 'TRUE', 'F', 'FALSE');
 320              if (in_array($truefalsecheck, $validtfanswers)) {
 321                  $question->qtype = 'truefalse';
 322  
 323              } else { // Must be shortanswer.
 324                  $question->qtype = 'shortanswer';
 325              }
 326          }
 327  
 328          // Extract any idnumber and tags from the comments.
 329          list($question->idnumber, $question->tags) =
 330                  $this->extract_idnumber_and_tags_from_comment($comments);
 331  
 332          if (!isset($question->qtype)) {
 333              $giftqtypenotset = get_string('giftqtypenotset', 'qformat_gift');
 334              $this->error($giftqtypenotset, $text);
 335              return false;
 336          }
 337  
 338          switch ($question->qtype) {
 339              case 'description':
 340                  $question->defaultmark = 0;
 341                  $question->length = 0;
 342                  return $question;
 343  
 344              case 'essay':
 345                  $question->responseformat = 'editor';
 346                  $question->responserequired = 1;
 347                  $question->responsefieldlines = 15;
 348                  $question->attachments = 0;
 349                  $question->attachmentsrequired = 0;
 350                  $question->graderinfo = array(
 351                          'text' => '', 'format' => FORMAT_HTML, 'files' => array());
 352                  $question->responsetemplate = array(
 353                          'text' => '', 'format' => FORMAT_HTML);
 354                  return $question;
 355  
 356              case 'multichoice':
 357                  // "Temporary" solution to enable choice of answernumbering on GIFT import
 358                  // by respecting default set for multichoice questions (MDL-59447)
 359                  $question->answernumbering = get_config('qtype_multichoice', 'answernumbering');
 360  
 361                  if (strpos($answertext, "=") === false) {
 362                      $question->single = 0; // Multiple answers are enabled if no single answer is 100% correct.
 363                  } else {
 364                      $question->single = 1; // Only one answer allowed (the default).
 365                  }
 366                  $question = $this->add_blank_combined_feedback($question);
 367  
 368                  $answertext = str_replace("=", "~=", $answertext);
 369                  $answers = explode("~", $answertext);
 370                  if (isset($answers[0])) {
 371                      $answers[0] = trim($answers[0]);
 372                  }
 373                  if (empty($answers[0])) {
 374                      array_shift($answers);
 375                  }
 376  
 377                  $countanswers = count($answers);
 378  
 379                  if (!$this->check_answer_count(2, $answers, $text)) {
 380                      return false;
 381                  }
 382  
 383                  foreach ($answers as $key => $answer) {
 384                      $answer = trim($answer);
 385  
 386                      // Determine answer weight.
 387                      if ($answer[0] == '=') {
 388                          $answerweight = 1;
 389                          $answer = substr($answer, 1);
 390  
 391                      } else if (preg_match($giftanswerweightregex, $answer)) {    // Check for properly formatted answer weight.
 392                          $answerweight = $this->answerweightparser($answer);
 393  
 394                      } else {     // Default, i.e., wrong anwer.
 395                          $answerweight = 0;
 396                      }
 397                      list($question->answer[$key], $question->feedback[$key]) =
 398                              $this->commentparser($answer, $question->questiontextformat);
 399                      $question->fraction[$key] = $answerweight;
 400                  }  // End foreach answer.
 401  
 402                  return $question;
 403  
 404              case 'match':
 405                  $question = $this->add_blank_combined_feedback($question);
 406  
 407                  $answers = explode('=', $answertext);
 408                  if (isset($answers[0])) {
 409                      $answers[0] = trim($answers[0]);
 410                  }
 411                  if (empty($answers[0])) {
 412                      array_shift($answers);
 413                  }
 414  
 415                  if (!$this->check_answer_count(2, $answers, $text)) {
 416                      return false;
 417                  }
 418  
 419                  foreach ($answers as $key => $answer) {
 420                      $answer = trim($answer);
 421                      if (strpos($answer, "->") === false) {
 422                          $this->error(get_string('giftmatchingformat', 'qformat_gift'), $answer);
 423                          return false;
 424                      }
 425  
 426                      $marker = strpos($answer, '->');
 427                      $question->subquestions[$key] = $this->parse_text_with_format(
 428                              substr($answer, 0, $marker), $question->questiontextformat);
 429                      $question->subanswers[$key] = trim($this->escapedchar_post(
 430                              substr($answer, $marker + 2)));
 431                  }
 432  
 433                  return $question;
 434  
 435              case 'truefalse':
 436                  list($answer, $wrongfeedback, $rightfeedback) =
 437                          $this->split_truefalse_comment($answertext, $question->questiontextformat);
 438  
 439                  if ($answer['text'] == "T" || $answer['text'] == "TRUE") {
 440                      $question->correctanswer = 1;
 441                      $question->feedbacktrue = $rightfeedback;
 442                      $question->feedbackfalse = $wrongfeedback;
 443                  } else {
 444                      $question->correctanswer = 0;
 445                      $question->feedbacktrue = $wrongfeedback;
 446                      $question->feedbackfalse = $rightfeedback;
 447                  }
 448  
 449                  $question->penalty = 1;
 450  
 451                  return $question;
 452  
 453              case 'shortanswer':
 454                  // Shortanswer question.
 455                  $answers = explode("=", $answertext);
 456                  if (isset($answers[0])) {
 457                      $answers[0] = trim($answers[0]);
 458                  }
 459                  if (empty($answers[0])) {
 460                      array_shift($answers);
 461                  }
 462  
 463                  if (!$this->check_answer_count(1, $answers, $text)) {
 464                      return false;
 465                  }
 466  
 467                  foreach ($answers as $key => $answer) {
 468                      $answer = trim($answer);
 469  
 470                      // Answer weight.
 471                      if (preg_match($giftanswerweightregex, $answer)) {    // Check for properly formatted answer weight.
 472                          $answerweight = $this->answerweightparser($answer);
 473                      } else {     // Default, i.e., full-credit anwer.
 474                          $answerweight = 1;
 475                      }
 476  
 477                      list($answer, $question->feedback[$key]) = $this->commentparser(
 478                              $answer, $question->questiontextformat);
 479  
 480                      $question->answer[$key] = $answer['text'];
 481                      $question->fraction[$key] = $answerweight;
 482                  }
 483  
 484                  return $question;
 485  
 486              case 'numerical':
 487                  // Note similarities to ShortAnswer.
 488                  $answertext = substr($answertext, 1); // Remove leading "#".
 489  
 490                  // If there is feedback for a wrong answer, store it for now.
 491                  if (($pos = strpos($answertext, '~')) !== false) {
 492                      $wrongfeedback = substr($answertext, $pos);
 493                      $answertext = substr($answertext, 0, $pos);
 494                  } else {
 495                      $wrongfeedback = '';
 496                  }
 497  
 498                  $answers = explode("=", $answertext);
 499                  if (isset($answers[0])) {
 500                      $answers[0] = trim($answers[0]);
 501                  }
 502                  if (empty($answers[0])) {
 503                      array_shift($answers);
 504                  }
 505  
 506                  if (count($answers) == 0) {
 507                      // Invalid question.
 508                      $giftnonumericalanswers = get_string('giftnonumericalanswers', 'qformat_gift');
 509                      $this->error($giftnonumericalanswers, $text);
 510                      return false;
 511                  }
 512  
 513                  foreach ($answers as $key => $answer) {
 514                      $answer = trim($answer);
 515  
 516                      // Answer weight.
 517                      if (preg_match($giftanswerweightregex, $answer)) {    // Check for properly formatted answer weight.
 518                          $answerweight = $this->answerweightparser($answer);
 519                      } else {     // Default, i.e., full-credit anwer.
 520                          $answerweight = 1;
 521                      }
 522  
 523                      list($answer, $question->feedback[$key]) = $this->commentparser(
 524                              $answer, $question->questiontextformat);
 525                      $question->fraction[$key] = $answerweight;
 526                      $answer = $answer['text'];
 527  
 528                      // Calculate Answer and Min/Max values.
 529                      if (strpos($answer, "..") > 0) { // Optional [min]..[max] format.
 530                          $marker = strpos($answer, "..");
 531                          $max = trim(substr($answer, $marker + 2));
 532                          $min = trim(substr($answer, 0, $marker));
 533                          $ans = ($max + $min)/2;
 534                          $tol = $max - $ans;
 535                      } else if (strpos($answer, ':') > 0) { // Standard [answer]:[errormargin] format.
 536                          $marker = strpos($answer, ':');
 537                          $tol = trim(substr($answer, $marker+1));
 538                          $ans = trim(substr($answer, 0, $marker));
 539                      } else { // Only one valid answer (zero errormargin).
 540                          $tol = 0;
 541                          $ans = trim($answer);
 542                      }
 543  
 544                      if (!(is_numeric($ans) || $ans = '*') || !is_numeric($tol)) {
 545                              $errornotnumbers = get_string('errornotnumbers');
 546                              $this->error($errornotnumbers, $text);
 547                          return false;
 548                      }
 549  
 550                      // Store results.
 551                      $question->answer[$key] = $ans;
 552                      $question->tolerance[$key] = $tol;
 553                  }
 554  
 555                  if ($wrongfeedback) {
 556                      $key += 1;
 557                      $question->fraction[$key] = 0;
 558                      list($notused, $question->feedback[$key]) = $this->commentparser(
 559                              $wrongfeedback, $question->questiontextformat);
 560                      $question->answer[$key] = '*';
 561                      $question->tolerance[$key] = '';
 562                  }
 563  
 564                  return $question;
 565  
 566              default:
 567                  $this->error(get_string('giftnovalidquestion', 'qformat_gift'), $text);
 568                  return false;
 569  
 570          }
 571      }
 572  
 573      protected function repchar($text, $notused = 0) {
 574          // Escapes 'reserved' characters # = ~ {) :
 575          // Removes new lines.
 576          $reserved = array(  '\\',  '#', '=', '~', '{', '}', ':', "\n", "\r");
 577          $escaped =  array('\\\\', '\#', '\=', '\~', '\{', '\}', '\:', '\n', '');
 578  
 579          $newtext = str_replace($reserved, $escaped, $text);
 580          return $newtext;
 581      }
 582  
 583      /**
 584       * @param int $format one of the FORMAT_ constants.
 585       * @return string the corresponding name.
 586       */
 587      protected function format_const_to_name($format) {
 588          if ($format == FORMAT_MOODLE) {
 589              return 'moodle';
 590          } else if ($format == FORMAT_HTML) {
 591              return 'html';
 592          } else if ($format == FORMAT_PLAIN) {
 593              return 'plain';
 594          } else if ($format == FORMAT_MARKDOWN) {
 595              return 'markdown';
 596          } else {
 597              return 'moodle';
 598          }
 599      }
 600  
 601      /**
 602       * @param int $format one of the FORMAT_ constants.
 603       * @return string the corresponding name.
 604       */
 605      protected function format_name_to_const($format) {
 606          if ($format == 'moodle') {
 607              return FORMAT_MOODLE;
 608          } else if ($format == 'html') {
 609              return FORMAT_HTML;
 610          } else if ($format == 'plain') {
 611              return FORMAT_PLAIN;
 612          } else if ($format == 'markdown') {
 613              return FORMAT_MARKDOWN;
 614          } else {
 615              return -1;
 616          }
 617      }
 618  
 619      /**
 620       * Extract any tags or idnumber declared in the question comment.
 621       *
 622       * @param string $comment E.g. "// Line 1.\n//Line 2.\n".
 623       * @return array with two elements. string $idnumber (or '') and string[] of tags.
 624       */
 625      public function extract_idnumber_and_tags_from_comment(string $comment): array {
 626  
 627          // Find the idnumber, if any. There should not be more than one, but if so, we just find the first.
 628          $idnumber = '';
 629          if (preg_match('~
 630                  # Start of id token.
 631                  \[id:
 632  
 633                  # Any number of (non-control) characters, with any ] escaped.
 634                  # This is the bit we want so capture it.
 635                  (
 636                      (?:\\\\]|[^][:cntrl:]])+
 637                  )
 638  
 639                  # End of id token.
 640                  ]
 641                  ~x', $comment, $match)) {
 642              $idnumber = str_replace('\]', ']', trim($match[1]));
 643          }
 644  
 645          // Find any tags.
 646          $tags = [];
 647          if (preg_match_all('~
 648                  # Start of tag token.
 649                  \[tag:
 650  
 651                  # Any number of allowed characters (see PARAM_TAG), with any ] escaped.
 652                  # This is the bit we want so capture it.
 653                  (
 654                      (?:\\\\]|[^]<>`[:cntrl:]]|)+
 655                  )
 656  
 657                  # End of tag token.
 658                  ]
 659                  ~x', $comment, $matches)) {
 660              foreach ($matches[1] as $rawtag) {
 661                  $tags[] = str_replace('\]', ']', trim($rawtag));
 662              }
 663          }
 664  
 665          return [$idnumber, $tags];
 666      }
 667  
 668      public function write_name($name) {
 669          return '::' . $this->repchar($name) . '::';
 670      }
 671  
 672      public function write_questiontext($text, $format, $defaultformat = FORMAT_MOODLE) {
 673          $output = '';
 674          if ($text != '' && $format != $defaultformat) {
 675              $output .= '[' . $this->format_const_to_name($format) . ']';
 676          }
 677          $output .= $this->repchar($text, $format);
 678          return $output;
 679      }
 680  
 681      /**
 682       * Outputs the general feedback for the question, if any. This needs to be the
 683       * last thing before the }.
 684       * @param object $question the question data.
 685       * @param string $indent to put before the general feedback. Defaults to a tab.
 686       *      If this is not blank, a newline is added after the line.
 687       */
 688      public function write_general_feedback($question, $indent = "\t") {
 689          $generalfeedback = $this->write_questiontext($question->generalfeedback,
 690                  $question->generalfeedbackformat, $question->questiontextformat);
 691  
 692          if ($generalfeedback) {
 693              $generalfeedback = '####' . $generalfeedback;
 694              if ($indent) {
 695                  $generalfeedback = $indent . $generalfeedback . "\n";
 696              }
 697          }
 698  
 699          return $generalfeedback;
 700      }
 701  
 702      public function writequestion($question) {
 703  
 704          // Start with a comment.
 705          $expout = "// question: {$question->id}  name: {$question->name}\n";
 706          $expout .= $this->write_idnumber_and_tags($question);
 707  
 708          // Output depends on question type.
 709          switch($question->qtype) {
 710  
 711              case 'category':
 712                  // Not a real question, used to insert category switch.
 713                  $expout .= "\$CATEGORY: $question->category\n";
 714                  break;
 715  
 716              case 'description':
 717                  $expout .= $this->write_name($question->name);
 718                  $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
 719                  break;
 720  
 721              case 'essay':
 722                  $expout .= $this->write_name($question->name);
 723                  $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
 724                  $expout .= "{";
 725                  $expout .= $this->write_general_feedback($question, '');
 726                  $expout .= "}\n";
 727                  break;
 728  
 729              case 'truefalse':
 730                  $trueanswer = $question->options->answers[$question->options->trueanswer];
 731                  $falseanswer = $question->options->answers[$question->options->falseanswer];
 732                  if ($trueanswer->fraction == 1) {
 733                      $answertext = 'TRUE';
 734                      $rightfeedback = $this->write_questiontext($trueanswer->feedback,
 735                              $trueanswer->feedbackformat, $question->questiontextformat);
 736                      $wrongfeedback = $this->write_questiontext($falseanswer->feedback,
 737                              $falseanswer->feedbackformat, $question->questiontextformat);
 738                  } else {
 739                      $answertext = 'FALSE';
 740                      $rightfeedback = $this->write_questiontext($falseanswer->feedback,
 741                              $falseanswer->feedbackformat, $question->questiontextformat);
 742                      $wrongfeedback = $this->write_questiontext($trueanswer->feedback,
 743                              $trueanswer->feedbackformat, $question->questiontextformat);
 744                  }
 745  
 746                  $expout .= $this->write_name($question->name);
 747                  $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
 748                  $expout .= '{' . $this->repchar($answertext);
 749                  if ($wrongfeedback) {
 750                      $expout .= '#' . $wrongfeedback;
 751                  } else if ($rightfeedback) {
 752                      $expout .= '#';
 753                  }
 754                  if ($rightfeedback) {
 755                      $expout .= '#' . $rightfeedback;
 756                  }
 757                  $expout .= $this->write_general_feedback($question, '');
 758                  $expout .= "}\n";
 759                  break;
 760  
 761              case 'multichoice':
 762                  $expout .= $this->write_name($question->name);
 763                  $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
 764                  $expout .= "{\n";
 765                  foreach ($question->options->answers as $answer) {
 766                      if ($answer->fraction == 1 && $question->options->single) {
 767                          $answertext = '=';
 768                      } else if ($answer->fraction == 0) {
 769                          $answertext = '~';
 770                      } else {
 771                          $weight = $answer->fraction * 100;
 772                          $answertext = '~%' . $weight . '%';
 773                      }
 774                      $expout .= "\t" . $answertext . $this->write_questiontext($answer->answer,
 775                                  $answer->answerformat, $question->questiontextformat);
 776                      if ($answer->feedback != '') {
 777                          $expout .= '#' . $this->write_questiontext($answer->feedback,
 778                                  $answer->feedbackformat, $question->questiontextformat);
 779                      }
 780                      $expout .= "\n";
 781                  }
 782                  $expout .= $this->write_general_feedback($question);
 783                  $expout .= "}\n";
 784                  break;
 785  
 786              case 'shortanswer':
 787                  $expout .= $this->write_name($question->name);
 788                  $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
 789                  $expout .= "{\n";
 790                  foreach ($question->options->answers as $answer) {
 791                      $weight = 100 * $answer->fraction;
 792                      $expout .= "\t=%" . $weight . '%' . $this->repchar($answer->answer) .
 793                              '#' . $this->write_questiontext($answer->feedback,
 794                                  $answer->feedbackformat, $question->questiontextformat) . "\n";
 795                  }
 796                  $expout .= $this->write_general_feedback($question);
 797                  $expout .= "}\n";
 798                  break;
 799  
 800              case 'numerical':
 801                  $expout .= $this->write_name($question->name);
 802                  $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
 803                  $expout .= "{#\n";
 804                  foreach ($question->options->answers as $answer) {
 805                      if ($answer->answer != '' && $answer->answer != '*') {
 806                          $weight = 100 * $answer->fraction;
 807                          $expout .= "\t=%" . $weight . '%' . $answer->answer . ':' .
 808                                  (float)$answer->tolerance . '#' . $this->write_questiontext($answer->feedback,
 809                                  $answer->feedbackformat, $question->questiontextformat) . "\n";
 810                      } else {
 811                          $expout .= "\t~#" . $this->write_questiontext($answer->feedback,
 812                                  $answer->feedbackformat, $question->questiontextformat) . "\n";
 813                      }
 814                  }
 815                  $expout .= $this->write_general_feedback($question);
 816                  $expout .= "}\n";
 817                  break;
 818  
 819              case 'match':
 820                  $expout .= $this->write_name($question->name);
 821                  $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
 822                  $expout .= "{\n";
 823                  foreach ($question->options->subquestions as $subquestion) {
 824                      $expout .= "\t=" . $this->write_questiontext($subquestion->questiontext,
 825                              $subquestion->questiontextformat, $question->questiontextformat) .
 826                              ' -> ' . $this->repchar($subquestion->answertext) . "\n";
 827                  }
 828                  $expout .= $this->write_general_feedback($question);
 829                  $expout .= "}\n";
 830                  break;
 831  
 832              default:
 833                  // Check for plugins.
 834                  if ($out = $this->try_exporting_using_qtypes($question->qtype, $question)) {
 835                      $expout .= $out;
 836                  }
 837          }
 838  
 839          // Add empty line to delimit questions.
 840          $expout .= "\n";
 841          return $expout;
 842      }
 843  
 844      /**
 845       * Prepare any question idnumber or tags for export.
 846       *
 847       * @param stdClass $questiondata the question data we are exporting.
 848       * @return string a string that can be written as a line in the GIFT file,
 849       *      e.g. "// [id:myid] [tag:some-tag]\n". Will be '' if none.
 850       */
 851      public function write_idnumber_and_tags(stdClass $questiondata): string {
 852          if ($questiondata->qtype == 'category') {
 853              return '';
 854          }
 855  
 856          $bits = [];
 857  
 858          if (isset($questiondata->idnumber) && $questiondata->idnumber !== '') {
 859              $bits[] = '[id:' . str_replace(']', '\]', $questiondata->idnumber) . ']';
 860          }
 861  
 862          // Write the question tags.
 863          if (core_tag_tag::is_enabled('core_question', 'question')) {
 864              $tagobjects = core_tag_tag::get_item_tags('core_question', 'question', $questiondata->id);
 865  
 866              if (!empty($tagobjects)) {
 867                  $context = context::instance_by_id($questiondata->contextid);
 868                  $sortedtagobjects = question_sort_tags($tagobjects, $context, [$this->course]);
 869  
 870                  // Currently we ignore course tags. This should probably be fixed in future.
 871  
 872                  if (!empty($sortedtagobjects->tags)) {
 873                      foreach ($sortedtagobjects->tags as $tag) {
 874                          $bits[] = '[tag:' . str_replace(']', '\]', $tag) . ']';
 875                      }
 876                  }
 877              }
 878          }
 879  
 880          if (!$bits) {
 881              return '';
 882          }
 883  
 884          return '// ' . implode(' ', $bits) . "\n";
 885      }
 886  }