MDL-62820 question import: properly escape output
[moodle.git] / question / format.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Defines the base class for question import and export formats.
19  *
20  * @package    moodlecore
21  * @subpackage questionbank
22  * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
27 defined('MOODLE_INTERNAL') || die();
30 /**
31  * Base class for question import and export formats.
32  *
33  * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
34  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35  */
36 class qformat_default {
38     public $displayerrors = true;
39     public $category = null;
40     public $questions = array();
41     public $course = null;
42     public $filename = '';
43     public $realfilename = '';
44     public $matchgrades = 'error';
45     public $catfromfile = 0;
46     public $contextfromfile = 0;
47     public $cattofile = 0;
48     public $contexttofile = 0;
49     public $questionids = array();
50     public $importerrors = 0;
51     public $stoponerror = true;
52     public $translator = null;
53     public $canaccessbackupdata = true;
54     protected $importcontext = null;
56     // functions to indicate import/export functionality
57     // override to return true if implemented
59     /** @return bool whether this plugin provides import functionality. */
60     public function provide_import() {
61         return false;
62     }
64     /** @return bool whether this plugin provides export functionality. */
65     public function provide_export() {
66         return false;
67     }
69     /** The string mime-type of the files that this plugin reads or writes. */
70     public function mime_type() {
71         return mimeinfo('type', $this->export_file_extension());
72     }
74     /**
75      * @return string the file extension (including .) that is normally used for
76      * files handled by this plugin.
77      */
78     public function export_file_extension() {
79         return '.txt';
80     }
82     /**
83      * Check if the given file is capable of being imported by this plugin.
84      *
85      * Note that expensive or detailed integrity checks on the file should
86      * not be performed by this method. Simple file type or magic-number tests
87      * would be suitable.
88      *
89      * @param stored_file $file the file to check
90      * @return bool whether this plugin can import the file
91      */
92     public function can_import_file($file) {
93         return ($file->get_mimetype() == $this->mime_type());
94     }
96     // Accessor methods
98     /**
99      * set the category
100      * @param object category the category object
101      */
102     public function setCategory($category) {
103         if (count($this->questions)) {
104             debugging('You shouldn\'t call setCategory after setQuestions');
105         }
106         $this->category = $category;
107         $this->importcontext = context::instance_by_id($this->category->contextid);
108     }
110     /**
111      * Set the specific questions to export. Should not include questions with
112      * parents (sub questions of cloze question type).
113      * Only used for question export.
114      * @param array of question objects
115      */
116     public function setQuestions($questions) {
117         if ($this->category !== null) {
118             debugging('You shouldn\'t call setQuestions after setCategory');
119         }
120         $this->questions = $questions;
121     }
123     /**
124      * set the course class variable
125      * @param course object Moodle course variable
126      */
127     public function setCourse($course) {
128         $this->course = $course;
129     }
131     /**
132      * set an array of contexts.
133      * @param array $contexts Moodle course variable
134      */
135     public function setContexts($contexts) {
136         $this->contexts = $contexts;
137         $this->translator = new context_to_string_translator($this->contexts);
138     }
140     /**
141      * set the filename
142      * @param string filename name of file to import/export
143      */
144     public function setFilename($filename) {
145         $this->filename = $filename;
146     }
148     /**
149      * set the "real" filename
150      * (this is what the user typed, regardless of wha happened next)
151      * @param string realfilename name of file as typed by user
152      */
153     public function setRealfilename($realfilename) {
154         $this->realfilename = $realfilename;
155     }
157     /**
158      * set matchgrades
159      * @param string matchgrades error or nearest for grades
160      */
161     public function setMatchgrades($matchgrades) {
162         $this->matchgrades = $matchgrades;
163     }
165     /**
166      * set catfromfile
167      * @param bool catfromfile allow categories embedded in import file
168      */
169     public function setCatfromfile($catfromfile) {
170         $this->catfromfile = $catfromfile;
171     }
173     /**
174      * set contextfromfile
175      * @param bool $contextfromfile allow contexts embedded in import file
176      */
177     public function setContextfromfile($contextfromfile) {
178         $this->contextfromfile = $contextfromfile;
179     }
181     /**
182      * set cattofile
183      * @param bool cattofile exports categories within export file
184      */
185     public function setCattofile($cattofile) {
186         $this->cattofile = $cattofile;
187     }
189     /**
190      * set contexttofile
191      * @param bool cattofile exports categories within export file
192      */
193     public function setContexttofile($contexttofile) {
194         $this->contexttofile = $contexttofile;
195     }
197     /**
198      * set stoponerror
199      * @param bool stoponerror stops database write if any errors reported
200      */
201     public function setStoponerror($stoponerror) {
202         $this->stoponerror = $stoponerror;
203     }
205     /**
206      * @param bool $canaccess Whether the current use can access the backup data folder. Determines
207      * where export files are saved.
208      */
209     public function set_can_access_backupdata($canaccess) {
210         $this->canaccessbackupdata = $canaccess;
211     }
213     /***********************
214      * IMPORTING FUNCTIONS
215      ***********************/
217     /**
218      * Handle parsing error
219      */
220     protected function error($message, $text='', $questionname='') {
221         $importerrorquestion = get_string('importerrorquestion', 'question');
223         echo "<div class=\"importerror\">\n";
224         echo "<strong>{$importerrorquestion} {$questionname}</strong>";
225         if (!empty($text)) {
226             $text = s($text);
227             echo "<blockquote>{$text}</blockquote>\n";
228         }
229         echo "<strong>{$message}</strong>\n";
230         echo "</div>";
232         $this->importerrors++;
233     }
235     /**
236      * Import for questiontype plugins
237      * Do not override.
238      * @param data mixed The segment of data containing the question
239      * @param question object processed (so far) by standard import code if appropriate
240      * @param extra mixed any additional format specific data that may be passed by the format
241      * @param qtypehint hint about a question type from format
242      * @return object question object suitable for save_options() or false if cannot handle
243      */
244     public function try_importing_using_qtypes($data, $question = null, $extra = null,
245             $qtypehint = '') {
247         // work out what format we are using
248         $formatname = substr(get_class($this), strlen('qformat_'));
249         $methodname = "import_from_{$formatname}";
251         //first try importing using a hint from format
252         if (!empty($qtypehint)) {
253             $qtype = question_bank::get_qtype($qtypehint, false);
254             if (is_object($qtype) && method_exists($qtype, $methodname)) {
255                 $question = $qtype->$methodname($data, $question, $this, $extra);
256                 if ($question) {
257                     return $question;
258                 }
259             }
260         }
262         // loop through installed questiontypes checking for
263         // function to handle this question
264         foreach (question_bank::get_all_qtypes() as $qtype) {
265             if (method_exists($qtype, $methodname)) {
266                 if ($question = $qtype->$methodname($data, $question, $this, $extra)) {
267                     return $question;
268                 }
269             }
270         }
271         return false;
272     }
274     /**
275      * Perform any required pre-processing
276      * @return bool success
277      */
278     public function importpreprocess() {
279         return true;
280     }
282     /**
283      * Process the file
284      * This method should not normally be overidden
285      * @param object $category
286      * @return bool success
287      */
288     public function importprocess($category) {
289         global $USER, $CFG, $DB, $OUTPUT;
291         // Raise time and memory, as importing can be quite intensive.
292         core_php_time_limit::raise();
293         raise_memory_limit(MEMORY_EXTRA);
295         // STAGE 1: Parse the file
296         echo $OUTPUT->notification(get_string('parsingquestions', 'question'), 'notifysuccess');
298         if (! $lines = $this->readdata($this->filename)) {
299             echo $OUTPUT->notification(get_string('cannotread', 'question'));
300             return false;
301         }
303         if (!$questions = $this->readquestions($lines)) {   // Extract all the questions
304             echo $OUTPUT->notification(get_string('noquestionsinfile', 'question'));
305             return false;
306         }
308         // STAGE 2: Write data to database
309         echo $OUTPUT->notification(get_string('importingquestions', 'question',
310                 $this->count_questions($questions)), 'notifysuccess');
312         // check for errors before we continue
313         if ($this->stoponerror and ($this->importerrors>0)) {
314             echo $OUTPUT->notification(get_string('importparseerror', 'question'));
315             return true;
316         }
318         // get list of valid answer grades
319         $gradeoptionsfull = question_bank::fraction_options_full();
321         // check answer grades are valid
322         // (now need to do this here because of 'stop on error': MDL-10689)
323         $gradeerrors = 0;
324         $goodquestions = array();
325         foreach ($questions as $question) {
326             if (!empty($question->fraction) and (is_array($question->fraction))) {
327                 $fractions = $question->fraction;
328                 $invalidfractions = array();
329                 foreach ($fractions as $key => $fraction) {
330                     $newfraction = match_grade_options($gradeoptionsfull, $fraction,
331                             $this->matchgrades);
332                     if ($newfraction === false) {
333                         $invalidfractions[] = $fraction;
334                     } else {
335                         $fractions[$key] = $newfraction;
336                     }
337                 }
338                 if ($invalidfractions) {
339                     echo $OUTPUT->notification(get_string('invalidgrade', 'question',
340                             implode(', ', $invalidfractions)));
341                     ++$gradeerrors;
342                     continue;
343                 } else {
344                     $question->fraction = $fractions;
345                 }
346             }
347             $goodquestions[] = $question;
348         }
349         $questions = $goodquestions;
351         // check for errors before we continue
352         if ($this->stoponerror && $gradeerrors > 0) {
353             return false;
354         }
356         // count number of questions processed
357         $count = 0;
359         foreach ($questions as $question) {   // Process and store each question
360             $transaction = $DB->start_delegated_transaction();
362             // reset the php timeout
363             core_php_time_limit::raise();
365             // check for category modifiers
366             if ($question->qtype == 'category') {
367                 if ($this->catfromfile) {
368                     // find/create category object
369                     $catpath = $question->category;
370                     $newcategory = $this->create_category_path($catpath);
371                     if (!empty($newcategory)) {
372                         $this->category = $newcategory;
373                     }
374                 }
375                 $transaction->allow_commit();
376                 continue;
377             }
378             $question->context = $this->importcontext;
380             $count++;
382             echo "<hr /><p><b>{$count}</b>. ".$this->format_question_text($question)."</p>";
384             $question->category = $this->category->id;
385             $question->stamp = make_unique_id_code();  // Set the unique code (not to be changed)
387             $question->createdby = $USER->id;
388             $question->timecreated = time();
389             $question->modifiedby = $USER->id;
390             $question->timemodified = time();
391             $fileoptions = array(
392                     'subdirs' => true,
393                     'maxfiles' => -1,
394                     'maxbytes' => 0,
395                 );
397             $question->id = $DB->insert_record('question', $question);
399             if (isset($question->questiontextitemid)) {
400                 $question->questiontext = file_save_draft_area_files($question->questiontextitemid,
401                         $this->importcontext->id, 'question', 'questiontext', $question->id,
402                         $fileoptions, $question->questiontext);
403             } else if (isset($question->questiontextfiles)) {
404                 foreach ($question->questiontextfiles as $file) {
405                     question_bank::get_qtype($question->qtype)->import_file(
406                             $this->importcontext, 'question', 'questiontext', $question->id, $file);
407                 }
408             }
409             if (isset($question->generalfeedbackitemid)) {
410                 $question->generalfeedback = file_save_draft_area_files($question->generalfeedbackitemid,
411                         $this->importcontext->id, 'question', 'generalfeedback', $question->id,
412                         $fileoptions, $question->generalfeedback);
413             } else if (isset($question->generalfeedbackfiles)) {
414                 foreach ($question->generalfeedbackfiles as $file) {
415                     question_bank::get_qtype($question->qtype)->import_file(
416                             $this->importcontext, 'question', 'generalfeedback', $question->id, $file);
417                 }
418             }
419             $DB->update_record('question', $question);
421             $this->questionids[] = $question->id;
423             // Now to save all the answers and type-specific options
425             $result = question_bank::get_qtype($question->qtype)->save_question_options($question);
427             if (core_tag_tag::is_enabled('core_question', 'question')) {
428                 // Is the current context we're importing in a course context?
429                 $importingcontext = $this->importcontext;
430                 $importingcoursecontext = $importingcontext->get_course_context(false);
431                 $isimportingcontextcourseoractivity = !empty($importingcoursecontext);
433                 if (!empty($question->coursetags)) {
434                     if ($isimportingcontextcourseoractivity) {
435                         $mergedtags = array_merge($question->coursetags, $question->tags);
437                         core_tag_tag::set_item_tags('core_question', 'question', $question->id,
438                             $question->context, $mergedtags);
439                     } else {
440                         core_tag_tag::set_item_tags('core_question', 'question', $question->id,
441                             context_course::instance($this->course->id), $question->coursetags);
443                         if (!empty($question->tags)) {
444                             core_tag_tag::set_item_tags('core_question', 'question', $question->id,
445                                 $importingcontext, $question->tags);
446                         }
447                     }
448                 } else if (!empty($question->tags)) {
449                     core_tag_tag::set_item_tags('core_question', 'question', $question->id,
450                         $question->context, $question->tags);
451                 }
452             }
454             if (!empty($result->error)) {
455                 echo $OUTPUT->notification($result->error);
456                 // Can't use $transaction->rollback(); since it requires an exception,
457                 // and I don't want to rewrite this code to change the error handling now.
458                 $DB->force_transaction_rollback();
459                 return false;
460             }
462             $transaction->allow_commit();
464             if (!empty($result->notice)) {
465                 echo $OUTPUT->notification($result->notice);
466                 return true;
467             }
469             // Give the question a unique version stamp determined by question_hash()
470             $DB->set_field('question', 'version', question_hash($question),
471                     array('id' => $question->id));
472         }
473         return true;
474     }
476     /**
477      * Count all non-category questions in the questions array.
478      *
479      * @param array questions An array of question objects.
480      * @return int The count.
481      *
482      */
483     protected function count_questions($questions) {
484         $count = 0;
485         if (!is_array($questions)) {
486             return $count;
487         }
488         foreach ($questions as $question) {
489             if (!is_object($question) || !isset($question->qtype) ||
490                     ($question->qtype == 'category')) {
491                 continue;
492             }
493             $count++;
494         }
495         return $count;
496     }
498     /**
499      * find and/or create the category described by a delimited list
500      * e.g. $course$/tom/dick/harry or tom/dick/harry
501      *
502      * removes any context string no matter whether $getcontext is set
503      * but if $getcontext is set then ignore the context and use selected category context.
504      *
505      * @param string catpath delimited category path
506      * @param int courseid course to search for categories
507      * @return mixed category object or null if fails
508      */
509     protected function create_category_path($catpath) {
510         global $DB;
511         $catnames = $this->split_category_path($catpath);
512         $parent = 0;
513         $category = null;
515         // check for context id in path, it might not be there in pre 1.9 exports
516         $matchcount = preg_match('/^\$([a-z]+)\$$/', $catnames[0], $matches);
517         if ($matchcount == 1) {
518             $contextid = $this->translator->string_to_context($matches[1]);
519             array_shift($catnames);
520         } else {
521             $contextid = false;
522         }
524         // Before 3.5, question categories could be created at top level.
525         // From 3.5 onwards, all question categories should be a child of a special category called the "top" category.
526         if (isset($catnames[0]) && (($catnames[0] != 'top') || (count($catnames) < 3))) {
527             array_unshift($catnames, 'top');
528         }
530         if ($this->contextfromfile && $contextid !== false) {
531             $context = context::instance_by_id($contextid);
532             require_capability('moodle/question:add', $context);
533         } else {
534             $context = context::instance_by_id($this->category->contextid);
535         }
536         $this->importcontext = $context;
538         // Now create any categories that need to be created.
539         foreach ($catnames as $catname) {
540             if ($parent == 0) {
541                 $category = question_get_top_category($context->id, true);
542                 $parent = $category->id;
543             } else if ($category = $DB->get_record('question_categories',
544                     array('name' => $catname, 'contextid' => $context->id, 'parent' => $parent))) {
545                 $parent = $category->id;
546             } else if ($parent == 0) {
547                 $category = question_get_top_category($context->id, true);
548                 $parent = $category->id;
549             } else {
550                 require_capability('moodle/question:managecategory', $context);
551                 // create the new category
552                 $category = new stdClass();
553                 $category->contextid = $context->id;
554                 $category->name = $catname;
555                 $category->info = '';
556                 $category->parent = $parent;
557                 $category->sortorder = 999;
558                 $category->stamp = make_unique_id_code();
559                 $id = $DB->insert_record('question_categories', $category);
560                 $category->id = $id;
561                 $parent = $id;
562             }
563         }
564         return $category;
565     }
567     /**
568      * Return complete file within an array, one item per line
569      * @param string filename name of file
570      * @return mixed contents array or false on failure
571      */
572     protected function readdata($filename) {
573         if (is_readable($filename)) {
574             $filearray = file($filename);
576             // If the first line of the file starts with a UTF-8 BOM, remove it.
577             $filearray[0] = core_text::trim_utf8_bom($filearray[0]);
579             // Check for Macintosh OS line returns (ie file on one line), and fix.
580             if (preg_match("~\r~", $filearray[0]) AND !preg_match("~\n~", $filearray[0])) {
581                 return explode("\r", $filearray[0]);
582             } else {
583                 return $filearray;
584             }
585         }
586         return false;
587     }
589     /**
590      * Parses an array of lines into an array of questions,
591      * where each item is a question object as defined by
592      * readquestion().   Questions are defined as anything
593      * between blank lines.
594      *
595      * NOTE this method used to take $context as a second argument. However, at
596      * the point where this method was called, it was impossible to know what
597      * context the quetsions were going to be saved into, so the value could be
598      * wrong. Also, none of the standard question formats were using this argument,
599      * so it was removed. See MDL-32220.
600      *
601      * If your format does not use blank lines as a delimiter
602      * then you will need to override this method. Even then
603      * try to use readquestion for each question
604      * @param array lines array of lines from readdata
605      * @return array array of question objects
606      */
607     protected function readquestions($lines) {
609         $questions = array();
610         $currentquestion = array();
612         foreach ($lines as $line) {
613             $line = trim($line);
614             if (empty($line)) {
615                 if (!empty($currentquestion)) {
616                     if ($question = $this->readquestion($currentquestion)) {
617                         $questions[] = $question;
618                     }
619                     $currentquestion = array();
620                 }
621             } else {
622                 $currentquestion[] = $line;
623             }
624         }
626         if (!empty($currentquestion)) {  // There may be a final question
627             if ($question = $this->readquestion($currentquestion)) {
628                 $questions[] = $question;
629             }
630         }
632         return $questions;
633     }
635     /**
636      * return an "empty" question
637      * Somewhere to specify question parameters that are not handled
638      * by import but are required db fields.
639      * This should not be overridden.
640      * @return object default question
641      */
642     protected function defaultquestion() {
643         global $CFG;
644         static $defaultshuffleanswers = null;
645         if (is_null($defaultshuffleanswers)) {
646             $defaultshuffleanswers = get_config('quiz', 'shuffleanswers');
647         }
649         $question = new stdClass();
650         $question->shuffleanswers = $defaultshuffleanswers;
651         $question->defaultmark = 1;
652         $question->image = "";
653         $question->usecase = 0;
654         $question->multiplier = array();
655         $question->questiontextformat = FORMAT_MOODLE;
656         $question->generalfeedback = '';
657         $question->generalfeedbackformat = FORMAT_MOODLE;
658         $question->answernumbering = 'abc';
659         $question->penalty = 0.3333333;
660         $question->length = 1;
662         // this option in case the questiontypes class wants
663         // to know where the data came from
664         $question->export_process = true;
665         $question->import_process = true;
667         $this->add_blank_combined_feedback($question);
669         return $question;
670     }
672     /**
673      * Construct a reasonable default question name, based on the start of the question text.
674      * @param string $questiontext the question text.
675      * @param string $default default question name to use if the constructed one comes out blank.
676      * @return string a reasonable question name.
677      */
678     public function create_default_question_name($questiontext, $default) {
679         $name = $this->clean_question_name(shorten_text($questiontext, 80));
680         if ($name) {
681             return $name;
682         } else {
683             return $default;
684         }
685     }
687     /**
688      * Ensure that a question name does not contain anything nasty, and will fit in the DB field.
689      * @param string $name the raw question name.
690      * @return string a safe question name.
691      */
692     public function clean_question_name($name) {
693         $name = clean_param($name, PARAM_TEXT); // Matches what the question editing form does.
694         $name = trim($name);
695         $trimlength = 251;
696         while (core_text::strlen($name) > 255 && $trimlength > 0) {
697             $name = shorten_text($name, $trimlength);
698             $trimlength -= 10;
699         }
700         return $name;
701     }
703     /**
704      * Add a blank combined feedback to a question object.
705      * @param object question
706      * @return object question
707      */
708     protected function add_blank_combined_feedback($question) {
709         $question->correctfeedback = [
710             'text' => '',
711             'format' => $question->questiontextformat,
712             'files' => []
713         ];
714         $question->partiallycorrectfeedback = [
715             'text' => '',
716             'format' => $question->questiontextformat,
717             'files' => []
718         ];
719         $question->incorrectfeedback = [
720             'text' => '',
721             'format' => $question->questiontextformat,
722             'files' => []
723         ];
724         return $question;
725     }
727     /**
728      * Given the data known to define a question in
729      * this format, this function converts it into a question
730      * object suitable for processing and insertion into Moodle.
731      *
732      * If your format does not use blank lines to delimit questions
733      * (e.g. an XML format) you must override 'readquestions' too
734      * @param $lines mixed data that represents question
735      * @return object question object
736      */
737     protected function readquestion($lines) {
738         // We should never get there unless the qformat plugin is broken.
739         throw new coding_exception('Question format plugin is missing important code: readquestion.');
741         return null;
742     }
744     /**
745      * Override if any post-processing is required
746      * @return bool success
747      */
748     public function importpostprocess() {
749         return true;
750     }
752     /*******************
753      * EXPORT FUNCTIONS
754      *******************/
756     /**
757      * Provide export functionality for plugin questiontypes
758      * Do not override
759      * @param name questiontype name
760      * @param question object data to export
761      * @param extra mixed any addition format specific data needed
762      * @return string the data to append to export or false if error (or unhandled)
763      */
764     protected function try_exporting_using_qtypes($name, $question, $extra=null) {
765         // work out the name of format in use
766         $formatname = substr(get_class($this), strlen('qformat_'));
767         $methodname = "export_to_{$formatname}";
769         $qtype = question_bank::get_qtype($name, false);
770         if (method_exists($qtype, $methodname)) {
771             return $qtype->$methodname($question, $this, $extra);
772         }
773         return false;
774     }
776     /**
777      * Do any pre-processing that may be required
778      * @param bool success
779      */
780     public function exportpreprocess() {
781         return true;
782     }
784     /**
785      * Enable any processing to be done on the content
786      * just prior to the file being saved
787      * default is to do nothing
788      * @param string output text
789      * @param string processed output text
790      */
791     protected function presave_process($content) {
792         return $content;
793     }
795     /**
796      * Perform the export.
797      * For most types this should not need to be overrided.
798      *
799      * @param   bool    $checkcapabilities Whether to check capabilities when exporting the questions.
800      * @return  string  The content of the export.
801      */
802     public function exportprocess($checkcapabilities = true) {
803         global $CFG, $OUTPUT, $DB, $USER;
805         // get the questions (from database) in this category
806         // only get q's with no parents (no cloze subquestions specifically)
807         if ($this->category) {
808             $questions = get_questions_category($this->category, true);
809         } else {
810             $questions = $this->questions;
811         }
813         $count = 0;
815         // results are first written into string (and then to a file)
816         // so create/initialize the string here
817         $expout = "";
819         // track which category questions are in
820         // if it changes we will record the category change in the output
821         // file if selected. 0 means that it will get printed before the 1st question
822         $trackcategory = 0;
824         // iterate through questions
825         foreach ($questions as $question) {
826             // used by file api
827             $contextid = $DB->get_field('question_categories', 'contextid',
828                     array('id' => $question->category));
829             $question->contextid = $contextid;
831             // do not export hidden questions
832             if (!empty($question->hidden)) {
833                 continue;
834             }
836             // do not export random questions
837             if ($question->qtype == 'random') {
838                 continue;
839             }
841             // check if we need to record category change
842             if ($this->cattofile) {
843                 if ($question->category != $trackcategory) {
844                     $trackcategory = $question->category;
845                     $categoryname = $this->get_category_path($trackcategory, $this->contexttofile);
847                     // create 'dummy' question for category export
848                     $dummyquestion = new stdClass();
849                     $dummyquestion->qtype = 'category';
850                     $dummyquestion->category = $categoryname;
851                     $dummyquestion->name = 'Switch category to ' . $categoryname;
852                     $dummyquestion->id = 0;
853                     $dummyquestion->questiontextformat = '';
854                     $dummyquestion->contextid = 0;
855                     $expout .= $this->writequestion($dummyquestion) . "\n";
856                 }
857             }
859             // export the question displaying message
860             $count++;
862             if (!$checkcapabilities || question_has_capability_on($question, 'view')) {
863                 $expout .= $this->writequestion($question, $contextid) . "\n";
864             }
865         }
867         // continue path for following error checks
868         $course = $this->course;
869         $continuepath = "{$CFG->wwwroot}/question/export.php?courseid={$course->id}";
871         // did we actually process anything
872         if ($count==0) {
873             print_error('noquestions', 'question', $continuepath);
874         }
876         // final pre-process on exported data
877         $expout = $this->presave_process($expout);
878         return $expout;
879     }
881     /**
882      * get the category as a path (e.g., tom/dick/harry)
883      * @param int id the id of the most nested catgory
884      * @return string the path
885      */
886     protected function get_category_path($id, $includecontext = true) {
887         global $DB;
889         if (!$category = $DB->get_record('question_categories', array('id' => $id))) {
890             print_error('cannotfindcategory', 'error', '', $id);
891         }
892         $contextstring = $this->translator->context_to_string($category->contextid);
894         $pathsections = array();
895         do {
896             $pathsections[] = $category->name;
897             $id = $category->parent;
898         } while ($category = $DB->get_record('question_categories', array('id' => $id)));
900         if ($includecontext) {
901             $pathsections[] = '$' . $contextstring . '$';
902         }
904         $path = $this->assemble_category_path(array_reverse($pathsections));
906         return $path;
907     }
909     /**
910      * Convert a list of category names, possibly preceeded by one of the
911      * context tokens like $course$, into a string representation of the
912      * category path.
913      *
914      * Names are separated by / delimiters. And /s in the name are replaced by //.
915      *
916      * To reverse the process and split the paths into names, use
917      * {@link split_category_path()}.
918      *
919      * @param array $names
920      * @return string
921      */
922     protected function assemble_category_path($names) {
923         $escapednames = array();
924         foreach ($names as $name) {
925             $escapedname = str_replace('/', '//', $name);
926             if (substr($escapedname, 0, 1) == '/') {
927                 $escapedname = ' ' . $escapedname;
928             }
929             if (substr($escapedname, -1) == '/') {
930                 $escapedname = $escapedname . ' ';
931             }
932             $escapednames[] = $escapedname;
933         }
934         return implode('/', $escapednames);
935     }
937     /**
938      * Convert a string, as returned by {@link assemble_category_path()},
939      * back into an array of category names.
940      *
941      * Each category name is cleaned by a call to clean_param(, PARAM_TEXT),
942      * which matches the cleaning in question/category_form.php.
943      *
944      * @param string $path
945      * @return array of category names.
946      */
947     protected function split_category_path($path) {
948         $rawnames = preg_split('~(?<!/)/(?!/)~', $path);
949         $names = array();
950         foreach ($rawnames as $rawname) {
951             $names[] = clean_param(trim(str_replace('//', '/', $rawname)), PARAM_TEXT);
952         }
953         return $names;
954     }
956     /**
957      * Do an post-processing that may be required
958      * @return bool success
959      */
960     protected function exportpostprocess() {
961         return true;
962     }
964     /**
965      * convert a single question object into text output in the given
966      * format.
967      * This must be overriden
968      * @param object question question object
969      * @return mixed question export text or null if not implemented
970      */
971     protected function writequestion($question) {
972         // if not overidden, then this is an error.
973         throw new coding_exception('Question format plugin is missing important code: writequestion.');
974         return null;
975     }
977     /**
978      * Convert the question text to plain text, so it can safely be displayed
979      * during import to let the user see roughly what is going on.
980      */
981     protected function format_question_text($question) {
982         return s(question_utils::to_plain_text($question->questiontext,
983                 $question->questiontextformat));
984     }
987 class qformat_based_on_xml extends qformat_default {
989     /**
990      * A lot of imported files contain unwanted entities.
991      * This method tries to clean up all known problems.
992      * @param string str string to correct
993      * @return string the corrected string
994      */
995     public function cleaninput($str) {
997         $html_code_list = array(
998             "&#039;" => "'",
999             "&#8217;" => "'",
1000             "&#8220;" => "\"",
1001             "&#8221;" => "\"",
1002             "&#8211;" => "-",
1003             "&#8212;" => "-",
1004         );
1005         $str = strtr($str, $html_code_list);
1006         // Use core_text entities_to_utf8 function to convert only numerical entities.
1007         $str = core_text::entities_to_utf8($str, false);
1008         return $str;
1009     }
1011     /**
1012      * Return the array moodle is expecting
1013      * for an HTML text. No processing is done on $text.
1014      * qformat classes that want to process $text
1015      * for instance to import external images files
1016      * and recode urls in $text must overwrite this method.
1017      * @param array $text some HTML text string
1018      * @return array with keys text, format and files.
1019      */
1020     public function text_field($text) {
1021         return array(
1022             'text' => trim($text),
1023             'format' => FORMAT_HTML,
1024             'files' => array(),
1025         );
1026     }
1028     /**
1029      * Return the value of a node, given a path to the node
1030      * if it doesn't exist return the default value.
1031      * @param array xml data to read
1032      * @param array path path to node expressed as array
1033      * @param mixed default
1034      * @param bool istext process as text
1035      * @param string error if set value must exist, return false and issue message if not
1036      * @return mixed value
1037      */
1038     public function getpath($xml, $path, $default, $istext=false, $error='') {
1039         foreach ($path as $index) {
1040             if (!isset($xml[$index])) {
1041                 if (!empty($error)) {
1042                     $this->error($error);
1043                     return false;
1044                 } else {
1045                     return $default;
1046                 }
1047             }
1049             $xml = $xml[$index];
1050         }
1052         if ($istext) {
1053             if (!is_string($xml)) {
1054                 $this->error(get_string('invalidxml', 'qformat_xml'));
1055             }
1056             $xml = trim($xml);
1057         }
1059         return $xml;
1060     }