MDL-61410 question: merge course and question tags
[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 {
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      * Do the export
797      * For most types this should not need to be overrided
798      * @return stored_file
799      */
800     public function exportprocess() {
801         global $CFG, $OUTPUT, $DB, $USER;
803         // get the questions (from database) in this category
804         // only get q's with no parents (no cloze subquestions specifically)
805         if ($this->category) {
806             $questions = get_questions_category($this->category, true);
807         } else {
808             $questions = $this->questions;
809         }
811         $count = 0;
813         // results are first written into string (and then to a file)
814         // so create/initialize the string here
815         $expout = "";
817         // track which category questions are in
818         // if it changes we will record the category change in the output
819         // file if selected. 0 means that it will get printed before the 1st question
820         $trackcategory = 0;
822         // iterate through questions
823         foreach ($questions as $question) {
824             // used by file api
825             $contextid = $DB->get_field('question_categories', 'contextid',
826                     array('id' => $question->category));
827             $question->contextid = $contextid;
829             // do not export hidden questions
830             if (!empty($question->hidden)) {
831                 continue;
832             }
834             // do not export random questions
835             if ($question->qtype == 'random') {
836                 continue;
837             }
839             // check if we need to record category change
840             if ($this->cattofile) {
841                 if ($question->category != $trackcategory) {
842                     $trackcategory = $question->category;
843                     $categoryname = $this->get_category_path($trackcategory, $this->contexttofile);
845                     // create 'dummy' question for category export
846                     $dummyquestion = new stdClass();
847                     $dummyquestion->qtype = 'category';
848                     $dummyquestion->category = $categoryname;
849                     $dummyquestion->name = 'Switch category to ' . $categoryname;
850                     $dummyquestion->id = 0;
851                     $dummyquestion->questiontextformat = '';
852                     $dummyquestion->contextid = 0;
853                     $expout .= $this->writequestion($dummyquestion) . "\n";
854                 }
855             }
857             // export the question displaying message
858             $count++;
860             if (question_has_capability_on($question, 'view', $question->category)) {
861                 $expout .= $this->writequestion($question, $contextid) . "\n";
862             }
863         }
865         // continue path for following error checks
866         $course = $this->course;
867         $continuepath = "{$CFG->wwwroot}/question/export.php?courseid={$course->id}";
869         // did we actually process anything
870         if ($count==0) {
871             print_error('noquestions', 'question', $continuepath);
872         }
874         // final pre-process on exported data
875         $expout = $this->presave_process($expout);
876         return $expout;
877     }
879     /**
880      * get the category as a path (e.g., tom/dick/harry)
881      * @param int id the id of the most nested catgory
882      * @return string the path
883      */
884     protected function get_category_path($id, $includecontext = true) {
885         global $DB;
887         if (!$category = $DB->get_record('question_categories', array('id' => $id))) {
888             print_error('cannotfindcategory', 'error', '', $id);
889         }
890         $contextstring = $this->translator->context_to_string($category->contextid);
892         $pathsections = array();
893         do {
894             $pathsections[] = $category->name;
895             $id = $category->parent;
896         } while ($category = $DB->get_record('question_categories', array('id' => $id)));
898         if ($includecontext) {
899             $pathsections[] = '$' . $contextstring . '$';
900         }
902         $path = $this->assemble_category_path(array_reverse($pathsections));
904         return $path;
905     }
907     /**
908      * Convert a list of category names, possibly preceeded by one of the
909      * context tokens like $course$, into a string representation of the
910      * category path.
911      *
912      * Names are separated by / delimiters. And /s in the name are replaced by //.
913      *
914      * To reverse the process and split the paths into names, use
915      * {@link split_category_path()}.
916      *
917      * @param array $names
918      * @return string
919      */
920     protected function assemble_category_path($names) {
921         $escapednames = array();
922         foreach ($names as $name) {
923             $escapedname = str_replace('/', '//', $name);
924             if (substr($escapedname, 0, 1) == '/') {
925                 $escapedname = ' ' . $escapedname;
926             }
927             if (substr($escapedname, -1) == '/') {
928                 $escapedname = $escapedname . ' ';
929             }
930             $escapednames[] = $escapedname;
931         }
932         return implode('/', $escapednames);
933     }
935     /**
936      * Convert a string, as returned by {@link assemble_category_path()},
937      * back into an array of category names.
938      *
939      * Each category name is cleaned by a call to clean_param(, PARAM_TEXT),
940      * which matches the cleaning in question/category_form.php.
941      *
942      * @param string $path
943      * @return array of category names.
944      */
945     protected function split_category_path($path) {
946         $rawnames = preg_split('~(?<!/)/(?!/)~', $path);
947         $names = array();
948         foreach ($rawnames as $rawname) {
949             $names[] = clean_param(trim(str_replace('//', '/', $rawname)), PARAM_TEXT);
950         }
951         return $names;
952     }
954     /**
955      * Do an post-processing that may be required
956      * @return bool success
957      */
958     protected function exportpostprocess() {
959         return true;
960     }
962     /**
963      * convert a single question object into text output in the given
964      * format.
965      * This must be overriden
966      * @param object question question object
967      * @return mixed question export text or null if not implemented
968      */
969     protected function writequestion($question) {
970         // if not overidden, then this is an error.
971         throw new coding_exception('Question format plugin is missing important code: writequestion.');
972         return null;
973     }
975     /**
976      * Convert the question text to plain text, so it can safely be displayed
977      * during import to let the user see roughly what is going on.
978      */
979     protected function format_question_text($question) {
980         return question_utils::to_plain_text($question->questiontext,
981                 $question->questiontextformat);
982     }
985 class qformat_based_on_xml extends qformat_default {
987     /**
988      * A lot of imported files contain unwanted entities.
989      * This method tries to clean up all known problems.
990      * @param string str string to correct
991      * @return string the corrected string
992      */
993     public function cleaninput($str) {
995         $html_code_list = array(
996             "&#039;" => "'",
997             "&#8217;" => "'",
998             "&#8220;" => "\"",
999             "&#8221;" => "\"",
1000             "&#8211;" => "-",
1001             "&#8212;" => "-",
1002         );
1003         $str = strtr($str, $html_code_list);
1004         // Use core_text entities_to_utf8 function to convert only numerical entities.
1005         $str = core_text::entities_to_utf8($str, false);
1006         return $str;
1007     }
1009     /**
1010      * Return the array moodle is expecting
1011      * for an HTML text. No processing is done on $text.
1012      * qformat classes that want to process $text
1013      * for instance to import external images files
1014      * and recode urls in $text must overwrite this method.
1015      * @param array $text some HTML text string
1016      * @return array with keys text, format and files.
1017      */
1018     public function text_field($text) {
1019         return array(
1020             'text' => trim($text),
1021             'format' => FORMAT_HTML,
1022             'files' => array(),
1023         );
1024     }
1026     /**
1027      * Return the value of a node, given a path to the node
1028      * if it doesn't exist return the default value.
1029      * @param array xml data to read
1030      * @param array path path to node expressed as array
1031      * @param mixed default
1032      * @param bool istext process as text
1033      * @param string error if set value must exist, return false and issue message if not
1034      * @return mixed value
1035      */
1036     public function getpath($xml, $path, $default, $istext=false, $error='') {
1037         foreach ($path as $index) {
1038             if (!isset($xml[$index])) {
1039                 if (!empty($error)) {
1040                     $this->error($error);
1041                     return false;
1042                 } else {
1043                     return $default;
1044                 }
1045             }
1047             $xml = $xml[$index];
1048         }
1050         if ($istext) {
1051             if (!is_string($xml)) {
1052                 $this->error(get_string('invalidxml', 'qformat_xml'));
1053             }
1054             $xml = trim($xml);
1055         }
1057         return $xml;
1058     }