MDL-7969 Documented requirement for first column in get_records_sql etc to be a uniqu...
[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
f5565b69 35class qformat_gift extends qformat_default {
84769fd8 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
dfdce7fb 85 $escapedcharacters = array("\\:", "\\#", "\\=", "\\{", "\\}", "\\~", "\\n" ); //dlnsk
86 $placeholders = array("&&058;", "&&035;", "&&061;", "&&123;", "&&125;", "&&126;", "&&010" ); //dlnsk
84769fd8 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
dfdce7fb 96 $placeholders = array("&&058;", "&&035;", "&&061;", "&&123;", "&&125;", "&&126;", "&&010"); //dlnsk
97 $characters = array(":", "#", "=", "{", "}", "~", "\n" ); //dlnsk
84769fd8 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
b39c7aad 145 // Look for category modifier
6575b04f 146 if (ereg( '^\$CATEGORY: *([A-Za-z0-9/]+)[[:space:]]', $text, $matches)) {
b39c7aad 147 $newcategory = $matches[1];
148
149 // build fake question to contain category
150 $question->qtype = 'category';
151 $question->category = $newcategory;
152 return $question;
153 }
154
84769fd8 155 // QUESTION NAME parser
156 if (substr($text, 0, 2) == "::") {
157 $text = substr($text, 2);
158
159 $namefinish = strpos($text, "::");
160 if ($namefinish === false) {
161 $question->name = false;
162 // name will be assigned after processing question text below
163 } else {
164 $questionname = substr($text, 0, $namefinish);
165 $question->name = addslashes(trim($this->escapedchar_post($questionname)));
166 $text = trim(substr($text, $namefinish+2)); // Remove name from text
167 }
168 } else {
169 $question->name = false;
170 }
171
172
173 // FIND ANSWER section
174 $answerstart = strpos($text, "{");
175 if ($answerstart === false) {
176 if ($this->displayerrors) {
177 echo "<p>$text<p>Could not find a {";
178 }
179 return false;
180 }
181
182 $answerfinish = strpos($text, "}");
183 if ($answerfinish === false) {
184 if ($this->displayerrors) {
185 echo "<p>$text<p>Could not find a }";
186 }
187 return false;
188 }
189
190 $answerlength = $answerfinish - $answerstart;
191 $answertext = trim(substr($text, $answerstart + 1, $answerlength - 1));
192
193 // Format QUESTION TEXT without answer, inserting "_____" as necessary
194 if (substr($text, -1) == "}") {
195 // no blank line if answers follow question, outside of closing punctuation
196 $questiontext = substr_replace($text, "", $answerstart, $answerlength+1);
197 } else {
198 // inserts blank line for missing word format
199 $questiontext = substr_replace($text, "_____", $answerstart, $answerlength+1);
200 }
201
202 // get questiontext format from questiontext
203 $oldquestiontext = $questiontext;
204 $questiontextformat = 0;
205 if (substr($questiontext,0,1)=='[') {
206 $questiontext = substr( $questiontext,1 );
207 $rh_brace = strpos( $questiontext, ']' );
208 $qtformat= substr( $questiontext, 0, $rh_brace );
209 $questiontext = substr( $questiontext, $rh_brace+1 );
210 if (!$questiontextformat = text_format_name( $qtformat )) {
211 $questiontext = $oldquestiontext;
212 }
213 }
214 $question->questiontextformat = $questiontextformat;
215 $question->questiontext = addslashes(trim($this->escapedchar_post($questiontext)));
216
217 // set question name if not already set
218 if ($question->name === false) {
219 $question->name = $question->questiontext;
220 }
221
8daaaafc 222 // ensure name is not longer than 250 characters
223 $question->name = shorten_text( $question->name, 250 );
84769fd8 224
225 // determine QUESTION TYPE
226 $question->qtype = NULL;
227
228 if ($answertext{0} == "#"){
229 $question->qtype = NUMERICAL;
230
231 } elseif (strpos($answertext, "~") !== false) {
232 // only Multiplechoice questions contain tilde ~
233 $question->qtype = MULTICHOICE;
234
235 } elseif (strpos($answertext, "=") !== false
236 AND strpos($answertext, "->") !== false) {
237 // only Matching contains both = and ->
238 $question->qtype = MATCH;
239
240 } else { // either TRUEFALSE or SHORTANSWER
241
242 // TRUEFALSE question check
243 $truefalse_check = $answertext;
244 if (strpos($answertext,"#") > 0){
245 // strip comments to check for TrueFalse question
246 $truefalse_check = trim(substr($answertext, 0, strpos($answertext,"#")));
247 }
248
249 $valid_tf_answers = array("T", "TRUE", "F", "FALSE");
250 if (in_array($truefalse_check, $valid_tf_answers)) {
251 $question->qtype = TRUEFALSE;
252
253 } else { // Must be SHORTANSWER
254 $question->qtype = SHORTANSWER;
255 }
256 }
257
258 if (!isset($question->qtype)) {
259 if ($this->displayerrors) {
260 echo "<p>$text<p>Question type not set.";
261 }
262 return false;
263 }
264
265 switch ($question->qtype) {
266 case MULTICHOICE:
267 if (strpos($answertext,"=") === false) {
268 $question->single = 0; // multiple answers are enabled if no single answer is 100% correct
269 } else {
270 $question->single = 1; // only one answer allowed (the default)
271 }
272
273 $answertext = str_replace("=", "~=", $answertext);
274 $answers = explode("~", $answertext);
275 if (isset($answers[0])) {
276 $answers[0] = trim($answers[0]);
277 }
278 if (empty($answers[0])) {
279 array_shift($answers);
280 }
281
282 $countanswers = count($answers);
283
2befe778 284 if (!$this->check_answer_count( 2,$answers,$text )) {
285 return false;
286 break;
84769fd8 287 }
288
289 foreach ($answers as $key => $answer) {
290 $answer = trim($answer);
291
292 // determine answer weight
293 if ($answer[0] == "=") {
294 $answer_weight = 1;
295 $answer = substr($answer, 1);
296
297 } elseif (ereg($gift_answerweight_regex, $answer)) { // check for properly formatted answer weight
298 $answer_weight = $this->answerweightparser($answer);
299
300 } else { //default, i.e., wrong anwer
301 $answer_weight = 0;
302 }
303 $question->fraction[$key] = $answer_weight;
304 $question->feedback[$key] = $this->commentparser($answer); // commentparser also removes comment from $answer
172f6d95 305 $question->answer[$key] = addslashes($this->escapedchar_post($answer));
306 $question->correctfeedback = '';
307 $question->partiallycorrectfeedback = '';
308 $question->incorrectfeedback = '';
84769fd8 309 } // end foreach answer
310
311 //$question->defaultgrade = 1;
312 //$question->image = ""; // No images with this format
313 return $question;
314 break;
315
316 case MATCH:
317 $answers = explode("=", $answertext);
318 if (isset($answers[0])) {
319 $answers[0] = trim($answers[0]);
320 }
321 if (empty($answers[0])) {
322 array_shift($answers);
323 }
324
2befe778 325 if (!$this->check_answer_count( 2,$answers,$text )) {
326 return false;
327 break;
84769fd8 328 }
329
330 foreach ($answers as $key => $answer) {
331 $answer = trim($answer);
87ee4968 332 if (strpos($answer, "->") === false) {
84769fd8 333 if ($this->displayerrors) {
334 echo "<p>$text<p>Error processing Matching question.<br />
335 Improperly formatted answer: $answer";
336 }
337 return false;
338 break 2;
339 }
340
341 $marker = strpos($answer,"->");
342 $question->subquestions[$key] = addslashes(trim($this->escapedchar_post(substr($answer, 0, $marker))));
343 $question->subanswers[$key] = addslashes(trim($this->escapedchar_post(substr($answer, $marker+2))));
344
345 } // end foreach answer
346
347 //$question->defaultgrade = 1;
348 //$question->image = ""; // No images with this format
349 return $question;
350 break;
351
352 case TRUEFALSE:
353 $answer = $answertext;
354 $comment = $this->commentparser($answer); // commentparser also removes comment from $answer
355 $feedback = $this->split_truefalse_comment( $comment );
356
357 if ($answer == "T" OR $answer == "TRUE") {
358 $question->answer = 1;
359 $question->feedbackfalse = $feedback['true'];; //feedback if answer is wrong
360 $question->feedbacktrue = $feedback['false']; // make sure this exists to stop notifications
361 } else {
362 $question->answer = 0;
363 $question->feedbacktrue = $feedback['true']; //feedback if answer is wrong
364 $question->feedbackfalse = $feedback['false']; // make sure this exists to stop notifications
365 }
366
367 //$question->defaultgrade = 1;
368 //$question->image = ""; // No images with this format
369 return $question;
370 break;
371
372 case SHORTANSWER:
373 // SHORTANSWER Question
374 $answers = explode("=", $answertext);
375 if (isset($answers[0])) {
376 $answers[0] = trim($answers[0]);
377 }
378 if (empty($answers[0])) {
379 array_shift($answers);
380 }
381
2befe778 382 if (!$this->check_answer_count( 1,$answers,$text )) {
383 return false;
384 break;
84769fd8 385 }
386
387 foreach ($answers as $key => $answer) {
388 $answer = trim($answer);
389
390 // Answer Weight
391 if (ereg($gift_answerweight_regex, $answer)) { // check for properly formatted answer weight
392 $answer_weight = $this->answerweightparser($answer);
393 } else { //default, i.e., full-credit anwer
394 $answer_weight = 1;
395 }
396 $question->fraction[$key] = $answer_weight;
397 $question->feedback[$key] = $this->commentparser($answer); //commentparser also removes comment from $answer
398 $question->answer[$key] = addslashes($this->escapedchar_post($answer));
399 } // end foreach
400
401 //$question->usecase = 0; // Ignore case
402 //$question->defaultgrade = 1;
403 //$question->image = ""; // No images with this format
404 return $question;
405 break;
406
407 case NUMERICAL:
408 // Note similarities to ShortAnswer
409 $answertext = substr($answertext, 1); // remove leading "#"
410
1fe641f7 411 // If there is feedback for a wrong answer, store it for now.
412 if (($pos = strpos($answertext, '~')) !== false) {
413 $wrongfeedback = substr($answertext, $pos);
414 $answertext = substr($answertext, 0, $pos);
415 } else {
416 $wrongfeedback = '';
417 }
418
84769fd8 419 $answers = explode("=", $answertext);
420 if (isset($answers[0])) {
421 $answers[0] = trim($answers[0]);
422 }
423 if (empty($answers[0])) {
424 array_shift($answers);
425 }
426
427 if (count($answers) == 0) {
428 // invalid question
429 if ($this->displayerrors) {
430 echo "<p>$text<p>No answers found in answertext (Numerical answer)";
431 }
432 return false;
433 break;
434 }
435
436 foreach ($answers as $key => $answer) {
437 $answer = trim($answer);
438
439 // Answer weight
440 if (ereg($gift_answerweight_regex, $answer)) { // check for properly formatted answer weight
441 $answer_weight = $this->answerweightparser($answer);
442 } else { //default, i.e., full-credit anwer
443 $answer_weight = 1;
444 }
445 $question->fraction[$key] = $answer_weight;
446 $question->feedback[$key] = $this->commentparser($answer); //commentparser also removes comment from $answer
447
448 //Calculate Answer and Min/Max values
449 if (strpos($answer,"..") > 0) { // optional [min]..[max] format
450 $marker = strpos($answer,"..");
451 $max = trim(substr($answer, $marker+2));
452 $min = trim(substr($answer, 0, $marker));
453 $ans = ($max + $min)/2;
454 $tol = $max - $ans;
455 } elseif (strpos($answer,":") > 0){ // standard [answer]:[errormargin] format
456 $marker = strpos($answer,":");
457 $tol = trim(substr($answer, $marker+1));
458 $ans = trim(substr($answer, 0, $marker));
459 } else { // only one valid answer (zero errormargin)
460 $tol = 0;
461 $ans = trim($answer);
462 }
463
464 if (!is_numeric($ans)
465 OR !is_numeric($tol)) {
466 if ($this->displayerrors) {
467 $err = get_string( 'errornotnumbers' );
468 echo "<p>$text</p><p>$err</p>
469 <p>Answer: <u>$answer</u></p><p>Tolerance: <u>$tol</u></p> ";
470 }
471 return false;
472 break;
473 }
474
475 // store results
476 $question->answer[$key] = $ans;
477 $question->tolerance[$key] = $tol;
478 } // end foreach
479
1fe641f7 480 if ($wrongfeedback) {
481 $key += 1;
482 $question->fraction[$key] = 0;
483 $question->feedback[$key] = $this->commentparser($wrongfeedback);
484 $question->answer[$key] = '';
485 $question->tolerance[$key] = '';
486 }
487
84769fd8 488 //$question->defaultgrade = 1;
489 //$question->image = ""; // No images with this format
490 //$question->multiplier = array(); // no numeric multipliers with GIFT
491 return $question;
492 break;
493
494 default:
495 if ($this->displayerrors) {
496 echo "<p>$text<p> No valid question type. Error in switch(question->qtype)";
497 }
498 return false;
499 break;
500
501 } // end switch ($question->qtype)
502
503 } // end function readquestion($lines)
504
505function repchar( $text, $format=0 ) {
dfdce7fb 506 // escapes 'reserved' characters # = ~ { ) : and removes new lines
84769fd8 507 // also pushes text through format routine
dfdce7fb 508 $reserved = array( '#', '=', '~', '{', '}', ':', "\n","\r");
509 $escaped = array( '\#','\=','\~','\{','\}','\:','\n','' ); //dlnsk
84769fd8 510
511 $newtext = str_replace( $reserved, $escaped, $text );
512 $format = 0; // turn this off for now
513 if ($format) {
514 $newtext = format_text( $format );
515 }
516 return $newtext;
517 }
518
519function writequestion( $question ) {
520 // turns question into string
521 // question reflects database fields for general question and specific to type
522
523 // initial string;
524 $expout = "";
525
526 // add comment
527 $expout .= "// question: $question->id name: $question->name \n";
528
529 // get question text format
530 $textformat = $question->questiontextformat;
531 $tfname = "";
532 if ($textformat!=FORMAT_MOODLE) {
533 $tfname = text_format_name( (int)$textformat );
534 $tfname = "[$tfname]";
535 }
536
537 // output depends on question type
538 switch($question->qtype) {
539 case TRUEFALSE:
540 $answers = $question->options->answers;
36e2232e 541 foreach ($answers as $answer) {
542 if (trim($answer->answer)=='True') {
543 if ($answer->fraction==1) {
544 $answertext = 'TRUE';
545 $right_feedback = $answer->feedback;
546 }
547 else {
548 $answertext = 'FALSE';
549 $wrong_feedback = $answer->feedback;
550 }
551 }
552 else {
553 if ($answer->fraction==1) {
554 $answertext = 'FALSE';
555 $right_feedback = $answer->feedback;
556 }
557 else {
558 $answertext = 'TRUE';
559 $wrong_feedback = $answer->feedback;
560 }
561 }
84769fd8 562 }
36e2232e 563
564 $wrong_feedback = $this->repchar( $wrong_feedback );
565 $right_feedback = $this->repchar( $right_feedback );
dfdce7fb 566 $expout .= "::".$this->repchar($question->name)."::".$tfname.$this->repchar( $question->questiontext,$textformat );
567 $expout .= "{".$this->repchar( $answertext );
84769fd8 568 if ($wrong_feedback!="") {
569 $expout .= "#".$wrong_feedback;
570 }
571 if ($right_feedback!="") {
572 $expout .= "#".$right_feedback;
573 }
574 $expout .= "}\n";
575 break;
576 case MULTICHOICE:
dfdce7fb 577 $expout .= "::".$this->repchar($question->name)."::".$tfname.$this->repchar( $question->questiontext, $textformat )."{\n";
84769fd8 578 foreach($question->options->answers as $answer) {
579 if ($answer->fraction==1) {
580 $answertext = '=';
581 }
582 elseif ($answer->fraction==0) {
583 $answertext = '~';
584 }
585 else {
586 $export_weight = $answer->fraction*100;
587 $answertext = "~%$export_weight%";
588 }
589 $expout .= "\t".$answertext.$this->repchar( $answer->answer );
590 if ($answer->feedback!="") {
591 $expout .= "#".$this->repchar( $answer->feedback );
592 }
593 $expout .= "\n";
594 }
595 $expout .= "}\n";
596 break;
597 case SHORTANSWER:
dfdce7fb 598 $expout .= "::".$this->repchar($question->name)."::".$tfname.$this->repchar( $question->questiontext, $textformat )."{\n";
84769fd8 599 foreach($question->options->answers as $answer) {
600 $weight = 100 * $answer->fraction;
601 $expout .= "\t=%".$weight."%".$this->repchar( $answer->answer )."#".$this->repchar( $answer->feedback )."\n";
602 }
603 $expout .= "}\n";
604 break;
605 case NUMERICAL:
dfdce7fb 606 $expout .= "::".$this->repchar($question->name)."::".$tfname.$this->repchar( $question->questiontext, $textformat )."{#\n";
1fe641f7 607 foreach ($question->options->answers as $answer) {
1fe641f7 608 if ($answer->answer != '') {
609 $expout .= "\t=".$answer->answer.":".(float)$answer->tolerance."#".$this->repchar( $answer->feedback )."\n";
610 } else {
611 $expout .= "\t~#".$this->repchar( $answer->feedback )."\n";
612 }
613 }
84769fd8 614 $expout .= "}\n";
615 break;
616 case MATCH:
dfdce7fb 617 $expout .= "::".$this->repchar($question->name)."::".$tfname.$this->repchar( $question->questiontext, $textformat )."{\n";
84769fd8 618 foreach($question->options->subquestions as $subquestion) {
619 $expout .= "\t=".$this->repchar( $subquestion->questiontext )." -> ".$this->repchar( $subquestion->answertext )."\n";
620 }
621 $expout .= "}\n";
622 break;
623 case DESCRIPTION:
624 $expout .= "// DESCRIPTION type is not supported\n";
625 break;
626 case MULTIANSWER:
627 $expout .= "// CLOZE type is not supported\n";
628 break;
629 default:
630 notify("No handler for qtype $question->qtype for GIFT export" );
631 }
632 // add empty line to delimit questions
633 $expout .= "\n";
634 return $expout;
635}
636}
637?>