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.
   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   * Blackboard V5 and V6 question importer.
  19   *
  20   * @package    qformat_blackboard_six
  21   * @copyright  2003 Scott Elliott
  22   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  defined('MOODLE_INTERNAL') || die();
  26  
  27  require_once($CFG->libdir . '/xmlize.php');
  28  
  29  /**
  30   * Blackboard pool question importer class.
  31   *
  32   * @package    qformat_blackboard_six
  33   * @copyright  2003 Scott Elliott
  34   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  35   */
  36  class qformat_blackboard_six_pool extends qformat_blackboard_six_base {
  37      /**
  38       * @var bool Is the current question's question text escaped HTML
  39       * (true for most if not all Blackboard files).
  40       */
  41      public $ishtml = true;
  42  
  43      /**
  44       * Parse the xml document into an array of questions
  45       *
  46       * This *could* burn memory - but it won't happen that much
  47       * so fingers crossed!
  48       *
  49       * @param array $text array of lines from the input file.
  50       * @return array (of objects) questions objects.
  51       */
  52      protected function readquestions($text) {
  53  
  54          // This converts xml to big nasty data structure,
  55          // the 0 means keep white space as it is.
  56          try {
  57              $xml = xmlize($text, 0, 'UTF-8', true);
  58          } catch (xml_format_exception $e) {
  59              $this->error($e->getMessage(), '');
  60              return false;
  61          }
  62  
  63          $questions = array();
  64  
  65          $this->process_category($xml, $questions);
  66  
  67          $this->process_tf($xml, $questions);
  68          $this->process_mc($xml, $questions);
  69          $this->process_ma($xml, $questions);
  70          $this->process_fib($xml, $questions);
  71          $this->process_matching($xml, $questions);
  72          $this->process_essay($xml, $questions);
  73  
  74          return $questions;
  75      }
  76  
  77      /**
  78       * Do question import processing common to every qtype.
  79       *
  80       * @param array $questiondata the xml tree related to the current question
  81       * @return object initialized question object.
  82       */
  83      public function process_common($questiondata) {
  84  
  85          // This routine initialises the question object.
  86          $question = $this->defaultquestion();
  87  
  88          // Determine if the question is already escaped html.
  89          $this->ishtml = $this->getpath($questiondata,
  90                  array('#', 'BODY', 0, '#', 'FLAGS', 0, '#', 'ISHTML', 0, '@', 'value'),
  91                  false, false);
  92  
  93          // Put questiontext in question object.
  94          $text = $this->getpath($questiondata,
  95                  array('#', 'BODY', 0, '#', 'TEXT', 0, '#'),
  96                  '', true, get_string('importnotext', 'qformat_blackboard_six'));
  97  
  98          $questiontext = $this->cleaned_text_field($text);
  99          $question->questiontext = $questiontext['text'];
 100          $question->questiontextformat = $questiontext['format']; // Needed because add_blank_combined_feedback uses it.
 101          if (isset($questiontext['itemid'])) {
 102              $question->questiontextitemid = $questiontext['itemid'];
 103          }
 104  
 105          // Put name in question object. We must ensure it is not empty and it is less than 250 chars.
 106          $id = $this->getpath($questiondata, array('@', 'id'), '',  true);
 107          $question->name = $this->create_default_question_name($question->questiontext,
 108                  get_string('defaultname', 'qformat_blackboard_six' , $id));
 109  
 110          $question->generalfeedback = '';
 111          $question->generalfeedbackformat = FORMAT_HTML;
 112          $question->generalfeedbackfiles = array();
 113  
 114          // TODO : read the mark from the POOL TITLE QUESTIONLIST section.
 115          $question->defaultmark = 1;
 116          return $question;
 117      }
 118  
 119      /**
 120       * Add a category question entry based on the pool file title
 121       * @param array $xml the xml tree
 122       * @param array $questions the questions already parsed
 123       */
 124      public function process_category($xml, &$questions) {
 125          $title = $this->getpath($xml, array('POOL', '#', 'TITLE', 0, '@', 'value'), '', true);
 126  
 127          $dummyquestion = new stdClass();
 128          $dummyquestion->qtype = 'category';
 129          $dummyquestion->category = $this->cleaninput($this->clean_question_name($title));
 130  
 131          $questions[] = $dummyquestion;
 132      }
 133  
 134      /**
 135       * Process Essay Questions
 136       * @param array $xml the xml tree
 137       * @param array $questions the questions already parsed
 138       */
 139      public function process_essay($xml, &$questions) {
 140  
 141          if ($this->getpath($xml, array('POOL', '#', 'QUESTION_ESSAY'), false, false)) {
 142              $essayquestions = $this->getpath($xml,
 143                      array('POOL', '#', 'QUESTION_ESSAY'), false, false);
 144          } else {
 145              return;
 146          }
 147  
 148          foreach ($essayquestions as $thisquestion) {
 149  
 150              $question = $this->process_common($thisquestion);
 151  
 152              $question->qtype = 'essay';
 153  
 154              $question->answer = '';
 155              $answer = $this->getpath($thisquestion,
 156                      array('#', 'ANSWER', 0, '#', 'TEXT', 0, '#'), '', true);
 157              $question->graderinfo = $this->cleaned_text_field($answer);
 158              $question->responsetemplate = $this->text_field('');
 159              $question->feedback = '';
 160              $question->responseformat = 'editor';
 161              $question->responserequired = 1;
 162              $question->responsefieldlines = 15;
 163              $question->attachments = 0;
 164              $question->attachmentsrequired = 0;
 165              $question->fraction = 0;
 166  
 167              $questions[] = $question;
 168          }
 169      }
 170  
 171      /**
 172       * Process True / False Questions
 173       * @param array $xml the xml tree
 174       * @param array $questions the questions already parsed
 175       */
 176      public function process_tf($xml, &$questions) {
 177  
 178          if ($this->getpath($xml, array('POOL', '#', 'QUESTION_TRUEFALSE'), false, false)) {
 179              $tfquestions = $this->getpath($xml,
 180                      array('POOL', '#', 'QUESTION_TRUEFALSE'), false, false);
 181          } else {
 182              return;
 183          }
 184  
 185          foreach ($tfquestions as $thisquestion) {
 186  
 187              $question = $this->process_common($thisquestion);
 188  
 189              $question->qtype = 'truefalse';
 190              $question->single = 1; // Only one answer is allowed.
 191  
 192              $choices = $this->getpath($thisquestion, array('#', 'ANSWER'), array(), false);
 193  
 194              $correctanswer = $this->getpath($thisquestion,
 195                      array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER', 0, '@', 'answer_id'),
 196                      '', true);
 197  
 198              // First choice is true, second is false.
 199              $id = $this->getpath($choices[0], array('@', 'id'), '', true);
 200              $correctfeedback = $this->getpath($thisquestion,
 201                      array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'),
 202                      '', true);
 203              $incorrectfeedback = $this->getpath($thisquestion,
 204                      array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'),
 205                      '', true);
 206              if (strcmp($id,  $correctanswer) == 0) {  // True is correct.
 207                  $question->answer = 1;
 208                  $question->feedbacktrue = $this->cleaned_text_field($correctfeedback);
 209                  $question->feedbackfalse = $this->cleaned_text_field($incorrectfeedback);
 210              } else {  // False is correct.
 211                  $question->answer = 0;
 212                  $question->feedbacktrue = $this->cleaned_text_field($incorrectfeedback);
 213                  $question->feedbackfalse = $this->cleaned_text_field($correctfeedback);
 214              }
 215              $question->correctanswer = $question->answer;
 216              $questions[] = $question;
 217          }
 218      }
 219  
 220      /**
 221       * Process Multiple Choice Questions with single answer
 222       * @param array $xml the xml tree
 223       * @param array $questions the questions already parsed
 224       */
 225      public function process_mc($xml, &$questions) {
 226  
 227          if ($this->getpath($xml, array('POOL', '#', 'QUESTION_MULTIPLECHOICE'), false, false)) {
 228              $mcquestions = $this->getpath($xml,
 229                      array('POOL', '#', 'QUESTION_MULTIPLECHOICE'), false, false);
 230          } else {
 231              return;
 232          }
 233  
 234          foreach ($mcquestions as $thisquestion) {
 235  
 236              $question = $this->process_common($thisquestion);
 237  
 238              $correctfeedback = $this->getpath($thisquestion,
 239                      array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'),
 240                      '', true);
 241              $incorrectfeedback = $this->getpath($thisquestion,
 242                      array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'),
 243                      '', true);
 244              $question->correctfeedback = $this->cleaned_text_field($correctfeedback);
 245              $question->partiallycorrectfeedback = $this->text_field('');
 246              $question->incorrectfeedback = $this->cleaned_text_field($incorrectfeedback);
 247  
 248              $question->qtype = 'multichoice';
 249              $question->single = 1; // Only one answer is allowed.
 250  
 251              $choices = $this->getpath($thisquestion, array('#', 'ANSWER'), false, false);
 252              $correctanswerid = $this->getpath($thisquestion,
 253                          array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER', 0, '@', 'answer_id'),
 254                          '', true);
 255              foreach ($choices as $choice) {
 256                  $choicetext = $this->getpath($choice, array('#', 'TEXT', 0, '#'), '', true);
 257                  // Put this choice in the question object.
 258                  $question->answer[] = $this->cleaned_text_field($choicetext);
 259  
 260                  $choiceid = $this->getpath($choice, array('@', 'id'), '', true);
 261                  // If choice is the right answer, give 100% mark, otherwise give 0%.
 262                  if (strcmp ($choiceid, $correctanswerid) == 0) {
 263                      $question->fraction[] = 1;
 264                  } else {
 265                      $question->fraction[] = 0;
 266                  }
 267                  // There is never feedback specific to each choice.
 268                  $question->feedback[] = $this->text_field('');
 269              }
 270              $questions[] = $question;
 271          }
 272      }
 273  
 274      /**
 275       * Process Multiple Choice Questions With Multiple Answers
 276       * @param array $xml the xml tree
 277       * @param array $questions the questions already parsed
 278       */
 279      public function process_ma($xml, &$questions) {
 280          if ($this->getpath($xml, array('POOL', '#', 'QUESTION_MULTIPLEANSWER'), false, false)) {
 281              $maquestions = $this->getpath($xml,
 282                      array('POOL', '#', 'QUESTION_MULTIPLEANSWER'), false, false);
 283          } else {
 284              return;
 285          }
 286  
 287          foreach ($maquestions as $thisquestion) {
 288              $question = $this->process_common($thisquestion);
 289  
 290              $correctfeedback = $this->getpath($thisquestion,
 291                      array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'),
 292                      '', true);
 293              $incorrectfeedback = $this->getpath($thisquestion,
 294                      array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'),
 295                      '', true);
 296              $question->correctfeedback = $this->cleaned_text_field($correctfeedback);
 297              // As there is no partially correct feedback we use incorrect one.
 298              $question->partiallycorrectfeedback = $this->cleaned_text_field($incorrectfeedback);
 299              $question->incorrectfeedback = $this->cleaned_text_field($incorrectfeedback);
 300  
 301              $question->qtype = 'multichoice';
 302              $question->defaultmark = 1;
 303              $question->single = 0; // More than one answers allowed.
 304  
 305              $choices = $this->getpath($thisquestion, array('#', 'ANSWER'), false, false);
 306              $correctanswerids = array();
 307              foreach ($this->getpath($thisquestion,
 308                      array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER'), false, false) as $correctanswer) {
 309                  if ($correctanswer) {
 310                      $correctanswerids[] = $this->getpath($correctanswer,
 311                              array('@', 'answer_id'),
 312                              '', true);
 313                  }
 314              }
 315              $fraction = 1 / count($correctanswerids);
 316  
 317              foreach ($choices as $choice) {
 318                  $choicetext = $this->getpath($choice, array('#', 'TEXT', 0, '#'), '', true);
 319                  // Put this choice in the question object.
 320                  $question->answer[] = $this->cleaned_text_field($choicetext);
 321  
 322                  $choiceid = $this->getpath($choice, array('@', 'id'), '', true);
 323  
 324                  $iscorrect = in_array($choiceid, $correctanswerids);
 325  
 326                  if ($iscorrect) {
 327                      $question->fraction[] = $fraction;
 328                  } else {
 329                      $question->fraction[] = 0;
 330                  }
 331                  // There is never feedback specific to each choice.
 332                  $question->feedback[] = $this->text_field('');
 333              }
 334              $questions[] = $question;
 335          }
 336      }
 337  
 338      /**
 339       * Process Fill in the Blank Questions
 340       * @param array $xml the xml tree
 341       * @param array $questions the questions already parsed
 342       */
 343      public function process_fib($xml, &$questions) {
 344          if ($this->getpath($xml, array('POOL', '#', 'QUESTION_FILLINBLANK'), false, false)) {
 345              $fibquestions = $this->getpath($xml,
 346                      array('POOL', '#', 'QUESTION_FILLINBLANK'), false, false);
 347          } else {
 348              return;
 349          }
 350  
 351          foreach ($fibquestions as $thisquestion) {
 352  
 353              $question = $this->process_common($thisquestion);
 354  
 355              $question->qtype = 'shortanswer';
 356              $question->usecase = 0; // Ignore case.
 357  
 358              $correctfeedback = $this->getpath($thisquestion,
 359                      array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'),
 360                      '', true);
 361              $incorrectfeedback = $this->getpath($thisquestion,
 362                      array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'),
 363                      '', true);
 364              $answers = $this->getpath($thisquestion, array('#', 'ANSWER'), false, false);
 365              foreach ($answers as $answer) {
 366                  $question->answer[] = $this->getpath($answer,
 367                          array('#', 'TEXT', 0, '#'), '', true);
 368                  $question->fraction[] = 1;
 369                  $question->feedback[] = $this->cleaned_text_field($correctfeedback);
 370              }
 371              $question->answer[] = '*';
 372              $question->fraction[] = 0;
 373              $question->feedback[] = $this->cleaned_text_field($incorrectfeedback);
 374  
 375              $questions[] = $question;
 376          }
 377      }
 378  
 379      /**
 380       * Process Matching Questions
 381       * @param array $xml the xml tree
 382       * @param array $questions the questions already parsed
 383       */
 384      public function process_matching($xml, &$questions) {
 385          if ($this->getpath($xml, array('POOL', '#', 'QUESTION_MATCH'), false, false)) {
 386              $matchquestions = $this->getpath($xml,
 387                      array('POOL', '#', 'QUESTION_MATCH'), false, false);
 388          } else {
 389              return;
 390          }
 391          // Blackboard questions can't be imported in core Moodle without a loss in data,
 392          // as core match question don't allow HTML in subanswers. The contributed ddmatch
 393          // question type support HTML in subanswers.
 394          // The ddmatch question type is not part of core, so we need to check if it is defined.
 395          $ddmatchisinstalled = question_bank::is_qtype_installed('ddmatch');
 396  
 397          foreach ($matchquestions as $thisquestion) {
 398  
 399              $question = $this->process_common($thisquestion);
 400              if ($ddmatchisinstalled) {
 401                  $question->qtype = 'ddmatch';
 402              } else {
 403                  $question->qtype = 'match';
 404              }
 405  
 406              $correctfeedback = $this->getpath($thisquestion,
 407                      array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'),
 408                      '', true);
 409              $incorrectfeedback = $this->getpath($thisquestion,
 410                      array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'),
 411                      '', true);
 412              $question->correctfeedback = $this->cleaned_text_field($correctfeedback);
 413              // As there is no partially correct feedback we use incorrect one.
 414              $question->partiallycorrectfeedback = $this->cleaned_text_field($incorrectfeedback);
 415              $question->incorrectfeedback = $this->cleaned_text_field($incorrectfeedback);
 416  
 417              $choices = $this->getpath($thisquestion,
 418                      array('#', 'CHOICE'), false, false); // Blackboard "choices" are Moodle subanswers.
 419              $answers = $this->getpath($thisquestion,
 420                      array('#', 'ANSWER'), false, false); // Blackboard "answers" are Moodle subquestions.
 421              $correctanswers = $this->getpath($thisquestion,
 422                      array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER'), false, false); // Mapping between choices and answers.
 423              $mappings = array();
 424              foreach ($correctanswers as $correctanswer) {
 425                  if ($correctanswer) {
 426                      $correctchoiceid = $this->getpath($correctanswer,
 427                                  array('@', 'choice_id'), '', true);
 428                      $correctanswerid = $this->getpath($correctanswer,
 429                              array('@', 'answer_id'),
 430                              '', true);
 431                      $mappings[$correctanswerid] = $correctchoiceid;
 432                  }
 433              }
 434  
 435              foreach ($choices as $choice) {
 436                  if ($ddmatchisinstalled) {
 437                      $choicetext = $this->cleaned_text_field($this->getpath($choice,
 438                              array('#', 'TEXT', 0, '#'), '', true));
 439                  } else {
 440                      $choicetext = trim(strip_tags($this->getpath($choice,
 441                              array('#', 'TEXT', 0, '#'), '', true)));
 442                  }
 443  
 444                  if ($choicetext != '') { // Only import non empty subanswers.
 445                      $subquestion = '';
 446                      $choiceid = $this->getpath($choice,
 447                              array('@', 'id'), '', true);
 448                      $fiber = array_search($choiceid, $mappings);
 449                      $fiber = array_keys ($mappings, $choiceid);
 450                      foreach ($fiber as $correctanswerid) {
 451                          // We have found a correspondance for this choice so we need to take the associated answer.
 452                          foreach ($answers as $answer) {
 453                              $currentanswerid = $this->getpath($answer,
 454                                      array('@', 'id'), '', true);
 455                              if (strcmp ($currentanswerid, $correctanswerid) == 0) {
 456                                  $subquestion = $this->getpath($answer,
 457                                          array('#', 'TEXT', 0, '#'), '', true);
 458                                  break;
 459                              }
 460                          }
 461                          $question->subquestions[] = $this->cleaned_text_field($subquestion);
 462                          $question->subanswers[] = $choicetext;
 463                      }
 464  
 465                      if ($subquestion == '') { // Then in this case, $choice is a distractor.
 466                          $question->subquestions[] = $this->text_field('');
 467                          $question->subanswers[] = $choicetext;
 468                      }
 469                  }
 470              }
 471  
 472              // Verify that this matching question has enough subquestions and subanswers.
 473              $subquestioncount = 0;
 474              $subanswercount = 0;
 475              $subanswers = $question->subanswers;
 476              foreach ($question->subquestions as $key => $subquestion) {
 477                  $subquestion = $subquestion['text'];
 478                  $subanswer = $subanswers[$key];
 479                  if ($subquestion != '') {
 480                      $subquestioncount++;
 481                  }
 482                  $subanswercount++;
 483              }
 484              if ($subquestioncount < 2 || $subanswercount < 3) {
 485                      $this->error(get_string('notenoughtsubans', 'qformat_blackboard_six', $question->questiontext));
 486              } else {
 487                  $questions[] = $question;
 488              }
 489  
 490          }
 491      }
 492  }