2 ////////////////////////////////////////////////////////////////////////////
3 /// Hotpotatoes 5.0 and 6.0 Format
5 /// This Moodle class provides all functions necessary to import
6 /// (export is not implemented ... yet)
8 ////////////////////////////////////////////////////////////////////////////
10 // Based on default.php, included by ../import.php
12 * @package questionbank
13 * @subpackage importexport
16 class qformat_hotpot
extends qformat_default
{
18 function provide_import() {
22 function readquestions ($lines) {
23 /// Parses an array of lines into an array of questions,
24 /// where each item is a question object as defined by
27 // set courseid and baseurl
28 global $CFG, $COURSE, $course;
30 case isset($this->course
->id
):
31 // import to quiz module
32 $courseid = $this->course
->id
;
34 case isset($course->id
):
35 // import to lesson module
36 $courseid = $course->id
;
38 case isset($COURSE->id
):
40 $courseid = $COURSE->id
;
43 // shouldn't happen !!
46 if ($CFG->slasharguments
) {
47 $baseurl = "$CFG->wwwroot/file.php/$courseid/";
49 $baseurl = "$CFG->wwwroot/file.php?file=/$courseid/";
52 // get import file name
54 if (isset($params) && !empty($params->choosefile
)) {
55 // course file (Moodle >=1.6+)
56 $filename = $params->choosefile
;
58 // uploaded file (all Moodles)
59 $filename = basename($_FILES['newfile']['tmp_name']);
62 // get hotpot file source
63 $source = implode($lines, " ");
64 $source = hotpot_convert_relative_urls($source, $baseurl, $filename);
66 // create xml tree for this hotpot
67 $xml = new hotpot_xml_tree($source);
69 // determine the quiz type
71 $keys = array_keys($xml->xml
);
72 foreach ($keys as $key) {
73 if (preg_match('/^(hotpot|textoys)-(\w+)-file$/i', $key, $matches)) {
74 $xml->quiztype
= strtolower($matches[2]);
75 $xml->xml_root
= "['$key']['#']";
80 // convert xml to questions array
82 switch ($xml->quiztype
) {
84 $this->process_jcloze($xml, $questions);
87 $this->process_jcross($xml, $questions);
90 $this->process_jmatch($xml, $questions);
93 $this->process_jmix($xml, $questions);
97 $this->process_jquiz($xml, $questions);
100 if (empty($xml->quiztype
)) {
101 notice("Input file not recognized as a Hot Potatoes XML file");
103 notice("Unknown quiz type '$xml->quiztype'");
109 function process_jcloze(&$xml, &$questions) {
110 // define default grade (per cloze gap)
114 // detect old Moodles (1.4 and earlier)
117 if ($columns = $db->MetaColumns("{$CFG->prefix}question_multianswer")) {
118 foreach ($columns as $column) {
119 if ($column->name
=='answers' ||
$column->name
=='positionkey' ||
$column->name
=='answertype' ||
$column->name
=='norm') {
125 // xml tags for the start of the gap-fill exercise
126 $tags = 'data,gap-fill';
129 while (($exercise = "[$x]['#']") && $xml->xml_value($tags, $exercise)) {
130 // there is usually only one exercise in a file
132 if (method_exists($this, 'defaultquestion')) {
133 $question = $this->defaultquestion();
135 $question = new stdClass();
136 $question->usecase
= 0; // Ignore case
137 $question->image
= ""; // No images with this format
139 $question->qtype
= MULTIANSWER
;
141 $question->name
= $this->hotpot_get_title($xml, $x);
142 $question->questiontext
= $this->hotpot_get_reading($xml);
144 // setup answer arrays
146 $question->answers
= array();
148 global $COURSE; // initialized in questions/import.php
149 $question->course
= $COURSE->id
;
150 $question->options
= new stdClass();
151 $question->options
->questions
= array(); // one for each gap
155 while ($text = $xml->xml_value($tags, $exercise."[$q]")) {
156 // add next bit of text
157 $question->questiontext
.= $this->hotpot_prepare_str($text);
160 $question_record = $exercise."['question-record'][$q]['#']";
161 if ($xml->xml_value($tags, $question_record)) {
166 $question->questiontext
.= '{#'.$positionkey.'}';
168 // initialize answer settings
170 $question->answers
[$q]->positionkey
= $positionkey;
171 $question->answers
[$q]->answertype
= SHORTANSWER
;
172 $question->answers
[$q]->norm
= $defaultgrade;
173 $question->answers
[$q]->alternatives
= array();
175 $wrapped = new stdClass();
176 $wrapped->qtype
= SHORTANSWER
;
177 $wrapped->usecase
= 0;
178 $wrapped->defaultgrade
= $defaultgrade;
179 $wrapped->questiontextformat
= 0;
180 $wrapped->answer
= array();
181 $wrapped->fraction
= array();
182 $wrapped->feedback
= array();
188 while (($answer=$question_record."['answer'][$a]['#']") && $xml->xml_value($tags, $answer)) {
189 $text = $this->hotpot_prepare_str($xml->xml_value($tags, $answer."['text'][0]['#']"));
190 $correct = $xml->xml_value($tags, $answer."['correct'][0]['#']");
191 $feedback = $this->hotpot_prepare_str($xml->xml_value($tags, $answer."['feedback'][0]['#']"));
193 // set score (0=0%, 1=100%)
194 $fraction = empty($correct) ?
0 : 1;
197 $question->answers
[$q]->alternatives
[$a] = new stdClass();
198 $question->answers
[$q]->alternatives
[$a]->answer
= $text;
199 $question->answers
[$q]->alternatives
[$a]->fraction
= $fraction;
200 $question->answers
[$q]->alternatives
[$a]->feedback
= $feedback;
202 $wrapped->answer
[] = $text;
203 $wrapped->fraction
[] = $fraction;
204 $wrapped->feedback
[] = $feedback;
205 $answers[] = (empty($fraction) ?
'' : '=').$text.(empty($feedback) ?
'' : ('#'.$feedback));
210 // compile answers into question text, if necessary
214 $wrapped->questiontext
= '{'.$defaultgrade.':SHORTANSWER:'.implode('~', $answers).'}';
215 $question->options
->questions
[] = $wrapped;
221 // define total grade for this exercise
222 $question->defaultgrade
= $gap_count * $defaultgrade;
224 $questions[] = $question;
226 } // end while $exercise
229 function process_jcross(&$xml, &$questions) {
230 // xml tags to the start of the crossword exercise clue items
231 $tags = 'data,crossword,clues,item';
234 while (($item = "[$x]['#']") && $xml->xml_value($tags, $item)) {
236 $text = $xml->xml_value($tags, $item."['def'][0]['#']");
237 $answer = $xml->xml_value($tags, $item."['word'][0]['#']");
239 if ($text && $answer) {
240 if (method_exists($this, 'defaultquestion')) {
241 $question = $this->defaultquestion();
243 $question = new stdClass();
244 $question->usecase
= 0; // Ignore case
245 $question->image
= ""; // No images with this format
247 $question->qtype
= SHORTANSWER
;
248 $question->name
= $this->hotpot_get_title($xml, $x, true);
250 $question->questiontext
= $this->hotpot_prepare_str($text);
251 $question->answer
= array($this->hotpot_prepare_str($answer));
252 $question->fraction
= array(1);
253 $question->feedback
= array('');
255 $questions[] = $question;
261 function process_jmatch(&$xml, &$questions) {
262 // define default grade (per matched pair)
266 // xml tags to the start of the matching exercise
267 $tags = 'data,matching-exercise';
270 while (($exercise = "[$x]['#']") && $xml->xml_value($tags, $exercise)) {
271 // there is usually only one exercise in a file
273 if (method_exists($this, 'defaultquestion')) {
274 $question = $this->defaultquestion();
276 $question = new stdClass();
277 $question->usecase
= 0; // Ignore case
278 $question->image
= ""; // No images with this format
280 $question->qtype
= MATCH
;
281 $question->name
= $this->hotpot_get_title($xml, $x);
283 $question->questiontext
= $this->hotpot_get_reading($xml);
284 $question->questiontext
.= $this->hotpot_get_instructions($xml);
286 $question->subquestions
= array();
287 $question->subanswers
= array();
289 while (($pair = $exercise."['pair'][$p]['#']") && $xml->xml_value($tags, $pair)) {
290 $left = $xml->xml_value($tags, $pair."['left-item'][0]['#']['text'][0]['#']");
291 $right = $xml->xml_value($tags, $pair."['right-item'][0]['#']['text'][0]['#']");
292 if ($left && $right) {
294 $question->subquestions
[$p] = $this->hotpot_prepare_str($left);
295 $question->subanswers
[$p] = $this->hotpot_prepare_str($right);
299 $question->defaultgrade
= $match_count * $defaultgrade;
300 $questions[] = $question;
305 function process_jmix(&$xml, &$questions) {
306 // define default grade (per segment)
310 // xml tags to the start of the jumbled order exercise
311 $tags = 'data,jumbled-order-exercise';
314 while (($exercise = "[$x]['#']") && $xml->xml_value($tags, $exercise)) {
315 // there is usually only one exercise in a file
317 if (method_exists($this, 'defaultquestion')) {
318 $question = $this->defaultquestion();
320 $question = new stdClass();
321 $question->usecase
= 0; // Ignore case
322 $question->image
= ""; // No images with this format
324 $question->qtype
= SHORTANSWER
;
325 $question->name
= $this->hotpot_get_title($xml, $x);
327 $question->answer
= array();
328 $question->fraction
= array();
329 $question->feedback
= array();
333 while ($segment = $xml->xml_value($tags, $exercise."['main-order'][0]['#']['segment'][$i]['#']")) {
334 $segments[] = $this->hotpot_prepare_str($segment);
338 $answer = implode(' ', $segments);
340 $this->hotpot_seed_RNG();
343 $question->questiontext
= $this->hotpot_get_reading($xml);
344 $question->questiontext
.= $this->hotpot_get_instructions($xml);
345 $question->questiontext
.= ' <NOBR><B>[ '.implode(' ', $segments).' ]</B></NOBR>';
348 while (!empty($answer)) {
349 $question->answer
[$a] = $answer;
350 $question->fraction
[$a] = 1;
351 $question->feedback
[$a] = '';
352 $answer = $this->hotpot_prepare_str($xml->xml_value($tags, $exercise."['alternate'][$a]['#']"));
355 $question->defaultgrade
= $segment_count * $defaultgrade;
356 $questions[] = $question;
360 function process_jquiz(&$xml, &$questions) {
361 // define default grade (per question)
364 // xml tags to the start of the questions
365 $tags = 'data,questions';
368 while (($exercise = "[$x]['#']") && $xml->xml_value($tags, $exercise)) {
369 // there is usually only one 'questions' object in a single exercise
372 while (($question_record = $exercise."['question-record'][$q]['#']") && $xml->xml_value($tags, $question_record)) {
374 if (method_exists($this, 'defaultquestion')) {
375 $question = $this->defaultquestion();
377 $question = new stdClass();
378 $question->usecase
= 0; // Ignore case
379 $question->image
= ""; // No images with this format
381 $question->defaultgrade
= $defaultgrade;
382 $question->name
= $this->hotpot_get_title($xml, $q, true);
384 $text = $xml->xml_value($tags, $question_record."['question'][0]['#']");
385 $question->questiontext
= $this->hotpot_prepare_str($text);
387 if ($xml->xml_value($tags, $question_record."['answers']")) {
389 $answers = $question_record."['answers'][0]['#']";
392 $answers = $question_record;
394 if($xml->xml_value($tags, $question_record."['question-type']")) {
396 $type = $xml->xml_value($tags, $question_record."['question-type'][0]['#']");
397 // 1 : multiple choice
400 // 4 : multiple select
403 switch ($xml->quiztype
) {
405 $must_select_all = $xml->xml_value($tags, $question_record."['must-select-all'][0]['#']");
406 if (empty($must_select_all)) {
407 $type = 1; // multichoice
409 $type = 4; // multiselect
413 $type = 2; // shortanswer
416 $type = 0; // unknown
419 $question->qtype
= ($type==2 ? SHORTANSWER
: MULTICHOICE
);
420 $question->single
= ($type==4 ?
0 : 1);
422 // workaround required to calculate scores for multiple select answers
423 $no_of_correct_answers = 0;
426 while (($answer = $answers."['answer'][$a]['#']") && $xml->xml_value($tags, $answer)) {
427 $correct = $xml->xml_value($tags, $answer."['correct'][0]['#']");
428 if (empty($correct)) {
431 $no_of_correct_answers++
;
437 $question->answer
= array();
438 $question->fraction
= array();
439 $question->feedback
= array();
441 $correct_answers = array();
442 $correct_answers_all_zero = true;
443 while (($answer = $answers."['answer'][$a]['#']") && $xml->xml_value($tags, $answer)) {
444 $correct = $xml->xml_value($tags, $answer."['correct'][0]['#']");
445 if (empty($correct)) {
447 } else if ($type==4) { // multiple select
448 // strange behavior if the $fraction isn't exact to 5 decimal places
449 $fraction = round(1/$no_of_correct_answers, 5);
451 if ($xml->xml_value($tags, $answer."['percent-correct']")) {
453 $percent = $xml->xml_value($tags, $answer."['percent-correct'][0]['#']");
454 $fraction = $percent/100;
460 $answertext = $this->hotpot_prepare_str($xml->xml_value($tags, $answer."['text'][0]['#']"));
461 if ($answertext!='') {
462 $question->answer
[$aa] = $answertext;
463 $question->fraction
[$aa] = $fraction;
464 $question->feedback
[$aa] = $this->hotpot_prepare_str($xml->xml_value($tags, $answer."['feedback'][0]['#']"));
467 $correct_answers_all_zero = false;
469 $correct_answers[] = $aa;
475 if ($correct_answers_all_zero) {
476 // correct answers all have score of 0%,
477 // so reset score for correct answers 100%
478 foreach ($correct_answers as $aa) {
479 $question->fraction
[$aa] = 1;
482 $questions[] = $question;
489 function hotpot_seed_RNG() {
490 // seed the random number generator
491 static $HOTPOT_SEEDED_RNG = FALSE;
492 if (!$HOTPOT_SEEDED_RNG) {
493 srand((double) microtime() * 1000000);
494 $HOTPOT_SEEDED_RNG = TRUE;
497 function hotpot_get_title(&$xml, $x, $flag=false) {
498 $title = $xml->xml_value('data,title');
500 $title .= ' ('.($x+
1).')';
502 return $this->hotpot_prepare_str($title);
504 function hotpot_get_instructions(&$xml) {
505 $text = $xml->xml_value('hotpot-config-file,instructions');
507 $text = "Hot Potatoes $xml->quiztype";
509 return $this->hotpot_prepare_str($text);
511 function hotpot_get_reading(&$xml) {
513 $tags = 'data,reading';
514 if ($xml->xml_value("$tags,include-reading")) {
515 if ($title = $xml->xml_value("$tags,reading-title")) {
516 $str .= "<H3>$title</H3>";
518 if ($text = $xml->xml_value("$tags,reading-text")) {
519 $str .= "<P>$text</P>";
522 return $this->hotpot_prepare_str($str);
524 function hotpot_prepare_str($str) {
525 // convert html entities to unicode and add slashes
526 $str = preg_replace('/&#x([0-9a-f]+);/ie', "hotpot_charcode_to_utf8(hexdec('\\1'))", $str);
527 $str = preg_replace('/&#([0-9]+);/e', "hotpot_charcode_to_utf8(\\1)", $str);
528 return addslashes($str);
532 // get the standard XML parser supplied with Moodle
533 require_once("$CFG->libdir/xmlize.php");
535 class hotpot_xml_tree
{
536 function hotpot_xml_tree($str, $xml_root='') {
538 $this->xml
= array();
540 // encode htmlentities in JCloze
541 $this->encode_cdata($str, 'gap-fill');
542 // xmlize (=convert xml to tree)
543 $this->xml
= xmlize($str, 0);
545 $this->xml_root
= $xml_root;
547 function xml_value($tags, $more_tags="[0]['#']") {
549 $tags = empty($tags) ?
'' : "['".str_replace(",", "'][0]['#']['", $tags)."']";
550 eval('$value = &$this->xml'.$this->xml_root
.$tags.$more_tags.';');
552 if (is_string($value)) {
554 // decode angle brackets and ampersands
555 $value = strtr($value, array('<'=>'<', '>'=>'>', '&'=>'&'));
557 // remove white space between <table>, <ul|OL|DL> and <OBJECT|EMBED> parts
558 // (so it doesn't get converted to <br />)
560 . 'TABLE|/?CAPTION|/?COL|/?COLGROUP|/?TBODY|/?TFOOT|/?THEAD|/?TD|/?TH|/?TR'
563 . '|EMBED|OBJECT|APPLET|/?PARAM'
564 //. '|SELECT|/?OPTION'
565 //. '|FIELDSET|/?LEGEND'
566 //. '|FRAMESET|/?FRAME'
569 $search = '#(<'.$htmltags.'[^>]*'.'>)\s+'.'(?='.'<'.')#is';
570 $value = preg_replace($search, '\\1', $value);
572 // replace remaining newlines with <br />
573 $value = str_replace("\n", '<br />', $value);
575 // encode unicode characters as HTML entities
576 // (in particular, accented charaters that have not been encoded by HP)
578 // multibyte unicode characters can be detected by checking the hex value of the first character
579 // 00 - 7F : ascii char (roman alphabet + punctuation)
580 // 80 - BF : byte 2, 3 or 4 of a unicode char
581 // C0 - DF : 1st byte of 2-byte char
582 // E0 - EF : 1st byte of 3-byte char
583 // F0 - FF : 1st byte of 4-byte char
584 // if the string doesn't match the above, it might be
585 // 80 - FF : single-byte, non-ascii char
586 $search = '#('.'[\xc0-\xdf][\x80-\xbf]'.'|'.'[\xe0-\xef][\x80-\xbf]{2}'.'|'.'[\xf0-\xff][\x80-\xbf]{3}'.'|'.'[\x80-\xff]'.')#se';
587 $value = preg_replace($search, "hotpot_utf8_to_html_entity('\\1')", $value);
591 function encode_cdata(&$str, $tag) {
594 static $HTML_ENTITIES = array(
601 static $ILLEGAL_STRINGS = array(
603 "\n" => '<br />',
604 ']]>' => ']]>',
607 // extract the $tag from the $str(ing), if possible
608 $pattern = '|(^.*<'.$tag.'[^>]*)(>.*<)(/'.$tag.'>.*$)|is';
609 if (preg_match($pattern, $str, $matches)) {
611 // encode problematic CDATA chars and strings
612 $matches[2] = strtr($matches[2], $ILLEGAL_STRINGS);
615 // if there are any ampersands in "open text"
616 // surround them by CDATA start and end markers
617 // (and convert HTML entities to plain text)
618 $search = '/>([^<]*&[^<]*)</e';
619 $replace = '"><![CDATA[".strtr("$1", $HTML_ENTITIES)."]]><"';
620 $matches[2] = preg_replace($search, $replace, $matches[2]);
622 $str = $matches[1].$matches[2].$matches[3];
627 function hotpot_charcode_to_utf8($charcode) {
628 if ($charcode <= 0x7F) {
629 // ascii char (roman alphabet + punctuation)
630 return chr($charcode);
632 if ($charcode <= 0x7FF) {
634 return chr(($charcode >> 0x06) +
0xC0).chr(($charcode & 0x3F) +
128);
636 if ($charcode <= 0xFFFF) {
638 return chr(($charcode >> 0x0C) +
0xE0).chr((($charcode >> 0x06) & 0x3F) +
0x80).chr(($charcode & 0x3F) +
0x80);
640 if ($charcode <= 0x1FFFFF) {
642 return chr(($charcode >> 0x12) +
0xF0).chr((($charcode >> 0x0C) & 0x3F) +
0x80).chr((($charcode >> 0x06) & 0x3F) +
0x80).chr(($charcode & 0x3F) +
0x80);
644 // unidentified char code !!
648 function hotpot_utf8_to_html_entity($char) {
649 // http://www.zend.com/codex.php?id=835&single=1
651 // array used to figure what number to decrement from character order value
652 // according to number of characters used to map unicode to ascii by utf-8
653 static $HOTPOT_UTF8_DECREMENT = array(
654 1=>0, 2=>192, 3=>224, 4=>240
657 // the number of bits to shift each character by
658 static $HOTPOT_UTF8_SHIFT = array(
660 2=>array(0=>6, 1=>0),
661 3=>array(0=>12, 1=>6, 2=>0),
662 4=>array(0=>18, 1=>12, 2=>6, 3=>0)
666 $len = strlen($char);
667 for ($pos=0; $pos<$len; $pos++
) {
668 $ord = ord ($char{$pos});
669 $ord -= ($pos ?
128 : $HOTPOT_UTF8_DECREMENT[$len]);
670 $dec +
= ($ord << $HOTPOT_UTF8_SHIFT[$len][$pos]);
672 return '&#x'.sprintf('%04X', $dec).';';
675 function hotpot_convert_relative_urls($str, $baseurl, $filename) {
676 $tagopen = '(?:(<)|(<)|(&#x003C;))'; // left angle bracket
677 $tagclose = '(?(2)>|(?(3)>|(?(4)&#x003E;)))'; // right angle bracket (to match left angle bracket)
679 $space = '\s+'; // at least one space
680 $anychar = '(?:[^>]*?)'; // any character
682 $quoteopen = '("|"|&quot;)'; // open quote
683 $quoteclose = '\\5'; // close quote (to match open quote)
685 $replace = "hotpot_convert_relative_url('".$baseurl."', '".$filename."', '\\1', '\\6', '\\7')";
687 $tags = array('script'=>'src', 'link'=>'href', 'a'=>'href','img'=>'src','param'=>'value', 'object'=>'data', 'embed'=>'src');
688 foreach ($tags as $tag=>$attribute) {
690 $url = '\S+?\.\S+?'; // must include a filename and have no spaces
694 $search = "%($tagopen$tag$space$anychar$attribute=$quoteopen)($url)($quoteclose$anychar$tagclose)%ise";
695 $str = preg_replace($search, $replace, $str);
701 function hotpot_convert_relative_url($baseurl, $filename, $opentag, $url, $closetag, $stripslashes=true) {
703 $opentag = stripslashes($opentag);
704 $url = stripslashes($url);
705 $closetag = stripslashes($closetag);
708 // catch <PARAM name="FlashVars" value="TheSound=soundfile.mp3">
709 // ampersands can appear as "&", "&" or "&#x0026;amp;"
710 if (preg_match('|^'.'\w+=[^&]+'.'('.'&((amp;#x0026;)?amp;)?'.'\w+=[^&]+)*'.'$|', $url)) {
715 // parse the $url into $matches
717 // [2] query string, if any
718 // [3] anchor fragment, if any
719 } else if (preg_match('|^'.'([^?]*)'.'((?:\\?[^#]*)?)'.'((?:#.*)?)'.'$|', $url, $matches)) {
721 $query = $matches[2];
722 $fragment = $matches[3];
724 // there appears to be no query or fragment in this url
731 $url = hotpot_convert_url($baseurl, $filename, $url, false);
735 $search = '#'.'(file|src|thesound)='."([^&]+)".'#ise';
736 $replace = "'\\1='.hotpot_convert_url('".$baseurl."','".$filename."','\\2')";
737 $query = preg_replace($search, $replace, $query);
740 $url = $opentag.$url.$query.$fragment.$closetag;
745 function hotpot_convert_url($baseurl, $filename, $url, $stripslashes=true) {
746 // maintain a cache of converted urls
747 static $HOTPOT_RELATIVE_URLS = array();
750 $url = stripslashes($url);
753 // is this an absolute url? (or javascript pseudo url)
754 if (preg_match('%^(http://|/|javascript:)%i', $url)) {
757 // has this relative url already been converted?
758 } else if (isset($HOTPOT_RELATIVE_URLS[$url])) {
759 $url = $HOTPOT_RELATIVE_URLS[$url];
764 // get the subdirectory, $dir, of the quiz $filename
765 $dir = dirname($filename);
767 // allow for leading "./" and "../"
768 while (preg_match('|^(\.{1,2})/(.*)$|', $url, $matches)) {
769 if ($matches[1]=='..') {
770 $dir = dirname($dir);
775 // add subdirectory, $dir, to $baseurl, if necessary
776 if ($dir && $dir<>'.') {
780 // prefix $url with $baseurl
781 $url = "$baseurl$url";
784 $HOTPOT_RELATIVE_URLS[$relativeurl] = $url;
789 // allow importing in Moodle v1.4 (and less)
790 // same core functions but different class name
791 if (!class_exists("quiz_file_format")) {
792 class quiz_file_format
extends qformat_default
{
793 function readquestions ($lines) {
794 $format = new qformat_hotpot();
795 return $format->readquestions($lines);