MDL-34738 qformat blackboard: blackboard format is broken
[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
8a8dd7d8
JMV
46 public function mime_type() {
47 return mimeinfo('type', '.dat');
48 }
49
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 }
84769fd8 76
8a8dd7d8
JMV
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) {
86
87 $text = implode($lines, ' ');
88 unset($lines);
89
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 }
84769fd8 98
c7df5006 99 $questions = array();
84769fd8 100
c7df5006
TH
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);
84769fd8 107
c7df5006
TH
108 return $questions;
109 }
84769fd8 110
8a8dd7d8
JMV
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;
118
119 // This routine initialises the question object.
120 $question = $this->defaultquestion();
121
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);
126
127 // Put questiontext in question object.
128 $text = $this->getpath($questiondata,
129 array('#', 'BODY', 0, '#', 'TEXT', 0, '#'),
130 '', true, get_string('importnotext', 'qformat_blackboard'));
131
132 if ($this->ishtml) {
133 $question->questiontext = $this->cleaninput($text);
134 $question->questiontextformat = FORMAT_HTML;
135 $question->questiontextfiles = array();
136
137 } else {
138 $question->questiontext = $text;
c7df5006 139 }
8a8dd7d8
JMV
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);
c7df5006 147 }
aeb15530 148
8a8dd7d8
JMV
149 $question->generalfeedback = '';
150 $question->generalfeedbackformat = FORMAT_HTML;
151 $question->generalfeedbackfiles = array();
aeb15530 152
8a8dd7d8
JMV
153 // TODO : read the mark from the POOL TITLE QUESTIONLIST section.
154 $question->defaultmark = 1;
155 return $question;
156 }
157
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) {
164
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 }
aeb15530 171
8a8dd7d8 172 foreach ($essayquestions as $thisquestion) {
b81d2dbe 173
8a8dd7d8 174 $question = $this->process_common($thisquestion);
aeb15530 175
8a8dd7d8 176 $question->qtype = 'essay';
aeb15530 177
c7df5006 178 $question->answer = '';
8a8dd7d8
JMV
179 $answer = $this->getpath($thisquestion,
180 array('#', 'ANSWER', 0, '#', 'TEXT', 0, '#'), '', true);
181 $question->graderinfo = $this->text_field($this->cleaninput($answer));
c7df5006 182 $question->feedback = '';
8a8dd7d8
JMV
183 $question->responseformat = 'editor';
184 $question->responsefieldlines = 15;
185 $question->attachments = 0;
c7df5006 186 $question->fraction = 0;
b81d2dbe 187
c7df5006
TH
188 $questions[] = $question;
189 }
eaaf06f1 190 }
84769fd8 191
8a8dd7d8
JMV
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) {
198
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 {
c7df5006
TH
203 return;
204 }
84769fd8 205
8a8dd7d8 206 foreach ($tfquestions as $thisquestion) {
2e15284e 207
8a8dd7d8 208 $question = $this->process_common($thisquestion);
2e15284e 209
8a8dd7d8
JMV
210 $question->qtype = 'truefalse';
211 $question->single = 1; // Only one answer is allowed.
84769fd8 212
8a8dd7d8 213 $choices = $this->getpath($thisquestion, array('#', 'ANSWER'), array(), false);
84769fd8 214
8a8dd7d8
JMV
215 $correct_answer = $this->getpath($thisquestion,
216 array('#', 'GRADABLE', 0, '#', 'CORRECTANSWER', 0, '@', 'answer_id'),
217 '', true);
84769fd8 218
8a8dd7d8
JMV
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.
c7df5006 228 $question->answer = 1;
8a8dd7d8
JMV
229 $question->feedbacktrue = $this->text_field($this->cleaninput($correctfeedback));
230 $question->feedbackfalse = $this->text_field($this->cleaninput($incorrectfeedback));
231 } else { // False is correct.
c7df5006 232 $question->answer = 0;
8a8dd7d8
JMV
233 $question->feedbacktrue = $this->text_field($this->cleaninput($incorrectfeedback));
234 $question->feedbackfalse = $this->text_field($this->cleaninput($correctfeedback));
c7df5006
TH
235 }
236 $question->correctanswer = $question->answer;
237 $questions[] = $question;
8a8dd7d8 238 }
eaaf06f1 239 }
84769fd8 240
8a8dd7d8
JMV
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) {
247
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 {
c7df5006
TH
252 return;
253 }
84769fd8 254
8a8dd7d8
JMV
255 foreach ($mcquestions as $thisquestion) {
256
257 $question = $this->process_common($thisquestion);
258
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));
268
269 $question->qtype = 'multichoice';
270 $question->single = 1; // Only one answer is allowed.
271
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));
280
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;
c7df5006 285 } else {
8a8dd7d8 286 $question->fraction[] = 0;
2e15284e 287 }
8a8dd7d8
JMV
288 // There is never feedback specific to each choice.
289 $question->feedback[] = $this->text_field('');
84769fd8 290 }
c7df5006 291 $questions[] = $question;
84769fd8 292 }
84769fd8 293 }
84769fd8 294
8a8dd7d8
JMV
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 {
c7df5006
TH
305 return;
306 }
84769fd8 307
8a8dd7d8
JMV
308 foreach ($maquestions as $thisquestion) {
309 $question = $this->process_common($thisquestion);
310
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));
321
322 $question->qtype = 'multichoice';
c7df5006 323 $question->defaultmark = 1;
8a8dd7d8
JMV
324 $question->single = 0; // More than one answers allowed.
325
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 }
c7df5006 335 }
8a8dd7d8 336 $fraction = 1/count($correct_answer_ids);
84769fd8 337
8a8dd7d8
JMV
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));
84769fd8 342
8a8dd7d8 343 $choice_id = $this->getpath($choice, array('@', 'id'), '', true);
84769fd8 344
8a8dd7d8 345 $iscorrect = in_array($choice_id, $correct_answer_ids);
84769fd8 346
c7df5006 347 if ($iscorrect) {
8a8dd7d8 348 $question->fraction[] = $fraction;
c7df5006 349 } else {
8a8dd7d8 350 $question->fraction[] = 0;
c7df5006 351 }
8a8dd7d8
JMV
352 // There is never feedback specific to each choice.
353 $question->feedback[] = $this->text_field('');
84769fd8 354 }
c7df5006
TH
355 $questions[] = $question;
356 }
84769fd8 357 }
84769fd8 358
8a8dd7d8
JMV
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 {
c7df5006
TH
369 return;
370 }
84769fd8 371
8a8dd7d8
JMV
372 foreach ($fibquestions as $thisquestion) {
373
374 $question = $this->process_common($thisquestion);
375
376 $question->qtype = 'shortanswer';
377 $question->usecase = 0; // Ignore case.
378
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));
c7df5006 391 }
8a8dd7d8
JMV
392 $question->answer[] = '*';
393 $question->fraction[] = 0;
394 $question->feedback[] = $this->text_field($this->cleaninput($incorrectfeedback));
aeb15530 395
c7df5006
TH
396 $questions[] = $question;
397 }
53a4d39f 398 }
84769fd8 399
8a8dd7d8
JMV
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 {
c7df5006
TH
410 return;
411 }
8a8dd7d8
JMV
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');
417
418 foreach ($matchquestions as $thisquestion) {
419
420 $question = $this->process_common($thisquestion);
421 if ($ddmatch_is_installed) {
422 $question->qtype = 'ddmatch';
423 } else {
424 $question->qtype = 'match';
c7df5006 425 }
84769fd8 426
8a8dd7d8
JMV
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));
437
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 }
84769fd8 455
8a8dd7d8
JMV
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 }
84769fd8 464
8a8dd7d8
JMV
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);
c7df5006
TH
479 break;
480 }
2befe778 481 }
8a8dd7d8
JMV
482 $question->subquestions[] = $this->text_field($this->cleaninput($subquestion));
483 $question->subanswers[] = $choicetext;
484 }
485
486 if ($subquestion == '') { // Then in this case, $choice is a distractor.
487 $question->subquestions[] = $this->text_field('');
488 $question->subanswers[] = $choicetext;
2befe778 489 }
2befe778 490 }
2befe778 491 }
aeb15530 492
8a8dd7d8
JMV
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 }
aeb15530 510
c7df5006 511 }
2befe778 512 }
84769fd8 513}