Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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/>.
16
 
17
/**
18
 * GIFT format question importer/exporter.
19
 *
20
 * @package    qformat_gift
21
 * @copyright  2003 Paul Tsuchido Shew
22
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
 
25
 
26
defined('MOODLE_INTERNAL') || die();
27
 
28
 
29
/**
30
 * The GIFT import filter was designed as an easy to use method
31
 * for teachers writing questions as a text file. It supports most
32
 * question types and the missing word format.
33
 *
34
 * Multiple Choice / Missing Word
35
 *     Who's buried in Grant's tomb?{~Grant ~Jefferson =no one}
36
 *     Grant is {~buried =entombed ~living} in Grant's tomb.
37
 * True-False:
38
 *     Grant is buried in Grant's tomb.{FALSE}
39
 * Short-Answer.
40
 *     Who's buried in Grant's tomb?{=no one =nobody}
41
 * Numerical
42
 *     When was Ulysses S. Grant born?{#1822:5}
43
 * Matching
44
 *     Match the following countries with their corresponding
45
 *     capitals.{=Canada->Ottawa =Italy->Rome =Japan->Tokyo}
46
 *
47
 * Comment lines start with a double backslash (//).
48
 * Optional question names are enclosed in double colon(::).
49
 * Answer feedback is indicated with hash mark (#).
50
 * Percentage answer weights immediately follow the tilde (for
51
 * multiple choice) or equal sign (for short answer and numerical),
52
 * and are enclosed in percent signs (% %). See docs and examples.txt for more.
53
 *
54
 * This filter was written through the collaboration of numerous
55
 * members of the Moodle community. It was originally based on
56
 * the missingword format, which included code from Thomas Robb
57
 * and others. Paul Tsuchido Shew wrote this filter in December 2003.
58
 *
59
 * @copyright  2003 Paul Tsuchido Shew
60
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
61
 */
