MDL-34738 fix for the problem found during testing : dat files not accepted on some...
[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     /**
47      * Check if the given file is capable of being imported by this plugin.
48      * As {@link file_storage::mimetype()} now uses finfo PHP extension if available,
49      * the value returned by $file->get_mimetype for a .dat file is not the same on all servers.
50      * So if the parent method fails we must use mimeinfo on the filename.
51      * @param stored_file $file the file to check
52      * @return bool whether this plugin can import the file
53      */
54     public function can_import_file($file) {
55         return parent::can_import_file($file) || mimeinfo('type', $file->get_filename()) == $this->mime_type();
56     }
58     public function mime_type() {
59         return mimeinfo('type', '.dat');
60     }
62     /**
63      * Some softwares put entities in exported files.
64      * This method try to clean up known problems.
65      * @param string str string to correct
66      * @return string the corrected string
67      */
68     public function cleaninput($str) {
69         if (!$this->ishtml) {
70             return $str;
71         }
72         $html_code_list = array(
73             "&#039;" => "'",
74             "&#8217;" => "'",
75             "&#091;" => "[",
76             "&#8220;" => "\"",
77             "&#8221;" => "\"",
78             "&#093;" => "]",
79             "&#039;" => "'",
80             "&#8211;" => "-",
81             "&#8212;" => "-",
82         );
83         $str = strtr($str, $html_code_list);
84         // Use textlib entities_to_utf8 function to convert only numerical entities.
85         $str = textlib::entities_to_utf8($str, false);
86         return $str;
87     }
89     /**
90      * Parse the array of lines into an array of questions
91      * this *could* burn memory - but it won't happen that much
92      * so fingers crossed!
93      * @param array of lines from the input file.
94      * @param stdClass $context
95      * @return array (of objects) question objects.
96      */
97     protected function readquestions($lines) {
99         $text = implode($lines, ' ');
100         unset($lines);
102         // This converts xml to big nasty data structure,
103         // the 0 means keep white space as it is.
104         try {
105             $xml = xmlize($text, 0, 'UTF-8', true);
106         } catch (xml_format_exception $e) {
107             $this->error($e->getMessage(), '');
108             return false;
109         }
111         $questions = array();
113         $this->process_tf($xml, $questions);
114         $this->process_mc($xml, $questions);
115         $this->process_ma($xml, $questions);
116         $this->process_fib($xml, $questions);
117         $this->process_matching($xml, $questions);
118         $this->process_essay($xml, $questions);
120         return $questions;
121     }
123     /**
124      * Do question import processing common to every qtype.
125      * @param array $questiondata the xml tree related to the current question
126      * @return object initialized question object.
127      */
128     public function process_common($questiondata) {
129         global $CFG;
131         // This routine initialises the question object.
132         $question = $this->defaultquestion();
134         // Determine if the question is already escaped html.
135         $this->ishtml = $this->getpath($questiondata,
136                 array('#', 'BODY', 0, '#', 'FLAGS', 0, '#', 'ISHTML', 0, '@', 'value'),
137                 false, false);
139         // Put questiontext in question object.
140         $text = $this->getpath($questiondata,
141                 array('#', 'BODY', 0, '#', 'TEXT', 0, '#'),
142                 '', true, get_string('importnotext', 'qformat_blackboard'));
144         if ($this->ishtml) {
145             $question->questiontext = $this->cleaninput($text);
146             $question->questiontextformat = FORMAT_HTML;
147             $question->questiontextfiles = array();
149         } else {
150             $question->questiontext = $text;
151         }
152         // Put name in question object. We must ensure it is not empty and it is less than 250 chars.
153         $question->name = shorten_text(strip_tags($question->questiontext), 200);
154         $question->name = substr($question->name, 0, 250);
155         if (!$question->name) {
156             $id = $this->getpath($questiondata,
157                     array('@', 'id'), '',  true);
158             $question->name = get_string('defaultname', 'qformat_blackboard' , $id);
159         }
161         $question->generalfeedback = '';
162         $question->generalfeedbackformat = FORMAT_HTML;
163         $question->generalfeedbackfiles = array();
165         // TODO : read the mark from the POOL TITLE QUESTIONLIST section.
166         $question->defaultmark = 1;
167         return $question;
168     }
170     /**
171      * Process Essay Questions
172      * @param array xml the xml tree
173      * @param array questions the questions already parsed
174      */
175     public function process_essay($xml, &$questions) {
177         if ($this->getpath($xml, array('POOL', '#', 'QUESTION_ESSAY'), false, false)) {
178             $essayquestions = $this->getpath($xml,
179                     array('POOL', '#', 'QUESTION_ESSAY'), false, false);
180         } else {
181             return;
182         }
184         foreach ($essayquestions as $thisquestion) {
186             $question = $this->process_common($thisquestion);
188             $question->qtype = 'essay';
190             $question->answer = '';
191             $answer = $this->getpath($thisquestion,
192                     array('#', 'ANSWER', 0, '#', 'TEXT', 0, '#'), '', true);
193             $question->graderinfo =  $this->text_field($this->cleaninput($answer));
194             $question->feedback = '';
195             $question->responseformat = 'editor';
196             $question->responsefieldlines = 15;
197             $question->attachments = 0;
198             $question->fraction = 0;
200             $questions[] = $question;
201         }
202     }
204     /**
205      * Process True / False Questions
206      * @param array xml the xml tree
207      * @param array questions the questions already parsed
208      */
209     public function process_tf($xml, &$questions) {
211         if ($this->getpath($xml, array('POOL', '#', 'QUESTION_TRUEFALSE'), false, false)) {
212             $tfquestions = $this->getpath($xml,
213                     array('POOL', '#', 'QUESTION_TRUEFALSE'), false, false);
214         } else {
215             return;
216         }
218         foreach ($tfquestions as $thisquestion) {
220             $question = $this->process_common($thisquestion);
222             $question->qtype = 'truefalse';
223             $question->single = 1; // Only one answer is allowed.
225             $choices = $this->getpath($thisquestion, array('#', 'ANSWER'), array(), false);
227             $correct_answer = $this->getpath($thisquestion,
228                     array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER', 0, '@', 'answer_id'),
229                     '', true);
231             // First choice is true, second is false.
232             $id = $this->getpath($choices[0], array('@', 'id'), '', true);
233             $correctfeedback = $this->getpath($thisquestion,
234                     array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'),
235                     '', true);
236             $incorrectfeedback = $this->getpath($thisquestion,
237                     array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'),
238                     '', true);
239             if (strcmp($id,  $correct_answer) == 0) {  // True is correct.
240                 $question->answer = 1;
241                 $question->feedbacktrue = $this->text_field($this->cleaninput($correctfeedback));
242                 $question->feedbackfalse = $this->text_field($this->cleaninput($incorrectfeedback));
243             } else {  // False is correct.
244                 $question->answer = 0;
245                 $question->feedbacktrue = $this->text_field($this->cleaninput($incorrectfeedback));
246                 $question->feedbackfalse = $this->text_field($this->cleaninput($correctfeedback));
247             }
248             $question->correctanswer = $question->answer;
249             $questions[] = $question;
250         }
251     }
253     /**
254      * Process Multiple Choice Questions with single answer
255      * @param array xml the xml tree
256      * @param array questions the questions already parsed
257      */
258     public function process_mc($xml, &$questions) {
260         if ($this->getpath($xml, array('POOL', '#', 'QUESTION_MULTIPLECHOICE'), false, false)) {
261             $mcquestions = $this->getpath($xml,
262                     array('POOL', '#', 'QUESTION_MULTIPLECHOICE'), false, false);
263         } else {
264             return;
265         }
267         foreach ($mcquestions as $thisquestion) {
269             $question = $this->process_common($thisquestion);
271             $correctfeedback = $this->getpath($thisquestion,
272                     array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'),
273                     '', true);
274             $incorrectfeedback = $this->getpath($thisquestion,
275                     array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'),
276                     '', true);
277             $question->correctfeedback = $this->text_field($this->cleaninput($correctfeedback));
278             $question->partiallycorrectfeedback = $this->text_field('');
279             $question->incorrectfeedback = $this->text_field($this->cleaninput($incorrectfeedback));
281             $question->qtype = 'multichoice';
282             $question->single = 1; // Only one answer is allowed.
284             $choices = $this->getpath($thisquestion, array('#', 'ANSWER'), false, false);
285             $correct_answer_id = $this->getpath($thisquestion,
286                         array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER', 0, '@', 'answer_id'),
287                         '', true);
288             foreach ($choices as $choice) {
289                 $choicetext = $this->getpath($choice, array('#', 'TEXT', 0, '#'), '', true);
290                 // Put this choice in the question object.
291                 $question->answer[] =  $this->text_field($this->cleaninput($choicetext));
293                 $choice_id = $this->getpath($choice, array('@', 'id'), '', true);
294                 // If choice is the right answer, give 100% mark, otherwise give 0%.
295                 if (strcmp ($choice_id, $correct_answer_id) == 0) {
296                     $question->fraction[] = 1;
297                 } else {
298                     $question->fraction[] = 0;
299                 }
300                 // There is never feedback specific to each choice.
301                 $question->feedback[] =  $this->text_field('');
302             }
303             $questions[] = $question;
304         }
305     }
307     /**
308      * Process Multiple Choice Questions With Multiple Answers
309      * @param array xml the xml tree
310      * @param array questions the questions already parsed
311      */
312     public function process_ma($xml, &$questions) {
313         if ($this->getpath($xml, array('POOL', '#', 'QUESTION_MULTIPLEANSWER'), false, false)) {
314             $maquestions = $this->getpath($xml,
315                     array('POOL', '#', 'QUESTION_MULTIPLEANSWER'), false, false);
316         } else {
317             return;
318         }
320         foreach ($maquestions as $thisquestion) {
321             $question = $this->process_common($thisquestion);
323             $correctfeedback = $this->getpath($thisquestion,
324                     array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'),
325                     '', true);
326             $incorrectfeedback = $this->getpath($thisquestion,
327                     array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'),
328                     '', true);
329             $question->correctfeedback = $this->text_field($this->cleaninput($correctfeedback));
330             // As there is no partially correct feedback we use incorrect one.
331             $question->partiallycorrectfeedback = $this->text_field($this->cleaninput($incorrectfeedback));
332             $question->incorrectfeedback = $this->text_field($this->cleaninput($incorrectfeedback));
334             $question->qtype = 'multichoice';
335             $question->defaultmark = 1;
336             $question->single = 0; // More than one answers allowed.
338             $choices = $this->getpath($thisquestion, array('#', 'ANSWER'), false, false);
339             $correct_answer_ids = array();
340             foreach ($this->getpath($thisquestion,
341                     array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER'), false, false) as $correctanswer) {
342                 if ($correctanswer) {
343                     $correct_answer_ids[] = $this->getpath($correctanswer,
344                             array('@', 'answer_id'),
345                             '', true);
346                 }
347             }
348             $fraction = 1/count($correct_answer_ids);
350             foreach ($choices as $choice) {
351                 $choicetext = $this->getpath($choice, array('#', 'TEXT', 0, '#'), '', true);
352                 // Put this choice in the question object.
353                 $question->answer[] =  $this->text_field($this->cleaninput($choicetext));
355                 $choice_id = $this->getpath($choice, array('@', 'id'), '', true);
357                 $iscorrect = in_array($choice_id, $correct_answer_ids);
359                 if ($iscorrect) {
360                     $question->fraction[] = $fraction;
361                 } else {
362                     $question->fraction[] = 0;
363                 }
364                 // There is never feedback specific to each choice.
365                 $question->feedback[] =  $this->text_field('');
366             }
367             $questions[] = $question;
368         }
369     }
371     /**
372      * Process Fill in the Blank Questions
373      * @param array xml the xml tree
374      * @param array questions the questions already parsed
375      */
376     public function process_fib($xml, &$questions) {
377         if ($this->getpath($xml, array('POOL', '#', 'QUESTION_FILLINBLANK'), false, false)) {
378             $fibquestions = $this->getpath($xml,
379                     array('POOL', '#', 'QUESTION_FILLINBLANK'), false, false);
380         } else {
381             return;
382         }
384         foreach ($fibquestions as $thisquestion) {
386             $question = $this->process_common($thisquestion);
388             $question->qtype = 'shortanswer';
389             $question->usecase = 0; // Ignore case.
391             $correctfeedback = $this->getpath($thisquestion,
392                     array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'),
393                     '', true);
394             $incorrectfeedback = $this->getpath($thisquestion,
395                     array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'),
396                     '', true);
397             $answers = $this->getpath($thisquestion, array('#', 'ANSWER'), false, false);
398             foreach ($answers as $answer) {
399                 $question->answer[] = $this->getpath($answer,
400                         array('#', 'TEXT', 0, '#'), '', true);
401                 $question->fraction[] = 1;
402                 $question->feedback[] = $this->text_field($this->cleaninput($correctfeedback));
403             }
404             $question->answer[] = '*';
405             $question->fraction[] = 0;
406             $question->feedback[] = $this->text_field($this->cleaninput($incorrectfeedback));
408             $questions[] = $question;
409         }
410     }
412     /**
413      * Process Matching Questions
414      * @param array xml the xml tree
415      * @param array questions the questions already parsed
416      */
417     public function process_matching($xml, &$questions) {
418         if ($this->getpath($xml, array('POOL', '#', 'QUESTION_MATCH'), false, false)) {
419             $matchquestions = $this->getpath($xml,
420                     array('POOL', '#', 'QUESTION_MATCH'), false, false);
421         } else {
422             return;
423         }
424         // Blackboard questions can't be imported in core Moodle without a loss in data,
425         // as core match question don't allow HTML in subanswers. The contributed ddmatch
426         // question type support HTML in subanswers.
427         // The ddmatch question type is not part of core, so we need to check if it is defined.
428         $ddmatch_is_installed = question_bank::is_qtype_installed('ddmatch');
430         foreach ($matchquestions as $thisquestion) {
432             $question = $this->process_common($thisquestion);
433             if ($ddmatch_is_installed) {
434                 $question->qtype = 'ddmatch';
435             } else {
436                 $question->qtype = 'match';
437             }
439             $correctfeedback = $this->getpath($thisquestion,
440                     array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_CORRECT', 0, '#'),
441                     '', true);
442             $incorrectfeedback = $this->getpath($thisquestion,
443                     array('#', 'GRADABLE', 0, '#', 'FEEDBACK_WHEN_INCORRECT', 0, '#'),
444                     '', true);
445             $question->correctfeedback = $this->text_field($this->cleaninput($correctfeedback));
446             // As there is no partially correct feedback we use incorrect one.
447             $question->partiallycorrectfeedback = $this->text_field($this->cleaninput($incorrectfeedback));
448             $question->incorrectfeedback = $this->text_field($this->cleaninput($incorrectfeedback));
450             $choices = $this->getpath($thisquestion,
451                     array('#', 'CHOICE'), false, false); // Blackboard "choices" are Moodle subanswers.
452             $answers = $this->getpath($thisquestion,
453                     array('#', 'ANSWER'), false, false); // Blackboard "answers" are Moodle subquestions.
454             $correctanswers = $this->getpath($thisquestion,
455                     array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER'), false, false); // Mapping between choices and answers.
456             $mappings = array();
457             foreach ($correctanswers as $correctanswer) {
458                 if ($correctanswer) {
459                     $correct_choice_id = $this->getpath($correctanswer,
460                                 array('@', 'choice_id'), '', true);
461                     $correct_answer_id = $this->getpath($correctanswer,
462                             array('@', 'answer_id'),
463                             '', true);
464                     $mappings[$correct_answer_id] = $correct_choice_id;
465                 }
466             }
468             foreach ($choices as $choice) {
469                 if ($ddmatch_is_installed) {
470                     $choicetext = $this->text_field($this->cleaninput($this->getpath($choice,
471                             array('#', 'TEXT', 0, '#'), '', true)));
472                 } else {
473                     $choicetext = trim(strip_tags($this->getpath($choice,
474                             array('#', 'TEXT', 0, '#'), '', true)));
475                 }
477                 if ($choicetext != '') { // Only import non empty subanswers.
478                     $subquestion = '';
479                     $choice_id = $this->getpath($choice,
480                             array('@', 'id'), '', true);
481                     $fiber = array_search($choice_id, $mappings);
482                     $fiber = array_keys ($mappings, $choice_id);
483                     foreach ($fiber as $correct_answer_id) {
484                         // We have found a correspondance for this choice so we need to take the associated answer.
485                         foreach ($answers as $answer) {
486                             $current_ans_id = $this->getpath($answer,
487                                     array('@', 'id'), '', true);
488                             if (strcmp ($current_ans_id, $correct_answer_id) == 0) {
489                                 $subquestion = $this->getpath($answer,
490                                         array('#', 'TEXT', 0, '#'), '', true);
491                                 break;
492                             }
493                         }
494                         $question->subquestions[] = $this->text_field($this->cleaninput($subquestion));
495                         $question->subanswers[] = $choicetext;
496                     }
498                     if ($subquestion == '') { // Then in this case, $choice is a distractor.
499                         $question->subquestions[] = $this->text_field('');
500                         $question->subanswers[] = $choicetext;
501                     }
502                 }
503             }
505             // Verify that this matching question has enough subquestions and subanswers.
506             $subquestioncount = 0;
507             $subanswercount = 0;
508             $subanswers = $question->subanswers;
509             foreach ($question->subquestions as $key => $subquestion) {
510                 $subquestion = $subquestion['text'];
511                 $subanswer = $subanswers[$key];
512                 if ($subquestion != '') {
513                     $subquestioncount++;
514                 }
515                 $subanswercount++;
516             }
517             if ($subquestioncount < 2 || $subanswercount < 3) {
518                     $this->error(get_string('notenoughtsubans', 'qformat_blackboard', $question->questiontext));
519             } else {
520                 $questions[] = $question;
521             }
523         }
524     }