PageRenderTime 61ms CodeModel.GetById 30ms RepoModel.GetById 0ms app.codeStats 0ms

/question/format/hotpot/format.php

https://github.com/borrown/moodle
PHP | 612 lines | 471 code | 62 blank | 79 comment | 103 complexity | be6e51e1438508d80212aa3dcea533cc MD5 | raw file
Possible License(s): GPL-2.0, LGPL-2.1, BSD-3-Clause, LGPL-2.0
  1. <?PHP // $Id$
  2. ////////////////////////////////////////////////////////////////////////////
  3. /// Hotpotatoes 5.0 and 6.0 Format
  4. ///
  5. /// This Moodle class provides all functions necessary to import
  6. /// (export is not implemented ... yet)
  7. ///
  8. ////////////////////////////////////////////////////////////////////////////
  9. // Based on default.php, included by ../import.php
  10. /**
  11. * @package questionbank
  12. * @subpackage importexport
  13. */
  14. require_once($CFG->dirroot . '/mod/hotpot/lib.php');
  15. class qformat_hotpot extends qformat_default {
  16. function provide_import() {
  17. return true;
  18. }
  19. function readquestions ($lines) {
  20. /// Parses an array of lines into an array of questions,
  21. /// where each item is a question object as defined by
  22. /// readquestion().
  23. // set courseid and baseurl
  24. global $CFG, $COURSE, $course;
  25. switch (true) {
  26. case isset($this->course->id):
  27. // import to quiz module
  28. $courseid = $this->course->id;
  29. break;
  30. case isset($course->id):
  31. // import to lesson module
  32. $courseid = $course->id;
  33. break;
  34. case isset($COURSE->id):
  35. // last resort
  36. $courseid = $COURSE->id;
  37. break;
  38. default:
  39. // shouldn't happen !!
  40. $courseid = 0;
  41. }
  42. require_once($CFG->libdir.'/filelib.php');
  43. $baseurl = get_file_url($courseid).'/';
  44. // get import file name
  45. global $params;
  46. if (! empty($this->realfilename)) {
  47. $filename = $this->realfilename;
  48. } else if (isset($params) && !empty($params->choosefile)) {
  49. // course file (Moodle >=1.6+)
  50. $filename = $params->choosefile;
  51. } else {
  52. // uploaded file (all Moodles)
  53. $filename = basename($_FILES['newfile']['tmp_name']);
  54. }
  55. // get hotpot file source
  56. $source = implode($lines, " ");
  57. $source = hotpot_convert_relative_urls($source, $baseurl, $filename);
  58. // create xml tree for this hotpot
  59. $xml = new hotpot_xml_tree($source);
  60. // determine the quiz type
  61. $xml->quiztype = '';
  62. $keys = array_keys($xml->xml);
  63. foreach ($keys as $key) {
  64. if (preg_match('/^(hotpot|textoys)-(\w+)-file$/i', $key, $matches)) {
  65. $xml->quiztype = strtolower($matches[2]);
  66. $xml->xml_root = "['$key']['#']";
  67. break;
  68. }
  69. }
  70. // convert xml to questions array
  71. $questions = array();
  72. switch ($xml->quiztype) {
  73. case 'jcloze':
  74. $this->process_jcloze($xml, $questions);
  75. break;
  76. case 'jcross':
  77. $this->process_jcross($xml, $questions);
  78. break;
  79. case 'jmatch':
  80. $this->process_jmatch($xml, $questions);
  81. break;
  82. case 'jmix':
  83. $this->process_jmix($xml, $questions);
  84. break;
  85. case 'jbc':
  86. case 'jquiz':
  87. $this->process_jquiz($xml, $questions);
  88. break;
  89. default:
  90. if (empty($xml->quiztype)) {
  91. notice("Input file not recognized as a Hot Potatoes XML file");
  92. } else {
  93. notice("Unknown quiz type '$xml->quiztype'");
  94. }
  95. } // end switch
  96. if (count($questions)) {
  97. return $questions;
  98. } else {
  99. if (method_exists($this, 'error')) { // Moodle >= 1.8
  100. $this->error(get_string('giftnovalidquestion', 'quiz'));
  101. }
  102. return false;
  103. }
  104. }
  105. function process_jcloze(&$xml, &$questions) {
  106. // define default grade (per cloze gap)
  107. $defaultgrade = 1;
  108. $gap_count = 0;
  109. // detect old Moodles (1.4 and earlier)
  110. global $CFG, $db;
  111. $moodle_14 = false;
  112. if ($columns = $db->MetaColumns("{$CFG->prefix}question_multianswer")) {
  113. foreach ($columns as $column) {
  114. if ($column->name=='answers' || $column->name=='positionkey' || $column->name=='answertype' || $column->name=='norm') {
  115. $moodle_14 = true;
  116. }
  117. }
  118. }
  119. // xml tags for the start of the gap-fill exercise
  120. $tags = 'data,gap-fill';
  121. $x = 0;
  122. while (($exercise = "[$x]['#']") && $xml->xml_value($tags, $exercise)) {
  123. // there is usually only one exercise in a file
  124. if (method_exists($this, 'defaultquestion')) {
  125. $question = $this->defaultquestion();
  126. } else {
  127. $question = new stdClass();
  128. $question->usecase = 0; // Ignore case
  129. $question->image = ""; // No images with this format
  130. }
  131. $question->qtype = MULTIANSWER;
  132. $question->name = $this->hotpot_get_title($xml, $x);
  133. $question->questiontext = $this->hotpot_get_reading($xml);
  134. // setup answer arrays
  135. if ($moodle_14) {
  136. $question->answers = array();
  137. } else {
  138. global $COURSE; // initialized in questions/import.php
  139. $question->course = $COURSE->id;
  140. $question->options = new stdClass();
  141. $question->options->questions = array(); // one for each gap
  142. }
  143. $q = 0;
  144. while ($text = $xml->xml_value($tags, $exercise."[$q]")) {
  145. // add next bit of text
  146. $question->questiontext .= $this->hotpot_prepare_str($text);
  147. // check for a gap
  148. $question_record = $exercise."['question-record'][$q]['#']";
  149. if ($xml->xml_value($tags, $question_record)) {
  150. // add gap
  151. $gap_count ++;
  152. $positionkey = $q+1;
  153. $question->questiontext .= '{#'.$positionkey.'}';
  154. // initialize answer settings
  155. if ($moodle_14) {
  156. $question->answers[$q]->positionkey = $positionkey;
  157. $question->answers[$q]->answertype = SHORTANSWER;
  158. $question->answers[$q]->norm = $defaultgrade;
  159. $question->answers[$q]->alternatives = array();
  160. } else {
  161. $wrapped = new stdClass();
  162. $wrapped->qtype = SHORTANSWER;
  163. $wrapped->usecase = 0;
  164. $wrapped->defaultgrade = $defaultgrade;
  165. $wrapped->questiontextformat = 0;
  166. $wrapped->answer = array();
  167. $wrapped->fraction = array();
  168. $wrapped->feedback = array();
  169. $answers = array();
  170. }
  171. // add answers
  172. $a = 0;
  173. while (($answer=$question_record."['answer'][$a]['#']") && $xml->xml_value($tags, $answer)) {
  174. $text = $this->hotpot_prepare_str($xml->xml_value($tags, $answer."['text'][0]['#']"));
  175. $correct = $xml->xml_value($tags, $answer."['correct'][0]['#']");
  176. $feedback = $this->hotpot_prepare_str($xml->xml_value($tags, $answer."['feedback'][0]['#']"));
  177. if (strlen($text)) {
  178. // set score (0=0%, 1=100%)
  179. $fraction = empty($correct) ? 0 : 1;
  180. // store answer
  181. if ($moodle_14) {
  182. $question->answers[$q]->alternatives[$a] = new stdClass();
  183. $question->answers[$q]->alternatives[$a]->answer = $text;
  184. $question->answers[$q]->alternatives[$a]->fraction = $fraction;
  185. $question->answers[$q]->alternatives[$a]->feedback = $feedback;
  186. } else {
  187. $wrapped->answer[] = $text;
  188. $wrapped->fraction[] = $fraction;
  189. $wrapped->feedback[] = $feedback;
  190. $answers[] = (empty($fraction) ? '' : '=').$text.(empty($feedback) ? '' : ('#'.$feedback));
  191. }
  192. }
  193. $a++;
  194. }
  195. // compile answers into question text, if necessary
  196. if ($moodle_14) {
  197. // do nothing
  198. } else {
  199. $wrapped->questiontext = '{'.$defaultgrade.':SHORTANSWER:'.implode('~', $answers).'}';
  200. $question->options->questions[] = $wrapped;
  201. }
  202. } // end if gap
  203. $q++;
  204. } // end while $text
  205. if ($q) {
  206. // define total grade for this exercise
  207. $question->defaultgrade = $gap_count * $defaultgrade;
  208. // add this cloze as a single question object
  209. $questions[] = $question;
  210. } else {
  211. // no gaps found in this text so don't add this question
  212. // import will fail and error message will be displayed:
  213. }
  214. $x++;
  215. } // end while $exercise
  216. }
  217. function process_jcross(&$xml, &$questions) {
  218. // xml tags to the start of the crossword exercise clue items
  219. $tags = 'data,crossword,clues,item';
  220. $x = 0;
  221. while (($item = "[$x]['#']") && $xml->xml_value($tags, $item)) {
  222. $text = $xml->xml_value($tags, $item."['def'][0]['#']");
  223. $answer = $xml->xml_value($tags, $item."['word'][0]['#']");
  224. if ($text && $answer) {
  225. if (method_exists($this, 'defaultquestion')) {
  226. $question = $this->defaultquestion();
  227. } else {
  228. $question = new stdClass();
  229. $question->usecase = 0; // Ignore case
  230. $question->image = ""; // No images with this format
  231. }
  232. $question->qtype = SHORTANSWER;
  233. $question->name = $this->hotpot_get_title($xml, $x, true);
  234. $question->questiontext = $this->hotpot_prepare_str($text);
  235. $question->answer = array($this->hotpot_prepare_str($answer));
  236. $question->fraction = array(1);
  237. $question->feedback = array('');
  238. $questions[] = $question;
  239. }
  240. $x++;
  241. }
  242. }
  243. function process_jmatch(&$xml, &$questions) {
  244. // define default grade (per matched pair)
  245. $defaultgrade = 1;
  246. $match_count = 0;
  247. // xml tags to the start of the matching exercise
  248. $tags = 'data,matching-exercise';
  249. $x = 0;
  250. while (($exercise = "[$x]['#']") && $xml->xml_value($tags, $exercise)) {
  251. // there is usually only one exercise in a file
  252. if (method_exists($this, 'defaultquestion')) {
  253. $question = $this->defaultquestion();
  254. } else {
  255. $question = new stdClass();
  256. $question->usecase = 0; // Ignore case
  257. $question->image = ""; // No images with this format
  258. }
  259. $question->qtype = MATCH;
  260. $question->name = $this->hotpot_get_title($xml, $x);
  261. $question->questiontext = $this->hotpot_get_reading($xml);
  262. $question->questiontext .= $this->hotpot_get_instructions($xml);
  263. $question->subquestions = array();
  264. $question->subanswers = array();
  265. $p = 0;
  266. while (($pair = $exercise."['pair'][$p]['#']") && $xml->xml_value($tags, $pair)) {
  267. $left = $xml->xml_value($tags, $pair."['left-item'][0]['#']['text'][0]['#']");
  268. $right = $xml->xml_value($tags, $pair."['right-item'][0]['#']['text'][0]['#']");
  269. if ($left && $right) {
  270. $match_count++;
  271. $question->subquestions[$p] = $this->hotpot_prepare_str($left);
  272. $question->subanswers[$p] = $this->hotpot_prepare_str($right);
  273. }
  274. $p++;
  275. }
  276. $question->defaultgrade = $match_count * $defaultgrade;
  277. $questions[] = $question;
  278. $x++;
  279. }
  280. }
  281. function process_jmix(&$xml, &$questions) {
  282. // define default grade (per segment)
  283. $defaultgrade = 1;
  284. $segment_count = 0;
  285. // xml tags to the start of the jumbled order exercise
  286. $tags = 'data,jumbled-order-exercise';
  287. $x = 0;
  288. while (($exercise = "[$x]['#']") && $xml->xml_value($tags, $exercise)) {
  289. // there is usually only one exercise in a file
  290. if (method_exists($this, 'defaultquestion')) {
  291. $question = $this->defaultquestion();
  292. } else {
  293. $question = new stdClass();
  294. $question->usecase = 0; // Ignore case
  295. $question->image = ""; // No images with this format
  296. }
  297. $question->qtype = SHORTANSWER;
  298. $question->name = $this->hotpot_get_title($xml, $x);
  299. $question->answer = array();
  300. $question->fraction = array();
  301. $question->feedback = array();
  302. $i = 0;
  303. $segments = array();
  304. while ($segment = $xml->xml_value($tags, $exercise."['main-order'][0]['#']['segment'][$i]['#']")) {
  305. $segments[] = $this->hotpot_prepare_str($segment);
  306. $segment_count++;
  307. $i++;
  308. }
  309. $answer = implode(' ', $segments);
  310. $this->hotpot_seed_RNG();
  311. shuffle($segments);
  312. $question->questiontext = $this->hotpot_get_reading($xml);
  313. $question->questiontext .= $this->hotpot_get_instructions($xml);
  314. $question->questiontext .= ' &nbsp; <NOBR><B>[ &nbsp; '.implode(' &nbsp; ', $segments).' &nbsp; ]</B></NOBR>';
  315. $a = 0;
  316. while (!empty($answer)) {
  317. $question->answer[$a] = $answer;
  318. $question->fraction[$a] = 1;
  319. $question->feedback[$a] = '';
  320. $answer = $this->hotpot_prepare_str($xml->xml_value($tags, $exercise."['alternate'][$a]['#']"));
  321. $a++;
  322. }
  323. $question->defaultgrade = $segment_count * $defaultgrade;
  324. $questions[] = $question;
  325. $x++;
  326. }
  327. }
  328. function process_jquiz(&$xml, &$questions) {
  329. // define default grade (per question)
  330. $defaultgrade = 1;
  331. // xml tags to the start of the questions
  332. $tags = 'data,questions';
  333. $x = 0;
  334. while (($exercise = "[$x]['#']") && $xml->xml_value($tags, $exercise)) {
  335. // there is usually only one 'questions' object in a single exercise
  336. $q = 0;
  337. while (($question_record = $exercise."['question-record'][$q]['#']") && $xml->xml_value($tags, $question_record)) {
  338. if (method_exists($this, 'defaultquestion')) {
  339. $question = $this->defaultquestion();
  340. } else {
  341. $question = new stdClass();
  342. $question->usecase = 0; // Ignore case
  343. $question->image = ""; // No images with this format
  344. }
  345. $question->defaultgrade = $defaultgrade;
  346. $question->name = $this->hotpot_get_title($xml, $q, true);
  347. $text = $xml->xml_value($tags, $question_record."['question'][0]['#']");
  348. $question->questiontext = $this->hotpot_prepare_str($text);
  349. if ($xml->xml_value($tags, $question_record."['answers']")) {
  350. // HP6 JQuiz
  351. $answers = $question_record."['answers'][0]['#']";
  352. } else {
  353. // HP5 JBC or JQuiz
  354. $answers = $question_record;
  355. }
  356. if($xml->xml_value($tags, $question_record."['question-type']")) {
  357. // HP6 JQuiz
  358. $type = $xml->xml_value($tags, $question_record."['question-type'][0]['#']");
  359. // 1 : multiple choice
  360. // 2 : short-answer
  361. // 3 : hybrid
  362. // 4 : multiple select
  363. } else {
  364. // HP5
  365. switch ($xml->quiztype) {
  366. case 'jbc':
  367. $must_select_all = $xml->xml_value($tags, $question_record."['must-select-all'][0]['#']");
  368. if (empty($must_select_all)) {
  369. $type = 1; // multichoice
  370. } else {
  371. $type = 4; // multiselect
  372. }
  373. break;
  374. case 'jquiz':
  375. $type = 2; // shortanswer
  376. break;
  377. default:
  378. $type = 0; // unknown
  379. }
  380. }
  381. $question->qtype = ($type==2 ? SHORTANSWER : MULTICHOICE);
  382. $question->single = ($type==4 ? 0 : 1);
  383. // workaround required to calculate scores for multiple select answers
  384. $no_of_correct_answers = 0;
  385. if ($type==4) {
  386. $a = 0;
  387. while (($answer = $answers."['answer'][$a]['#']") && $xml->xml_value($tags, $answer)) {
  388. $correct = $xml->xml_value($tags, $answer."['correct'][0]['#']");
  389. if (empty($correct)) {
  390. // do nothing
  391. } else {
  392. $no_of_correct_answers++;
  393. }
  394. $a++;
  395. }
  396. }
  397. $a = 0;
  398. $question->answer = array();
  399. $question->fraction = array();
  400. $question->feedback = array();
  401. $aa = 0;
  402. $correct_answers = array();
  403. $correct_answers_all_zero = true;
  404. while (($answer = $answers."['answer'][$a]['#']") && $xml->xml_value($tags, $answer)) {
  405. $correct = $xml->xml_value($tags, $answer."['correct'][0]['#']");
  406. if (empty($correct)) {
  407. $fraction = 0;
  408. } else if ($type==4) { // multiple select
  409. // strange behavior if the $fraction isn't exact to 5 decimal places
  410. $fraction = round(1/$no_of_correct_answers, 5);
  411. } else {
  412. if ($xml->xml_value($tags, $answer."['percent-correct']")) {
  413. // HP6 JQuiz
  414. $percent = $xml->xml_value($tags, $answer."['percent-correct'][0]['#']");
  415. $fraction = $percent/100;
  416. } else {
  417. // HP5 JBC or JQuiz
  418. $fraction = 1;
  419. }
  420. }
  421. $answertext = $this->hotpot_prepare_str($xml->xml_value($tags, $answer."['text'][0]['#']"));
  422. if ($answertext!='') {
  423. $question->answer[$aa] = $answertext;
  424. $question->fraction[$aa] = $fraction;
  425. $question->feedback[$aa] = $this->hotpot_prepare_str($xml->xml_value($tags, $answer."['feedback'][0]['#']"));
  426. if ($correct) {
  427. if ($fraction) {
  428. $correct_answers_all_zero = false;
  429. }
  430. $correct_answers[] = $aa;
  431. }
  432. $aa++;
  433. }
  434. $a++;
  435. }
  436. if ($correct_answers_all_zero) {
  437. // correct answers all have score of 0%,
  438. // so reset score for correct answers 100%
  439. foreach ($correct_answers as $aa) {
  440. $question->fraction[$aa] = 1;
  441. }
  442. }
  443. // add a sanity check for empty questions, see MDL-17779
  444. if (!empty($question->questiontext)) {
  445. $questions[] = $question;
  446. }
  447. $q++;
  448. }
  449. $x++;
  450. }
  451. }
  452. function hotpot_seed_RNG() {
  453. // seed the random number generator
  454. static $HOTPOT_SEEDED_RNG = FALSE;
  455. if (!$HOTPOT_SEEDED_RNG) {
  456. srand((double) microtime() * 1000000);
  457. $HOTPOT_SEEDED_RNG = TRUE;
  458. }
  459. }
  460. function hotpot_get_title(&$xml, $x, $flag=false) {
  461. $title = $xml->xml_value('data,title');
  462. if ($x || $flag) {
  463. $title .= ' ('.($x+1).')';
  464. }
  465. return $this->hotpot_prepare_str($title);
  466. }
  467. function hotpot_get_instructions(&$xml) {
  468. $text = $xml->xml_value('hotpot-config-file,'.$xml->quiztype.',instructions');
  469. if (empty($text)) {
  470. $text = "Hot Potatoes $xml->quiztype";
  471. }
  472. return $this->hotpot_prepare_str($text);
  473. }
  474. function hotpot_get_reading(&$xml) {
  475. $str = '';
  476. $tags = 'data,reading';
  477. if ($xml->xml_value("$tags,include-reading")) {
  478. if ($title = $xml->xml_value("$tags,reading-title")) {
  479. $str .= "<H3>$title</H3>";
  480. }
  481. if ($text = $xml->xml_value("$tags,reading-text")) {
  482. $str .= "<P>$text</P>";
  483. }
  484. }
  485. return $this->hotpot_prepare_str($str);
  486. }
  487. function hotpot_prepare_str($str) {
  488. // convert html entities to unicode and add slashes
  489. $str = preg_replace_callback('/&#([0-9]+);/', array(&$this, 'hotpot_prepare_str_dec'), $str);
  490. $str = preg_replace_callback('/&#x([0-9a-f]+);/i', array(&$this, 'hotpot_prepare_str_hexdec'), $str);
  491. return addslashes($str);
  492. }
  493. function hotpot_prepare_str_dec(&$matches) {
  494. return hotpot_charcode_to_utf8($matches[1]);
  495. }
  496. function hotpot_prepare_str_hexdec(&$matches) {
  497. return hotpot_charcode_to_utf8(hexdec($matches[1]));
  498. }
  499. } // end class
  500. function hotpot_charcode_to_utf8($charcode) {
  501. // thanks to Miguel Perez: http://jp2.php.net/chr (19-Sep-2007)
  502. if ($charcode <= 0x7F) {
  503. // ascii char (roman alphabet + punctuation)
  504. return chr($charcode);
  505. }
  506. if ($charcode <= 0x7FF) {
  507. // 2-byte char
  508. return chr(($charcode >> 0x06) + 0xC0).chr(($charcode & 0x3F) + 0x80);
  509. }
  510. if ($charcode <= 0xFFFF) {
  511. // 3-byte char
  512. return chr(($charcode >> 0x0C) + 0xE0).chr((($charcode >> 0x06) & 0x3F) + 0x80).chr(($charcode & 0x3F) + 0x80);
  513. }
  514. if ($charcode <= 0x1FFFFF) {
  515. // 4-byte char
  516. return chr(($charcode >> 0x12) + 0xF0).chr((($charcode >> 0x0C) & 0x3F) + 0x80).chr((($charcode >> 0x06) & 0x3F) + 0x80).chr(($charcode & 0x3F) + 0x80);
  517. }
  518. // unidentified char code !!
  519. return ' ';
  520. }
  521. function hotpot_convert_relative_urls($str, $baseurl, $filename) {
  522. $tagopen = '(?:(<)|(&lt;)|(&amp;#x003C;))'; // left angle bracket
  523. $tagclose = '(?(2)>|(?(3)&gt;|(?(4)&amp;#x003E;)))'; // right angle bracket (to match left angle bracket)
  524. $space = '\s+'; // at least one space
  525. $anychar = '(?:[^>]*?)'; // any character
  526. $quoteopen = '("|&quot;|&amp;quot;)'; // open quote
  527. $quoteclose = '\\5'; // close quote (to match open quote)
  528. $replace = "hotpot_convert_relative_url('".$baseurl."', '".$filename."', '\\1', '\\6', '\\7')";
  529. $tags = array('script'=>'src', 'link'=>'href', 'a'=>'href','img'=>'src','param'=>'value', 'object'=>'data', 'embed'=>'src');
  530. foreach ($tags as $tag=>$attribute) {
  531. if ($tag=='param') {
  532. $url = '\S+?\.\S+?'; // must include a filename and have no spaces
  533. } else {
  534. $url = '.*?';
  535. }
  536. $search = "/($tagopen$tag$space$anychar$attribute=$quoteopen)($url)($quoteclose$anychar$tagclose)/is";
  537. if (preg_match_all($search, $str, $matches, PREG_OFFSET_CAPTURE)) {
  538. $i_max = count($matches[0]) - 1;
  539. for ($i=$i_max; $i>=0; $i--) {
  540. $match = $matches[0][$i][0];
  541. $start = $matches[0][$i][1];
  542. $replace = hotpot_convert_relative_url(
  543. $baseurl, $filename, $matches[1][$i][0], $matches[6][$i][0], $matches[7][$i][0], false
  544. );
  545. $str = substr_replace($str, $replace, $start, strlen($match));
  546. }
  547. }
  548. }
  549. return $str;
  550. }