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