Moving quiz import/export files to new question area.
[moodle.git] / question / format / gift / format.php
CommitLineData
84769fd8 1<?php // $Id$
2//
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.
7//
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.
11// True-False:
12// Grant is buried in Grant's tomb.{FALSE}
13// Short-Answer.
14// Who's buried in Grant's tomb?{=no one =nobody}
15// Numerical
16// When was Ulysses S. Grant born?{#1822:5}
17// Matching
18// Match the following countries with their corresponding
19// capitals.{=Canada->Ottawa =Italy->Rome =Japan->Tokyo}
20//
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.
27//
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
34
35class quiz_format_gift extends quiz_default_format {
36
37 function provide_import() {
38 return true;
39 }
40
41 function provide_export() {
42 return true;
43 }
44
45 function answerweightparser(&$answer) {
46 $answer = substr($answer, 1); // removes initial %
47 $end_position = strpos($answer, "%");
48 $answer_weight = substr($answer, 0, $end_position); // gets weight as integer
49 $answer_weight = $answer_weight/100; // converts to percent
50 $answer = substr($answer, $end_position+1); // removes comment from answer
51 return $answer_weight;
52 }
53
54
55 function commentparser(&$answer) {
56 if (strpos($answer,"#") > 0){
57 $hashpos = strpos($answer,"#");
58 $comment = substr($answer, $hashpos+1);
59 $comment = addslashes(trim($this->escapedchar_post($comment)));
60 $answer = substr($answer, 0, $hashpos);
61 } else {
62 $comment = " ";
63 }
64 return $comment;
65 }
66
67 function split_truefalse_comment($comment){
68 // splits up comment around # marks
69 // returns an array of true/false feedback
70 $feedback = explode('#',$comment);
71 if (count($feedback)>=2) {
72 $true_feedback = $feedback[0];
73 $false_feedback = $feedback[1];
74 }
75 else {
76 $true_feedback = $feedback[0];
77 $false_feedback = '';
78 }
79 return array( 'true'=>$true_feedback, 'false'=>$false_feedback );
80 }
81
82 function escapedchar_pre($string) {
83 //Replaces escaped control characters with a placeholder BEFORE processing
84
85 $escapedcharacters = array("\\#", "\\=", "\\{", "\\}", "\\~", "\\n" ); //dlnsk
86 $placeholders = array("&&035;", "&&061;", "&&123;", "&&125;", "&&126;", "&&010" ); //dlnsk
87
88 $string = str_replace("\\\\", "&&092;", $string);
89 $string = str_replace($escapedcharacters, $placeholders, $string);
90 $string = str_replace("&&092;", "\\", $string);
91 return $string;
92 }
93
94 function escapedchar_post($string) {
95 //Replaces placeholders with corresponding character AFTER processing is done
96 $placeholders = array("&&035;", "&&061;", "&&123;", "&&125;", "&&126;", "&&010"); //dlnsk
97 $characters = array("#", "=", "{", "}", "~", "\n" ); //dlnsk
98 $string = str_replace($placeholders, $characters, $string);
99 return $string;
100 }
101
102 function check_answer_count( $min, $answers, $text ) {
103 $countanswers = count($answers);
104 if ($countanswers < $min) {
105 if ($this->displayerrors) {
106 $errormessage = get_string( 'importminerror', 'quiz' );
107 echo "<p>$text</p>\n";
108 echo "<p>$errormessage</p>\n";
109 }
110 return false;
111 }
112
113 return true;
114 }
115
116
117 function readquestion($lines) {
118 // Given an array of lines known to define a question in this format, this function
119 // converts it into a question object suitable for processing and insertion into Moodle.
120
121 $question = $this->defaultquestion();
122 $comment = NULL;
123 // define replaced by simple assignment, stop redefine notices
124 $gift_answerweight_regex = "^%\-*([0-9]{1,2})\.?([0-9]*)%";
125
126 // REMOVED COMMENTED LINES and IMPLODE
127 foreach ($lines as $key => $line) {
128 $line = trim($line);
129 if (substr($line, 0, 2) == "//") {
130 // echo "Commented line removed.<br />";
131 $lines[$key] = " ";
132 }
133 }
134
135 $text = trim(implode(" ", $lines));
136
137 if ($text == "") {
138 // echo "<p>Empty line.</p>";
139 return false;
140 }
141
142 // Substitute escaped control characters with placeholders
143 $text = $this->escapedchar_pre($text);
144
145 // QUESTION NAME parser
146 if (substr($text, 0, 2) == "::") {
147 $text = substr($text, 2);
148
149 $namefinish = strpos($text, "::");
150 if ($namefinish === false) {
151 $question->name = false;
152 // name will be assigned after processing question text below
153 } else {
154 $questionname = substr($text, 0, $namefinish);
155 $question->name = addslashes(trim($this->escapedchar_post($questionname)));
156 $text = trim(substr($text, $namefinish+2)); // Remove name from text
157 }
158 } else {
159 $question->name = false;
160 }
161
162
163 // FIND ANSWER section
164 $answerstart = strpos($text, "{");
165 if ($answerstart === false) {
166 if ($this->displayerrors) {
167 echo "<p>$text<p>Could not find a {";
168 }
169 return false;
170 }
171
172 $answerfinish = strpos($text, "}");
173 if ($answerfinish === false) {
174 if ($this->displayerrors) {
175 echo "<p>$text<p>Could not find a }";
176 }
177 return false;
178 }
179
180 $answerlength = $answerfinish - $answerstart;
181 $answertext = trim(substr($text, $answerstart + 1, $answerlength - 1));
182
183 // Format QUESTION TEXT without answer, inserting "_____" as necessary
184 if (substr($text, -1) == "}") {
185 // no blank line if answers follow question, outside of closing punctuation
186 $questiontext = substr_replace($text, "", $answerstart, $answerlength+1);
187 } else {
188 // inserts blank line for missing word format
189 $questiontext = substr_replace($text, "_____", $answerstart, $answerlength+1);
190 }
191
192 // get questiontext format from questiontext
193 $oldquestiontext = $questiontext;
194 $questiontextformat = 0;
195 if (substr($questiontext,0,1)=='[') {
196 $questiontext = substr( $questiontext,1 );
197 $rh_brace = strpos( $questiontext, ']' );
198 $qtformat= substr( $questiontext, 0, $rh_brace );
199 $questiontext = substr( $questiontext, $rh_brace+1 );
200 if (!$questiontextformat = text_format_name( $qtformat )) {
201 $questiontext = $oldquestiontext;
202 }
203 }
204 $question->questiontextformat = $questiontextformat;
205 $question->questiontext = addslashes(trim($this->escapedchar_post($questiontext)));
206
207 // set question name if not already set
208 if ($question->name === false) {
209 $question->name = $question->questiontext;
210 }
211
212
213 // determine QUESTION TYPE
214 $question->qtype = NULL;
215
216 if ($answertext{0} == "#"){
217 $question->qtype = NUMERICAL;
218
219 } elseif (strpos($answertext, "~") !== false) {
220 // only Multiplechoice questions contain tilde ~
221 $question->qtype = MULTICHOICE;
222
223 } elseif (strpos($answertext, "=") !== false
224 AND strpos($answertext, "->") !== false) {
225 // only Matching contains both = and ->
226 $question->qtype = MATCH;
227
228 } else { // either TRUEFALSE or SHORTANSWER
229
230 // TRUEFALSE question check
231 $truefalse_check = $answertext;
232 if (strpos($answertext,"#") > 0){
233 // strip comments to check for TrueFalse question
234 $truefalse_check = trim(substr($answertext, 0, strpos($answertext,"#")));
235 }
236
237 $valid_tf_answers = array("T", "TRUE", "F", "FALSE");
238 if (in_array($truefalse_check, $valid_tf_answers)) {
239 $question->qtype = TRUEFALSE;
240
241 } else { // Must be SHORTANSWER
242 $question->qtype = SHORTANSWER;
243 }
244 }
245
246 if (!isset($question->qtype)) {
247 if ($this->displayerrors) {
248 echo "<p>$text<p>Question type not set.";
249 }
250 return false;
251 }
252
253 switch ($question->qtype) {
254 case MULTICHOICE:
255 if (strpos($answertext,"=") === false) {
256 $question->single = 0; // multiple answers are enabled if no single answer is 100% correct
257 } else {
258 $question->single = 1; // only one answer allowed (the default)
259 }
260
261 $answertext = str_replace("=", "~=", $answertext);
262 $answers = explode("~", $answertext);
263 if (isset($answers[0])) {
264 $answers[0] = trim($answers[0]);
265 }
266 if (empty($answers[0])) {
267 array_shift($answers);
268 }
269
270 $countanswers = count($answers);
271
272 if (!$this->check_answer_count( 2,$answers,$text )) {
273 return false;
274 break;
275 }
276
277 foreach ($answers as $key => $answer) {
278 $answer = trim($answer);
279
280 // determine answer weight
281 if ($answer[0] == "=") {
282 $answer_weight = 1;
283 $answer = substr($answer, 1);
284
285 } elseif (ereg($gift_answerweight_regex, $answer)) { // check for properly formatted answer weight
286 $answer_weight = $this->answerweightparser($answer);
287
288 } else { //default, i.e., wrong anwer
289 $answer_weight = 0;
290 }
291 $question->fraction[$key] = $answer_weight;
292 $question->feedback[$key] = $this->commentparser($answer); // commentparser also removes comment from $answer
293 $question->answer[$key] = addslashes($this->escapedchar_post($answer));
294 } // end foreach answer
295
296 //$question->defaultgrade = 1;
297 //$question->image = ""; // No images with this format
298 return $question;
299 break;
300
301 case MATCH:
302 $answers = explode("=", $answertext);
303 if (isset($answers[0])) {
304 $answers[0] = trim($answers[0]);
305 }
306 if (empty($answers[0])) {
307 array_shift($answers);
308 }
309
310 if (!$this->check_answer_count( 2,$answers,$text )) {
311 return false;
312 break;
313 }
314
315 foreach ($answers as $key => $answer) {
316 $answer = trim($answer);
317 if (strpos($answer, "->") <= 0) {
318 if ($this->displayerrors) {
319 echo "<p>$text<p>Error processing Matching question.<br />
320 Improperly formatted answer: $answer";
321 }
322 return false;
323 break 2;
324 }
325
326 $marker = strpos($answer,"->");
327 $question->subquestions[$key] = addslashes(trim($this->escapedchar_post(substr($answer, 0, $marker))));
328 $question->subanswers[$key] = addslashes(trim($this->escapedchar_post(substr($answer, $marker+2))));
329
330 } // end foreach answer
331
332 //$question->defaultgrade = 1;
333 //$question->image = ""; // No images with this format
334 return $question;
335 break;
336
337 case TRUEFALSE:
338 $answer = $answertext;
339 $comment = $this->commentparser($answer); // commentparser also removes comment from $answer
340 $feedback = $this->split_truefalse_comment( $comment );
341
342 if ($answer == "T" OR $answer == "TRUE") {
343 $question->answer = 1;
344 $question->feedbackfalse = $feedback['true'];; //feedback if answer is wrong
345 $question->feedbacktrue = $feedback['false']; // make sure this exists to stop notifications
346 } else {
347 $question->answer = 0;
348 $question->feedbacktrue = $feedback['true']; //feedback if answer is wrong
349 $question->feedbackfalse = $feedback['false']; // make sure this exists to stop notifications
350 }
351
352 //$question->defaultgrade = 1;
353 //$question->image = ""; // No images with this format
354 return $question;
355 break;
356
357 case SHORTANSWER:
358 // SHORTANSWER Question
359 $answers = explode("=", $answertext);
360 if (isset($answers[0])) {
361 $answers[0] = trim($answers[0]);
362 }
363 if (empty($answers[0])) {
364 array_shift($answers);
365 }
366
367 if (!$this->check_answer_count( 1,$answers,$text )) {
368 return false;
369 break;
370 }
371
372 foreach ($answers as $key => $answer) {
373 $answer = trim($answer);
374
375 // Answer Weight
376 if (ereg($gift_answerweight_regex, $answer)) { // check for properly formatted answer weight
377 $answer_weight = $this->answerweightparser($answer);
378 } else { //default, i.e., full-credit anwer
379 $answer_weight = 1;
380 }
381 $question->fraction[$key] = $answer_weight;
382 $question->feedback[$key] = $this->commentparser($answer); //commentparser also removes comment from $answer
383 $question->answer[$key] = addslashes($this->escapedchar_post($answer));
384 } // end foreach
385
386 //$question->usecase = 0; // Ignore case
387 //$question->defaultgrade = 1;
388 //$question->image = ""; // No images with this format
389 return $question;
390 break;
391
392 case NUMERICAL:
393 // Note similarities to ShortAnswer
394 $answertext = substr($answertext, 1); // remove leading "#"
395
396 $answers = explode("=", $answertext);
397 if (isset($answers[0])) {
398 $answers[0] = trim($answers[0]);
399 }
400 if (empty($answers[0])) {
401 array_shift($answers);
402 }
403
404 if (count($answers) == 0) {
405 // invalid question
406 if ($this->displayerrors) {
407 echo "<p>$text<p>No answers found in answertext (Numerical answer)";
408 }
409 return false;
410 break;
411 }
412
413 foreach ($answers as $key => $answer) {
414 $answer = trim($answer);
415
416 // Answer weight
417 if (ereg($gift_answerweight_regex, $answer)) { // check for properly formatted answer weight
418 $answer_weight = $this->answerweightparser($answer);
419 } else { //default, i.e., full-credit anwer
420 $answer_weight = 1;
421 }
422 $question->fraction[$key] = $answer_weight;
423 $question->feedback[$key] = $this->commentparser($answer); //commentparser also removes comment from $answer
424
425 //Calculate Answer and Min/Max values
426 if (strpos($answer,"..") > 0) { // optional [min]..[max] format
427 $marker = strpos($answer,"..");
428 $max = trim(substr($answer, $marker+2));
429 $min = trim(substr($answer, 0, $marker));
430 $ans = ($max + $min)/2;
431 $tol = $max - $ans;
432 } elseif (strpos($answer,":") > 0){ // standard [answer]:[errormargin] format
433 $marker = strpos($answer,":");
434 $tol = trim(substr($answer, $marker+1));
435 $ans = trim(substr($answer, 0, $marker));
436 } else { // only one valid answer (zero errormargin)
437 $tol = 0;
438 $ans = trim($answer);
439 }
440
441 if (!is_numeric($ans)
442 OR !is_numeric($tol)) {
443 if ($this->displayerrors) {
444 $err = get_string( 'errornotnumbers' );
445 echo "<p>$text</p><p>$err</p>
446 <p>Answer: <u>$answer</u></p><p>Tolerance: <u>$tol</u></p> ";
447 }
448 return false;
449 break;
450 }
451
452 // store results
453 $question->answer[$key] = $ans;
454 $question->tolerance[$key] = $tol;
455 } // end foreach
456
457 //$question->defaultgrade = 1;
458 //$question->image = ""; // No images with this format
459 //$question->multiplier = array(); // no numeric multipliers with GIFT
460 return $question;
461 break;
462
463 default:
464 if ($this->displayerrors) {
465 echo "<p>$text<p> No valid question type. Error in switch(question->qtype)";
466 }
467 return false;
468 break;
469
470 } // end switch ($question->qtype)
471
472 } // end function readquestion($lines)
473
474function repchar( $text, $format=0 ) {
475 // escapes 'reserved' characters # = ~ { ) and removes new lines
476 // also pushes text through format routine
477 $reserved = array( '#', '=', '~', '{', '}', "\n","\r");
478 $escaped = array( '\#','\=','\~','\{','\}','\n','' ); //dlnsk
479
480 $newtext = str_replace( $reserved, $escaped, $text );
481 $format = 0; // turn this off for now
482 if ($format) {
483 $newtext = format_text( $format );
484 }
485 return $newtext;
486 }
487
488function writequestion( $question ) {
489 // turns question into string
490 // question reflects database fields for general question and specific to type
491
492 // initial string;
493 $expout = "";
494
495 // add comment
496 $expout .= "// question: $question->id name: $question->name \n";
497
498 // get question text format
499 $textformat = $question->questiontextformat;
500 $tfname = "";
501 if ($textformat!=FORMAT_MOODLE) {
502 $tfname = text_format_name( (int)$textformat );
503 $tfname = "[$tfname]";
504 }
505
506 // output depends on question type
507 switch($question->qtype) {
508 case TRUEFALSE:
509 $answers = $question->options->answers;
510 if ($answers['true']->fraction==1) {
511 $answertext = 'TRUE';
512 $wrong_feedback = $this->repchar( $answers['false']->feedback );
513 $right_feedback = $this->repchar( $answers['true']->feedback );
514 }
515 else {
516 $answertext = 'FALSE';
517 $wrong_feedback = $this->repchar( $answers['true']->feedback );
518 $right_feedback = $this->repchar( $answers['false']->feedback );
519 }
520 $expout .= "::".$question->name."::".$tfname.$this->repchar( $question->questiontext,$textformat )."{".$this->repchar( $answertext );
521 if ($wrong_feedback!="") {
522 $expout .= "#".$wrong_feedback;
523 }
524 if ($right_feedback!="") {
525 $expout .= "#".$right_feedback;
526 }
527 $expout .= "}\n";
528 break;
529 case MULTICHOICE:
530 $expout .= "::".$question->name."::".$tfname.$this->repchar( $question->questiontext, $textformat )."{\n";
531 foreach($question->options->answers as $answer) {
532 if ($answer->fraction==1) {
533 $answertext = '=';
534 }
535 elseif ($answer->fraction==0) {
536 $answertext = '~';
537 }
538 else {
539 $export_weight = $answer->fraction*100;
540 $answertext = "~%$export_weight%";
541 }
542 $expout .= "\t".$answertext.$this->repchar( $answer->answer );
543 if ($answer->feedback!="") {
544 $expout .= "#".$this->repchar( $answer->feedback );
545 }
546 $expout .= "\n";
547 }
548 $expout .= "}\n";
549 break;
550 case SHORTANSWER:
551 $expout .= "::".$question->name."::".$tfname.$this->repchar( $question->questiontext, $textformat )."{\n";
552 foreach($question->options->answers as $answer) {
553 $weight = 100 * $answer->fraction;
554 $expout .= "\t=%".$weight."%".$this->repchar( $answer->answer )."#".$this->repchar( $answer->feedback )."\n";
555 }
556 $expout .= "}\n";
557 break;
558 case NUMERICAL:
559 $answer = array_pop( $question->options->answers );
560 $tolerance = $answer->tolerance;
561 $min = $answer->answer - $tolerance;
562 $max = $answer->answer + $tolerance;
563 $expout .= "::".$question->name."::".$tfname.$this->repchar( $question->questiontext, $textformat )."{\n";
564 $expout .= "\t#".$min."..".$max."#".$this->repchar( $answer->feedback )."\n";
565 $expout .= "}\n";
566 break;
567 case MATCH:
568 $expout .= "::".$question->name."::".$tfname.$this->repchar( $question->questiontext, $textformat )."{\n";
569 foreach($question->options->subquestions as $subquestion) {
570 $expout .= "\t=".$this->repchar( $subquestion->questiontext )." -> ".$this->repchar( $subquestion->answertext )."\n";
571 }
572 $expout .= "}\n";
573 break;
574 case DESCRIPTION:
575 $expout .= "// DESCRIPTION type is not supported\n";
576 break;
577 case MULTIANSWER:
578 $expout .= "// CLOZE type is not supported\n";
579 break;
580 default:
581 notify("No handler for qtype $question->qtype for GIFT export" );
582 }
583 // add empty line to delimit questions
584 $expout .= "\n";
585 return $expout;
586}
587}
588?>