MDL-16094 File storage conversion Quiz and Questions
[moodle.git] / question / format.php
1 <?php
2 /**
3  * Base class for question import and export formats.
4  *
5  * @author Martin Dougiamas, Howard Miller, and many others.
6  *         {@link http://moodle.org}
7  * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
8  * @package questionbank
9  * @subpackage importexport
10  */
11 class qformat_default {
13     var $displayerrors = true;
14     var $category = NULL;
15     var $questions = array();
16     var $course = NULL;
17     var $filename = '';
18     var $realfilename = '';
19     var $matchgrades = 'error';
20     var $catfromfile = 0;
21     var $contextfromfile = 0;
22     var $cattofile = 0;
23     var $contexttofile = 0;
24     var $questionids = array();
25     var $importerrors = 0;
26     var $stoponerror = true;
27     var $translator = null;
28     var $canaccessbackupdata = true;
31 // functions to indicate import/export functionality
32 // override to return true if implemented
34     function provide_import() {
35       return false;
36     }
38     function provide_export() {
39       return false;
40     }
42 // Accessor methods
44     /**
45      * set the category
46      * @param object category the category object
47      */
48     function setCategory( $category ) {
49         if (count($this->questions)){
50             debugging('You shouldn\'t call setCategory after setQuestions');
51         }
52         $this->category = $category;
53     }
55     /**
56      * Set the specific questions to export. Should not include questions with
57      * parents (sub questions of cloze question type).
58      * Only used for question export.
59      * @param array of question objects
60      */
61     function setQuestions( $questions ) {
62         if ($this->category !== null){
63             debugging('You shouldn\'t call setQuestions after setCategory');
64         }
65         $this->questions = $questions;
66     }
68     /**
69      * set the course class variable
70      * @param course object Moodle course variable
71      */
72     function setCourse( $course ) {
73         $this->course = $course;
74     }
75     /**
76      * set an array of contexts.
77      * @param array $contexts Moodle course variable
78      */
79     function setContexts($contexts) {
80         $this->contexts = $contexts;
81         $this->translator = new context_to_string_translator($this->contexts);
82     }
84     /**
85      * set the filename
86      * @param string filename name of file to import/export
87      */
88     function setFilename( $filename ) {
89         $this->filename = $filename;
90     }
92     /**
93      * set the "real" filename
94      * (this is what the user typed, regardless of wha happened next)
95      * @param string realfilename name of file as typed by user
96      */
97     function setRealfilename( $realfilename ) {
98         $this->realfilename = $realfilename;
99     }
101     /**
102      * set matchgrades
103      * @param string matchgrades error or nearest for grades
104      */
105     function setMatchgrades( $matchgrades ) {
106         $this->matchgrades = $matchgrades;
107     }
109     /**
110      * set catfromfile
111      * @param bool catfromfile allow categories embedded in import file
112      */
113     function setCatfromfile( $catfromfile ) {
114         $this->catfromfile = $catfromfile;
115     }
117     /**
118      * set contextfromfile
119      * @param bool $contextfromfile allow contexts embedded in import file
120      */
121     function setContextfromfile($contextfromfile) {
122         $this->contextfromfile = $contextfromfile;
123     }
125     /**
126      * set cattofile
127      * @param bool cattofile exports categories within export file
128      */
129     function setCattofile( $cattofile ) {
130         $this->cattofile = $cattofile;
131     }
132     /**
133      * set contexttofile
134      * @param bool cattofile exports categories within export file
135      */
136     function setContexttofile($contexttofile) {
137         $this->contexttofile = $contexttofile;
138     }
140     /**
141      * set stoponerror
142      * @param bool stoponerror stops database write if any errors reported
143      */
144     function setStoponerror( $stoponerror ) {
145         $this->stoponerror = $stoponerror;
146     }
148     /**
149      * @param boolean $canaccess Whether the current use can access the backup data folder. Determines
150      * where export files are saved.
151      */
152     function set_can_access_backupdata($canaccess) {
153         $this->canaccessbackupdata = $canaccess;
154     }
156 /***********************
157  * IMPORTING FUNCTIONS
158  ***********************/
160     /**
161      * Handle parsing error
162      */
163     function error( $message, $text='', $questionname='' ) {
164         $importerrorquestion = get_string('importerrorquestion','quiz');
166         echo "<div class=\"importerror\">\n";
167         echo "<strong>$importerrorquestion $questionname</strong>";
168         if (!empty($text)) {
169             $text = s($text);
170             echo "<blockquote>$text</blockquote>\n";
171         }
172         echo "<strong>$message</strong>\n";
173         echo "</div>";
175          $this->importerrors++;
176     }
178     /**
179      * Import for questiontype plugins
180      * Do not override.
181      * @param data mixed The segment of data containing the question
182      * @param question object processed (so far) by standard import code if appropriate
183      * @param extra mixed any additional format specific data that may be passed by the format
184      * @param qtypehint hint about a question type from format
185      * @return object question object suitable for save_options() or false if cannot handle
186      */
187     function try_importing_using_qtypes( $data, $question=null, $extra=null, $qtypehint='') {
188         global $QTYPES;
190         // work out what format we are using
191         $formatname = substr(get_class($this), strlen('qformat_'));
192         $methodname = "import_from_$formatname";
194         //first try importing using a hint from format
195         if (!empty($qtypehint)) {
196             $qtype = $QTYPES[$qtypehint];
197             if (is_object($qtype) && method_exists($qtype, $methodname)) {
198                 $question = $qtype->$methodname($data, $question, $this, $extra);
199                 if ($question) {
200                     return $question;
201                 }
202             }
203         }
205         // loop through installed questiontypes checking for
206         // function to handle this question
207         foreach ($QTYPES as $qtype) {
208             if (method_exists( $qtype, $methodname)) {
209                 if ($question = $qtype->$methodname( $data, $question, $this, $extra )) {
210                     return $question;
211                 }
212             }
213         }
214         return false;
215     }
217     /**
218      * Perform any required pre-processing
219      * @return boolean success
220      */
221     function importpreprocess() {
222         return true;
223     }
225     /**
226      * Process the file
227      * This method should not normally be overidden
228      * @return boolean success
229      */
230     function importprocess() {
231         global $USER, $DB, $OUTPUT;
233        // reset the timer in case file upload was slow
234        @set_time_limit();
236        // STAGE 1: Parse the file
237        echo $OUTPUT->notification( get_string('parsingquestions','quiz') );
239         if (! $lines = $this->readdata($this->filename)) {
240             echo $OUTPUT->notification( get_string('cannotread','quiz') );
241             return false;
242         }
244         if (! $questions = $this->readquestions($lines)) {   // Extract all the questions
245             echo $OUTPUT->notification( get_string('noquestionsinfile','quiz') );
246             return false;
247         }
249         // STAGE 2: Write data to database
250         echo $OUTPUT->notification( get_string('importingquestions','quiz',$this->count_questions($questions)) );
252         // check for errors before we continue
253         if ($this->stoponerror and ($this->importerrors>0)) {
254             echo $OUTPUT->notification( get_string('importparseerror','quiz') );
255             return true;
256         }
258         // get list of valid answer grades
259         $grades = get_grade_options();
260         $gradeoptionsfull = $grades->gradeoptionsfull;
262         // check answer grades are valid
263         // (now need to do this here because of 'stop on error': MDL-10689)
264         $gradeerrors = 0;
265         $goodquestions = array();
266         foreach ($questions as $question) {
267             if (!empty($question->fraction) and (is_array($question->fraction))) {
268                 $fractions = $question->fraction;
269                 $answersvalid = true; // in case they are!
270                 foreach ($fractions as $key => $fraction) {
271                     $newfraction = match_grade_options($gradeoptionsfull, $fraction, $this->matchgrades);
272                     if ($newfraction===false) {
273                         $answersvalid = false;
274                     }
275                     else {
276                         $fractions[$key] = $newfraction;
277                     }
278                 }
279                 if (!$answersvalid) {
280                     echo $OUTPUT->notification(get_string('matcherror', 'quiz'));
281                     ++$gradeerrors;
282                     continue;
283                 }
284                 else {
285                     $question->fraction = $fractions;
286                 }
287             }
288             $goodquestions[] = $question;
289         }
290         $questions = $goodquestions;
292         // check for errors before we continue
293         if ($this->stoponerror and ($gradeerrors>0)) {
294             return false;
295         }
297         // count number of questions processed
298         $count = 0;
300         foreach ($questions as $question) {   // Process and store each question
302             // reset the php timeout
303             @set_time_limit();
305             // check for category modifiers
306             if ($question->qtype=='category') {
307                 if ($this->catfromfile) {
308                     // find/create category object
309                     $catpath = $question->category;
310                     $newcategory = $this->create_category_path($catpath);
311                     if (!empty($newcategory)) {
312                         $this->category = $newcategory;
313                     }
314                 }
315                 continue;
316             }
318             $count++;
320             echo "<hr /><p><b>$count</b>. ".$this->format_question_text($question)."</p>";
322             $question->category = $this->category->id;
323             $question->stamp = make_unique_id_code();  // Set the unique code (not to be changed)
325             $question->createdby = $USER->id;
326             $question->timecreated = time();
328             $question->id = $DB->insert_record("question", $question);
330             $this->questionids[] = $question->id;
332             // Now to save all the answers and type-specific options
334             global $QTYPES;
335             $result = $QTYPES[$question->qtype]
336                     ->save_question_options($question);
338             if (!empty($result->error)) {
339                 echo $OUTPUT->notification($result->error);
340                 return false;
341             }
343             if (!empty($result->notice)) {
344                 echo $OUTPUT->notification($result->notice);
345                 return true;
346             }
348             // Give the question a unique version stamp determined by question_hash()
349             $DB->set_field('question', 'version', question_hash($question), array('id'=>$question->id));
350         }
351         return true;
352     }
353     /**
354      * Count all non-category questions in the questions array.
355      *
356      * @param array questions An array of question objects.
357      * @return int The count.
358      *
359      */
360     function count_questions($questions) {
361         $count = 0;
362         if (!is_array($questions)) {
363             return $count;
364         }
365         foreach ($questions as $question) {
366             if (!is_object($question) || !isset($question->qtype) || ($question->qtype == 'category')) {
367                 continue;
368             }
369             $count++;
370         }
371         return $count;
372     }
374     /**
375      * find and/or create the category described by a delimited list
376      * e.g. $course$/tom/dick/harry or tom/dick/harry
377      *
378      * removes any context string no matter whether $getcontext is set
379      * but if $getcontext is set then ignore the context and use selected category context.
380      *
381      * @param string catpath delimited category path
382      * @param int courseid course to search for categories
383      * @return mixed category object or null if fails
384      */
385     function create_category_path($catpath) {
386         global $DB;
387         $catnames = $this->split_category_path($catpath);
388         $parent = 0;
389         $category = null;
391         // check for context id in path, it might not be there in pre 1.9 exports
392         $matchcount = preg_match('/^\$([a-z]+)\$$/', $catnames[0], $matches);
393         if ($matchcount==1) {
394             $contextid = $this->translator->string_to_context($matches[1]);
395             array_shift($catnames);
396         } else {
397             $contextid = false;
398         }
400         if ($this->contextfromfile && $contextid !== false) {
401             $context = get_context_instance_by_id($contextid);
402             require_capability('moodle/question:add', $context);
403         } else {
404             $context = get_context_instance_by_id($this->category->contextid);
405         }
407         // Now create any categories that need to be created.
408         foreach ($catnames as $catname) {
409             if ($category = $DB->get_record( 'question_categories', array('name' => $catname, 'contextid' => $context->id, 'parent' => $parent))) {
410                 $parent = $category->id;
411             } else {
412                 require_capability('moodle/question:managecategory', $context);
413                 // create the new category
414                 $category = new object;
415                 $category->contextid = $context->id;
416                 $category->name = $catname;
417                 $category->info = '';
418                 $category->parent = $parent;
419                 $category->sortorder = 999;
420                 $category->stamp = make_unique_id_code();
421                 $id = $DB->insert_record('question_categories', $category);
422                 $category->id = $id;
423                 $parent = $id;
424             }
425         }
426         return $category;
427     }
429     /**
430      * Return complete file within an array, one item per line
431      * @param string filename name of file
432      * @return mixed contents array or false on failure
433      */
434     function readdata($filename) {
435         if (is_readable($filename)) {
436             $filearray = file($filename);
438             /// Check for Macintosh OS line returns (ie file on one line), and fix
439             if (preg_match("~\r~", $filearray[0]) AND !preg_match("~\n~", $filearray[0])) {
440                 return explode("\r", $filearray[0]);
441             } else {
442                 return $filearray;
443             }
444         }
445         return false;
446     }
448     /**
449      * Parses an array of lines into an array of questions,
450      * where each item is a question object as defined by
451      * readquestion().   Questions are defined as anything
452      * between blank lines.
453      *
454      * If your format does not use blank lines as a delimiter
455      * then you will need to override this method. Even then
456      * try to use readquestion for each question
457      * @param array lines array of lines from readdata
458      * @return array array of question objects
459      */
460     function readquestions($lines) {
462         $questions = array();
463         $currentquestion = array();
465         foreach ($lines as $line) {
466             $line = trim($line);
467             if (empty($line)) {
468                 if (!empty($currentquestion)) {
469                     if ($question = $this->readquestion($currentquestion)) {
470                         $questions[] = $question;
471                     }
472                     $currentquestion = array();
473                 }
474             } else {
475                 $currentquestion[] = $line;
476             }
477         }
479         if (!empty($currentquestion)) {  // There may be a final question
480             if ($question = $this->readquestion($currentquestion)) {
481                 $questions[] = $question;
482             }
483         }
485         return $questions;
486     }
489     /**
490      * return an "empty" question
491      * Somewhere to specify question parameters that are not handled
492      * by import but are required db fields.
493      * This should not be overridden.
494      * @return object default question
495      */
496     function defaultquestion() {
497         global $CFG;
498         static $defaultshuffleanswers = null;
499         if (is_null($defaultshuffleanswers)) {
500             $defaultshuffleanswers = get_config('quiz', 'shuffleanswers');
501         }
503         $question = new stdClass();
504         $question->shuffleanswers = $defaultshuffleanswers;
505         $question->defaultgrade = 1;
506         $question->image = "";
507         $question->usecase = 0;
508         $question->multiplier = array();
509         $question->generalfeedback = '';
510         $question->correctfeedback = '';
511         $question->partiallycorrectfeedback = '';
512         $question->incorrectfeedback = '';
513         $question->answernumbering = 'abc';
514         $question->penalty = 0.1;
515         $question->length = 1;
517         // this option in case the questiontypes class wants
518         // to know where the data came from
519         $question->export_process = true;
520         $question->import_process = true;
522         return $question;
523     }
525     /**
526      * Given the data known to define a question in
527      * this format, this function converts it into a question
528      * object suitable for processing and insertion into Moodle.
529      *
530      * If your format does not use blank lines to delimit questions
531      * (e.g. an XML format) you must override 'readquestions' too
532      * @param $lines mixed data that represents question
533      * @return object question object
534      */
535     function readquestion($lines) {
537         $formatnotimplemented = get_string( 'formatnotimplemented','quiz' );
538         echo "<p>$formatnotimplemented</p>";
540         return NULL;
541     }
543     /**
544      * Override if any post-processing is required
545      * @return boolean success
546      */
547     function importpostprocess() {
548         return true;
549     }
551     /**
552      * Import an image file encoded in base64 format
553      * @param string path path (in course data) to store picture
554      * @param string base64 encoded picture
555      * @return string filename (nb. collisions are handled)
556      */
557     function importimagefile( $path, $base64 ) {
558         global $CFG;
560         // all this to get the destination directory
561         // and filename!
562         $fullpath = "{$CFG->dataroot}/{$this->course->id}/$path";
563         $path_parts = pathinfo( $fullpath );
564         $destination = $path_parts['dirname'];
565         $file = clean_filename( $path_parts['basename'] );
567         // check if path exists
568         check_dir_exists($destination, true, true );
570         // detect and fix any filename collision - get unique filename
571         $newfiles = resolve_filename_collisions( $destination, array($file) );
572         $newfile = $newfiles[0];
574         // convert and save file contents
575         if (!$content = base64_decode( $base64 )) {
576             return '';
577         }
578         $newfullpath = "$destination/$newfile";
579         if (!$fh = fopen( $newfullpath, 'w' )) {
580             return '';
581         }
582         if (!fwrite( $fh, $content )) {
583             return '';
584         }
585         fclose( $fh );
587         // return the (possibly) new filename
588         $newfile = preg_replace("~{$CFG->dataroot}/{$this->course->id}/~", '',$newfullpath);
589         return $newfile;
590     }
593 /*******************
594  * EXPORT FUNCTIONS
595  *******************/
597     /**
598      * Provide export functionality for plugin questiontypes
599      * Do not override
600      * @param name questiontype name
601      * @param question object data to export
602      * @param extra mixed any addition format specific data needed
603      * @return string the data to append to export or false if error (or unhandled)
604      */
605     function try_exporting_using_qtypes( $name, $question, $extra=null ) {
606         global $QTYPES;
608         // work out the name of format in use
609         $formatname = substr( get_class( $this ), strlen( 'qformat_' ));
610         $methodname = "export_to_$formatname";
612         if (array_key_exists( $name, $QTYPES )) {
613             $qtype = $QTYPES[ $name ];
614             if (method_exists( $qtype, $methodname )) {
615                 if ($data = $qtype->$methodname( $question, $this, $extra )) {
616                     return $data;
617                 }
618             }
619         }
620         return false;
621     }
623     /**
624      * Return the files extension appropriate for this type
625      * override if you don't want .txt
626      * @return string file extension
627      */
628     function export_file_extension() {
629         return ".txt";
630     }
632     /**
633      * Do any pre-processing that may be required
634      * @param boolean success
635      */
636     function exportpreprocess() {
637         return true;
638     }
640     /**
641      * Enable any processing to be done on the content
642      * just prior to the file being saved
643      * default is to do nothing
644      * @param string output text
645      * @param string processed output text
646      */
647     function presave_process( $content ) {
648         return $content;
649     }
651     /**
652      * Do the export
653      * For most types this should not need to be overrided
654      * @return boolean success
655      */
656     function exportprocess() {
657         global $CFG, $OUTPUT;
659         // create a directory for the exports (if not already existing)
660         if (! $export_dir = make_upload_directory($this->question_get_export_dir())) {
661               print_error('cannotcreatepath', 'quiz', $export_dir);
662         }
663         $path = $CFG->dataroot.'/'.$this->question_get_export_dir();
665         // get the questions (from database) in this category
666         // only get q's with no parents (no cloze subquestions specifically)
667         if ($this->category){
668             $questions = get_questions_category( $this->category, true );
669         } else {
670             $questions = $this->questions;
671         }
673         echo $OUTPUT->notification( get_string('exportingquestions','quiz') );
674         $count = 0;
676         // results are first written into string (and then to a file)
677         // so create/initialize the string here
678         $expout = "";
680         // track which category questions are in
681         // if it changes we will record the category change in the output
682         // file if selected. 0 means that it will get printed before the 1st question
683         $trackcategory = 0;
685         // iterate through questions
686         foreach($questions as $question) {
688             // do not export hidden questions
689             if (!empty($question->hidden)) {
690                 continue;
691             }
693             // do not export random questions
694             if ($question->qtype==RANDOM) {
695                 continue;
696             }
698             // check if we need to record category change
699             if ($this->cattofile) {
700                 if ($question->category != $trackcategory) {
701                     $trackcategory = $question->category;
702                     $categoryname = $this->get_category_path($trackcategory, $this->contexttofile);
704                     // create 'dummy' question for category export
705                     $dummyquestion = new object;
706                     $dummyquestion->qtype = 'category';
707                     $dummyquestion->category = $categoryname;
708                     $dummyquestion->name = 'Switch category to ' . $categoryname;
709                     $dummyquestion->id = 0;
710                     $dummyquestion->questiontextformat = '';
711                     $expout .= $this->writequestion($dummyquestion) . "\n";
712                 }
713             }
715             // export the question displaying message
716             $count++;
717             echo "<hr /><p><b>$count</b>. ".$this->format_question_text($question)."</p>";
718             if (question_has_capability_on($question, 'view', $question->category)){
719                 $expout .= $this->writequestion( $question ) . "\n";
720             }
721         }
723         // continue path for following error checks
724         $course = $this->course;
725         $continuepath = "$CFG->wwwroot/question/export.php?courseid=$course->id";
727         // did we actually process anything
728         if ($count==0) {
729             print_error( 'noquestions','quiz',$continuepath );
730         }
732         // final pre-process on exported data
733         $expout = $this->presave_process( $expout );
735         // write file
736         $filepath = $path."/".$this->filename . $this->export_file_extension();
737         if (!$fh=fopen($filepath,"w")) {
738             print_error( 'cannotopen','quiz',$continuepath,$filepath );
739         }
740         if (!fwrite($fh, $expout, strlen($expout) )) {
741             print_error( 'cannotwrite','quiz',$continuepath,$filepath );
742         }
743         fclose($fh);
744         return true;
745     }
747     /**
748      * get the category as a path (e.g., tom/dick/harry)
749      * @param int id the id of the most nested catgory
750      * @return string the path
751      */
752     function get_category_path($id, $includecontext = true) {
753         global $DB;
755         if (!$category = $DB->get_record('question_categories',array('id' =>$id))) {
756             print_error('cannotfindcategory', 'error', '', $id);
757         }
758         $contextstring = $this->translator->context_to_string($category->contextid);
760         $pathsections = array();
761         do {
762             $pathsections[] = $category->name;
763             $id = $category->parent;
764         } while ($category = $DB->get_record( 'question_categories', array('id' => $id )));
766         if ($includecontext){
767             $pathsections[] = '$' . $contextstring . '$';
768         }
770         $path = $this->assemble_category_path(array_reverse($pathsections));
772         return $path;
773     }
775     /**
776      * Convert a list of category names, possibly preceeded by one of the
777      * context tokens like $course$, into a string representation of the
778      * category path.
779      *
780      * Names are separated by / delimiters. And /s in the name are replaced by //.
781      *
782      * To reverse the process and split the paths into names, use
783      * {@link split_category_path()}.
784      *
785      * @param array $names
786      * @return string
787      */
788     protected function assemble_category_path($names) {
789         $escapednames = array();
790         foreach ($names as $name) {
791             $escapedname = str_replace('/', '//', $name);
792             if (substr($escapedname, 0, 1) == '/') {
793                 $escapedname = ' ' . $escapedname;
794             }
795             if (substr($escapedname, -1) == '/') {
796                 $escapedname = $escapedname . ' ';
797             }
798             $escapednames[] = $escapedname;
799         }
800         return implode('/', $escapednames);
801     }
803     /**
804      * Convert a string, as returned by {@link assemble_category_path()},
805      * back into an array of category names.
806      *
807      * Each category name is cleaned by a call to clean_param(, PARAM_MULTILANG),
808      * which matches the cleaning in question/category_form.php. Not that this
809      * addslashes the names, ready for insertion into the database.
810      *
811      * @param string $path
812      * @return array of category names.
813      */
814     protected function split_category_path($path) {
815         $rawnames = preg_split('~(?<!/)/(?!/)~', $path);
816         $names = array();
817         foreach ($rawnames as $rawname) {
818             $names[] = clean_param(trim(str_replace('//', '/', $rawname)), PARAM_MULTILANG);
819         }
820         return $names;
821     }
823     /**
824      * Do an post-processing that may be required
825      * @return boolean success
826      */
827     function exportpostprocess() {
828         return true;
829     }
831     /**
832      * convert a single question object into text output in the given
833      * format.
834      * This must be overriden
835      * @param object question question object
836      * @return mixed question export text or null if not implemented
837      */
838     function writequestion($question) {
839         // if not overidden, then this is an error.
840         $formatnotimplemented = get_string( 'formatnotimplemented','quiz' );
841         echo "<p>$formatnotimplemented</p>";
842         return NULL;
843     }
845     /**
846      * get directory into which export is going
847      * @return string file path
848      */
849     function question_get_export_dir() {
850         global $USER;
851         if ($this->canaccessbackupdata) {
852             $dirname = get_string("exportfilename","quiz");
853             $path = $this->course->id.'/backupdata/'.$dirname; // backupdata is protected directory
854         } else {
855             $path = 'temp/questionexport/' . $USER->id;
856         }
857         return $path;
858     }
860     /**
861      * where question specifies a moodle (text) format this
862      * performs the conversion.
863      */
864     function format_question_text($question) {
865         global $DB;
866         $formatoptions = new stdClass;
867         $formatoptions->noclean = true;
868         $formatoptions->para = false;
869         if (empty($question->questiontextformat)) {
870             $format = FORMAT_MOODLE;
871         } else {
872             $format = $question->questiontextformat;
873         }
874         $text = $question->questiontext;
875         return format_text(html_to_text($text), $format, $formatoptions);
876     }