. /** * Blackboard question importer. * * @package qformat_blackboard * @copyright 2003 Scott Elliott * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/xmlize.php'); /** * Blackboard question importer. * * @copyright 2003 Scott Elliott * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qformat_blackboard extends qformat_based_on_xml { // Is the current question's question text escaped HTML (true for most if not all Blackboard files). public $ishtml = true; public function provide_import() { return true; } public function mime_type() { return mimeinfo('type', '.dat'); } /** * Some softwares put entities in exported files. * This method try to clean up known problems. * @param string str string to correct * @return string the corrected string */ public function cleaninput($str) { if (!$this->ishtml) { return $str; } $html_code_list = array( "'" => "'", "’" => "'", "[" => "[", "“" => "\"", "”" => "\"", "]" => "]", "'" => "'", "–" => "-", "—" => "-", ); $str = strtr($str, $html_code_list); // Use textlib entities_to_utf8 function to convert only numerical entities. $str = textlib::entities_to_utf8($str, false); return $str; } /** * Parse the array of lines into an array of questions * this *could* burn memory - but it won't happen that much * so fingers crossed! * @param array of lines from the input file. * @param stdClass $context * @return array (of objects) question objects. */ protected function readquestions($lines) { $text = implode($lines, ' '); unset($lines); // This converts xml to big nasty data structure, // the 0 means keep white space as it is. try { $xml = xmlize($text, 0, 'UTF-8', true); } catch (xml_format_exception $e) { $this->error($e->getMessage(), ''); return false; } $questions = array(); $this->process_tf($xml, $questions); $this->process_mc($xml, $questions); $this->process_ma($xml, $questions); $this->process_fib($xml, $questions); $this->process_matching($xml, $questions); $this->process_essay($xml, $questions); return $questions; } /** * Do question import processing common to every qtype. * @param array $questiondata the xml tree related to the current question * @return object initialized question object. */ public function process_common($questiondata) { global $CFG; // This routine initialises the question object. $question = $this->defaultquestion(); // Determine if the question is already escaped html. $this->ishtml = $this->getpath($questiondata, array('#', 'BODY', 0, '#', 'FLAGS', 0, '#', 'ISHTML', 0, '@', 'value'), false, false); // Put questiontext in question object. $text = $this->getpath($questiondata, array('#', 'BODY', 0, '#', 'TEXT', 0, '#'), '', true, get_string('importnotext', 'qformat_blackboard')); if ($this->ishtml) { $question->questiontext = $this->cleaninput($text); $question->questiontextformat = FORMAT_HTML; $question->questiontextfiles = array(); } else { $question->questiontext = $text; } // Put name in question object. We must ensure it is not empty and it is less than 250 chars. $id = $this->getpath($questiondata, array('@', 'id'), '', true); $question->name = $this->create_default_question_name($question->questiontext, get_string('defaultname', 'qformat_blackboard', $id)); $question->generalfeedback = ''; $question->generalfeedbackformat = FORMAT_HTML; $question->generalfeedbackfiles = array(); // TODO : read the mark from the POOL TITLE QUESTIONLIST section. $question->defaultmark = 1; return $question; } /** * Process Essay Questions * @param array xml the xml tree * @param array questions the questions already parsed */ public function process_essay($xml, &$questions) { if ($this->getpath($xml, array('POOL', '#', 'QUESTION_ESSAY'), false, false)) { $essayquestions = $this->getpath($xml, array('POOL', '#', 'QUESTION_ESSAY'), false, false); } else { return; } foreach ($essayquestions as $thisquestion) { $question = $this->process_common($thisquestion); $question->qtype = 'essay'; $question->answer = ''; $answer = $this->getpath($thisquestion, array('#', 'ANSWER', 0, '#', 'TEXT', 0, '#'), '', true); $question->graderinfo = $this->text_field($this->cleaninput($answer)); $question->feedback = ''; $question->responseformat = 'editor'; $question->responsefieldlines = 15; $question->attachments = 0; $question->fraction = 0; $questions[] = $question; } } /** * Process True / False Questions * @param array xml the xml tree * @param array questions the questions already parsed */ public function process_tf($xml, &$questions) { if ($this->getpath($xml, array('POOL', '#', 'QUESTION_TRUEFALSE'), false, false)) { $tfquestions = $this->getpath($xml, array('POOL', '#', 'QUESTION_TRUEFALSE'), false, false); } else { return; } foreach ($tfquestions as $thisquestion) { $question = $this->process_common($thisquestion); $question->qtype = 'truefalse'; $question->single = 1; // Only one answer is allowed. $choices = $this->getpath($thisquestion, array('#', 'ANSWER'), array(), false); $correct_answer = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER', 0, '@', 'answer_id'), '', true); // First choice is true, second is false. $id = $this->getpath($choices[0], array('@', 'id'), '', true); $correctfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'), '', true); $incorrectfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'), '', true); if (strcmp($id, $correct_answer) == 0) { // True is correct. $question->answer = 1; $question->feedbacktrue = $this->text_field($this->cleaninput($correctfeedback)); $question->feedbackfalse = $this->text_field($this->cleaninput($incorrectfeedback)); } else { // False is correct. $question->answer = 0; $question->feedbacktrue = $this->text_field($this->cleaninput($incorrectfeedback)); $question->feedbackfalse = $this->text_field($this->cleaninput($correctfeedback)); } $question->correctanswer = $question->answer; $questions[] = $question; } } /** * Process Multiple Choice Questions with single answer * @param array xml the xml tree * @param array questions the questions already parsed */ public function process_mc($xml, &$questions) { if ($this->getpath($xml, array('POOL', '#', 'QUESTION_MULTIPLECHOICE'), false, false)) { $mcquestions = $this->getpath($xml, array('POOL', '#', 'QUESTION_MULTIPLECHOICE'), false, false); } else { return; } foreach ($mcquestions as $thisquestion) { $question = $this->process_common($thisquestion); $correctfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'), '', true); $incorrectfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'), '', true); $question->correctfeedback = $this->text_field($this->cleaninput($correctfeedback)); $question->partiallycorrectfeedback = $this->text_field(''); $question->incorrectfeedback = $this->text_field($this->cleaninput($incorrectfeedback)); $question->qtype = 'multichoice'; $question->single = 1; // Only one answer is allowed. $choices = $this->getpath($thisquestion, array('#', 'ANSWER'), false, false); $correct_answer_id = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER', 0, '@', 'answer_id'), '', true); foreach ($choices as $choice) { $choicetext = $this->getpath($choice, array('#', 'TEXT', 0, '#'), '', true); // Put this choice in the question object. $question->answer[] = $this->text_field($this->cleaninput($choicetext)); $choice_id = $this->getpath($choice, array('@', 'id'), '', true); // If choice is the right answer, give 100% mark, otherwise give 0%. if (strcmp ($choice_id, $correct_answer_id) == 0) { $question->fraction[] = 1; } else { $question->fraction[] = 0; } // There is never feedback specific to each choice. $question->feedback[] = $this->text_field(''); } $questions[] = $question; } } /** * Process Multiple Choice Questions With Multiple Answers * @param array xml the xml tree * @param array questions the questions already parsed */ public function process_ma($xml, &$questions) { if ($this->getpath($xml, array('POOL', '#', 'QUESTION_MULTIPLEANSWER'), false, false)) { $maquestions = $this->getpath($xml, array('POOL', '#', 'QUESTION_MULTIPLEANSWER'), false, false); } else { return; } foreach ($maquestions as $thisquestion) { $question = $this->process_common($thisquestion); $correctfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'), '', true); $incorrectfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'), '', true); $question->correctfeedback = $this->text_field($this->cleaninput($correctfeedback)); // As there is no partially correct feedback we use incorrect one. $question->partiallycorrectfeedback = $this->text_field($this->cleaninput($incorrectfeedback)); $question->incorrectfeedback = $this->text_field($this->cleaninput($incorrectfeedback)); $question->qtype = 'multichoice'; $question->defaultmark = 1; $question->single = 0; // More than one answers allowed. $choices = $this->getpath($thisquestion, array('#', 'ANSWER'), false, false); $correct_answer_ids = array(); foreach ($this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER'), false, false) as $correctanswer) { if ($correctanswer) { $correct_answer_ids[] = $this->getpath($correctanswer, array('@', 'answer_id'), '', true); } } $fraction = 1/count($correct_answer_ids); foreach ($choices as $choice) { $choicetext = $this->getpath($choice, array('#', 'TEXT', 0, '#'), '', true); // Put this choice in the question object. $question->answer[] = $this->text_field($this->cleaninput($choicetext)); $choice_id = $this->getpath($choice, array('@', 'id'), '', true); $iscorrect = in_array($choice_id, $correct_answer_ids); if ($iscorrect) { $question->fraction[] = $fraction; } else { $question->fraction[] = 0; } // There is never feedback specific to each choice. $question->feedback[] = $this->text_field(''); } $questions[] = $question; } } /** * Process Fill in the Blank Questions * @param array xml the xml tree * @param array questions the questions already parsed */ public function process_fib($xml, &$questions) { if ($this->getpath($xml, array('POOL', '#', 'QUESTION_FILLINBLANK'), false, false)) { $fibquestions = $this->getpath($xml, array('POOL', '#', 'QUESTION_FILLINBLANK'), false, false); } else { return; } foreach ($fibquestions as $thisquestion) { $question = $this->process_common($thisquestion); $question->qtype = 'shortanswer'; $question->usecase = 0; // Ignore case. $correctfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'), '', true); $incorrectfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'), '', true); $answers = $this->getpath($thisquestion, array('#', 'ANSWER'), false, false); foreach ($answers as $answer) { $question->answer[] = $this->getpath($answer, array('#', 'TEXT', 0, '#'), '', true); $question->fraction[] = 1; $question->feedback[] = $this->text_field($this->cleaninput($correctfeedback)); } $question->answer[] = '*'; $question->fraction[] = 0; $question->feedback[] = $this->text_field($this->cleaninput($incorrectfeedback)); $questions[] = $question; } } /** * Process Matching Questions * @param array xml the xml tree * @param array questions the questions already parsed */ public function process_matching($xml, &$questions) { if ($this->getpath($xml, array('POOL', '#', 'QUESTION_MATCH'), false, false)) { $matchquestions = $this->getpath($xml, array('POOL', '#', 'QUESTION_MATCH'), false, false); } else { return; } // Blackboard questions can't be imported in core Moodle without a loss in data, // as core match question don't allow HTML in subanswers. The contributed ddmatch // question type support HTML in subanswers. // The ddmatch question type is not part of core, so we need to check if it is defined. $ddmatch_is_installed = question_bank::is_qtype_installed('ddmatch'); foreach ($matchquestions as $thisquestion) { $question = $this->process_common($thisquestion); if ($ddmatch_is_installed) { $question->qtype = 'ddmatch'; } else { $question->qtype = 'match'; } $correctfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'), '', true); $incorrectfeedback = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'), '', true); $question->correctfeedback = $this->text_field($this->cleaninput($correctfeedback)); // As there is no partially correct feedback we use incorrect one. $question->partiallycorrectfeedback = $this->text_field($this->cleaninput($incorrectfeedback)); $question->incorrectfeedback = $this->text_field($this->cleaninput($incorrectfeedback)); $choices = $this->getpath($thisquestion, array('#', 'CHOICE'), false, false); // Blackboard "choices" are Moodle subanswers. $answers = $this->getpath($thisquestion, array('#', 'ANSWER'), false, false); // Blackboard "answers" are Moodle subquestions. $correctanswers = $this->getpath($thisquestion, array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER'), false, false); // Mapping between choices and answers. $mappings = array(); foreach ($correctanswers as $correctanswer) { if ($correctanswer) { $correct_choice_id = $this->getpath($correctanswer, array('@', 'choice_id'), '', true); $correct_answer_id = $this->getpath($correctanswer, array('@', 'answer_id'), '', true); $mappings[$correct_answer_id] = $correct_choice_id; } } foreach ($choices as $choice) { if ($ddmatch_is_installed) { $choicetext = $this->text_field($this->cleaninput($this->getpath($choice, array('#', 'TEXT', 0, '#'), '', true))); } else { $choicetext = trim(strip_tags($this->getpath($choice, array('#', 'TEXT', 0, '#'), '', true))); } if ($choicetext != '') { // Only import non empty subanswers. $subquestion = ''; $choice_id = $this->getpath($choice, array('@', 'id'), '', true); $fiber = array_search($choice_id, $mappings); $fiber = array_keys ($mappings, $choice_id); foreach ($fiber as $correct_answer_id) { // We have found a correspondance for this choice so we need to take the associated answer. foreach ($answers as $answer) { $current_ans_id = $this->getpath($answer, array('@', 'id'), '', true); if (strcmp ($current_ans_id, $correct_answer_id) == 0) { $subquestion = $this->getpath($answer, array('#', 'TEXT', 0, '#'), '', true); break; } } $question->subquestions[] = $this->text_field($this->cleaninput($subquestion)); $question->subanswers[] = $choicetext; } if ($subquestion == '') { // Then in this case, $choice is a distractor. $question->subquestions[] = $this->text_field(''); $question->subanswers[] = $choicetext; } } } // Verify that this matching question has enough subquestions and subanswers. $subquestioncount = 0; $subanswercount = 0; $subanswers = $question->subanswers; foreach ($question->subquestions as $key => $subquestion) { $subquestion = $subquestion['text']; $subanswer = $subanswers[$key]; if ($subquestion != '') { $subquestioncount++; } $subanswercount++; } if ($subquestioncount < 2 || $subanswercount < 3) { $this->error(get_string('notenoughtsubans', 'qformat_blackboard', $question->questiontext)); } else { $questions[] = $question; } } } }