MDL-34738 fix for the problem found during testing : dat files not accepted on some...
[moodle.git] / question / format / blackboard / format.php
CommitLineData
aeb15530 1<?php
d3603157
TH
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/>.
b81d2dbe 16
41a89a07 17/**
d3603157
TH
18 * Blackboard question importer.
19 *
8a8dd7d8 20 * @package qformat_blackboard
d3603157
TH
21 * @copyright 2003 Scott Elliott
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
41a89a07 23 */
84769fd8 24
d3603157 25
a17b297d
TH
26defined('MOODLE_INTERNAL') || die();
27
8a8dd7d8 28require_once($CFG->libdir . '/xmlize.php');
d3603157
TH
29
30
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 */
8a8dd7d8
JMV
37class 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;
40
84769fd8 41
c7df5006 42 public function provide_import() {
84769fd8 43 return true;
44 }
45
9832fde1
JMV
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 }
57
8a8dd7d8
JMV
58 public function mime_type() {
59 return mimeinfo('type', '.dat');
60 }
61
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 }
84769fd8 88
8a8dd7d8
JMV
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) {
98
99 $text = implode($lines, ' ');
100 unset($lines);
101
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 }
84769fd8 110
c7df5006 111 $questions = array();
84769fd8 112
c7df5006
TH
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);
84769fd8 119
c7df5006
TH
120 return $questions;
121 }
84769fd8 122
8a8dd7d8
JMV
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;
130
131 // This routine initialises the question object.
132 $question = $this->defaultquestion();
133
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);
138
139 // Put questiontext in question object.
140 $text = $this->getpath($questiondata,
141 array('#', 'BODY', 0, '#', 'TEXT', 0, '#'),
142 '', true, get_string('importnotext', 'qformat_blackboard'));
143
144 if ($this->ishtml) {
145 $question->questiontext = $this->cleaninput($text);
146 $question->questiontextformat = FORMAT_HTML;
147 $question->questiontextfiles = array();
148
149 } else {
150 $question->questiontext = $text;
c7df5006 151 }
8a8dd7d8
JMV
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);
c7df5006 159 }
aeb15530 160
8a8dd7d8
JMV
161 $question->generalfeedback = '';
162 $question->generalfeedbackformat = FORMAT_HTML;
163 $question->generalfeedbackfiles = array();
aeb15530 164
8a8dd7d8
JMV
165 // TODO : read the mark from the POOL TITLE QUESTIONLIST section.
166 $question->defaultmark = 1;
167 return $question;
168 }
169
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) {
176
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 }
aeb15530 183
8a8dd7d8 184 foreach ($essayquestions as $thisquestion) {
b81d2dbe 185
8a8dd7d8 186 $question = $this->process_common($thisquestion);
aeb15530 187
8a8dd7d8 188 $question->qtype = 'essay';
aeb15530 189
c7df5006 190 $question->answer = '';
8a8dd7d8
JMV
191 $answer = $this->getpath($thisquestion,
192 array('#', 'ANSWER', 0, '#', 'TEXT', 0, '#'), '', true);
193 $question->graderinfo = $this->text_field($this->cleaninput($answer));
c7df5006 194 $question->feedback = '';
8a8dd7d8
JMV
195 $question->responseformat = 'editor';
196 $question->responsefieldlines = 15;
197 $question->attachments = 0;
c7df5006 198 $question->fraction = 0;
b81d2dbe 199
c7df5006
TH
200 $questions[] = $question;
201 }
eaaf06f1 202 }
84769fd8 203
8a8dd7d8
JMV
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) {
210
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 {
c7df5006
TH
215 return;
216 }
84769fd8 217
8a8dd7d8 218 foreach ($tfquestions as $thisquestion) {
2e15284e 219
8a8dd7d8 220 $question = $this->process_common($thisquestion);
2e15284e 221
8a8dd7d8
JMV
222 $question->qtype = 'truefalse';
223 $question->single = 1; // Only one answer is allowed.
84769fd8 224
8a8dd7d8 225 $choices = $this->getpath($thisquestion, array('#', 'ANSWER'), array(), false);
84769fd8 226
8a8dd7d8
JMV
227 $correct_answer = $this->getpath($thisquestion,
228 array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER', 0, '@', 'answer_id'),
229 '', true);
84769fd8 230
8a8dd7d8
JMV
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.
c7df5006 240 $question->answer = 1;
8a8dd7d8
JMV
241 $question->feedbacktrue = $this->text_field($this->cleaninput($correctfeedback));
242 $question->feedbackfalse = $this->text_field($this->cleaninput($incorrectfeedback));
243 } else { // False is correct.
c7df5006 244 $question->answer = 0;
8a8dd7d8
JMV
245 $question->feedbacktrue = $this->text_field($this->cleaninput($incorrectfeedback));
246 $question->feedbackfalse = $this->text_field($this->cleaninput($correctfeedback));
c7df5006
TH
247 }
248 $question->correctanswer = $question->answer;
249 $questions[] = $question;
8a8dd7d8 250 }
eaaf06f1 251 }
84769fd8 252
8a8dd7d8
JMV
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) {
259
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 {
c7df5006
TH
264 return;
265 }
84769fd8 266
8a8dd7d8
JMV
267 foreach ($mcquestions as $thisquestion) {
268
269 $question = $this->process_common($thisquestion);
270
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));
280
281 $question->qtype = 'multichoice';
282 $question->single = 1; // Only one answer is allowed.
283
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));
292
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;
c7df5006 297 } else {
8a8dd7d8 298 $question->fraction[] = 0;
2e15284e 299 }
8a8dd7d8
JMV
300 // There is never feedback specific to each choice.
301 $question->feedback[] = $this->text_field('');
84769fd8 302 }
c7df5006 303 $questions[] = $question;
84769fd8 304 }
84769fd8 305 }
84769fd8 306
8a8dd7d8
JMV
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 {
c7df5006
TH
317 return;
318 }
84769fd8 319
8a8dd7d8
JMV
320 foreach ($maquestions as $thisquestion) {
321 $question = $this->process_common($thisquestion);
322
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));
333
334 $question->qtype = 'multichoice';
c7df5006 335 $question->defaultmark = 1;
8a8dd7d8
JMV
336 $question->single = 0; // More than one answers allowed.
337
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 }
c7df5006 347 }
8a8dd7d8 348 $fraction = 1/count($correct_answer_ids);
84769fd8 349
8a8dd7d8
JMV
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));
84769fd8 354
8a8dd7d8 355 $choice_id = $this->getpath($choice, array('@', 'id'), '', true);
84769fd8 356
8a8dd7d8 357 $iscorrect = in_array($choice_id, $correct_answer_ids);
84769fd8 358
c7df5006 359 if ($iscorrect) {
8a8dd7d8 360 $question->fraction[] = $fraction;
c7df5006 361 } else {
8a8dd7d8 362 $question->fraction[] = 0;
c7df5006 363 }
8a8dd7d8
JMV
364 // There is never feedback specific to each choice.
365 $question->feedback[] = $this->text_field('');
84769fd8 366 }
c7df5006
TH
367 $questions[] = $question;
368 }
84769fd8 369 }
84769fd8 370
8a8dd7d8
JMV
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 {
c7df5006
TH
381 return;
382 }
84769fd8 383
8a8dd7d8
JMV
384 foreach ($fibquestions as $thisquestion) {
385
386 $question = $this->process_common($thisquestion);
387
388 $question->qtype = 'shortanswer';
389 $question->usecase = 0; // Ignore case.
390
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));
c7df5006 403 }
8a8dd7d8
JMV
404 $question->answer[] = '*';
405 $question->fraction[] = 0;
406 $question->feedback[] = $this->text_field($this->cleaninput($incorrectfeedback));
aeb15530 407
c7df5006
TH
408 $questions[] = $question;
409 }
53a4d39f 410 }
84769fd8 411
8a8dd7d8
JMV
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 {
c7df5006
TH
422 return;
423 }
8a8dd7d8
JMV
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');
429
430 foreach ($matchquestions as $thisquestion) {
431
432 $question = $this->process_common($thisquestion);
433 if ($ddmatch_is_installed) {
434 $question->qtype = 'ddmatch';
435 } else {
436 $question->qtype = 'match';
c7df5006 437 }
84769fd8 438
8a8dd7d8
JMV
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));
449
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 }
84769fd8 467
8a8dd7d8
JMV
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 }
84769fd8 476
8a8dd7d8
JMV
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);
c7df5006
TH
491 break;
492 }
2befe778 493 }
8a8dd7d8
JMV
494 $question->subquestions[] = $this->text_field($this->cleaninput($subquestion));
495 $question->subanswers[] = $choicetext;
496 }
497
498 if ($subquestion == '') { // Then in this case, $choice is a distractor.
499 $question->subquestions[] = $this->text_field('');
500 $question->subanswers[] = $choicetext;
2befe778 501 }
2befe778 502 }
2befe778 503 }
aeb15530 504
8a8dd7d8
JMV
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 }
aeb15530 522
c7df5006 523 }
2befe778 524 }
84769fd8 525}