Search moodle.org's
Developer Documentation

See Release Notes

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

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

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