PageRenderTime 43ms CodeModel.GetById 17ms RepoModel.GetById 1ms app.codeStats 0ms

/question/format/gift/format.php

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