MDL-71205 Default options for qtype_numerical
[moodle.git] / question / type / numerical / questiontype.php
CommitLineData
aeb15530 1<?php
fe6ce234
DC
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
f77f47ec 17
1fe641f7 18/**
f77f47ec
TH
19 * Question type class for the numerical question type.
20 *
7764183a
TH
21 * @package qtype
22 * @subpackage numerical
23 * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
24 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
fe6ce234 25 */
516cf3eb 26
7764183a 27
a17b297d
TH
28defined('MOODLE_INTERNAL') || die();
29
603bd001 30require_once($CFG->libdir . '/questionlib.php');
1da821bb
TH
31require_once($CFG->dirroot . '/question/type/numerical/question.php');
32
f77f47ec 33
1fe641f7 34/**
f77f47ec 35 * The numerical question type class.
1fe641f7 36 *
37 * This class contains some special features in order to make the
38 * question type embeddable within a multianswer (cloze) question
39 *
7764183a
TH
40 * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
41 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1fe641f7 42 */
f77f47ec 43class qtype_numerical extends question_type {
1fa39364 44 const UNITINPUT = 0;
8566369f
TH
45 const UNITRADIO = 1;
46 const UNITSELECT = 2;
1fa39364
TH
47
48 const UNITNONE = 3;
1fa39364
TH
49 const UNITGRADED = 1;
50 const UNITOPTIONAL = 0;
51
52 const UNITGRADEDOUTOFMARK = 1;
53 const UNITGRADEDOUTOFMAX = 2;
54
48aad79a
JMV
55 /**
56 * Validate that a string is a number formatted correctly for the current locale.
57 * @param string $x a string
58 * @return bool whether $x is a number that the numerical question type can interpret.
59 */
60 public static function is_valid_number(string $x) : bool {
61 $ap = new qtype_numerical_answer_processor(array());
62 list($value, $unit) = $ap->apply_units($x);
63 return !is_null($value) && !$unit;
64 }
65
f77f47ec
TH
66 public function get_question_options($question) {
67 global $CFG, $DB, $OUTPUT;
397bd295 68 parent::get_question_options($question);
516cf3eb 69 // Get the question answers and their respective tolerances
32a189d6 70 // Note: question_numerical is an extension of the answer table rather than
4f48fb42 71 // the question table as is usually the case for qtype
516cf3eb 72 // specific tables.
f34488b2 73 if (!$question->options->answers = $DB->get_records_sql(
516cf3eb 74 "SELECT a.*, n.tolerance " .
f34488b2 75 "FROM {question_answers} a, " .
76 " {question_numerical} n " .
77 "WHERE a.question = ? " .
026bec73 78 " AND a.id = n.answer " .
f34488b2 79 "ORDER BY a.id ASC", array($question->id))) {
fdd015b7
TH
80 echo $OUTPUT->notification('Error: Missing question answer for numerical question ' .
81 $question->id . '!');
516cf3eb 82 return false;
83 }
f77f47ec 84
fdd015b7
TH
85 $question->hints = $DB->get_records('question_hints',
86 array('questionid' => $question->id), 'id ASC');
f77f47ec 87
516cf3eb 88 $this->get_numerical_units($question);
3d9645ae 89 // Get_numerical_options() need to know if there are units
90 // to set correctly default values.
cf146692 91 $this->get_numerical_options($question);
516cf3eb 92
5a14d563 93 // If units are defined we strip off the default unit from the answer, if
516cf3eb 94 // it is present. (Required for compatibility with the old code and DB).
95 if ($defaultunit = $this->get_default_numerical_unit($question)) {
fdd015b7 96 foreach ($question->options->answers as $key => $val) {
516cf3eb 97 $answer = trim($val->answer);
98 $length = strlen($defaultunit->unit);
1fe641f7 99 if ($length && substr($answer, -$length) == $defaultunit->unit) {
516cf3eb 100 $question->options->answers[$key]->answer =
1fe641f7 101 substr($answer, 0, strlen($answer)-$length);
516cf3eb 102 }
103 }
104 }
c5da9906 105
106 return true;
107 }
f77f47ec
TH
108
109 public function get_numerical_units(&$question) {
92b36005 110 global $DB;
f77f47ec
TH
111
112 if ($units = $DB->get_records('question_numerical_units',
113 array('question' => $question->id), 'id ASC')) {
114 $units = array_values($units);
92b36005
PP
115 } else {
116 $units = array();
117 }
118 foreach ($units as $key => $unit) {
61cca0b7 119 $units[$key]->multiplier = clean_param($unit->multiplier, PARAM_FLOAT);
92b36005
PP
120 }
121 $question->options->units = $units;
122 return true;
123 }
124
fdd015b7 125 public function get_default_numerical_unit($question) {
92b36005
PP
126 if (isset($question->options->units[0])) {
127 foreach ($question->options->units as $unit) {
128 if (abs($unit->multiplier - 1.0) < '1.0e-' . ini_get('precision')) {
129 return $unit;
130 }
131 }
132 }
133 return false;
134 }
135
fdd015b7 136 public function get_numerical_options($question) {
c5da9906 137 global $DB;
fdd015b7
TH
138 if (!$options = $DB->get_record('question_numerical_options',
139 array('question' => $question->id))) {
52ad7e0c
TH
140 // Old question, set defaults.
141 $question->options->unitgradingtype = 0;
142 $question->options->unitpenalty = 0.1;
cf146692 143 if ($defaultunit = $this->get_default_numerical_unit($question)) {
52ad7e0c 144 $question->options->showunits = self::UNITINPUT;
fdd015b7 145 } else {
e0736817 146 $question->options->showunits = self::UNITNONE;
cf146692 147 }
52ad7e0c 148 $question->options->unitsleft = 0;
52ad7e0c 149
c5da9906 150 } else {
cf146692
PP
151 $question->options->unitgradingtype = $options->unitgradingtype;
152 $question->options->unitpenalty = $options->unitpenalty;
fe6ce234
DC
153 $question->options->showunits = $options->showunits;
154 $question->options->unitsleft = $options->unitsleft;
c5da9906 155 }
aeb15530 156
516cf3eb 157 return true;
158 }
fe6ce234 159
2d18de87
MK
160 public function save_defaults_for_new_questions(stdClass $fromform): void {
161 parent::save_defaults_for_new_questions($fromform);
162 $this->set_default_value('unitrole', $fromform->unitrole);
163 $this->set_default_value('unitpenalty', $fromform->unitpenalty);
164 $this->set_default_value('unitgradingtypes', $fromform->unitgradingtypes);
165 $this->set_default_value('multichoicedisplay', $fromform->multichoicedisplay);
166 $this->set_default_value('unitsleft', $fromform->unitsleft);
167 }
168
1fe641f7 169 /**
170 * Save the units and the answers associated with this question.
171 */
f77f47ec 172 public function save_question_options($question) {
f34488b2 173 global $DB;
fe6ce234
DC
174 $context = $question->context;
175
3d9645ae 176 // Get old versions of the objects.
69988ed4
TH
177 $oldanswers = $DB->get_records('question_answers',
178 array('question' => $question->id), 'id ASC');
179 $oldoptions = $DB->get_records('question_numerical',
180 array('question' => $question->id), 'answer ASC');
516cf3eb 181
1fe641f7 182 // Save the units.
fdd015b7 183 $result = $this->save_units($question);
516cf3eb 184 if (isset($result->error)) {
185 return $result;
186 } else {
69988ed4 187 $units = $result->units;
516cf3eb 188 }
189
3d9645ae 190 // Insert all the new answers.
69988ed4 191 foreach ($question->answer as $key => $answerdata) {
94a6d656 192 // Check for, and ingore, completely blank answer from the form.
69988ed4 193 if (trim($answerdata) == '' && $question->fraction[$key] == 0 &&
fe6ce234 194 html_is_blank($question->feedback[$key]['text'])) {
94a6d656 195 continue;
196 }
516cf3eb 197
69988ed4
TH
198 // Update an existing answer if possible.
199 $answer = array_shift($oldanswers);
200 if (!$answer) {
201 $answer = new stdClass();
202 $answer->question = $question->id;
203 $answer->answer = '';
204 $answer->feedback = '';
205 $answer->id = $DB->insert_record('question_answers', $answer);
206 }
207
208 if (trim($answerdata) === '*') {
94a6d656 209 $answer->answer = '*';
210 } else {
52ad7e0c
TH
211 $answer->answer = $this->apply_unit($answerdata, $units,
212 !empty($question->unitsleft));
94a6d656 213 if ($answer->answer === false) {
8766d198 214 $result->notice = get_string('invalidnumericanswer', 'qtype_numerical');
516cf3eb 215 }
94a6d656 216 }
217 $answer->fraction = $question->fraction[$key];
69988ed4
TH
218 $answer->feedback = $this->import_or_save_files($question->feedback[$key],
219 $context, 'question', 'answerfeedback', $answer->id);
fe6ce234 220 $answer->feedbackformat = $question->feedback[$key]['format'];
69988ed4 221 $DB->update_record('question_answers', $answer);
f34488b2 222
3d9645ae 223 // Set up the options object.
94a6d656 224 if (!$options = array_shift($oldoptions)) {
69988ed4 225 $options = new stdClass();
94a6d656 226 }
69988ed4
TH
227 $options->question = $question->id;
228 $options->answer = $answer->id;
94a6d656 229 if (trim($question->tolerance[$key]) == '') {
230 $options->tolerance = '';
231 } else {
52ad7e0c
TH
232 $options->tolerance = $this->apply_unit($question->tolerance[$key],
233 $units, !empty($question->unitsleft));
94a6d656 234 if ($options->tolerance === false) {
8766d198 235 $result->notice = get_string('invalidnumerictolerance', 'qtype_numerical');
94a6d656 236 }
6d1eb004 237 $options->tolerance = (string)$options->tolerance;
94a6d656 238 }
69988ed4 239 if (isset($options->id)) {
0bcf8b6f 240 $DB->update_record('question_numerical', $options);
69988ed4 241 } else {
0bcf8b6f 242 $DB->insert_record('question_numerical', $options);
1fe641f7 243 }
244 }
516cf3eb 245
69988ed4
TH
246 // Delete any left over old answer records.
247 $fs = get_file_storage();
fdd015b7 248 foreach ($oldanswers as $oldanswer) {
69988ed4
TH
249 $fs->delete_area_files($context->id, 'question', 'answerfeedback', $oldanswer->id);
250 $DB->delete_records('question_answers', array('id' => $oldanswer->id));
516cf3eb 251 }
fdd015b7 252 foreach ($oldoptions as $oldoption) {
69988ed4 253 $DB->delete_records('question_numerical', array('id' => $oldoption->id));
c5da9906 254 }
69988ed4 255
fdd015b7 256 $result = $this->save_unit_options($question);
69988ed4 257 if (!empty($result->error) || !empty($result->notice)) {
1fe641f7 258 return $result;
259 }
69988ed4 260
1fa39364
TH
261 $this->save_hints($question);
262
1fe641f7 263 return true;
516cf3eb 264 }
265
92b36005 266 /**
41dcc2a5 267 * The numerical options control the display and the grading of the unit
92b36005 268 * part of the numerical question and related types (calculateds)
fdd015b7
TH
269 * Questions previous to 2.0 do not have this table as multianswer questions
270 * in all versions including 2.0. The default values are set to give the same grade
92b36005 271 * as old question.
41dcc2a5 272 *
92b36005 273 */
e0736817 274 public function save_unit_options($question) {
c5da9906 275 global $DB;
0ff4bd08 276 $result = new stdClass();
69988ed4 277
e0736817
TH
278 $update = true;
279 $options = $DB->get_record('question_numerical_options',
280 array('question' => $question->id));
c5da9906 281 if (!$options) {
0ff4bd08 282 $options = new stdClass();
c5da9906 283 $options->question = $question->id;
69988ed4 284 $options->id = $DB->insert_record('question_numerical_options', $options);
c5da9906 285 }
69988ed4 286
1fa39364 287 if (isset($question->unitpenalty)) {
c5da9906 288 $options->unitpenalty = $question->unitpenalty;
1fa39364
TH
289 } else {
290 // Either an old question or a close question type.
e0736817 291 $options->unitpenalty = 1;
c5da9906 292 }
1fa39364
TH
293
294 $options->unitgradingtype = 0;
295 if (isset($question->unitrole)) {
296 // Saving the editing form.
297 $options->showunits = $question->unitrole;
298 if ($question->unitrole == self::UNITGRADED) {
299 $options->unitgradingtype = $question->unitgradingtypes;
fdd015b7 300 $options->showunits = $question->multichoicedisplay;
cf146692 301 }
1fa39364
TH
302
303 } else if (isset($question->showunits)) {
304 // Updated import, e.g. Moodle XML.
305 $options->showunits = $question->showunits;
57239949 306 if (isset($question->unitgradingtype)) {
307 $options->unitgradingtype = $question->unitgradingtype;
308 }
92b36005 309 } else {
1fa39364
TH
310 // Legacy import.
311 if ($defaultunit = $this->get_default_numerical_unit($question)) {
312 $options->showunits = self::UNITINPUT;
69988ed4 313 } else {
1fa39364 314 $options->showunits = self::UNITNONE;
cf146692 315 }
c5da9906 316 }
69988ed4 317
1fa39364 318 $options->unitsleft = !empty($question->unitsleft);
22f17bca 319
69988ed4 320 $DB->update_record('question_numerical_options', $options);
fe6ce234 321
f77f47ec
TH
322 // Report any problems.
323 if (!empty($result->notice)) {
324 return $result;
325 }
326
327 return true;
c5da9906 328 }
329
fdd015b7 330 public function save_units($question) {
f34488b2 331 global $DB;
0ff4bd08 332 $result = new stdClass();
516cf3eb 333
ed0ba6da 334 // Delete the units previously saved for this question.
f34488b2 335 $DB->delete_records('question_numerical_units', array('question' => $question->id));
ed0ba6da 336
26b26662 337 // Nothing to do.
338 if (!isset($question->multiplier)) {
339 $result->units = array();
340 return $result;
341 }
342
ed0ba6da 343 // Save the new units.
516cf3eb 344 $units = array();
cf146692 345 $unitalreadyinsert = array();
ed0ba6da 346 foreach ($question->multiplier as $i => $multiplier) {
3d9645ae 347 // Discard any unit which doesn't specify the unit or the multiplier.
fdd015b7
TH
348 if (!empty($question->multiplier[$i]) && !empty($question->unit[$i]) &&
349 !array_key_exists($question->unit[$i], $unitalreadyinsert)) {
350 $unitalreadyinsert[$question->unit[$i]] = 1;
0ff4bd08 351 $units[$i] = new stdClass();
516cf3eb 352 $units[$i]->question = $question->id;
52ad7e0c
TH
353 $units[$i]->multiplier = $this->apply_unit($question->multiplier[$i],
354 array(), false);
516cf3eb 355 $units[$i]->unit = $question->unit[$i];
979425b5 356 $DB->insert_record('question_numerical_units', $units[$i]);
516cf3eb 357 }
358 }
359 unset($question->multiplier, $question->unit);
360
516cf3eb 361 $result->units = &$units;
362 return $result;
363 }
364
f77f47ec
TH
365 protected function initialise_question_instance(question_definition $question, $questiondata) {
366 parent::initialise_question_instance($question, $questiondata);
367 $this->initialise_numerical_answers($question, $questiondata);
544de1c0
TH
368 $question->unitdisplay = $questiondata->options->showunits;
369 $question->unitgradingtype = $questiondata->options->unitgradingtype;
370 $question->unitpenalty = $questiondata->options->unitpenalty;
440aaccb 371 $question->unitsleft = $questiondata->options->unitsleft;
52ad7e0c
TH
372 $question->ap = $this->make_answer_processor($questiondata->options->units,
373 $questiondata->options->unitsleft);
c5da9906 374 }
375
18f9b2d2 376 public function initialise_numerical_answers(question_definition $question, $questiondata) {
f77f47ec
TH
377 $question->answers = array();
378 if (empty($questiondata->options->answers)) {
379 return;
380 }
381 foreach ($questiondata->options->answers as $a) {
544de1c0
TH
382 $question->answers[$a->id] = new qtype_numerical_answer($a->id, $a->answer,
383 $a->fraction, $a->feedback, $a->feedbackformat, $a->tolerance);
f77f47ec
TH
384 }
385 }
c5da9906 386
18f9b2d2 387 public function make_answer_processor($units, $unitsleft) {
ae3e2e6e 388 if (empty($units)) {
52ad7e0c 389 return new qtype_numerical_answer_processor(array());
c5da9906 390 }
52ad7e0c 391
ae3e2e6e
TH
392 $cleanedunits = array();
393 foreach ($units as $unit) {
394 $cleanedunits[$unit->unit] = $unit->multiplier;
f77f47ec 395 }
52ad7e0c 396
ae3e2e6e 397 return new qtype_numerical_answer_processor($cleanedunits, $unitsleft);
c5da9906 398 }
399
e0736817 400 public function delete_question($questionid, $contextid) {
f34488b2 401 global $DB;
9203b705
TH
402 $DB->delete_records('question_numerical', array('question' => $questionid));
403 $DB->delete_records('question_numerical_options', array('question' => $questionid));
404 $DB->delete_records('question_numerical_units', array('question' => $questionid));
405
406 parent::delete_question($questionid, $contextid);
516cf3eb 407 }
9203b705 408
0ff173d3
TH
409 public function get_random_guess_score($questiondata) {
410 foreach ($questiondata->options->answers as $aid => $answer) {
411 if ('*' == trim($answer->answer)) {
52ad7e0c 412 return max($answer->fraction - $questiondata->options->unitpenalty, 0);
0ff173d3
TH
413 }
414 }
415 return 0;
416 }
417
544de1c0 418 /**
52ad7e0c 419 * Add a unit to a response for display.
544de1c0
TH
420 * @param object $questiondata the data defining the quetsion.
421 * @param string $answer a response.
422 * @param object $unit a unit. If null, {@link get_default_numerical_unit()}
423 * is used.
424 */
425 public function add_unit($questiondata, $answer, $unit = null) {
426 if (is_null($unit)) {
427 $unit = $this->get_default_numerical_unit($questiondata);
428 }
429
430 if (!$unit) {
431 return $answer;
432 }
433
434 if (!empty($questiondata->options->unitsleft)) {
435 return $unit->unit . ' ' . $answer;
436 } else {
437 return $answer . ' ' . $unit->unit;
438 }
439 }
440
0ff173d3
TH
441 public function get_possible_responses($questiondata) {
442 $responses = array();
443
444 $unit = $this->get_default_numerical_unit($questiondata);
445
24400682 446 $starfound = false;
0ff173d3
TH
447 foreach ($questiondata->options->answers as $aid => $answer) {
448 $responseclass = $answer->answer;
449
24400682
TH
450 if ($responseclass === '*') {
451 $starfound = true;
452 } else {
544de1c0 453 $responseclass = $this->add_unit($questiondata, $responseclass, $unit);
0ff173d3
TH
454
455 $ans = new qtype_numerical_answer($answer->id, $answer->answer, $answer->fraction,
456 $answer->feedback, $answer->feedbackformat, $answer->tolerance);
457 list($min, $max) = $ans->get_tolerance_interval();
f4fe3968 458 $responseclass .= " ({$min}..{$max})";
0ff173d3
TH
459 }
460
461 $responses[$aid] = new question_possible_response($responseclass,
462 $answer->fraction);
463 }
24400682
TH
464
465 if (!$starfound) {
466 $responses[0] = new question_possible_response(
467 get_string('didnotmatchanyanswer', 'question'), 0);
468 }
469
0ff173d3
TH
470 $responses[null] = question_possible_response::no_response();
471
472 return array($questiondata->id => $responses);
473 }
474
516cf3eb 475 /**
1fe641f7 476 * Checks if the $rawresponse has a unit and applys it if appropriate.
477 *
478 * @param string $rawresponse The response string to be converted to a float.
479 * @param array $units An array with the defined units, where the
480 * unit is the key and the multiplier the value.
481 * @return float The rawresponse with the unit taken into
482 * account as a float.
483 */
e0736817 484 public function apply_unit($rawresponse, $units, $unitsleft) {
52ad7e0c 485 $ap = $this->make_answer_processor($units, $unitsleft);
3a6eb8ef
TH
486 list($value, $unit, $multiplier) = $ap->apply_units($rawresponse);
487 if (!is_null($multiplier)) {
488 $value *= $multiplier;
489 }
52ad7e0c 490 return $value;
b9bd6da4 491 }
fe6ce234 492
e0736817 493 public function move_files($questionid, $oldcontextid, $newcontextid) {
fe6ce234 494 $fs = get_file_storage();
5d548d3e
TH
495
496 parent::move_files($questionid, $oldcontextid, $newcontextid);
497 $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid);
d44480f6 498 $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);
fe6ce234
DC
499 }
500
9203b705
TH
501 protected function delete_files($questionid, $contextid) {
502 $fs = get_file_storage();
503
504 parent::delete_files($questionid, $contextid);
505 $this->delete_files_in_answers($questionid, $contextid);
d44480f6 506 $this->delete_files_in_hints($questionid, $contextid);
9203b705 507 }
516cf3eb 508}
0ff173d3 509
52ad7e0c 510
0ff173d3
TH
511/**
512 * This class processes numbers with units.
513 *
514 * @copyright 2010 The Open University
515 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
516 */
517class qtype_numerical_answer_processor {
518 /** @var array unit name => multiplier. */
519 protected $units;
520 /** @var string character used as decimal point. */
521 protected $decsep;
522 /** @var string character used as thousands separator. */
523 protected $thousandssep;
544de1c0
TH
524 /** @var boolean whether the units come before or after the number. */
525 protected $unitsbefore;
0ff173d3
TH
526
527 protected $regex = null;
528
e0736817
TH
529 public function __construct($units, $unitsbefore = false, $decsep = null,
530 $thousandssep = null) {
0ff173d3
TH
531 if (is_null($decsep)) {
532 $decsep = get_string('decsep', 'langconfig');
533 }
534 $this->decsep = $decsep;
535
536 if (is_null($thousandssep)) {
537 $thousandssep = get_string('thousandssep', 'langconfig');
538 }
539 $this->thousandssep = $thousandssep;
540
541 $this->units = $units;
544de1c0 542 $this->unitsbefore = $unitsbefore;
0ff173d3
TH
543 }
544
545 /**
546 * Set the decimal point and thousands separator character that should be used.
547 * @param string $decsep
548 * @param string $thousandssep
549 */
550 public function set_characters($decsep, $thousandssep) {
551 $this->decsep = $decsep;
552 $this->thousandssep = $thousandssep;
553 $this->regex = null;
554 }
555
556 /** @return string the decimal point character used. */
557 public function get_point() {
558 return $this->decsep;
559 }
560
561 /** @return string the thousands separator character used. */
562 public function get_separator() {
563 return $this->thousandssep;
564 }
565
f040d4b0 566 /**
326bb547 567 * @return bool If the student's response contains a '.' or a ',' that
f040d4b0
TH
568 * matches the thousands separator in the current locale. In this case, the
569 * parsing in apply_unit can give a result that the student did not expect.
570 */
571 public function contains_thousands_seaparator($value) {
572 if (!in_array($this->thousandssep, array('.', ','))) {
573 return false;
574 }
575
576 return strpos($value, $this->thousandssep) !== false;
577 }
578
0ff173d3
TH
579 /**
580 * Create the regular expression that {@link parse_response()} requires.
581 * @return string
582 */
583 protected function build_regex() {
584 if (!is_null($this->regex)) {
585 return $this->regex;
586 }
587
544de1c0
TH
588 $decsep = preg_quote($this->decsep, '/');
589 $thousandssep = preg_quote($this->thousandssep, '/');
590 $beforepointre = '([+-]?[' . $thousandssep . '\d]*)';
591 $decimalsre = $decsep . '(\d*)';
0ff173d3
TH
592 $exponentre = '(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)';
593
f4fe3968 594 $numberbit = "{$beforepointre}(?:{$decimalsre})?(?:{$exponentre})?";
b3c9da2f 595
544de1c0 596 if ($this->unitsbefore) {
f4fe3968 597 $this->regex = "/{$numberbit}$/";
544de1c0 598 } else {
f4fe3968 599 $this->regex = "/^{$numberbit}/";
0ff173d3 600 }
0ff173d3
TH
601 return $this->regex;
602 }
603
604 /**
1a1353a5
TH
605 * This method can be used for more locale-strict parsing of repsonses. At the
606 * moment we don't use it, and instead use the more lax parsing in apply_units.
607 * This is just a note that this funciton was used in the past, so if you are
608 * intersted, look through version control history.
609 *
0ff173d3
TH
610 * Take a string which is a number with or without a decimal point and exponent,
611 * and possibly followed by one of the units, and split it into bits.
612 * @param string $response a value, optionally with a unit.
613 * @return array four strings (some of which may be blank) the digits before
614 * and after the decimal point, the exponent, and the unit. All four will be
615 * null if the response cannot be parsed.
616 */
617 protected function parse_response($response) {
618 if (!preg_match($this->build_regex(), $response, $matches)) {
619 return array(null, null, null, null);
620 }
621
b3c9da2f
TH
622 $matches += array('', '', '', ''); // Fill in any missing matches.
623 list($matchedpart, $beforepoint, $decimals, $exponent) = $matches;
0ff173d3
TH
624
625 // Strip out thousands separators.
626 $beforepoint = str_replace($this->thousandssep, '', $beforepoint);
627
628 // Must be either something before, or something after the decimal point.
629 // (The only way to do this in the regex would make it much more complicated.)
630 if ($beforepoint === '' && $decimals === '') {
631 return array(null, null, null, null);
632 }
633
b3c9da2f
TH
634 if ($this->unitsbefore) {
635 $unit = substr($response, 0, -strlen($matchedpart));
636 } else {
637 $unit = substr($response, strlen($matchedpart));
638 }
544de1c0 639 $unit = trim($unit);
544de1c0 640
0ff173d3
TH
641 return array($beforepoint, $decimals, $exponent, $unit);
642 }
643
644 /**
1a1353a5
TH
645 * Takes a number in almost any localised form, and possibly with a unit
646 * after it. It separates off the unit, if present, and converts to the
647 * default unit, by using the given unit multiplier.
0ff173d3
TH
648 *
649 * @param string $response a value, optionally with a unit.
650 * @return array(numeric, sting) the value with the unit stripped, and normalised
651 * by the unit multiplier, if any, and the unit string, for reference.
652 */
5073fb74 653 public function apply_units($response, $separateunit = null) {
1a1353a5
TH
654 // Strip spaces (which may be thousands separators) and change other forms
655 // of writing e to e.
656 $response = str_replace(' ', '', $response);
657 $response = preg_replace('~(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)~', 'e$1', $response);
e64e28d7
DP
658
659 // If a . is present or there are multiple , (i.e. 2,456,789 ) assume ,
660 // is a thouseands separator, and strip it, else assume it is a decimal
661 // separator, and change it to ..
662 if (strpos($response, '.') !== false || substr_count($response, ',') > 1) {
663 $response = str_replace(',', '', $response);
664 } else {
326bb547 665 $response = str_replace([$this->thousandssep, $this->decsep, ','], ['', '.', '.'], $response);
1a1353a5 666 }
e64e28d7 667
1a1353a5
TH
668 $regex = '[+-]?(?:\d+(?:\\.\d*)?|\\.\d+)(?:e[-+]?\d+)?';
669 if ($this->unitsbefore) {
f4fe3968 670 $regex = "/{$regex}$/";
1a1353a5 671 } else {
f4fe3968 672 $regex = "/^{$regex}/";
1a1353a5
TH
673 }
674 if (!preg_match($regex, $response, $matches)) {
3a6eb8ef 675 return array(null, null, null);
0ff173d3
TH
676 }
677
1a1353a5
TH
678 $numberstring = $matches[0];
679 if ($this->unitsbefore) {
3d9645ae 680 // Substr returns false when it means '', so cast back to string.
6c314a36 681 $unit = (string) substr($response, 0, -strlen($numberstring));
1a1353a5 682 } else {
6c314a36 683 $unit = (string) substr($response, strlen($numberstring));
0ff173d3
TH
684 }
685
5073fb74
TH
686 if (!is_null($separateunit)) {
687 $unit = $separateunit;
688 }
689
6c314a36 690 if ($this->is_known_unit($unit)) {
3a6eb8ef 691 $multiplier = 1 / $this->units[$unit];
0ff173d3 692 } else {
3a6eb8ef 693 $multiplier = null;
0ff173d3
TH
694 }
695
3d9645ae 696 return array($numberstring + 0, $unit, $multiplier); // The + 0 is to convert to number.
0ff173d3 697 }
544de1c0 698
5073fb74
TH
699 /**
700 * @return string the default unit.
701 */
702 public function get_default_unit() {
703 reset($this->units);
704 return key($this->units);
705 }
706
544de1c0
TH
707 /**
708 * @param string $answer a response.
709 * @param string $unit a unit.
710 */
d7d8cee2
TH
711 public function add_unit($answer, $unit = null) {
712 if (is_null($unit)) {
5073fb74 713 $unit = $this->get_default_unit();
d7d8cee2
TH
714 }
715
544de1c0
TH
716 if (!$unit) {
717 return $answer;
718 }
719
720 if ($this->unitsbefore) {
721 return $unit . ' ' . $answer;
722 } else {
723 return $answer . ' ' . $unit;
724 }
725 }
5d2465c3
TH
726
727 /**
728 * Is this unit recognised.
729 * @param string $unit the unit
730 * @return bool whether this is a unit we recognise.
731 */
732 public function is_known_unit($unit) {
733 return array_key_exists($unit, $this->units);
734 }
5073fb74
TH
735
736 /**
737 * Whether the units go before or after the number.
738 * @return true = before, false = after.
739 */
740 public function are_units_before() {
741 return $this->unitsbefore;
742 }
743
744 /**
745 * Get the units as an array suitably for passing to html_writer::select.
746 * @return array of unit choices.
747 */
748 public function get_unit_options() {
749 $options = array();
750 foreach ($this->units as $unit => $notused) {
751 $options[$unit] = $unit;
752 }
753 return $options;
754 }
0ff173d3 755}