Merged branch 'MDL-27692_master' of git://github.com/MaryE/moodle.git
[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;
55     protected $importcontext = null;
57 // functions to indicate import/export functionality
58 // override to return true if implemented
60     /** @return bool whether this plugin provides import functionality. */
61     public function provide_import() {
62         return false;
63     }
65     /** @return bool whether this plugin provides export functionality. */
66     public function provide_export() {
67         return false;
68     }
70     /** The string mime-type of the files that this plugin reads or writes. */
71     public function mime_type() {
72         return mimeinfo('type', $this->export_file_extension());
73     }
75     /**
76      * @return string the file extension (including .) that is normally used for
77      * files handled by this plugin.
78      */
79     public function export_file_extension() {
80         return '.txt';
81     }
83 // Accessor methods
85     /**
86      * set the category
87      * @param object category the category object
88      */
89     public function setCategory($category) {
90         if (count($this->questions)) {
91             debugging('You shouldn\'t call setCategory after setQuestions');
92         }
93         $this->category = $category;
94     }
96     /**
97      * Set the specific questions to export. Should not include questions with
98      * parents (sub questions of cloze question type).
99      * Only used for question export.
100      * @param array of question objects
101      */
102     public function setQuestions($questions) {
103         if ($this->category !== null) {
104             debugging('You shouldn\'t call setQuestions after setCategory');
105         }
106         $this->questions = $questions;
107     }
109     /**
110      * set the course class variable
111      * @param course object Moodle course variable
112      */
113     public function setCourse($course) {
114         $this->course = $course;
115     }
117     /**
118      * set an array of contexts.
119      * @param array $contexts Moodle course variable
120      */
121     public function setContexts($contexts) {
122         $this->contexts = $contexts;
123         $this->translator = new context_to_string_translator($this->contexts);
124     }
126     /**
127      * set the filename
128      * @param string filename name of file to import/export
129      */
130     public function setFilename($filename) {
131         $this->filename = $filename;
132     }
134     /**
135      * set the "real" filename
136      * (this is what the user typed, regardless of wha happened next)
137      * @param string realfilename name of file as typed by user
138      */
139     public function setRealfilename($realfilename) {
140         $this->realfilename = $realfilename;
141     }
143     /**
144      * set matchgrades
145      * @param string matchgrades error or nearest for grades
146      */
147     public function setMatchgrades($matchgrades) {
148         $this->matchgrades = $matchgrades;
149     }
151     /**
152      * set catfromfile
153      * @param bool catfromfile allow categories embedded in import file
154      */
155     public function setCatfromfile($catfromfile) {
156         $this->catfromfile = $catfromfile;
157     }
159     /**
160      * set contextfromfile
161      * @param bool $contextfromfile allow contexts embedded in import file
162      */
163     public function setContextfromfile($contextfromfile) {
164         $this->contextfromfile = $contextfromfile;
165     }
167     /**
168      * set cattofile
169      * @param bool cattofile exports categories within export file
170      */
171     public function setCattofile($cattofile) {
172         $this->cattofile = $cattofile;
173     }
175     /**
176      * set contexttofile
177      * @param bool cattofile exports categories within export file
178      */
179     public function setContexttofile($contexttofile) {
180         $this->contexttofile = $contexttofile;
181     }
183     /**
184      * set stoponerror
185      * @param bool stoponerror stops database write if any errors reported
186      */
187     public function setStoponerror($stoponerror) {
188         $this->stoponerror = $stoponerror;
189     }
191     /**
192      * @param bool $canaccess Whether the current use can access the backup data folder. Determines
193      * where export files are saved.
194      */
195     public function set_can_access_backupdata($canaccess) {
196         $this->canaccessbackupdata = $canaccess;
197     }
199 /***********************
200  * IMPORTING FUNCTIONS
201  ***********************/
203     /**
204      * Handle parsing error
205      */
206     protected function error($message, $text='', $questionname='') {
207         $importerrorquestion = get_string('importerrorquestion', 'question');
209         echo "<div class=\"importerror\">\n";
210         echo "<strong>$importerrorquestion $questionname</strong>";
211         if (!empty($text)) {
212             $text = s($text);
213             echo "<blockquote>$text</blockquote>\n";
214         }
215         echo "<strong>$message</strong>\n";
216         echo "</div>";
218          $this->importerrors++;
219     }
221     /**
222      * Import for questiontype plugins
223      * Do not override.
224      * @param data mixed The segment of data containing the question
225      * @param question object processed (so far) by standard import code if appropriate
226      * @param extra mixed any additional format specific data that may be passed by the format
227      * @param qtypehint hint about a question type from format
228      * @return object question object suitable for save_options() or false if cannot handle
229      */
230     public function try_importing_using_qtypes($data, $question = null, $extra = null,
231             $qtypehint = '') {
233         // work out what format we are using
234         $formatname = substr(get_class($this), strlen('qformat_'));
235         $methodname = "import_from_$formatname";
237         //first try importing using a hint from format
238         if (!empty($qtypehint)) {
239             $qtype = question_bank::get_qtype($qtypehint, false);
240             if (is_object($qtype) && method_exists($qtype, $methodname)) {
241                 $question = $qtype->$methodname($data, $question, $this, $extra);
242                 if ($question) {
243                     return $question;
244                 }
245             }
246         }
248         // loop through installed questiontypes checking for
249         // function to handle this question
250         foreach (question_bank::get_all_qtypes() as $qtype) {
251             if (method_exists($qtype, $methodname)) {
252                 if ($question = $qtype->$methodname($data, $question, $this, $extra)) {
253                     return $question;
254                 }
255             }
256         }
257         return false;
258     }
260     /**
261      * Perform any required pre-processing
262      * @return bool success
263      */
264     public function importpreprocess() {
265         return true;
266     }
268     /**
269      * Process the file
270      * This method should not normally be overidden
271      * @param object $category
272      * @return bool success
273      */
274     public function importprocess($category) {
275         global $USER, $CFG, $DB, $OUTPUT;
277         $context = $category->context;
278         $this->importcontext = $context;
280         // reset the timer in case file upload was slow
281         set_time_limit(0);
283         // STAGE 1: Parse the file
284         echo $OUTPUT->notification(get_string('parsingquestions', 'question'), 'notifysuccess');
286         if (! $lines = $this->readdata($this->filename)) {
287             echo $OUTPUT->notification(get_string('cannotread', 'question'));
288             return false;
289         }
291         if (!$questions = $this->readquestions($lines, $context)) {   // Extract all the questions
292             echo $OUTPUT->notification(get_string('noquestionsinfile', 'question'));
293             return false;
294         }
296         // STAGE 2: Write data to database
297         echo $OUTPUT->notification(get_string('importingquestions', 'question',
298                 $this->count_questions($questions)), 'notifysuccess');
300         // check for errors before we continue
301         if ($this->stoponerror and ($this->importerrors>0)) {
302             echo $OUTPUT->notification(get_string('importparseerror', 'question'));
303             return true;
304         }
306         // get list of valid answer grades
307         $grades = get_grade_options();
308         $gradeoptionsfull = $grades->gradeoptionsfull;
310         // check answer grades are valid
311         // (now need to do this here because of 'stop on error': MDL-10689)
312         $gradeerrors = 0;
313         $goodquestions = array();
314         foreach ($questions as $question) {
315             if (!empty($question->fraction) and (is_array($question->fraction))) {
316                 $fractions = $question->fraction;
317                 $answersvalid = true; // in case they are!
318                 foreach ($fractions as $key => $fraction) {
319                     $newfraction = match_grade_options($gradeoptionsfull, $fraction, $this->matchgrades);
320                     if ($newfraction===false) {
321                         $answersvalid = false;
322                     }
323                     else {
324                         $fractions[$key] = $newfraction;
325                     }
326                 }
327                 if (!$answersvalid) {
328                     echo $OUTPUT->notification(get_string('invalidgrade', 'question'));
329                     ++$gradeerrors;
330                     continue;
331                 }
332                 else {
333                     $question->fraction = $fractions;
334                 }
335             }
336             $goodquestions[] = $question;
337         }
338         $questions = $goodquestions;
340         // check for errors before we continue
341         if ($this->stoponerror and ($gradeerrors>0)) {
342             return false;
343         }
345         // count number of questions processed
346         $count = 0;
348         foreach ($questions as $question) {   // Process and store each question
350             // reset the php timeout
351             set_time_limit(0);
353             // check for category modifiers
354             if ($question->qtype == 'category') {
355                 if ($this->catfromfile) {
356                     // find/create category object
357                     $catpath = $question->category;
358                     $newcategory = $this->create_category_path($catpath);
359                     if (!empty($newcategory)) {
360                         $this->category = $newcategory;
361                     }
362                 }
363                 continue;
364             }
365             $question->context = $context;
367             $count++;
369             echo "<hr /><p><b>$count</b>. ".$this->format_question_text($question)."</p>";
371             $question->category = $this->category->id;
372             $question->stamp = make_unique_id_code();  // Set the unique code (not to be changed)
374             $question->createdby = $USER->id;
375             $question->timecreated = time();
377             $question->id = $DB->insert_record('question', $question);
378             if (isset($question->questiontextfiles)) {
379                 foreach ($question->questiontextfiles as $file) {
380                     question_bank::get_qtype($question->qtype)->import_file(
381                             $context, 'question', 'questiontext', $question->id, $file);
382                 }
383             }
384             if (isset($question->generalfeedbackfiles)) {
385                 foreach ($question->generalfeedbackfiles as $file) {
386                     question_bank::get_qtype($question->qtype)->import_file(
387                             $context, 'question', 'generalfeedback', $question->id, $file);
388                 }
389             }
391             $this->questionids[] = $question->id;
393             // Now to save all the answers and type-specific options
395             $result = question_bank::get_qtype($question->qtype)->save_question_options($question);
397             if (!empty($CFG->usetags) && isset($question->tags)) {
398                 require_once($CFG->dirroot . '/tag/lib.php');
399                 tag_set('question', $question->id, $question->tags);
400             }
402             if (!empty($result->error)) {
403                 echo $OUTPUT->notification($result->error);
404                 return false;
405             }
407             if (!empty($result->notice)) {
408                 echo $OUTPUT->notification($result->notice);
409                 return true;
410             }
412             // Give the question a unique version stamp determined by question_hash()
413             $DB->set_field('question', 'version', question_hash($question), array('id'=>$question->id));
414         }
415         return true;
416     }
418     /**
419      * Count all non-category questions in the questions array.
420      *
421      * @param array questions An array of question objects.
422      * @return int The count.
423      *
424      */
425     protected function count_questions($questions) {
426         $count = 0;
427         if (!is_array($questions)) {
428             return $count;
429         }
430         foreach ($questions as $question) {
431             if (!is_object($question) || !isset($question->qtype) || ($question->qtype == 'category')) {
432                 continue;
433             }
434             $count++;
435         }
436         return $count;
437     }
439     /**
440      * find and/or create the category described by a delimited list
441      * e.g. $course$/tom/dick/harry or tom/dick/harry
442      *
443      * removes any context string no matter whether $getcontext is set
444      * but if $getcontext is set then ignore the context and use selected category context.
445      *
446      * @param string catpath delimited category path
447      * @param int courseid course to search for categories
448      * @return mixed category object or null if fails
449      */
450     protected function create_category_path($catpath) {
451         global $DB;
452         $catnames = $this->split_category_path($catpath);
453         $parent = 0;
454         $category = null;
456         // check for context id in path, it might not be there in pre 1.9 exports
457         $matchcount = preg_match('/^\$([a-z]+)\$$/', $catnames[0], $matches);
458         if ($matchcount == 1) {
459             $contextid = $this->translator->string_to_context($matches[1]);
460             array_shift($catnames);
461         } else {
462             $contextid = false;
463         }
465         if ($this->contextfromfile && $contextid !== false) {
466             $context = get_context_instance_by_id($contextid);
467             require_capability('moodle/question:add', $context);
468         } else {
469             $context = get_context_instance_by_id($this->category->contextid);
470         }
472         // Now create any categories that need to be created.
473         foreach ($catnames as $catname) {
474             if ($category = $DB->get_record('question_categories', array('name' => $catname, 'contextid' => $context->id, 'parent' => $parent))) {
475                 $parent = $category->id;
476             } else {
477                 require_capability('moodle/question:managecategory', $context);
478                 // create the new category
479                 $category = new stdClass();
480                 $category->contextid = $context->id;
481                 $category->name = $catname;
482                 $category->info = '';
483                 $category->parent = $parent;
484                 $category->sortorder = 999;
485                 $category->stamp = make_unique_id_code();
486                 $id = $DB->insert_record('question_categories', $category);
487                 $category->id = $id;
488                 $parent = $id;
489             }
490         }
491         return $category;
492     }
494     /**
495      * Return complete file within an array, one item per line
496      * @param string filename name of file
497      * @return mixed contents array or false on failure
498      */
499     protected function readdata($filename) {
500         if (is_readable($filename)) {
501             $filearray = file($filename);
503             /// Check for Macintosh OS line returns (ie file on one line), and fix
504             if (preg_match("~\r~", $filearray[0]) AND !preg_match("~\n~", $filearray[0])) {
505                 return explode("\r", $filearray[0]);
506             } else {
507                 return $filearray;
508             }
509         }
510         return false;
511     }
513     /**
514      * Parses an array of lines into an array of questions,
515      * where each item is a question object as defined by
516      * readquestion().   Questions are defined as anything
517      * between blank lines.
518      *
519      * If your format does not use blank lines as a delimiter
520      * then you will need to override this method. Even then
521      * try to use readquestion for each question
522      * @param array lines array of lines from readdata
523      * @param object $context
524      * @return array array of question objects
525      */
526     protected function readquestions($lines, $context) {
528         $questions = array();
529         $currentquestion = array();
531         foreach ($lines as $line) {
532             $line = trim($line);
533             if (empty($line)) {
534                 if (!empty($currentquestion)) {
535                     if ($question = $this->readquestion($currentquestion)) {
536                         $questions[] = $question;
537                     }
538                     $currentquestion = array();
539                 }
540             } else {
541                 $currentquestion[] = $line;
542             }
543         }
545         if (!empty($currentquestion)) {  // There may be a final question
546             if ($question = $this->readquestion($currentquestion, $context)) {
547                 $questions[] = $question;
548             }
549         }
551         return $questions;
552     }
554     /**
555      * return an "empty" question
556      * Somewhere to specify question parameters that are not handled
557      * by import but are required db fields.
558      * This should not be overridden.
559      * @return object default question
560      */
561     protected function defaultquestion() {
562         global $CFG;
563         static $defaultshuffleanswers = null;
564         if (is_null($defaultshuffleanswers)) {
565             $defaultshuffleanswers = get_config('quiz', 'shuffleanswers');
566         }
568         $question = new stdClass();
569         $question->shuffleanswers = $defaultshuffleanswers;
570         $question->defaultmark = 1;
571         $question->image = "";
572         $question->usecase = 0;
573         $question->multiplier = array();
574         $question->generalfeedback = '';
575         $question->correctfeedback = '';
576         $question->partiallycorrectfeedback = '';
577         $question->incorrectfeedback = '';
578         $question->answernumbering = 'abc';
579         $question->penalty = 0.3333333;
580         $question->length = 1;
582         // this option in case the questiontypes class wants
583         // to know where the data came from
584         $question->export_process = true;
585         $question->import_process = true;
587         return $question;
588     }
590     /**
591      * Given the data known to define a question in
592      * this format, this function converts it into a question
593      * object suitable for processing and insertion into Moodle.
594      *
595      * If your format does not use blank lines to delimit questions
596      * (e.g. an XML format) you must override 'readquestions' too
597      * @param $lines mixed data that represents question
598      * @return object question object
599      */
600     protected function readquestion($lines) {
602         $formatnotimplemented = get_string('formatnotimplemented', 'question');
603         echo "<p>$formatnotimplemented</p>";
605         return null;
606     }
608     /**
609      * Override if any post-processing is required
610      * @return bool success
611      */
612     public function importpostprocess() {
613         return true;
614     }
617 /*******************
618  * EXPORT FUNCTIONS
619  *******************/
621     /**
622      * Provide export functionality for plugin questiontypes
623      * Do not override
624      * @param name questiontype name
625      * @param question object data to export
626      * @param extra mixed any addition format specific data needed
627      * @return string the data to append to export or false if error (or unhandled)
628      */
629     protected function try_exporting_using_qtypes($name, $question, $extra=null) {
630         // work out the name of format in use
631         $formatname = substr(get_class($this), strlen('qformat_'));
632         $methodname = "export_to_$formatname";
634         $qtype = question_bank::get_qtype($name, false);
635         if (method_exists($qtype, $methodname)) {
636             return $qtype->$methodname($question, $this, $extra);
637         }
638         return false;
639     }
641     /**
642      * Do any pre-processing that may be required
643      * @param bool success
644      */
645     public function exportpreprocess() {
646         return true;
647     }
649     /**
650      * Enable any processing to be done on the content
651      * just prior to the file being saved
652      * default is to do nothing
653      * @param string output text
654      * @param string processed output text
655      */
656     protected function presave_process($content) {
657         return $content;
658     }
660     /**
661      * Do the export
662      * For most types this should not need to be overrided
663      * @return stored_file
664      */
665     public function exportprocess() {
666         global $CFG, $OUTPUT, $DB, $USER;
668         // get the questions (from database) in this category
669         // only get q's with no parents (no cloze subquestions specifically)
670         if ($this->category) {
671             $questions = get_questions_category($this->category, true);
672         } else {
673             $questions = $this->questions;
674         }
676         $count = 0;
678         // results are first written into string (and then to a file)
679         // so create/initialize the string here
680         $expout = "";
682         // track which category questions are in
683         // if it changes we will record the category change in the output
684         // file if selected. 0 means that it will get printed before the 1st question
685         $trackcategory = 0;
687         // iterate through questions
688         foreach($questions as $question) {
689             // used by file api
690             $contextid = $DB->get_field('question_categories', 'contextid', array('id'=>$question->category));
691             $question->contextid = $contextid;
693             // do not export hidden questions
694             if (!empty($question->hidden)) {
695                 continue;
696             }
698             // do not export random questions
699             if ($question->qtype==RANDOM) {
700                 continue;
701             }
703             // check if we need to record category change
704             if ($this->cattofile) {
705                 if ($question->category != $trackcategory) {
706                     $trackcategory = $question->category;
707                     $categoryname = $this->get_category_path($trackcategory, $this->contexttofile);
709                     // create 'dummy' question for category export
710                     $dummyquestion = new stdClass();
711                     $dummyquestion->qtype = 'category';
712                     $dummyquestion->category = $categoryname;
713                     $dummyquestion->name = 'Switch category to ' . $categoryname;
714                     $dummyquestion->id = 0;
715                     $dummyquestion->questiontextformat = '';
716                     $dummyquestion->contextid = 0;
717                     $expout .= $this->writequestion($dummyquestion) . "\n";
718                 }
719             }
721             // export the question displaying message
722             $count++;
724             if (question_has_capability_on($question, 'view', $question->category)) {
725                 $expout .= $this->writequestion($question, $contextid) . "\n";
726             }
727         }
729         // continue path for following error checks
730         $course = $this->course;
731         $continuepath = "$CFG->wwwroot/question/export.php?courseid=$course->id";
733         // did we actually process anything
734         if ($count==0) {
735             print_error('noquestions', 'question', $continuepath);
736         }
738         // final pre-process on exported data
739         $expout = $this->presave_process($expout);
740         return $expout;
741     }
743     /**
744      * get the category as a path (e.g., tom/dick/harry)
745      * @param int id the id of the most nested catgory
746      * @return string the path
747      */
748     protected function get_category_path($id, $includecontext = true) {
749         global $DB;
751         if (!$category = $DB->get_record('question_categories',array('id' =>$id))) {
752             print_error('cannotfindcategory', 'error', '', $id);
753         }
754         $contextstring = $this->translator->context_to_string($category->contextid);
756         $pathsections = array();
757         do {
758             $pathsections[] = $category->name;
759             $id = $category->parent;
760         } while ($category = $DB->get_record('question_categories', array('id' => $id)));
762         if ($includecontext) {
763             $pathsections[] = '$' . $contextstring . '$';
764         }
766         $path = $this->assemble_category_path(array_reverse($pathsections));
768         return $path;
769     }
771     /**
772      * Convert a list of category names, possibly preceeded by one of the
773      * context tokens like $course$, into a string representation of the
774      * category path.
775      *
776      * Names are separated by / delimiters. And /s in the name are replaced by //.
777      *
778      * To reverse the process and split the paths into names, use
779      * {@link split_category_path()}.
780      *
781      * @param array $names
782      * @return string
783      */
784     protected function assemble_category_path($names) {
785         $escapednames = array();
786         foreach ($names as $name) {
787             $escapedname = str_replace('/', '//', $name);
788             if (substr($escapedname, 0, 1) == '/') {
789                 $escapedname = ' ' . $escapedname;
790             }
791             if (substr($escapedname, -1) == '/') {
792                 $escapedname = $escapedname . ' ';
793             }
794             $escapednames[] = $escapedname;
795         }
796         return implode('/', $escapednames);
797     }
799     /**
800      * Convert a string, as returned by {@link assemble_category_path()},
801      * back into an array of category names.
802      *
803      * Each category name is cleaned by a call to clean_param(, PARAM_MULTILANG),
804      * which matches the cleaning in question/category_form.php.
805      *
806      * @param string $path
807      * @return array of category names.
808      */
809     protected function split_category_path($path) {
810         $rawnames = preg_split('~(?<!/)/(?!/)~', $path);
811         $names = array();
812         foreach ($rawnames as $rawname) {
813             $names[] = clean_param(trim(str_replace('//', '/', $rawname)), PARAM_MULTILANG);
814         }
815         return $names;
816     }
818     /**
819      * Do an post-processing that may be required
820      * @return bool success
821      */
822     protected function exportpostprocess() {
823         return true;
824     }
826     /**
827      * convert a single question object into text output in the given
828      * format.
829      * This must be overriden
830      * @param object question question object
831      * @return mixed question export text or null if not implemented
832      */
833     protected function writequestion($question) {
834         // if not overidden, then this is an error.
835         $formatnotimplemented = get_string('formatnotimplemented', 'question');
836         echo "<p>$formatnotimplemented</p>";
837         return null;
838     }
840     /**
841      * Convert the question text to plain text, so it can safely be displayed
842      * during import to let the user see roughly what is going on.
843      */
844     protected function format_question_text($question) {
845         global $DB;
846         $formatoptions = new stdClass();
847         $formatoptions->noclean = true;
848         return html_to_text(format_text($question->questiontext,
849                 $question->questiontextformat, $formatoptions), 0, false);
850     }
852     /**
853      * convert files into text output in the given format.
854      * @param array
855      * @param string encoding method
856      * @return string $string
857      */
858     protected function writefiles($files, $encoding='base64') {
859         if (empty($files)) {
860             return '';
861         }
862         $string = '';
863         foreach ($files as $file) {
864             if ($file->is_directory()) {
865                 continue;
866             }
867             $string .= '<file name="' . $file->get_filename() . '" encoding="' . $encoding . '">';
868             $string .= base64_encode($file->get_content());
869             $string .= '</file>';
870         }
871         return $string;
872     }