62
class qformat_gift extends qformat_default {
63
 
64
    public function provide_import() {
65
        return true;
66
    }
67
 
68
    public function provide_export() {
69
        return true;
70
    }
71
 
72
    public function export_file_extension() {
73
        return '.txt';
74
    }
75
 
76
    /**
77
     * Validate the given file.
78
     *
79
     * For more expensive or detailed integrity checks.
80
     *
81
     * @param stored_file $file the file to check
82
     * @return string the error message that occurred while validating the given file
83
     */
84
    public function validate_file(stored_file $file): string {
85
        return $this->validate_is_utf8_file($file);
86
    }
87
 
88
    protected function answerweightparser(&$answer) {
89
        $answer = substr($answer, 1);                        // Removes initial %.
90
        $endposition  = strpos($answer, "%");
91
        $answerweight = substr($answer, 0, $endposition);  // Gets weight as integer.
92
        $answerweight = $answerweight/100;                 // Converts to percent.
93
        $answer = substr($answer, $endposition+1);          // Removes comment from answer.
94
        return $answerweight;
95
    }
96
 
97
    protected function commentparser($answer, $defaultformat) {
98
        $bits = explode('#', $answer, 2);
99
        $ans = $this->parse_text_with_format(trim($bits[0]), $defaultformat);
100
        if (count($bits) > 1) {
101
            $feedback = $this->parse_text_with_format(trim($bits[1]), $defaultformat);
102
        } else {
103
            $feedback = array('text' => '', 'format' => $defaultformat, 'files' => array());
104
        }
105
        return array($ans, $feedback);
106
    }
107
 
108
    protected function split_truefalse_comment($answer, $defaultformat) {
109
        $bits = explode('#', $answer, 3);
110
        $ans = $this->parse_text_with_format(trim($bits[0]), $defaultformat);
111
        if (count($bits) > 1) {
112
            $wrongfeedback = $this->parse_text_with_format(trim($bits[1]), $defaultformat);
113
        } else {
114
            $wrongfeedback = array('text' => '', 'format' => $defaultformat, 'files' => array());
115
        }
116
        if (count($bits) > 2) {
117
            $rightfeedback = $this->parse_text_with_format(trim($bits[2]), $defaultformat);
118
        } else {
119
            $rightfeedback = array('text' => '', 'format' => $defaultformat, 'files' => array());
120
        }
121
        return array($ans, $wrongfeedback, $rightfeedback);
122
    }
123
 
124
    protected function escapedchar_pre($string) {
125
        // Replaces escaped control characters with a placeholder BEFORE processing.
126
 
127
        $escapedcharacters = array("\\:",    "\\#",    "\\=",    "\\{",    "\\}",    "\\~",    "\\n"  );
128
        $placeholders      = array("&&058;", "&&035;", "&&061;", "&&123;", "&&125;", "&&126;", "&&010");
129
 
130
        $string = str_replace("\\\\", "&&092;", $string);
131
        $string = str_replace($escapedcharacters, $placeholders, $string);
132
        $string = str_replace("&&092;", "\\", $string);
133
        return $string;
134
    }
135
 
136
    protected function escapedchar_post($string) {
137
        // Replaces placeholders with corresponding character AFTER processing is done.
138
        $placeholders = array("&&058;", "&&035;", "&&061;", "&&123;", "&&125;", "&&126;", "&&010");
139
        $characters   = array(":",     "#",      "=",      "{",      "}",      "~",      "\n"  );
140
        $string = str_replace($placeholders, $characters, $string);
141
        return $string;
142
    }
143
 
144
    protected function check_answer_count($min, $answers, $text) {
145
        $countanswers = count($answers);
146
        if ($countanswers < $min) {
147
            $this->error(get_string('importminerror', 'qformat_gift'), $text);
148
            return false;
149
        }
150
 
151
        return true;
152
    }
153
 
154
    protected function parse_text_with_format($text, $defaultformat = FORMAT_MOODLE) {
155
        $result = array(
156
            'text' => $text,
157
            'format' => $defaultformat,
158
            'files' => array(),
159
        );
160
        if (strpos($text, '[') === 0) {
161
            $formatend = strpos($text, ']');
162
            $result['format'] = $this->format_name_to_const(substr($text, 1, $formatend - 1));
163
            if ($result['format'] == -1) {
164
                $result['format'] = $defaultformat;
165
            } else {
166
                $result['text'] = substr($text, $formatend + 1);
167
            }
168
        }
169
        $result['text'] = trim($this->escapedchar_post($result['text']));
170
        return $result;
171
    }
172
 
173
    public function readquestion($lines) {
174
        // Given an array of lines known to define a question in this format, this function
175
        // converts it into a question object suitable for processing and insertion into Moodle.
176
 
177
        $question = $this->defaultquestion();
178
        // Define replaced by simple assignment, stop redefine notices.
179
        $giftanswerweightregex = '/^%\-*([0-9]{1,2})\.?([0-9]*)%/';
180
 
181
        // Separate comments and implode.
182
        $comments = '';
183
        foreach ($lines as $key => $line) {
184
            $line = trim($line);
185
            if (substr($line, 0, 2) == '//') {
186
                $comments .= $line . "\n";
187
                $lines[$key] = ' ';
188
            }
189
        }
190
        $text = trim(implode("\n", $lines));
191
 
192
        if ($text == '') {
193
            return false;
194
        }
195
 
196
        // Substitute escaped control characters with placeholders.
197
        $text = $this->escapedchar_pre($text);
198
 
199
        // Look for category modifier.
200
        if (preg_match('~^\$CATEGORY:~', $text)) {
201
            $newcategory = trim(substr($text, 10));
202
 
203
            // Build fake question to contain category.
204
            $question->qtype = 'category';
205
            $question->category = $newcategory;
206
            return $question;
207
        }
208
 
209
        // Question name parser.
210
        if (substr($text, 0, 2) == '::') {
211
            $text = substr($text, 2);
212
 
213
            $namefinish = strpos($text, '::');
214
            if ($namefinish === false) {
215
                $question->name = false;
216
                // Name will be assigned after processing question text below.
217
            } else {
218
                $questionname = substr($text, 0, $namefinish);
219
                $question->name = $this->clean_question_name($this->escapedchar_post($questionname));
220
                $text = trim(substr($text, $namefinish+2)); // Remove name from text.
221
            }
222
        } else {
223
            $question->name = false;
224
        }
225
 
226
        // Find the answer section.
227
        $answerstart = strpos($text, '{');
228
        $answerfinish = strpos($text, '}');
229
 
230
        $description = false;
231
        if ($answerstart === false && $answerfinish === false) {
232
            // No answer means it's a description.
233
            $description = true;
234
            $answertext = '';
235
            $answerlength = 0;
236
 
237
        } else if ($answerstart === false || $answerfinish === false) {
238
            $this->error(get_string('braceerror', 'qformat_gift'), $text);
239
            return false;
240
 
241
        } else {
242
            $answerlength = $answerfinish - $answerstart;
243
            $answertext = trim(substr($text, $answerstart + 1, $answerlength - 1));
244
        }
245
 
246
        // Format the question text, without answer, inserting "_____" as necessary.
247
        if ($description) {
248
            $questiontext = $text;
249
        } else if (substr($text, -1) == "}") {
250
            // No blank line if answers follow question, outside of closing punctuation.
251
            $questiontext = substr_replace($text, "", $answerstart, $answerlength + 1);
252
        } else {
253
            // Inserts blank line for missing word format.
254
            $questiontext = substr_replace($text, "_____", $answerstart, $answerlength + 1);
255
        }
256
 
257
        // Look to see if there is any general feedback.
258
        $gfseparator = strrpos($answertext, '####');
259
        if ($gfseparator === false) {
260
            $generalfeedback = '';
261
        } else {
262
            $generalfeedback = substr($answertext, $gfseparator + 4);
263
            $answertext = trim(substr($answertext, 0, $gfseparator));
264
        }
265
 
266
        // Get questiontext format from questiontext.
267
        $text = $this->parse_text_with_format($questiontext);
268
        $question->questiontextformat = $text['format'];
269
        $question->questiontext = $text['text'];
270
 
271
        // Get generalfeedback format from questiontext.
272
        $text = $this->parse_text_with_format($generalfeedback, $question->questiontextformat);
273
        $question->generalfeedback = $text['text'];
274
        $question->generalfeedbackformat = $text['format'];
275
 
276
        // Set question name if not already set.
277
        if ($question->name === false) {
278
            $question->name = $this->create_default_question_name($question->questiontext, get_string('questionname', 'question'));
279
        }
280
 
281
        // Determine question type.
282
        $question->qtype = null;
283
 
284
        // Extract any idnumber and tags from the comments.
285
        list($question->idnumber, $question->tags) = $this->extract_idnumber_and_tags_from_comment($comments);
286
 
287
        // Give plugins first try.
288
        // Plugins must promise not to intercept standard qtypes
289
        // MDL-12346, this could be called from lesson mod which has its own base class =(.
290
        if (method_exists($this, 'try_importing_using_qtypes')
291
                && ($tryquestion = $this->try_importing_using_qtypes($lines, $question, $answertext))) {
292
            return $tryquestion;
293
        }
294
 
295
        if ($description) {
296
            $question->qtype = 'description';
297
 
298
        } else if ($answertext == '') {
299
            $question->qtype = 'essay';
300
 
301
        } else if ($answertext[0] == '#') {
302
            $question->qtype = 'numerical';
303
 
304
        } else if (strpos($answertext, '~') !== false) {
305
            // Only Multiplechoice questions contain tilde ~.
306
            $question->qtype = 'multichoice';
307
 
308
        } else if (strpos($answertext, '=')  !== false
309
                && strpos($answertext, '->') !== false) {
310
            // Only Matching contains both = and ->.
311
            $question->qtype = 'match';
312
 
313
        } else { // Either truefalse or shortanswer.
314
 
315
            // Truefalse question check.
316
            $truefalsecheck = $answertext;
317
            if (strpos($answertext, '#') > 0) {
318
                // Strip comments to check for TrueFalse question.
319
                $truefalsecheck = trim(substr($answertext, 0, strpos($answertext, "#")));
320
            }
321
 
322
            $validtfanswers = array('T', 'TRUE', 'F', 'FALSE');
323
            if (in_array($truefalsecheck, $validtfanswers)) {
324
                $question->qtype = 'truefalse';
325
 
326
            } else { // Must be shortanswer.
327
                $question->qtype = 'shortanswer';
328
            }
329
        }
330
 
331
        if (!isset($question->qtype)) {
332
            $giftqtypenotset = get_string('giftqtypenotset', 'qformat_gift');
333
            $this->error($giftqtypenotset, $text);
334
            return false;
335
        }
336
 
337
        switch ($question->qtype) {
338
            case 'description':
339
                $question->defaultmark = 0;
340
                $question->length = 0;
341
                return $question;
342
 
343
            case 'essay':
344
                $question->responseformat = 'editor';
345
                $question->responserequired = 1;
346
                $question->responsefieldlines = 15;
347
                $question->attachments = 0;
348
                $question->attachmentsrequired = 0;
349
                $question->graderinfo = array(
350
                        'text' => '', 'format' => FORMAT_HTML, 'files' => array());
351
                $question->responsetemplate = array(
352
                        'text' => '', 'format' => FORMAT_HTML);
353
                return $question;
354
 
355
            case 'multichoice':
356
                // "Temporary" solution to enable choice of answernumbering on GIFT import
357
                // by respecting default set for multichoice questions (MDL-59447)
358
                $question->answernumbering = get_config('qtype_multichoice', 'answernumbering');
359
 
360
                if (strpos($answertext, "=") === false) {
361
                    $question->single = 0; // Multiple answers are enabled if no single answer is 100% correct.
362
                } else {
363
                    $question->single = 1; // Only one answer allowed (the default).
364
                }
365
                $question = $this->add_blank_combined_feedback($question);
366
 
367
                $answertext = str_replace("=", "~=", $answertext);
368
                $answers = explode("~", $answertext);
369
                if (isset($answers[0])) {
370
                    $answers[0] = trim($answers[0]);
371
                }
372
                if (empty($answers[0])) {
373
                    array_shift($answers);
374
                }
375
 
376
                $countanswers = count($answers);
377
 
378
                if (!$this->check_answer_count(2, $answers, $text)) {
379
                    return false;
380
                }
381
 
382
                foreach ($answers as $key => $answer) {
383
                    $answer = trim($answer);
384
 
385
                    // Determine answer weight.
386
                    if ($answer[0] == '=') {
387
                        $answerweight = 1;
388
                        $answer = substr($answer, 1);
389
 
390
                    } else if (preg_match($giftanswerweightregex, $answer)) {    // Check for properly formatted answer weight.
391
                        $answerweight = $this->answerweightparser($answer);
392
 
393
                    } else {     // Default, i.e., wrong anwer.
394
                        $answerweight = 0;
395
                    }
396
                    list($question->answer[$key], $question->feedback[$key]) =
397
                            $this->commentparser($answer, $question->questiontextformat);
398
                    $question->fraction[$key] = $answerweight;
399
                }  // End foreach answer.
400
 
401
                return $question;
402
 
403
            case 'match':
404
                $question = $this->add_blank_combined_feedback($question);
405
 
406
                $answers = explode('=', $answertext);
407
                if (isset($answers[0])) {
408
                    $answers[0] = trim($answers[0]);
409
                }
410
                if (empty($answers[0])) {
411
                    array_shift($answers);
412
                }
413
 
414
                if (!$this->check_answer_count(2, $answers, $text)) {
415
                    return false;
416
                }
417
 
418
                foreach ($answers as $key => $answer) {
419
                    $answer = trim($answer);
420
                    if (strpos($answer, "->") === false) {
421
                        $this->error(get_string('giftmatchingformat', 'qformat_gift'), $answer);
422
                        return false;
423
                    }
424
 
425
                    $marker = strpos($answer, '->');
426
                    $question->subquestions[$key] = $this->parse_text_with_format(
427
                            substr($answer, 0, $marker), $question->questiontextformat);
428
                    $question->subanswers[$key] = trim($this->escapedchar_post(
429
                            substr($answer, $marker + 2)));
430
                }
431
 
432
                return $question;
433
 
434
            case 'truefalse':
435
                list($answer, $wrongfeedback, $rightfeedback) =
436
                        $this->split_truefalse_comment($answertext, $question->questiontextformat);
437
 
438
                if ($answer['text'] == "T" || $answer['text'] == "TRUE") {
439
                    $question->correctanswer = 1;
440
                    $question->feedbacktrue = $rightfeedback;
441
                    $question->feedbackfalse = $wrongfeedback;
442
                } else {
443
                    $question->correctanswer = 0;
444
                    $question->feedbacktrue = $wrongfeedback;
445
                    $question->feedbackfalse = $rightfeedback;
446
                }
447
 
448
                $question->penalty = 1;
449
 
450
                return $question;
451
 
452
            case 'shortanswer':
453
                // Shortanswer question.
454
                $answers = explode("=", $answertext);
455
                if (isset($answers[0])) {
456
                    $answers[0] = trim($answers[0]);
457
                }
458
                if (empty($answers[0])) {
459
                    array_shift($answers);
460
                }
461
 
462
                if (!$this->check_answer_count(1, $answers, $text)) {
463
                    return false;
464
                }
465
 
466
                foreach ($answers as $key => $answer) {
467
                    $answer = trim($answer);
468
 
469
                    // Answer weight.
470
                    if (preg_match($giftanswerweightregex, $answer)) {    // Check for properly formatted answer weight.
471
                        $answerweight = $this->answerweightparser($answer);
472
                    } else {     // Default, i.e., full-credit anwer.
473
                        $answerweight = 1;
474
                    }
475
 
476
                    list($answer, $question->feedback[$key]) = $this->commentparser(
477
                            $answer, $question->questiontextformat);
478
 
479
                    $question->answer[$key] = $answer['text'];
480
                    $question->fraction[$key] = $answerweight;
481
                }
482
 
483
                return $question;
484
 
485
            case 'numerical':
486
                // Note similarities to ShortAnswer.
487
                $answertext = substr($answertext, 1); // Remove leading "#".
488
 
489
                // If there is feedback for a wrong answer, store it for now.
490
                if (($pos = strpos($answertext, '~')) !== false) {
491
                    $wrongfeedback = substr($answertext, $pos);
492
                    $answertext = substr($answertext, 0, $pos);
493
                } else {
494
                    $wrongfeedback = '';
495
                }
496
 
497
                $answers = explode("=", $answertext);
498
                if (isset($answers[0])) {
499
                    $answers[0] = trim($answers[0]);
500
                }
501
                if (empty($answers[0])) {
502
                    array_shift($answers);
503
                }
504
 
505
                if (count($answers) == 0) {
506
                    // Invalid question.
507
                    $giftnonumericalanswers = get_string('giftnonumericalanswers', 'qformat_gift');
508
                    $this->error($giftnonumericalanswers, $text);
509
                    return false;
510
                }
511
 
512
                foreach ($answers as $key => $answer) {
513
                    $answer = trim($answer);
514
 
515
                    // Answer weight.
516
                    if (preg_match($giftanswerweightregex, $answer)) {    // Check for properly formatted answer weight.
517
                        $answerweight = $this->answerweightparser($answer);
518
                    } else {     // Default, i.e., full-credit anwer.
519
                        $answerweight = 1;
520
                    }
521
 
522
                    list($answer, $question->feedback[$key]) = $this->commentparser(
523
                            $answer, $question->questiontextformat);
524
                    $question->fraction[$key] = $answerweight;
525
                    $answer = $answer['text'];
526
 
527
                    // Calculate Answer and Min/Max values.
528
                    if (strpos($answer, "..") > 0) { // Optional [min]..[max] format.
529
                        $marker = strpos($answer, "..");
530
                        $max = trim(substr($answer, $marker + 2));
531
                        $min = trim(substr($answer, 0, $marker));
532
                        $ans = ($max + $min)/2;
533
                        $tol = $max - $ans;
534
                    } else if (strpos($answer, ':') > 0) { // Standard [answer]:[errormargin] format.
535
                        $marker = strpos($answer, ':');
536
                        $tol = trim(substr($answer, $marker+1));
537
                        $ans = trim(substr($answer, 0, $marker));
538
                    } else { // Only one valid answer (zero errormargin).
539
                        $tol = 0;
540
                        $ans = trim($answer);
541
                    }
542
 
543
                    if (!(is_numeric($ans) || $ans = '*') || !is_numeric($tol)) {
544
                            $errornotnumbers = get_string('errornotnumbers');
545
                            $this->error($errornotnumbers, $text);
546
                        return false;
547
                    }
548
 
549
                    // Store results.
550
                    $question->answer[$key] = $ans;
551
                    $question->tolerance[$key] = $tol;
552
                }
553
 
554
                if ($wrongfeedback) {
555
                    $key += 1;
556
                    $question->fraction[$key] = 0;
557
                    list($notused, $question->feedback[$key]) = $this->commentparser(
558
                            $wrongfeedback, $question->questiontextformat);
559
                    $question->answer[$key] = '*';
560
                    $question->tolerance[$key] = '';
561
                }
562
 
563
                return $question;
564
 
565
            default:
566
                $this->error(get_string('giftnovalidquestion', 'qformat_gift'), $text);
567
                return false;
568
 
569
        }
570
    }
571
 
572
    protected function repchar($text, $notused = 0) {
573
        // Escapes 'reserved' characters # = ~ {) :
574
        // Removes new lines.
575
        $reserved = array(  '\\',  '#', '=', '~', '{', '}', ':', "\n", "\r");
576
        $escaped =  array('\\\\', '\#', '\=', '\~', '\{', '\}', '\:', '\n', '');
577
 
578
        $newtext = str_replace($reserved, $escaped, $text);
579
        return $newtext;
580
    }
581
 
582
    /**
583
     * @param int $format one of the FORMAT_ constants.
584
     * @return string the corresponding name.
585
     */
586
    protected function format_const_to_name($format) {
587
        if ($format == FORMAT_MOODLE) {
588
            return 'moodle';
589
        } else if ($format == FORMAT_HTML) {
590
            return 'html';
591
        } else if ($format == FORMAT_PLAIN) {
592
            return 'plain';
593
        } else if ($format == FORMAT_MARKDOWN) {
594
            return 'markdown';
595
        } else {
596
            return 'moodle';
597
        }
598
    }
599
 
600
    /**
601
     * @param int $format one of the FORMAT_ constants.
602
     * @return string the corresponding name.
603
     */
604
    protected function format_name_to_const($format) {
605
        if ($format == 'moodle') {
606
            return FORMAT_MOODLE;
607
        } else if ($format == 'html') {
608
            return FORMAT_HTML;
609
        } else if ($format == 'plain') {
610
            return FORMAT_PLAIN;
611
        } else if ($format == 'markdown') {
612
            return FORMAT_MARKDOWN;
613
        } else {
614
            return -1;
615
        }
616
    }
617
 
618
    /**
619
     * Extract any tags or idnumber declared in the question comment.
620
     *
621
     * @param string $comment E.g. "// Line 1.\n//Line 2.\n".
622
     * @return array with two elements. string $idnumber (or '') and string[] of tags.
623
     */
624
    public function extract_idnumber_and_tags_from_comment(string $comment): array {
625
 
626
        // Find the idnumber, if any. There should not be more than one, but if so, we just find the first.
627
        $idnumber = '';
628
        if (preg_match('~
629
                # Start of id token.
630
                \[id:
631
 
632
                # Any number of (non-control) characters, with any ] escaped.
633
                # This is the bit we want so capture it.
634
                (
635
                    (?:\\\\]|[^][:cntrl:]])+
636
                )
637
 
638
                # End of id token.
639
                ]
640
                ~x', $comment, $match)) {
641
            $idnumber = str_replace('\]', ']', trim($match[1]));
642
        }
643
 
644
        // Find any tags.
645
        $tags = [];
646
        if (preg_match_all('~
647
                # Start of tag token.
648
                \[tag:
649
 
650
                # Any number of allowed characters (see PARAM_TAG), with any ] escaped.
651
                # This is the bit we want so capture it.
652
                (
653
                    (?:\\\\]|[^]<>`[:cntrl:]]|)+
654
                )
655
 
656
                # End of tag token.
657
                ]
658
                ~x', $comment, $matches)) {
659
            foreach ($matches[1] as $rawtag) {
660
                $tags[] = str_replace('\]', ']', trim($rawtag));
661
            }
662
        }
663
 
664
        return [$idnumber, $tags];
665
    }
666
 
667
    public function write_name($name) {
668
        return '::' . $this->repchar($name) . '::';
669
    }
670
 
671
    public function write_questiontext($text, $format, $defaultformat = FORMAT_MOODLE) {
672
        $output = '';
673
        if ($text != '' && $format != $defaultformat) {
674
            $output .= '[' . $this->format_const_to_name($format) . ']';
675
        }
676
        $output .= $this->repchar($text, $format);
677
        return $output;
678
    }
679
 
680
    /**
681
     * Outputs the general feedback for the question, if any. This needs to be the
682
     * last thing before the }.
683
     * @param object $question the question data.
684
     * @param string $indent to put before the general feedback. Defaults to a tab.
685
     *      If this is not blank, a newline is added after the line.
686
     */
687
    public function write_general_feedback($question, $indent = "\t") {
688
        $generalfeedback = $this->write_questiontext($question->generalfeedback,
689
                $question->generalfeedbackformat, $question->questiontextformat);
690
 
691
        if ($generalfeedback) {
692
            $generalfeedback = '####' . $generalfeedback;
693
            if ($indent) {
694
                $generalfeedback = $indent . $generalfeedback . "\n";
695
            }
696
        }
697
 
698
        return $generalfeedback;
699
    }
700
 
701
    public function writequestion($question) {
702
 
703
        // Start with a comment.
704
        $expout = "// question: {$question->id}  name: {$question->name}\n";
705
        $expout .= $this->write_idnumber_and_tags($question);
706
 
707
        // Output depends on question type.
708
        switch($question->qtype) {
709
 
710
            case 'category':
711
                // Not a real question, used to insert category switch.
712
                $expout .= "\$CATEGORY: $question->category\n";
713
                break;
714
 
715
            case 'description':
716
                $expout .= $this->write_name($question->name);
717
                $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
718
                break;
719
 
720
            case 'essay':
721
                $expout .= $this->write_name($question->name);
722
                $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
723
                $expout .= "{";
724
                $expout .= $this->write_general_feedback($question, '');
725
                $expout .= "}\n";
726
                break;
727
 
728
            case 'truefalse':
729
                $trueanswer = $question->options->answers[$question->options->trueanswer];
730
                $falseanswer = $question->options->answers[$question->options->falseanswer];
731
                if ($trueanswer->fraction == 1) {
732
                    $answertext = 'TRUE';
733
                    $rightfeedback = $this->write_questiontext($trueanswer->feedback,
734
                            $trueanswer->feedbackformat, $question->questiontextformat);
735
                    $wrongfeedback = $this->write_questiontext($falseanswer->feedback,
736
                            $falseanswer->feedbackformat, $question->questiontextformat);
737
                } else {
738
                    $answertext = 'FALSE';
739
                    $rightfeedback = $this->write_questiontext($falseanswer->feedback,
740
                            $falseanswer->feedbackformat, $question->questiontextformat);
741
                    $wrongfeedback = $this->write_questiontext($trueanswer->feedback,
742
                            $trueanswer->feedbackformat, $question->questiontextformat);
743
                }
744
 
745
                $expout .= $this->write_name($question->name);
746
                $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
747
                $expout .= '{' . $this->repchar($answertext);
748
                if ($wrongfeedback) {
749
                    $expout .= '#' . $wrongfeedback;
750
                } else if ($rightfeedback) {
751
                    $expout .= '#';
752
                }
753
                if ($rightfeedback) {
754
                    $expout .= '#' . $rightfeedback;
755
                }
756
                $expout .= $this->write_general_feedback($question, '');
757
                $expout .= "}\n";
758
                break;
759
 
760
            case 'multichoice':
761
                $expout .= $this->write_name($question->name);
762
                $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
763
                $expout .= "{\n";
764
                foreach ($question->options->answers as $answer) {
765
                    if ($answer->fraction == 1 && $question->options->single) {
766
                        $answertext = '=';
767
                    } else if ($answer->fraction == 0) {
768
                        $answertext = '~';
769
                    } else {
770
                        $weight = $answer->fraction * 100;
771
                        $answertext = '~%' . $weight . '%';
772
                    }
773
                    $expout .= "\t" . $answertext . $this->write_questiontext($answer->answer,
774
                                $answer->answerformat, $question->questiontextformat);
775
                    if ($answer->feedback != '') {
776
                        $expout .= '#' . $this->write_questiontext($answer->feedback,
777
                                $answer->feedbackformat, $question->questiontextformat);
778
                    }
779
                    $expout .= "\n";
780
                }
781
                $expout .= $this->write_general_feedback($question);
782
                $expout .= "}\n";
783
                break;
784
 
785
            case 'shortanswer':
786
                $expout .= $this->write_name($question->name);
787
                $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
788
                $expout .= "{\n";
789
                foreach ($question->options->answers as $answer) {
790
                    $weight = 100 * $answer->fraction;
791
                    $expout .= "\t=%" . $weight . '%' . $this->repchar($answer->answer) .
792
                            '#' . $this->write_questiontext($answer->feedback,
793
                                $answer->feedbackformat, $question->questiontextformat) . "\n";
794
                }
795
                $expout .= $this->write_general_feedback($question);
796
                $expout .= "}\n";
797
                break;
798
 
799
            case 'numerical':
800
                $expout .= $this->write_name($question->name);
801
                $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
802
                $expout .= "{#\n";
803
                foreach ($question->options->answers as $answer) {
804
                    if ($answer->answer != '' && $answer->answer != '*') {
805
                        $weight = 100 * $answer->fraction;
806
                        $expout .= "\t=%" . $weight . '%' . $answer->answer . ':' .
807
                                (float)$answer->tolerance . '#' . $this->write_questiontext($answer->feedback,
808
                                $answer->feedbackformat, $question->questiontextformat) . "\n";
809
                    } else {
810
                        $expout .= "\t~#" . $this->write_questiontext($answer->feedback,
811
                                $answer->feedbackformat, $question->questiontextformat) . "\n";
812
                    }
813
                }
814
                $expout .= $this->write_general_feedback($question);
815
                $expout .= "}\n";
816
                break;
817
 
818
            case 'match':
819
                $expout .= $this->write_name($question->name);
820
                $expout .= $this->write_questiontext($question->questiontext, $question->questiontextformat);
821
                $expout .= "{\n";
822
                foreach ($question->options->subquestions as $subquestion) {
823
                    $expout .= "\t=" . $this->write_questiontext($subquestion->questiontext,
824
                            $subquestion->questiontextformat, $question->questiontextformat) .
825
                            ' -> ' . $this->repchar($subquestion->answertext) . "\n";
826
                }
827
                $expout .= $this->write_general_feedback($question);
828
                $expout .= "}\n";
829
                break;
830
 
831
            default:
832
                // Check for plugins.
833
                if ($out = $this->try_exporting_using_qtypes($question->qtype, $question)) {
834
                    $expout .= $out;
835
                }
836
        }
837
 
838
        // Add empty line to delimit questions.
839
        $expout .= "\n";
840
        return $expout;
841
    }
842
 
843
    /**
844
     * Prepare any question idnumber or tags for export.
845
     *
846
     * @param stdClass $questiondata the question data we are exporting.
847
     * @return string a string that can be written as a line in the GIFT file,
848
     *      e.g. "// [id:myid] [tag:some-tag]\n". Will be '' if none.
849
     */
850
    public function write_idnumber_and_tags(stdClass $questiondata): string {
851
        if ($questiondata->qtype == 'category') {
852
            return '';
853
        }
854
 
855
        $bits = [];
856
 
857
        if (isset($questiondata->idnumber) && $questiondata->idnumber !== '') {
858
            $bits[] = '[id:' . str_replace(']', '\]', $questiondata->idnumber) . ']';
859
        }
860
 
861
        // Write the question tags.
862
        if (core_tag_tag::is_enabled('core_question', 'question')) {
863
            $tagobjects = core_tag_tag::get_item_tags('core_question', 'question', $questiondata->id);
864
 
865
            if (!empty($tagobjects)) {
866
                $context = context::instance_by_id($questiondata->contextid);
867
                $sortedtagobjects = question_sort_tags($tagobjects, $context, [$this->course]);
868
 
869
                // Currently we ignore course tags. This should probably be fixed in future.
870
 
871
                if (!empty($sortedtagobjects->tags)) {
872
                    foreach ($sortedtagobjects->tags as $tag) {
873
                        $bits[] = '[tag:' . str_replace(']', '\]', $tag) . ']';
874
                    }
875
                }
876
            }
877
        }
878
 
879
        if (!$bits) {
880
            return '';
881
        }
882
 
883
        return '// ' . implode(' ', $bits) . "\n";
884
    }
885
}