3 ///////////////////////////////////////////////////////////////
4 // The GIFT import filter was designed as an easy to use method
5 // for teachers writing questions as a text file. It supports most
6 // question types and the missing word format.
8 // Multiple Choice / Missing Word
9 // Who's buried in Grant's tomb?{~Grant ~Jefferson =no one}
10 // Grant is {~buried =entombed ~living} in Grant's tomb.
12 // Grant is buried in Grant's tomb.{FALSE}
14 // Who's buried in Grant's tomb?{=no one =nobody}
16 // When was Ulysses S. Grant born?{#1822:5}
18 // Match the following countries with their corresponding
19 // capitals.{=Canada->Ottawa =Italy->Rome =Japan->Tokyo}
21 // Comment lines start with a double backslash (//).
22 // Optional question names are enclosed in double colon(::).
23 // Answer feedback is indicated with hash mark (#).
24 // Percentage answer weights immediately follow the tilde (for
25 // multiple choice) or equal sign (for short answer and numerical),
26 // and are enclosed in percent signs (% %). See docs and examples.txt for more.
28 // This filter was written through the collaboration of numerous
29 // members of the Moodle community. It was originally based on
30 // the missingword format, which included code from Thomas Robb
31 // and others. Paul Tsuchido Shew wrote this filter in December 2003.
32 //////////////////////////////////////////////////////////////////////////
33 // Based on default.php, included by ../import.php
35 * @package questionbank
36 * @subpackage importexport
38 class qformat_gift extends qformat_default {
40 function provide_import() {
44 function provide_export() {
48 function export_file_extension() {
52 function answerweightparser(&$answer) {
53 $answer = substr($answer, 1); // removes initial %
54 $end_position = strpos($answer, "%");
55 $answer_weight = substr($answer, 0, $end_position); // gets weight as integer
56 $answer_weight = $answer_weight/100; // converts to percent
57 $answer = substr($answer, $end_position+1); // removes comment from answer
58 return $answer_weight;
61 function commentparser($answer, $defaultformat) {
62 $bits = explode('#', $answer, 2);
63 $ans = $this->parse_text_with_format(trim($bits[0]), $defaultformat);
64 if (count($bits) > 1) {
65 $feedback = $this->parse_text_with_format(trim($bits[1]), $defaultformat);
67 $feedback = array('text' => '', 'format' => $defaultformat, 'files' => array());
69 return array($ans, $feedback);
72 function split_truefalse_comment($answer, $defaultformat) {
73 $bits = explode('#', $answer, 3);
74 $ans = $this->parse_text_with_format(trim($bits[0]), $defaultformat);
75 if (count($bits) > 1) {
76 $wrongfeedback = $this->parse_text_with_format(trim($bits[1]), $defaultformat);
78 $wrongfeedback = array('text' => '', 'format' => $defaultformat, 'files' => array());
80 if (count($bits) > 2) {
81 $rightfeedback = $this->parse_text_with_format(trim($bits[2]), $defaultformat);
83 $rightfeedback = array('text' => '', 'format' => $defaultformat, 'files' => array());
85 return array($ans, $wrongfeedback, $rightfeedback);
88 function escapedchar_pre($string) {
89 //Replaces escaped control characters with a placeholder BEFORE processing
91 $escapedcharacters = array("\\:", "\\#", "\\=", "\\{", "\\}", "\\~", "\\n" ); //dlnsk
92 $placeholders = array("&&058;", "&&035;", "&&061;", "&&123;", "&&125;", "&&126;", "&&010"); //dlnsk
94 $string = str_replace("\\\\", "&&092;", $string);
95 $string = str_replace($escapedcharacters, $placeholders, $string);
96 $string = str_replace("&&092;", "\\", $string);
100 function escapedchar_post($string) {
101 //Replaces placeholders with corresponding character AFTER processing is done
102 $placeholders = array("&&058;", "&&035;", "&&061;", "&&123;", "&&125;", "&&126;", "&&010"); //dlnsk
103 $characters = array(":", "#", "=", "{", "}", "~", "\n" ); //dlnsk
104 $string = str_replace($placeholders, $characters, $string);
108 function check_answer_count($min, $answers, $text) {
109 $countanswers = count($answers);
110 if ($countanswers < $min) {
111 $importminerror = get_string('importminerror', 'quiz');
112 $this->error($importminerror, $text);
119 protected function parse_text_with_format($text, $defaultformat = FORMAT_MOODLE) {
122 'format' => $defaultformat,
125 if (strpos($text, '[') === 0) {
126 $formatend = strpos($text, ']');
127 $result['format'] = $this->format_name_to_const(substr($text, 1, $formatend - 1));
128 if ($result['format'] == -1) {
129 $result['format'] = $defaultformat;
131 $result['text'] = substr($text, $formatend + 1);
134 $result['text'] = trim($this->escapedchar_post($result['text']));
138 function readquestion($lines) {
139 // Given an array of lines known to define a question in this format, this function
140 // converts it into a question object suitable for processing and insertion into Moodle.
142 $question = $this->defaultquestion();
144 // define replaced by simple assignment, stop redefine notices
145 $gift_answerweight_regex = '/^%\-*([0-9]{1,2})\.?([0-9]*)%/';
147 // REMOVED COMMENTED LINES and IMPLODE
148 foreach ($lines as $key => $line) {
150 if (substr($line, 0, 2) == '//') {
155 $text = trim(implode(' ', $lines));
161 // Substitute escaped control characters with placeholders
162 $text = $this->escapedchar_pre($text);
164 // Look for category modifier
165 if (preg_match('~^\$CATEGORY:~', $text)) {
166 // $newcategory = $matches[1];
167 $newcategory = trim(substr($text, 10));
169 // build fake question to contain category
170 $question->qtype = 'category';
171 $question->category = $newcategory;
175 // QUESTION NAME parser
176 if (substr($text, 0, 2) == '::') {
177 $text = substr($text, 2);
179 $namefinish = strpos($text, '::');
180 if ($namefinish === false) {
181 $question->name = false;
182 // name will be assigned after processing question text below
184 $questionname = substr($text, 0, $namefinish);
185 $question->name = trim($this->escapedchar_post($questionname));
186 $text = trim(substr($text, $namefinish+2)); // Remove name from text
189 $question->name = false;
193 // FIND ANSWER section
194 // no answer means its a description
195 $answerstart = strpos($text, '{');
196 $answerfinish = strpos($text, '}');
198 $description = false;
199 if (($answerstart === false) and ($answerfinish === false)) {
203 } else if (!(($answerstart !== false) and ($answerfinish !== false))) {
204 $this->error(get_string('braceerror', 'quiz'), $text);
207 $answerlength = $answerfinish - $answerstart;
208 $answertext = trim(substr($text, $answerstart + 1, $answerlength - 1));
211 // Format QUESTION TEXT without answer, inserting "_____" as necessary
213 $questiontext = $text;
214 } else if (substr($text, -1) == "}") {
215 // no blank line if answers follow question, outside of closing punctuation
216 $questiontext = substr_replace($text, "", $answerstart, $answerlength+1);
218 // inserts blank line for missing word format
219 $questiontext = substr_replace($text, "_____", $answerstart, $answerlength+1);
222 // Get questiontext format from questiontext
223 $text = $this->parse_text_with_format($questiontext);
224 $question->questiontextformat = $text['format'];
225 $question->generalfeedbackformat = $text['format'];
226 $question->questiontext = $text['text'];
228 // set question name if not already set
229 if ($question->name === false) {
230 $question->name = $question->questiontext;
233 // ensure name is not longer than 250 characters
234 $question->name = shorten_text($question->name, 200);
235 $question->name = strip_tags(substr($question->name, 0, 250));
237 // determine QUESTION TYPE
238 $question->qtype = NULL;
240 // give plugins first try
241 // plugins must promise not to intercept standard qtypes
242 // MDL-12346, this could be called from lesson mod which has its own base class =(
243 if (method_exists($this, 'try_importing_using_qtypes') && ($try_question = $this->try_importing_using_qtypes($lines, $question, $answertext))) {
244 return $try_question;
248 $question->qtype = DESCRIPTION;
250 } else if ($answertext == '') {
251 $question->qtype = ESSAY;
253 } else if ($answertext{0} == '#') {
254 $question->qtype = NUMERICAL;
256 } else if (strpos($answertext, '~') !== false) {
257 // only Multiplechoice questions contain tilde ~
258 $question->qtype = MULTICHOICE;
260 } else if (strpos($answertext, '=') !== false
261 && strpos($answertext, '->') !== false) {
262 // only Matching contains both = and ->
263 $question->qtype = MATCH;
265 } else { // either TRUEFALSE or SHORTANSWER
267 // TRUEFALSE question check
268 $truefalse_check = $answertext;
269 if (strpos($answertext, '#') > 0) {
270 // strip comments to check for TrueFalse question
271 $truefalse_check = trim(substr($answertext, 0, strpos($answertext,"#")));
274 $valid_tf_answers = array('T', 'TRUE', 'F', 'FALSE');
275 if (in_array($truefalse_check, $valid_tf_answers)) {
276 $question->qtype = TRUEFALSE;
278 } else { // Must be SHORTANSWER
279 $question->qtype = SHORTANSWER;
283 if (!isset($question->qtype)) {
284 $giftqtypenotset = get_string('giftqtypenotset', 'quiz');
285 $this->error($giftqtypenotset, $text);
289 switch ($question->qtype) {
291 $question->defaultgrade = 0;
292 $question->length = 0;
296 $question->fraction = 0;
297 $question->feedback['text'] = '';
298 $question->feedback['format'] = $question->questiontextformat;
299 $question->feedback['files'] = array();
303 if (strpos($answertext,"=") === false) {
304 $question->single = 0; // multiple answers are enabled if no single answer is 100% correct
306 $question->single = 1; // only one answer allowed (the default)
308 $question->correctfeedback['text'] = '';
309 $question->correctfeedback['format'] = $question->questiontextformat;
310 $question->correctfeedback['files'] = array();
311 $question->partiallycorrectfeedback['text'] = '';
312 $question->partiallycorrectfeedback['format'] = $question->questiontextformat;
313 $question->partiallycorrectfeedback['files'] = array();
314 $question->incorrectfeedback['text'] = '';
315 $question->incorrectfeedback['format'] = $question->questiontextformat;
316 $question->incorrectfeedback['files'] = array();
318 $answertext = str_replace("=", "~=", $answertext);
319 $answers = explode("~", $answertext);
320 if (isset($answers[0])) {
321 $answers[0] = trim($answers[0]);
323 if (empty($answers[0])) {
324 array_shift($answers);
327 $countanswers = count($answers);
329 if (!$this->check_answer_count(2, $answers, $text)) {
334 foreach ($answers as $key => $answer) {
335 $answer = trim($answer);
337 // determine answer weight
338 if ($answer[0] == '=') {
340 $answer = substr($answer, 1);
342 } else if (preg_match($gift_answerweight_regex, $answer)) { // check for properly formatted answer weight
343 $answer_weight = $this->answerweightparser($answer);
345 } else { //default, i.e., wrong anwer
348 list($question->answer[$key], $question->feedback[$key]) =
349 $this->commentparser($answer, $question->questiontextformat);
350 $question->fraction[$key] = $answer_weight;
351 } // end foreach answer
353 //$question->defaultgrade = 1;
354 //$question->image = ""; // No images with this format
359 $answers = explode('=', $answertext);
360 if (isset($answers[0])) {
361 $answers[0] = trim($answers[0]);
363 if (empty($answers[0])) {
364 array_shift($answers);
367 if (!$this->check_answer_count(2,$answers,$text)) {
372 foreach ($answers as $key => $answer) {
373 $answer = trim($answer);
374 if (strpos($answer, "->") === false) {
375 $giftmatchingformat = get_string('giftmatchingformat','quiz');
376 $this->error($giftmatchingformat, $answer);
381 $marker = strpos($answer, '->');
382 $question->subquestions[$key] = $this->parse_text_with_format(
383 substr($answer, 0, $marker), $question->questiontextformat);
384 $question->subanswers[$key] = trim($this->escapedchar_post(
385 substr($answer, $marker + 2)));
392 list($answer, $wrongfeedback, $rightfeedback) =
393 $this->split_truefalse_comment($answertext, $question->questiontextformat);
395 if ($answer['text'] == "T" OR $answer['text'] == "TRUE") {
396 $question->correctanswer = 1;
397 $question->feedbacktrue = $rightfeedback;
398 $question->feedbackfalse = $wrongfeedback;
400 $question->correctanswer = 0;
401 $question->feedbacktrue = $wrongfeedback;
402 $question->feedbackfalse = $rightfeedback;
405 $question->penalty = 1;
411 // SHORTANSWER Question
412 $answers = explode("=", $answertext);
413 if (isset($answers[0])) {
414 $answers[0] = trim($answers[0]);
416 if (empty($answers[0])) {
417 array_shift($answers);
420 if (!$this->check_answer_count(1, $answers, $text)) {
425 foreach ($answers as $key => $answer) {
426 $answer = trim($answer);
429 if (preg_match($gift_answerweight_regex, $answer)) { // check for properly formatted answer weight
430 $answer_weight = $this->answerweightparser($answer);
431 } else { //default, i.e., full-credit anwer
435 list($answer, $question->feedback[$key]) = $this->commentparser(
436 $answer, $question->questiontextformat);
438 $question->answer[$key] = $answer['text'];
439 $question->fraction[$key] = $answer_weight;
446 // Note similarities to ShortAnswer
447 $answertext = substr($answertext, 1); // remove leading "#"
449 // If there is feedback for a wrong answer, store it for now.
450 if (($pos = strpos($answertext, '~')) !== false) {
451 $wrongfeedback = substr($answertext, $pos);
452 $answertext = substr($answertext, 0, $pos);
457 $answers = explode("=", $answertext);
458 if (isset($answers[0])) {
459 $answers[0] = trim($answers[0]);
461 if (empty($answers[0])) {
462 array_shift($answers);
465 if (count($answers) == 0) {
467 $giftnonumericalanswers = get_string('giftnonumericalanswers','quiz');
468 $this->error($giftnonumericalanswers, $text);
473 foreach ($answers as $key => $answer) {
474 $answer = trim($answer);
477 if (preg_match($gift_answerweight_regex, $answer)) { // check for properly formatted answer weight
478 $answer_weight = $this->answerweightparser($answer);
479 } else { //default, i.e., full-credit anwer
483 list($answer, $question->feedback[$key]) = $this->commentparser(
484 $answer, $question->questiontextformat);
485 $question->fraction[$key] = $answer_weight;
486 $answer = $answer['text'];
488 //Calculate Answer and Min/Max values
489 if (strpos($answer,"..") > 0) { // optional [min]..[max] format
490 $marker = strpos($answer,"..");
491 $max = trim(substr($answer, $marker+2));
492 $min = trim(substr($answer, 0, $marker));
493 $ans = ($max + $min)/2;
495 } else if (strpos($answer, ':') > 0) { // standard [answer]:[errormargin] format
496 $marker = strpos($answer, ':');
497 $tol = trim(substr($answer, $marker+1));
498 $ans = trim(substr($answer, 0, $marker));
499 } else { // only one valid answer (zero errormargin)
501 $ans = trim($answer);
504 if (!(is_numeric($ans) || $ans = '*') || !is_numeric($tol)) {
505 $errornotnumbers = get_string('errornotnumbers');
506 $this->error($errornotnumbers, $text);
512 $question->answer[$key] = $ans;
513 $question->tolerance[$key] = $tol;
516 if ($wrongfeedback) {
518 $question->fraction[$key] = 0;
519 list($notused, $question->feedback[$key]) = $this->commentparser(
520 $wrongfeedback, $question->questiontextformat);
521 $question->answer[$key] = '*';
522 $question->tolerance[$key] = '';
529 $this->error(get_string('giftnovalidquestion', 'quiz'), $text);
537 function repchar($text, $notused = 0) {
538 // Escapes 'reserved' characters # = ~ {) :
540 $reserved = array( '#', '=', '~', '{', '}', ':', "\n", "\r");
541 $escaped = array('\#','\=','\~','\{','\}','\:', '\n', '' );
543 $newtext = str_replace($reserved, $escaped, $text);
548 * @param integer $format one of the FORMAT_ constants.
549 * @return string the corresponding name.
551 function format_const_to_name($format) {
552 if ($format == FORMAT_MOODLE) {
554 } else if ($format == FORMAT_HTML) {
556 } else if ($format == FORMAT_PLAIN) {
558 } else if ($format == FORMAT_MARKDOWN) {
566 * @param integer $format one of the FORMAT_ constants.
567 * @return string the corresponding name.
569 function format_name_to_const($format) {
570 if ($format == 'moodle') {
571 return FORMAT_MOODLE;
572 } else if ($format == 'html') {
574 } else if ($format == 'plain') {
576 } else if ($format == 'markdown') {
577 return FORMAT_MARKDOWN;
583 public function write_name($name) {
584 return '::' . $this->repchar($name) . '::';
587 public function write_questiontext($text, $format, $defaultformat = FORMAT_MOODLE) {
589 if ($text != '' && $format != $defaultformat) {
590 $output .= '[' . $this->format_const_to_name($format) . ']';
592 $output .= $this->repchar($text, $format);
596 function writequestion($question) {
597 global $QTYPES, $OUTPUT;
599 // Start with a comment
600 $expout = "// question: $question->id name: $question->name\n";
602 // output depends on question type
603 switch($question->qtype) {
606 // not a real question, used to insert category switch
607 $expout .= "\$CATEGORY: $question->category\n";
611 $expout .= $this->write_name($question->name);
612 $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
616 $expout .= $this->write_name($question->name);
617 $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
622 $trueanswer = $question->options->answers[$question->options->trueanswer];
623 $falseanswer = $question->options->answers[$question->options->falseanswer];
624 if ($trueanswer->fraction == 1) {
625 $answertext = 'TRUE';
626 $rightfeedback = $this->write_questiontext($trueanswer->feedback,
627 $trueanswer->feedbackformat, $question->questiontextformat);
628 $wrongfeedback = $this->write_questiontext($falseanswer->feedback,
629 $falseanswer->feedbackformat, $question->questiontextformat);
631 $answertext = 'FALSE';
632 $rightfeedback = $this->write_questiontext($falseanswer->feedback,
633 $falseanswer->feedbackformat, $question->questiontextformat);
634 $wrongfeedback = $this->write_questiontext($trueanswer->feedback,
635 $trueanswer->feedbackformat, $question->questiontextformat);
638 $expout .= $this->write_name($question->name);
639 $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
640 $expout .= '{' . $this->repchar($answertext);
641 if ($wrongfeedback) {
642 $expout .= '#' . $wrongfeedback;
643 } else if ($rightfeedback) {
646 if ($rightfeedback) {
647 $expout .= '#' . $rightfeedback;
653 $expout .= $this->write_name($question->name);
654 $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
656 foreach($question->options->answers as $answer) {
657 if ($answer->fraction == 1) {
659 } else if ($answer->fraction == 0) {
662 $weight = $answer->fraction * 100;
663 $answertext = '~%' . $weight . '%';
665 $expout .= "\t" . $answertext . $this->write_questiontext($answer->answer,
666 $answer->answerformat, $question->questiontextformat);
667 if ($answer->feedback != '') {
668 $expout .= '#' . $this->write_questiontext($answer->feedback,
669 $answer->feedbackformat, $question->questiontextformat);
677 $expout .= $this->write_name($question->name);
678 $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
680 foreach($question->options->answers as $answer) {
681 $weight = 100 * $answer->fraction;
682 $expout .= "\t=%" . $weight . '%' . $this->repchar($answer->answer) .
683 '#' . $this->write_questiontext($answer->feedback,
684 $answer->feedbackformat, $question->questiontextformat) . "\n";
690 $expout .= $this->write_name($question->name);
691 $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
693 foreach ($question->options->answers as $answer) {
694 if ($answer->answer != '' && $answer->answer != '*') {
695 $weight = 100 * $answer->fraction;
696 $expout .= "\t=%" . $weight . '%' . $answer->answer . ':' .
697 (float)$answer->tolerance . '#' . $this->write_questiontext($answer->feedback,
698 $answer->feedbackformat, $question->questiontextformat) . "\n";
700 $expout .= "\t~#" . $this->write_questiontext($answer->feedback,
701 $answer->feedbackformat, $question->questiontextformat) . "\n";
708 $expout .= $this->write_name($question->name);
709 $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
711 foreach($question->options->subquestions as $subquestion) {
712 $expout .= "\t=" . $this->repchar($this->write_questiontext($subquestion->questiontext, $subquestion->questiontextformat, $question->questiontextformat)) .
713 ' -> ' . $this->repchar($subquestion->answertext) . "\n";
720 if ($out = $this->try_exporting_using_qtypes($question->qtype, $question)) {
723 $expout .= "Question type $question->qtype is not supported\n";
724 echo $OUTPUT->notification(get_string('nohandler', 'qformat_gift',
725 $QTYPES[$question->qtype]->local_name()));
729 // Add empty line to delimit questions