6b563c70530f15db9d0a21c7f89b2c3e3ebc839b
[moodle.git] / mod / lesson / format.php
1 <?php
3 // This file is part of Moodle - http://moodle.org/
4 //
5 // Moodle is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
9 //
10 // Moodle is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 // GNU General Public License for more details.
14 //
15 // You should have received a copy of the GNU General Public License
16 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
18 /**
19  * format.php  - Default format class for file imports/exports. Doesn't do
20  * everything on it's own -- it needs to be extended.
21  *
22  * Included by import.ph
23  *
24  * @package mod_lesson
25  * @copyright  1999 onwards Martin Dougiamas  {@link http://moodle.com}
26  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
27  **/
29 defined('MOODLE_INTERNAL') || die();
31 /**
32  * Given some question info and some data about the the answers
33  * this function parses, organises and saves the question
34  *
35  * This is only used when IMPORTING questions and is only called
36  * from format.php
37  * Lifted from mod/quiz/lib.php -
38  *    1. all reference to oldanswers removed
39  *    2. all reference to quiz_multichoice table removed
40  *    3. In shortanswer questions usecase is store in the qoption field
41  *    4. In numeric questions store the range as two answers
42  *    5. truefalse options are ignored
43  *    6. For multichoice questions with more than one answer the qoption field is true
44  *
45  * @param opject $question Contains question data like question, type and answers.
46  * @return object Returns $result->error or $result->notice.
47  **/
48 function lesson_save_question_options($question, $lesson) {
49     global $DB;
51     // These lines are required to ensure that all page types have
52     // been loaded for the following switch
53     if (!($lesson instanceof lesson)) {
54         $lesson = new lesson($lesson);
55     }
56     $manager = lesson_page_type_manager::get($lesson);
58     $timenow = time();
59     $result = new stdClass();
60     switch ($question->qtype) {
61         case LESSON_PAGE_SHORTANSWER:
63             $answers = array();
64             $maxfraction = -1;
66             // Insert all the new answers
67             foreach ($question->answer as $key => $dataanswer) {
68                 if ($dataanswer != "") {
69                     $answer = new stdClass;
70                     $answer->lessonid   = $question->lessonid;
71                     $answer->pageid   = $question->id;
72                     if ($question->fraction[$key] >=0.5) {
73                         $answer->jumpto = LESSON_NEXTPAGE;
74                     }
75                     $answer->timecreated   = $timenow;
76                     $answer->grade = $question->fraction[$key] * 100;
77                     $answer->answer   = $dataanswer;
78                     $answer->response = $question->feedback[$key]['text'];
79                     $answer->responseformat = $question->feedback[$key]['format'];
80                     $answer->id = $DB->insert_record("lesson_answers", $answer);
81                     $answers[] = $answer->id;
82                     if ($question->fraction[$key] > $maxfraction) {
83                         $maxfraction = $question->fraction[$key];
84                     }
85                 }
86             }
89             /// Perform sanity checks on fractional grades
90             if ($maxfraction != 1) {
91                 $maxfraction = $maxfraction * 100;
92                 $result->notice = get_string("fractionsnomax", "lesson", $maxfraction);
93                 return $result;
94             }
95             break;
97         case LESSON_PAGE_NUMERICAL:   // Note similarities to shortanswer.
99             $answers = array();
100             $maxfraction = -1;
103             // for each answer store the pair of min and max values even if they are the same
104             foreach ($question->answer as $key => $dataanswer) {
105                 if ($dataanswer != "") {
106                     $answer = new stdClass;
107                     $answer->lessonid   = $question->lessonid;
108                     $answer->pageid   = $question->id;
109                     $answer->jumpto = LESSON_NEXTPAGE;
110                     $answer->timecreated   = $timenow;
111                     $answer->grade = $question->fraction[$key] * 100;
112                     $min = $question->answer[$key] - $question->tolerance[$key];
113                     $max = $question->answer[$key] + $question->tolerance[$key];
114                     $answer->answer   = $min.":".$max;
115                     // $answer->answer   = $question->min[$key].":".$question->max[$key]; original line for min/max
116                     $answer->response = $question->feedback[$key]['text'];
117                     $answer->responseformat = $question->feedback[$key]['format'];
118                     $answer->id = $DB->insert_record("lesson_answers", $answer);
120                     $answers[] = $answer->id;
121                     if ($question->fraction[$key] > $maxfraction) {
122                         $maxfraction = $question->fraction[$key];
123                     }
124                 }
125             }
127             /// Perform sanity checks on fractional grades
128             if ($maxfraction != 1) {
129                 $maxfraction = $maxfraction * 100;
130                 $result->notice = get_string("fractionsnomax", "lesson", $maxfraction);
131                 return $result;
132             }
133         break;
136         case LESSON_PAGE_TRUEFALSE:
138             // the truth
139             $answer = new stdClass();
140             $answer->lessonid   = $question->lessonid;
141             $answer->pageid = $question->id;
142             $answer->timecreated   = $timenow;
143             $answer->answer = get_string("true", "lesson");
144             $answer->grade = $question->correctanswer * 100;
145             if ($answer->grade > 50 ) {
146                 $answer->jumpto = LESSON_NEXTPAGE;
147             }
148             if (isset($question->feedbacktrue)) {
149                 $answer->response = $question->feedbacktrue['text'];
150                 $answer->responseformat = $question->feedbacktrue['format'];
151             }
152             $DB->insert_record("lesson_answers", $answer);
154             // the lie
155             $answer = new stdClass;
156             $answer->lessonid   = $question->lessonid;
157             $answer->pageid = $question->id;
158             $answer->timecreated   = $timenow;
159             $answer->answer = get_string("false", "lesson");
160             $answer->grade = (1 - (int)$question->correctanswer) * 100;
161             if ($answer->grade > 50 ) {
162                 $answer->jumpto = LESSON_NEXTPAGE;
163             }
164             if (isset($question->feedbackfalse)) {
165                 $answer->response = $question->feedbackfalse['text'];
166                 $answer->responseformat = $question->feedbackfalse['format'];
167             }
168             $DB->insert_record("lesson_answers", $answer);
170           break;
172         case LESSON_PAGE_MULTICHOICE:
174             $totalfraction = 0;
175             $maxfraction = -1;
177             $answers = array();
179             // Insert all the new answers
180             foreach ($question->answer as $key => $dataanswer) {
181                 if ($dataanswer != "") {
182                     $answer = new stdClass;
183                     $answer->lessonid   = $question->lessonid;
184                     $answer->pageid   = $question->id;
185                     $answer->timecreated   = $timenow;
186                     $answer->grade = $question->fraction[$key] * 100;
187                     // changed some defaults
188                     /* Original Code
189                     if ($answer->grade > 50 ) {
190                         $answer->jumpto = LESSON_NEXTPAGE;
191                     }
192                     Replaced with:                    */
193                     if ($answer->grade > 50 ) {
194                         $answer->jumpto = LESSON_NEXTPAGE;
195                         $answer->score = 1;
196                     }
197                     // end Replace
198                     $answer->answer   = $dataanswer['text'];
199                     $answer->answerformat   = $dataanswer['format'];
200                     $answer->response = $question->feedback[$key]['text'];
201                     $answer->responseformat = $question->feedback[$key]['format'];
202                     $answer->id = $DB->insert_record("lesson_answers", $answer);
203                     // for Sanity checks
204                     if ($question->fraction[$key] > 0) {
205                         $totalfraction += $question->fraction[$key];
206                     }
207                     if ($question->fraction[$key] > $maxfraction) {
208                         $maxfraction = $question->fraction[$key];
209                     }
210                 }
211             }
213             /// Perform sanity checks on fractional grades
214             if ($question->single) {
215                 if ($maxfraction != 1) {
216                     $maxfraction = $maxfraction * 100;
217                     $result->notice = get_string("fractionsnomax", "lesson", $maxfraction);
218                     return $result;
219                 }
220             } else {
221                 $totalfraction = round($totalfraction,2);
222                 if ($totalfraction != 1) {
223                     $totalfraction = $totalfraction * 100;
224                     $result->notice = get_string("fractionsaddwrong", "lesson", $totalfraction);
225                     return $result;
226                 }
227             }
228         break;
230         case LESSON_PAGE_MATCHING:
232             $subquestions = array();
234             $defaultanswer = new stdClass;
235             $defaultanswer->lessonid   = $question->lessonid;
236             $defaultanswer->pageid   = $question->id;
237             $defaultanswer->timecreated   = $timenow;
238             $defaultanswer->grade = 0;
240             // The first answer should always be the correct answer
241             $correctanswer = clone($defaultanswer);
242             $correctanswer->answer = get_string('thatsthecorrectanswer', 'lesson');
243             $correctanswer->jumpto = LESSON_NEXTPAGE;
244             $DB->insert_record("lesson_answers", $correctanswer);
246             // The second answer should always be the wrong answer
247             $wronganswer = clone($defaultanswer);
248             $wronganswer->answer = get_string('thatsthewronganswer', 'lesson');
249             $DB->insert_record("lesson_answers", $wronganswer);
251             $i = 0;
252             // Insert all the new question+answer pairs
253             foreach ($question->subquestions as $key => $questiontext) {
254                 $answertext = $question->subanswers[$key];
255                 if (!empty($questiontext) and !empty($answertext)) {
256                     $answer = clone($defaultanswer);
257                     $answer->answer = $questiontext['text'];
258                     $answer->answerformat   = $questiontext['format'];
259                     $answer->response   = $answertext;
260                     if ($i == 0) {
261                         // first answer contains the correct answer jump
262                         $answer->jumpto = LESSON_NEXTPAGE;
263                     }
264                     $subquestions[] = $DB->insert_record("lesson_answers", $answer);
265                     $i++;
266                 }
267             }
269             if (count($subquestions) < 3) {
270                 $result->notice = get_string("notenoughsubquestions", "lesson");
271                 return $result;
272             }
273             break;
274         default:
275             $result->error = "Unsupported question type ($question->qtype)!";
276             return $result;
277     }
278     return true;
282 class qformat_default {
284     var $displayerrors = true;
285     var $category = null;
286     var $questionids = array();
287     protected $importcontext = null;
288     var $qtypeconvert = array('numerical'   => LESSON_PAGE_NUMERICAL,
289                                'multichoice' => LESSON_PAGE_MULTICHOICE,
290                                'truefalse'   => LESSON_PAGE_TRUEFALSE,
291                                'shortanswer' => LESSON_PAGE_SHORTANSWER,
292                                'match'       => LESSON_PAGE_MATCHING
293                               );
295     // Importing functions
296     function provide_import() {
297         return false;
298     }
300     function set_importcontext($context) {
301         $this->importcontext = $context;
302     }
304     /**
305      * Handle parsing error
306      *
307      * @param string $message information about error
308      * @param string $text imported text that triggered the error
309      * @param string $questionname imported question name
310      */
311     protected function error($message, $text='', $questionname='') {
312         $importerrorquestion = get_string('importerrorquestion', 'question');
314         echo "<div class=\"importerror\">\n";
315         echo "<strong>$importerrorquestion $questionname</strong>";
316         if (!empty($text)) {
317             $text = s($text);
318             echo "<blockquote>$text</blockquote>\n";
319         }
320         echo "<strong>$message</strong>\n";
321         echo "</div>";
322     }
324     function importpreprocess() {
325         // Does any pre-processing that may be desired
326         return true;
327     }
329     function importprocess($filename, $lesson, $pageid) {
330         global $DB, $OUTPUT;
332     /// Processes a given file.  There's probably little need to change this
333         $timenow = time();
335         if (! $lines = $this->readdata($filename)) {
336             echo $OUTPUT->notification("File could not be read, or was empty");
337             return false;
338         }
340         if (! $questions = $this->readquestions($lines)) {   // Extract all the questions
341             echo $OUTPUT->notification("There are no questions in this file!");
342             return false;
343         }
345         //Avoid category as question type
346         echo $OUTPUT->notification(get_string('importcount', 'lesson',
347                 $this->count_questions($questions)), 'notifysuccess');
349         $count = 0;
350         $addquestionontop = false;
351         if ($pageid == 0) {
352             $addquestionontop = true;
353             $updatelessonpage = $DB->get_record('lesson_pages', array('lessonid' => $lesson->id, 'prevpageid' => 0));
354         } else {
355             $updatelessonpage = $DB->get_record('lesson_pages', array('lessonid' => $lesson->id, 'id' => $pageid));
356         }
358         $unsupportedquestions = 0;
360         foreach ($questions as $question) {   // Process and store each question
361             switch ($question->qtype) {
362                 //TODO: Bad way to bypass category in data... Quickfix for MDL-27964
363                 case 'category':
364                     break;
365                 // the good ones
366                 case 'shortanswer' :
367                 case 'numerical' :
368                 case 'truefalse' :
369                 case 'multichoice' :
370                 case 'match' :
371                     $count++;
373                     //Show nice formated question in one line.
374                     echo "<hr><p><b>$count</b>. ".$this->format_question_text($question)."</p>";
376                     $newpage = new stdClass;
377                     $newpage->lessonid = $lesson->id;
378                     $newpage->qtype = $this->qtypeconvert[$question->qtype];
379                     switch ($question->qtype) {
380                         case 'shortanswer' :
381                             if (isset($question->usecase)) {
382                                 $newpage->qoption = $question->usecase;
383                             }
384                             break;
385                         case 'multichoice' :
386                             if (isset($question->single)) {
387                                 $newpage->qoption = !$question->single;
388                             }
389                             break;
390                     }
391                     $newpage->timecreated = $timenow;
392                     if ($question->name != $question->questiontext) {
393                         $newpage->title = $question->name;
394                     } else {
395                         $newpage->title = "Page $count";
396                     }
397                     $newpage->contents = $question->questiontext;
398                     $newpage->contentsformat = isset($question->questionformat) ? $question->questionformat : FORMAT_HTML;
400                     // set up page links
401                     if ($pageid) {
402                         // the new page follows on from this page
403                         if (!$page = $DB->get_record("lesson_pages", array("id" => $pageid))) {
404                             print_error('invalidpageid', 'lesson');
405                         }
406                         $newpage->prevpageid = $pageid;
407                         $newpage->nextpageid = $page->nextpageid;
408                         // insert the page and reset $pageid
409                         $newpageid = $DB->insert_record("lesson_pages", $newpage);
410                         // update the linked list
411                         $DB->set_field("lesson_pages", "nextpageid", $newpageid, array("id" => $pageid));
412                     } else {
413                         // new page is the first page
414                         // get the existing (first) page (if any)
415                         $params = array ("lessonid" => $lesson->id, "prevpageid" => 0);
416                         if (!$page = $DB->get_record_select("lesson_pages", "lessonid = :lessonid AND prevpageid = :prevpageid", $params)) {
417                             // there are no existing pages
418                             $newpage->prevpageid = 0; // this is a first page
419                             $newpage->nextpageid = 0; // this is the only page
420                             $newpageid = $DB->insert_record("lesson_pages", $newpage);
421                         } else {
422                             // there are existing pages put this at the start
423                             $newpage->prevpageid = 0; // this is a first page
424                             $newpage->nextpageid = $page->id;
425                             $newpageid = $DB->insert_record("lesson_pages", $newpage);
426                             // update the linked list
427                             $DB->set_field("lesson_pages", "prevpageid", $newpageid, array("id" => $page->id));
428                         }
429                     }
431                     // reset $pageid and put the page ID in $question, used in save_question_option()
432                     $pageid = $newpageid;
433                     $question->id = $newpageid;
435                     $this->questionids[] = $question->id;
437                     // Import images in question text.
438                     if (isset($question->questiontextitemid)) {
439                         $questiontext = file_save_draft_area_files($question->questiontextitemid,
440                                 $this->importcontext->id, 'mod_lesson', 'page_contents', $newpageid,
441                                 null , $question->questiontext);
442                         // Update content with recoded urls.
443                         $DB->set_field("lesson_pages", "contents", $questiontext, array("id" => $newpageid));
444                     }
446                     // Now to save all the answers and type-specific options
448                     $question->lessonid = $lesson->id; // needed for foreign key
449                     $question->qtype = $this->qtypeconvert[$question->qtype];
450                     $result = lesson_save_question_options($question, $lesson);
452                     if (!empty($result->error)) {
453                         echo $OUTPUT->notification($result->error);
454                         return false;
455                     }
457                     if (!empty($result->notice)) {
458                         echo $OUTPUT->notification($result->notice);
459                         return true;
460                     }
461                     break;
462             // the Bad ones
463                 default :
464                     $unsupportedquestions++;
465                     break;
466             }
467         }
468         // Update the prev links if there were existing pages.
469         if (!empty($updatelessonpage)) {
470             if ($addquestionontop) {
471                 $DB->set_field("lesson_pages", "prevpageid", $pageid, array("id" => $updatelessonpage->id));
472             } else {
473                 $DB->set_field("lesson_pages", "prevpageid", $pageid, array("id" => $updatelessonpage->nextpageid));
474             }
475         }
476         if ($unsupportedquestions) {
477             echo $OUTPUT->notification(get_string('unknownqtypesnotimported', 'lesson', $unsupportedquestions));
478         }
479         return true;
480     }
482     /**
483      * Count all non-category questions in the questions array.
484      *
485      * @param array questions An array of question objects.
486      * @return int The count.
487      *
488      */
489     protected function count_questions($questions) {
490         $count = 0;
491         if (!is_array($questions)) {
492             return $count;
493         }
494         foreach ($questions as $question) {
495             if (!is_object($question) || !isset($question->qtype) ||
496                     ($question->qtype == 'category')) {
497                 continue;
498             }
499             $count++;
500         }
501         return $count;
502     }
504     function readdata($filename) {
505     /// Returns complete file with an array, one item per line
507         if (is_readable($filename)) {
508             $filearray = file($filename);
510             /// Check for Macintosh OS line returns (ie file on one line), and fix
511             if (preg_match("/\r/", $filearray[0]) AND !preg_match("/\n/", $filearray[0])) {
512                 return explode("\r", $filearray[0]);
513             } else {
514                 return $filearray;
515             }
516         }
517         return false;
518     }
520     protected function readquestions($lines) {
521     /// Parses an array of lines into an array of questions,
522     /// where each item is a question object as defined by
523     /// readquestion().   Questions are defined as anything
524     /// between blank lines.
526         $questions = array();
527         $currentquestion = array();
529         foreach ($lines as $line) {
530             $line = trim($line);
531             if (empty($line)) {
532                 if (!empty($currentquestion)) {
533                     if ($question = $this->readquestion($currentquestion)) {
534                         $questions[] = $question;
535                     }
536                     $currentquestion = array();
537                 }
538             } else {
539                 $currentquestion[] = $line;
540             }
541         }
543         if (!empty($currentquestion)) {  // There may be a final question
544             if ($question = $this->readquestion($currentquestion)) {
545                 $questions[] = $question;
546             }
547         }
549         return $questions;
550     }
553     protected function readquestion($lines) {
554     /// Given an array of lines known to define a question in
555     /// this format, this function converts it into a question
556     /// object suitable for processing and insertion into Moodle.
558         echo "<p>This flash question format has not yet been completed!</p>";
560         return null;
561     }
563     /**
564      * Construct a reasonable default question name, based on the start of the question text.
565      * @param string $questiontext the question text.
566      * @param string $default default question name to use if the constructed one comes out blank.
567      * @return string a reasonable question name.
568      */
569     public function create_default_question_name($questiontext, $default) {
570         $name = $this->clean_question_name(shorten_text($questiontext, 80));
571         if ($name) {
572             return $name;
573         } else {
574             return $default;
575         }
576     }
578     /**
579      * Ensure that a question name does not contain anything nasty, and will fit in the DB field.
580      * @param string $name the raw question name.
581      * @return string a safe question name.
582      */
583     public function clean_question_name($name) {
584         $name = clean_param($name, PARAM_TEXT); // Matches what the question editing form does.
585         $name = trim($name);
586         $trimlength = 251;
587         while (core_text::strlen($name) > 255 && $trimlength > 0) {
588             $name = shorten_text($name, $trimlength);
589             $trimlength -= 10;
590         }
591         return $name;
592     }
594     function defaultquestion() {
595     // returns an "empty" question
596     // Somewhere to specify question parameters that are not handled
597     // by import but are required db fields.
598     // This should not be overridden.
599         global $CFG;
601         $question = new stdClass();
602         $question->shuffleanswers = get_config('quiz', 'shuffleanswers');
603         $question->defaultmark = 1;
604         $question->image = "";
605         $question->usecase = 0;
606         $question->multiplier = array();
607         $question->generalfeedback = '';
608         $question->correctfeedback = '';
609         $question->partiallycorrectfeedback = '';
610         $question->incorrectfeedback = '';
611         $question->answernumbering = 'abc';
612         $question->penalty = 0.1;
613         $question->length = 1;
614         $question->qoption = 0;
615         $question->layout = 1;
617         // this option in case the questiontypes class wants
618         // to know where the data came from
619         $question->export_process = true;
620         $question->import_process = true;
622         return $question;
623     }
625     function importpostprocess() {
626         /// Does any post-processing that may be desired
627         /// Argument is a simple array of question ids that
628         /// have just been added.
629         return true;
630     }
632     /**
633      * Convert the question text to plain text, so it can safely be displayed
634      * during import to let the user see roughly what is going on.
635      */
636     protected function format_question_text($question) {
637         $formatoptions = new stdClass();
638         $formatoptions->noclean = true;
639         // The html_to_text call strips out all URLs, but format_text complains
640         // if it finds @@PLUGINFILE@@ tokens. So, we need to replace
641         // @@PLUGINFILE@@ with a real URL, but it doesn't matter what.
642         // We use http://example.com/.
643         $text = str_replace('@@PLUGINFILE@@/', 'http://example.com/', $question->questiontext);
644         return html_to_text(format_text($text,
645                 $question->questiontextformat, $formatoptions), 0, false);
646     }
648     /**
649      * Since the lesson module tries to re-use the question bank import classes in
650      * a crazy way, this is necessary to stop things breaking.
651      */
652     protected function add_blank_combined_feedback($question) {
653         return $question;
654     }
658 /**
659  * Since the lesson module tries to re-use the question bank import classes in
660  * a crazy way, this is necessary to stop things breaking. This should be exactly
661  * the same as the class defined in question/format.php.
662  */
663 class qformat_based_on_xml extends qformat_default {
664     /**
665      * A lot of imported files contain unwanted entities.
666      * This method tries to clean up all known problems.
667      * @param string str string to correct
668      * @return string the corrected string
669      */
670     public function cleaninput($str) {
672         $html_code_list = array(
673             "&#039;" => "'",
674             "&#8217;" => "'",
675             "&#8220;" => "\"",
676             "&#8221;" => "\"",
677             "&#8211;" => "-",
678             "&#8212;" => "-",
679         );
680         $str = strtr($str, $html_code_list);
681         // Use core_text entities_to_utf8 function to convert only numerical entities.
682         $str = core_text::entities_to_utf8($str, false);
683         return $str;
684     }
686     /**
687      * Return the array moodle is expecting
688      * for an HTML text. No processing is done on $text.
689      * qformat classes that want to process $text
690      * for instance to import external images files
691      * and recode urls in $text must overwrite this method.
692      * @param array $text some HTML text string
693      * @return array with keys text, format and files.
694      */
695     public function text_field($text) {
696         return array(
697             'text' => trim($text),
698             'format' => FORMAT_HTML,
699             'files' => array(),
700         );
701     }
703     /**
704      * Return the value of a node, given a path to the node
705      * if it doesn't exist return the default value.
706      * @param array xml data to read
707      * @param array path path to node expressed as array
708      * @param mixed default
709      * @param bool istext process as text
710      * @param string error if set value must exist, return false and issue message if not
711      * @return mixed value
712      */
713     public function getpath($xml, $path, $default, $istext=false, $error='') {
714         foreach ($path as $index) {
715             if (!isset($xml[$index])) {
716                 if (!empty($error)) {
717                     $this->error($error);
718                     return false;
719                 } else {
720                     return $default;
721                 }
722             }
724             $xml = $xml[$index];
725         }
727         if ($istext) {
728             if (!is_string($xml)) {
729                 $this->error(get_string('invalidxml', 'qformat_xml'));
730             }
731             $xml = trim($xml);
732         }
734         return $xml;
735     }