user = $user; } /** * Make a question, show it, and remember it as the student's open question. */ public function getGeneralKnowledge() { $problem = $this->getProblem(); if ($problem === null) { e107::getRender()->tablerender('General knowledge', 'There are no general knowledge questions available yet. Please refresh the page.'); return; } $questionHtml = $this->htmlify($problem); $db = e107::getDb(); $db->delete('tkLastQuestion', "user = '" . $db->escape($this->user) . "'"); $db->insert('tkLastQuestion', ['user' => $this->user, 'LastQuestionHtml' => base64_encode($questionHtml)]); e107::getRender()->tablerender('General knowledge', $questionHtml); } /** * Choose a question without showing it. * * @return array|null ['answer1' => correct option text, 'question' => string, 'type' => 'generalKnowledge', * 'options' => the four options in the order shown, 'category' => string], * or null when no question can be asked. */ public function getProblem() { $columns = $this->columns(); if ($columns === null) { return null; } $recent = $this->recentQuestions(); $category = $this->pickCategory($columns); $problem = $this->pickQuestion($columns, $category, $recent); if ($problem === null && $category !== null) { $problem = $this->pickQuestion($columns, null, $recent); // that category had nothing new } if ($problem === null) { $problem = $this->pickQuestion($columns, null, []); // everything was asked recently: repeat } return $problem; } /** * The real column names, found by name: ['question', 'a', 'b', 'c', 'd', and 'answer' and/or 'letter', * optionally 'category'], or null when the table isn't usable. */ private function columns() { $db = e107::getDb(); $db->gen("SHOW COLUMNS FROM `" . MPREFIX . self::TABLE . "`"); $found = []; while ($row = $db->fetch()) { $found[strtolower(preg_replace('/[^a-z0-9]/i', '', $row['Field']))] = $row['Field']; } $aliases = [ 'question' => ['question', 'questiontext'], 'a' => ['optiona', 'choicea', 'answera', 'a'], 'b' => ['optionb', 'choiceb', 'answerb', 'b'], 'c' => ['optionc', 'choicec', 'answerc', 'c'], 'd' => ['optiond', 'choiced', 'answerd', 'd'], 'answer' => ['correctanswer', 'answertext', 'rightanswer'], 'letter' => ['correctoption', 'correctletter', 'correctchoice'], 'category' => ['category', 'topic', 'subject'], ]; $map = []; foreach ($aliases as $name => $names) { foreach ($names as $alias) { if (isset($found[$alias])) { $map[$name] = $found[$alias]; break; } } } foreach (['question', 'a', 'b', 'c', 'd'] as $required) { if (!isset($map[$required])) { return null; } } if (!isset($map['answer']) && !isset($map['letter'])) { return null; } return $map; } /** * Keys of the questions in the student's last answers (cleaned text, lower case). * The report stores the question as "General knowledge: ". */ private function recentQuestions() { $db = e107::getDb(); $recent = []; $db->gen("SELECT `question` FROM `" . MPREFIX . "tkReport` WHERE `username` = '" . $db->escape($this->user) . "' AND `subject` = '" . self::SUBJECT . "' ORDER BY `id` DESC LIMIT " . (int) self::RECENT); while ($row = $db->fetch()) { $recent[$this->key(preg_replace('/^General knowledge:\s*/', '', (string) $row['question']))] = true; } return $recent; } /** * A category chosen at random, each weighted by the square root of how many * rows it has. Null when there is no category column. */ private function pickCategory(array $columns) { if (!isset($columns['category'])) { return null; } $db = e107::getDb(); $field = '`' . $columns['category'] . '`'; $db->gen("SELECT " . $field . " AS category, COUNT(*) AS n FROM `" . MPREFIX . self::TABLE . "` GROUP BY " . $field); $weights = []; $total = 0.0; while ($row = $db->fetch()) { $weight = sqrt((int) $row['n']); if ($weight > 0) { $weights[$row['category']] = $weight; $total += $weight; } } if ($total <= 0) { return null; } $point = (mt_rand() / mt_getrandmax()) * $total; foreach ($weights as $category => $weight) { $point -= $weight; if ($point <= 0) { return (string) $category; } } return (string) key($weights); } /** * The first usable question, not asked recently, among SAMPLE random rows. */ private function pickQuestion(array $columns, $category, array $recent) { $db = e107::getDb(); $where = ''; if ($category !== null && isset($columns['category'])) { $where = " WHERE `" . $columns['category'] . "` = '" . $db->escape($category) . "'"; } $db->gen("SELECT * FROM `" . MPREFIX . self::TABLE . "`" . $where . " ORDER BY RAND() LIMIT " . (int) self::SAMPLE); $rows = []; while ($row = $db->fetch()) { $rows[] = $row; } foreach ($rows as $row) { $problem = $this->buildProblem($columns, $row); if ($problem !== null && !isset($recent[$this->key($problem['question'])])) { return $problem; } } return null; } /** * One table row as a question, or null when the row can't be used (missing * text, repeated options, no single correct option, or the answer text and * the letter disagree). */ private function buildProblem(array $columns, array $row) { $question = $this->cleanQuestion($row[$columns['question']]); $options = [ $this->decode($row[$columns['a']]), $this->decode($row[$columns['b']]), $this->decode($row[$columns['c']]), $this->decode($row[$columns['d']]), ]; if ($question === '' || in_array('', $options, true) || count(array_unique($options)) < 4) { return null; } $correct = null; if (isset($columns['answer'])) { $text = $this->decode($row[$columns['answer']]); if (count(array_keys($options, $text, true)) === 1) { $correct = $text; } } if (isset($columns['letter'])) { $letter = strtoupper(trim((string) $row[$columns['letter']])); $byLetter = (strlen($letter) === 1 && $letter >= 'A' && $letter <= 'D') ? $options[ord($letter) - 65] : null; if ($correct === null) { $correct = $byLetter; } elseif ($byLetter !== null && $byLetter !== $correct) { return null; } } if ($correct === null) { return null; } shuffle($options); return [ 'answer1' => $correct, 'question' => $question, 'type' => self::SUBJECT, 'options' => $options, 'category' => isset($columns['category']) ? $this->decode($row[$columns['category']]) : '', ]; } /** * e107 may have stored apostrophes as ': show and compare the real text. */ private function decode($value) { return trim(html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8')); } /** * The question as it should be shown: decoded, without the * "(Choose the correct answer.)" padding of the imported file (which also * dropped the question mark: it is put back). */ private function cleanQuestion($text) { $text = $this->decode($text); $stripped = preg_replace('/(?:\s*\(Choose the correct answer\.\))+\s*$/u', '', $text, -1, $count); if ($stripped === null) { return $text; } if ($count > 0) { $stripped = rtrim(trim($stripped), '?') . '?'; } return trim($stripped); } /** * What makes two questions "the same": the cleaned text, lower case, no final punctuation. */ private function key($question) { return mb_strtolower(rtrim(trim((string) $question), "?. \t"), 'UTF-8'); } private function htmlify($problem) { // The right answer and the order of the options stay on the server; the form only carries a token. $token = TkAnswerStore::issue($this->user, $problem); $frm = e107::getForm(); $fString = $frm->open('questionForm', 'post', '', ['autocomplete' => 'off', 'class' => 'formclass']); $fString .= $frm->label('

General knowledge

') . "
\n"; if ($problem['category'] !== '') { $fString .= '

Category: ' . htmlspecialchars($problem['category']) . '

'; } $fString .= '

' . htmlspecialchars($problem['question']) . '

'; foreach ($problem['options'] as $i => $option) { $letter = chr(65 + $i); $fString .= '
'; } $fString .= $frm->hidden('checkerData', $token, ['id' => 'checkerData']); $fString .= $frm->hidden('subject', self::SUBJECT, ['id' => 'subject']); $fString .= '
' . "\n"; $fString .= $frm->button('Answer', 'Check answer') . "\n"; $fString .= $frm->close(); $fString .= '

'; return $fString; } }