浏览代码

More fixes

Gregory Huxley 1 周之前
父节点
当前提交
bb3c17237e
共有 7 个文件被更改,包括 969 次插入 和 792 次删除
  1. 183 196
      PreHeaderFunctions.php
  2. 149 110
      answerChecker2.php
  3. 173 206
      e_cron.php
  4. 106 61
      questionGenerators/english/anagrams.php
  5. 155 62
      questionGenerators/english/spelling2.php
  6. 104 93
      questionGenerators/science/elements2.php
  7. 99 64
      tk_stats_page.php

+ 183 - 196
PreHeaderFunctions.php

@@ -1,142 +1,168 @@
 <?php
 
-/*
- * To change this license header, choose License Headers in Project Properties.
- * To change this template file, choose Tools | Templates
- * and open the template in the editor.
- */
-
 /**
- * Description of ExcelFunctions
+ * Report exports for TimeKeeper:
+ *  - ExcelFunctions::GeneratePdf()     question report as HTML or PDF
+ *  - ExcelFunctions::GenerateExcel()   worksheet of random maths questions
+ *  - Summary::GenerateSummary()        kiosk-style score summary
  *
- * @author gregory
+ * Changes from the current repo version:
+ *  - ?user= is validated once, up front, for every entry point (GenerateSummary
+ *    had no validation at all, and GeneratePdf only validated inside the pdf
+ *    branch, after the SQL had already run with the raw value)
+ *  - all SQL escapes the user and uses MPREFIX instead of `e107.e107_`
+ *  - everything taken from the database or the URL is HTML-escaped (answers
+ *    are typed by the students, so they are untrusted)
+ *  - the correct/incorrect icons are text symbols: the <img> paths were
+ *    relative, so they broke once the PDF source moved to the temp directory
+ *  - PDF: wkhtmltopdf runs with local file access disabled, the PDF is only
+ *    sent if it was actually produced, and the temp files are always removed
+ *  - removed the debug "Memory use / Generation time" line and the early
+ *    $sql->close() (it closed the shared connection before e107 finished)
+ *  - Excel: the question loop started at row 0 (invalid, and the formulas
+ *    pointed at row 0), multiplication passed an array to rand() (fatal on
+ *    PHP 8), buildQuestion() looped forever if no subject was ticked, and the
+ *    question count was unbounded
+ *  - whereDate()/range handling lives in one trait instead of two copies
+ *  - removed the dead, broken coverPage() in ExcelFunctions (COUNT(_id))
  */
+trait TkReportRequest {
+
+    /** Validated ?user= value, or null when missing or not a plain username. */
+    private function requestUser() {
+        $user = filter_input(INPUT_GET, 'user');
+        if (is_string($user) && preg_match('/^[A-Za-z0-9_.-]{1,50}$/', $user)) {
+            return $user;
+        }
+        return null;
+    }
+
+    private function rangeWindows() {
+        $end = mktime(23, 59, 59, date('n'), date('j'));
+        return [
+            'today' => ['start' => strtotime('- 24 hours', $end), 'end' => $end],
+            'yesterday' => ['start' => strtotime('- 48 hours', $end), 'end' => strtotime('- 24 hours', $end)],
+            'pastWeek' => ['start' => strtotime('- 7 days', $end), 'end' => $end],
+            'pastMonth' => ['start' => strtotime('- 1 month', $end), 'end' => $end],
+            'pastQuarter' => ['start' => strtotime('- 3 month', $end), 'end' => $end],
+            'pastYear' => ['start' => strtotime('- 1 year', $end), 'end' => $end],
+        ];
+    }
+
+    /** The requested ?range=, restricted to the known names ('today' otherwise). */
+    private function currentRange() {
+        $range = filter_input(INPUT_GET, 'range');
+        $windows = $this->rangeWindows();
+        return (is_string($range) && isset($windows[$range])) ? $range : 'today';
+    }
+
+    private function whereDate() {
+        $windows = $this->rangeWindows();
+        $window = $windows[$this->currentRange()];
+        return 'AND answereddatestamp>=' . (int) $window['start'] . ' AND answereddatestamp<=' . (int) $window['end'];
+    }
+
+    private function esc($value) {
+        return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
+    }
+}
+
 class ExcelFunctions {
 
+    use TkReportRequest;
+
     public function GeneratePdf() {
-        $user = filter_input(INPUT_GET, 'user');
-        if (!is_string($user) || !preg_match('/^[A-Za-z0-9_-]{1,50}$/', $user)) {
+        $user = $this->requestUser();
+        if ($user === null) {
             return;
-        }  // before any query
+        }
 
-        $whereDate = $this->whereDate();
-        if ($user == "") {
+        $output = filter_input(INPUT_GET, 'output');
+        if ($output !== 'html' && $output !== 'pdf') {
             return;
         }
-        $text = '<h1>TimeKeeper Report</h1>';
-        $sql = e107::getDb();
 
-        // $text .= $this->coverPage($sql,$user,$whereDate);
+        $sql = e107::getDb();
+        $text = '<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><h1>TimeKeeper Report</h1>'
+                . $this->questionsTable($sql, $user, $this->whereDate())
+                . '</body></html>';
 
-        $text .= $this->questionsTable($sql, $user, $whereDate);
+        if ($output === 'html') {
+            echo $text;
+            exit;
+        }
 
-        $text .= "\nMemory use: " . (memory_get_peak_usage(true) / 1024 / 1024) . " MiB.\nGeneration time " . (microtime(true) - $_SERVER["REQUEST_TIME_FLOAT"]) . ' seconds.';
+        $base = tempnam(sys_get_temp_dir(), 'tk');
+        if ($base === false) {
+            return;
+        }
+        $html = $base . '.html';
+        $pdf = $base . '.pdf';
 
-        $sql->close();
+        file_put_contents($html, $text);
+        exec('wkhtmltopdf --quiet --disable-local-file-access ' . escapeshellarg($html) . ' ' . escapeshellarg($pdf) . ' 2>&1');
 
-        $output = filter_input(INPUT_GET, 'output');
-        if ($output == 'html') {
-            echo $text;
-            exit;
-        } elseif ($output == 'pdf') {
-            // pdf branch:
-            $base = tempnam(sys_get_temp_dir(), 'tk');
-            $html = $base . '.html';
-            $pdf = $base . '.pdf';
-            file_put_contents($html, $text);
-            exec('wkhtmltopdf ' . escapeshellarg($html) . ' ' . escapeshellarg($pdf));
+        if (is_file($pdf) && filesize($pdf) > 0) {
             header('Content-Type: application/pdf');
             header('Content-Length: ' . filesize($pdf));
+            header('Content-Disposition: inline; filename="timekeeper-report.pdf"');
             readfile($pdf);
-            unlink($html);
-            unlink($pdf);
-            unlink($base);
-            exit;
-        }
-    }
-
-    private function coverPage($sql, $user, $whereDate) {
-        if (false) {
-            $sql->gen("SELECT `subject`,
-                ANY_VALUE(`percent`) as percent, ANY_VALUE(`quantity`) as quantity
-            FROM e107.`e107_tkStats` WHERE `username` = '$user' GROUP BY (`subject`);");
-
-            $text = '<table border="1" cellspacing="3" cellpadding="2">'
-                    . '<tr><td style="text-align:left">Subject</td><td>Score</td><td>Questions Remaining</td></tr>';
-            while ($row = $sql->fetch()) {
-                $text .= '<tr><td style="text-align:left">' . ucfirst($row['subject']) . '</td><td>' . $row['percent'] . '%</td><td>' . (1000 - $row['quantity']) . '</td></tr>';
-            }
         } else {
-            $sql->gen("SELECT COUNT(_id) as cntr, CAST((SUM(correct)/COUNT(id)*100) as DECIMAL(5,2)) as percent
-            FROM e107.`e107_tkReport` WHERE username = '$user' 
-            $whereDate");
-            while ($row = $sql->fetch()) {
-                if (filter_input(INPUT_GET, 'range') == "" || filter_input(INPUT_GET, 'range') == "today") {
-                    $text .= '<p>' . $row['cntr'] . ' question(s) have been answered for today with ' . $row['percent'] . '% accuracy.</p>';
-                } else {
-                    $text .= '<p>' . $row['cntr'] . ' question(s) have been answered for this ' . filter_input(INPUT_GET, 'range') . ' with ' . $row['percent'] . '% accuracy.</p>';
-                }
-            }
+            header('HTTP/1.1 500 Internal Server Error');
+            echo 'The PDF could not be generated.';
         }
-        $text .= '</table>';
-        return $text;
+
+        @unlink($html);
+        @unlink($pdf);
+        @unlink($base);
+        exit;
     }
 
     private function questionsTable($sql, $user, $whereDate) {
+        $sql->gen("SELECT * FROM `" . MPREFIX . "tkReport` WHERE username='" . $sql->escape($user) . "' $whereDate ORDER BY subject, answereddatestamp");
 
-        $sql->gen("SELECT * FROM e107.e107_tkReport WHERE username='$user' $whereDate ORDER BY subject;");
-        $head = "";
-        $text = "";
+        $tableHead = '<table border="1" cellspacing="3" cellpadding="2"><tr>'
+                . '<td width="1295">Question</td>'
+                . '<td width="75">User Answer</td>'
+                . '<td width="75">Correct Answer</td>'
+                . '<td width="25"></td>'
+                . '</tr>';
+
+        $head = null;
+        $text = '';
 
         while ($row = $sql->fetch()) {
-            if ($head == "") {
-                $head = $row["subject"];
-                $text .= '<h3>' . ucfirst($head) . '</h3>'
-                        . '<table border="1" cellspacing="3" cellpadding="2">'
-                        . '<tr>'
-                        . '<td width="1295">Question</td>'
-                        . '<td width="75">User Answer</td>'
-                        . '<td width="75">Correct Answer</td>'
-                        . '<td width="25"></td>'
-                        . '</tr>';
-            } elseif ($head != $row["subject"]) {
-                $head = $row["subject"];
-                $text .= '</table><h3>' . ucfirst($head) . '</h3><table border="1" cellspacing="3" cellpadding="2"><tr><td width="1295">Question</td><td width="75">User Answer</td><td width="75">Correct Answer</td><td width="25"></td></tr>';
+            if ($row['subject'] !== $head) {
+                if ($head !== null) {
+                    $text .= '</table>';
+                }
+                $head = $row['subject'];
+                $text .= '<h3>' . $this->esc(ucfirst($head)) . '</h3>' . $tableHead;
             }
-            $text .= "<tr><td>" . $row["question"] . '</td><td>' . $row["userAnswer"] . '</td><td>' . $row["answer"] . '</td><td>' . $this->correctOrNot($row["correct"]) . '</td></tr>';
+            $text .= '<tr><td>' . $this->esc($row['question']) . '</td><td>' . $this->esc($row['userAnswer']) . '</td><td>' . $this->esc($row['answer']) . '</td><td>' . $this->correctOrNot($row['correct']) . '</td></tr>';
         }
-        $text .= '</table>';
-        return $text;
-    }
 
-    private function whereDate() {
-        $returnRange = filter_input(INPUT_GET, 'range');
-        $unixTimeTodayEnd = mktime(23, 59, 59, date('n'), date('j'));
-        $returnValues = ['today' => ['start' => strtotime('- 24 hours', $unixTimeTodayEnd), 'end' => $unixTimeTodayEnd]
-            , 'yesterday' => ['start' => strtotime('- 48 hours', $unixTimeTodayEnd), 'end' => strtotime('- 24 hours', $unixTimeTodayEnd)]
-            , 'pastWeek' => ['start' => strtotime('- 7 days', $unixTimeTodayEnd), 'end' => $unixTimeTodayEnd]
-            , 'pastMonth' => ['start' => strtotime('- 1 month', $unixTimeTodayEnd), 'end' => $unixTimeTodayEnd]
-            , 'pastQuarter' => ['start' => strtotime('- 3 month', $unixTimeTodayEnd), 'end' => $unixTimeTodayEnd]
-            , 'pastYear' => ['start' => strtotime('- 1 year', $unixTimeTodayEnd), 'end' => $unixTimeTodayEnd]
-        ];
-
-        if (!isset($returnValues[$returnRange])) {
-            $returnRange = 'today';
+        if ($head === null) {
+            return '<p>No questions were answered in this period.</p>';
         }
 
-        return "AND answereddatestamp>='" . $returnValues[$returnRange]['start'] . "' AND answereddatestamp<='" . $returnValues[$returnRange]['end'] . "'";
+        return $text . '</table>';
     }
 
     private function correctOrNot($state) {
         if ($state) {
-            return '<img src="' . e_IMAGE . 'admin_images/true_16.png"/>';
-        } else {
-            return '<img src="' . e_IMAGE . 'admin_images/false_16.png"/>';
+            return '<span style="color:green">&#10004;</span>';
         }
+        return '<span style="color:red">&#10008;</span>';
     }
 
     public function GenerateExcel() {
-        require_once('phar://lib/PHPExcel.phar/PHPExcel.php');
+        $lib = __DIR__ . '/lib/PHPExcel.phar';
+        if (!is_file($lib)) {
+            e107::getMessage()->addError('The Excel library (lib/PHPExcel.phar) is not installed.');
+            return;
+        }
+        require_once('phar://' . $lib . '/PHPExcel.php');
 
         $objPHPExcel = new PHPExcel();
         $objPHPExcel->getProperties()->setCreator("Gregory J Huxley")->setLastModifiedBy("TimeKeeper");
@@ -152,54 +178,54 @@ class ExcelFunctions {
     }
 
     private function buildSheet($objPHPExcel) {
-        $numberOfQuestions = filter_input(INPUT_POST, 'numberOfQuestions');
-        if (!$numberOfQuestions) {
+        $numberOfQuestions = (int) filter_input(INPUT_POST, 'numberOfQuestions');
+        if ($numberOfQuestions < 1) {
             $numberOfQuestions = 5;
         }
-        $objPHPExcel->getActiveSheet()->setTitle("Questions");
-        for ($i = 0; $i <= $numberOfQuestions; $i++) {
+        $numberOfQuestions = min($numberOfQuestions, 500);
+
+        $sheet = $objPHPExcel->setActiveSheetIndex(0);
+        $sheet->setTitle("Questions");
+
+        // Rows are 1-based, and the answer-check formulas refer to row $i.
+        for ($i = 1; $i <= $numberOfQuestions; $i++) {
             $counter = 0;
             foreach ($this->buildQuestion($i) as $value) {
-                $objPHPExcel->setActiveSheetIndex(0)->setCellValueByColumnAndRow($counter, $i, $value);
+                $sheet->setCellValueByColumnAndRow($counter, $i, $value);
                 $counter++;
             }
         }
     }
 
     private function buildQuestion($i) {//http://localhost/e107_plugins/timekeeper/timekeeper.php?numberOfQuestions=20&addition=0&addition__switch=0&subtraction=0&multiplication=0&division=0&GetExcel=Generate+Sheet
-        while (true) {
-            switch (rand(1, 4)) {
-                case 1:// addition
-                    $enabledSubject = filter_has_var(INPUT_POST, 'addition__switch');
-                    if ($enabledSubject) {
-                        return $this->addition($i);
-                    }
-                    break;
-                case 2:// subtraction
-                    $enabledSubject = filter_has_var(INPUT_POST, 'subtraction__switch');
-                    if ($enabledSubject) {
-                        return $this->subtraction($i);
-                    }
-                    break;
-                case 3:// multiplication
-                    $enabledSubject = filter_has_var(INPUT_POST, 'multiplication__switch');
-                    if ($enabledSubject) {
-                        return $this->multiplication($i);
-                    }
-                    break;
-                case 4:// division
-                    $enabledSubject = filter_has_var(INPUT_POST, 'division__switch');
-                    if ($enabledSubject) {
-                        return $this->division($i);
-                    }
-                    break;
+        $subjects = ['addition', 'subtraction', 'multiplication', 'division'];
+
+        $enabled = [];
+        foreach ($subjects as $subject) {
+            if (filter_has_var(INPUT_POST, $subject . '__switch')) {
+                $enabled[] = $subject;
             }
         }
+        if (empty($enabled)) {
+            $enabled = $subjects; // nothing ticked: use all of them rather than looping forever
+        }
+
+        switch ($enabled[array_rand($enabled)]) {
+            case 'addition':
+                return $this->addition($i);
+            case 'subtraction':
+                return $this->subtraction($i);
+            case 'multiplication':
+                return $this->multiplication($i);
+            default:
+                return $this->division($i);
+        }
     }
 
     private function addition($i) {
         $question = [rand(1, 1000)];
-        for ($int = 1; $int <= rand(1, 3); $int++) {
+        $extra = rand(1, 3);
+        for ($int = 1; $int <= $extra; $int++) {
             array_push($question, '+');
             array_push($question, rand(1, 1000));
         }
@@ -210,9 +236,10 @@ class ExcelFunctions {
 
     private function subtraction($i) {
         $question = [rand(100, 1000)];
-        for ($int = 1; $int <= rand(1, 2); $int++) {
+        $extra = rand(1, 2);
+        for ($int = 1; $int <= $extra; $int++) {
             array_push($question, '-');
-            array_push($question, rand(1, $question[0] / 2));
+            array_push($question, rand(1, (int) ($question[0] / 2)));
         }
         array_push($question, '=');
         array_push($question, $this->checkFormula($i, $question));
@@ -220,12 +247,10 @@ class ExcelFunctions {
     }
 
     private function multiplication($i) {
-        $startNumber = [rand(1, 99)];
-        $question = $startNumber;
-        for ($int = 1; $int <= rand(1, 1); $int++) {
-            array_push($question, '*');
-            array_push($question, rand(1, $startNumber));
-        }
+        $startNumber = rand(1, 99);
+        $question = [$startNumber];
+        array_push($question, '*');
+        array_push($question, rand(1, $startNumber));
         array_push($question, '=');
         array_push($question, $this->checkFormula($i, $question));
         return $question;
@@ -262,11 +287,12 @@ class ExcelFunctions {
                 $counter++;
             }
         }
+        unset($value);
         return '=IF(' . substr(strrchr($formula, "="), 1) . '="","",IF(' . $formula . ',"Correct","Wrong"))'; // =IF(L1="","",IF((A1*C1*E1*H1*J1)=L1,"Correct","Wrong"))
     }
 
     private function setHeader() {
-        // Redirect output to a client’s web browser (Excel2007)
+        // Redirect output to a client's web browser (Excel5)
         header('Content-Type: application/vnd.ms-excel');
         header('Content-Disposition: attachment;filename="Questions.xls"');
         header('Cache-Control: max-age=0');
@@ -281,77 +307,38 @@ class ExcelFunctions {
     }
 }
 
-/**
- * Description of Summary
- *
- * @author gregory
- */
 class Summary {
 
-    public function GenerateSummary() {//base64_encode
-        $user = filter_input(INPUT_GET, 'user');
-        $whereDate = $this->whereDate();
-        if ($user == "") {
+    use TkReportRequest;
+
+    public function GenerateSummary() {
+        $user = $this->requestUser();
+        if ($user === null) {
             return;
         }
 
         $sql = e107::getDb();
 
-        $text = '<body style="background-color:#31363b;"><div style="font-size:250%;color:#fcfcfc;">';
-
-        $text .= $this->coverPage($sql, $user, $whereDate);
-
-        //$text .= "\nMemory use: ".(memory_get_peak_usage(true)/1024/1024)." MiB.\nGeneration time " . (microtime(true) - $_SERVER["REQUEST_TIME_FLOAT"]).' seconds.';
-        //print_a($whereDate);
-        $sql->close();
-        echo $text . '</div></body>';
-        //$pdf->makePDF([$text, 'e107site', 'Gregory', 'Timekeeper Report', 'Timekeeper Report', 'Timekeeper Report', e_PLUGIN.'/timekeeper/timekeeper.php']);
-
+        echo '<!DOCTYPE html><html><head><meta charset="utf-8"></head>'
+        . '<body style="background-color:#31363b;"><div style="font-size:250%;color:#fcfcfc;">'
+        . $this->coverPage($sql, $user, $this->whereDate())
+        . '</div></body></html>';
         exit;
     }
 
     private function coverPage($sql, $user, $whereDate) {
-        if (false) {
-            $sql->gen("SELECT MAX(`timekeeper_yearLevel`) as timekeeper_yearLevel, `timekeeper_subject`,
-                ANY_VALUE(`timekeeper_percent`) as timekeeper_percent, ANY_VALUE(`timekeeper_quantity`) as timekeeper_quantity
-            FROM e107.`e107_timekeeperStats` WHERE `timekeeper_username` = '$user' GROUP BY (`timekeeper_subject`);");
-
-            $text = '<table border="1" cellspacing="3" cellpadding="2">'
-                    . '<tr><td style="text-align:left">Year</td><td style="text-align:left">Subject</td><td>Score</td><td>Questions Remaining</td></tr>';
-            while ($row = $sql->fetch()) {
-                $text .= '<tr><td>' . $row['timekeeper_yearLevel'] . '</td><td style="text-align:left">' . ucfirst($row['timekeeper_subject']) . '</td><td>' . $row['timekeeper_percent'] . '%</td><td>' . (1000 - $row['timekeeper_quantity']) . '</td></tr>';
-            }
-        } else {
-            $sql->gen("SELECT COUNT(id) as cntr, CAST((SUM(correct)/COUNT(id)*100) as DECIMAL(5,2)) as percent
-            FROM e107.`e107_tkReport` WHERE username = '$user' 
+        $sql->gen("SELECT COUNT(id) AS cntr, CAST((SUM(correct)/COUNT(id)*100) AS DECIMAL(5,2)) AS percent
+            FROM `" . MPREFIX . "tkReport` WHERE username = '" . $sql->escape($user) . "'
             $whereDate");
-            while ($row = $sql->fetch()) {
-                if (filter_input(INPUT_GET, 'range') == "" || filter_input(INPUT_GET, 'range') == "today") {
-                    $text .= 'Questions ' . filter_input(INPUT_GET, 'range') . '<br/><br/>' . $row['cntr'] . '<br/>' . $row['percent'] . '% correct.';
-                } else {
-                    $text .= 'Questions ' . filter_input(INPUT_GET, 'range') . ' ' . $row['cntr'] . '<br/><br/>' . $row['percent'] . '% correct.';
-                }
-            }
-        }
-        $text .= '</table>';
-        return $text;
-    }
 
-    private function whereDate() {
-        $returnRange = filter_input(INPUT_GET, 'range');
-        $unixTimeTodayEnd = mktime(23, 59, 59, date('n'), date('j'));
-        $returnValues = ['today' => ['start' => strtotime('- 24 hours', $unixTimeTodayEnd), 'end' => $unixTimeTodayEnd]
-            , 'yesterday' => ['start' => strtotime('- 48 hours', $unixTimeTodayEnd), 'end' => strtotime('- 24 hours', $unixTimeTodayEnd)]
-            , 'pastWeek' => ['start' => strtotime('- 7 days', $unixTimeTodayEnd), 'end' => $unixTimeTodayEnd]
-            , 'pastMonth' => ['start' => strtotime('- 1 month', $unixTimeTodayEnd), 'end' => $unixTimeTodayEnd]
-            , 'pastQuarter' => ['start' => strtotime('- 3 month', $unixTimeTodayEnd), 'end' => $unixTimeTodayEnd]
-            , 'pastYear' => ['start' => strtotime('- 1 year', $unixTimeTodayEnd), 'end' => $unixTimeTodayEnd]
-        ];
+        $row = $sql->fetch();
+        $count = is_array($row) ? (int) $row['cntr'] : 0;
+        $percent = (is_array($row) && $row['percent'] !== null) ? $row['percent'] : 0;
+        $range = $this->esc($this->currentRange());
 
-        if (!isset($returnValues[$returnRange])) {
-            $returnRange = 'today';
+        if ($range === 'today') {
+            return 'Questions ' . $range . '<br/><br/>' . $count . '<br/>' . $percent . '% correct.';
         }
-
-        return "AND answereddatestamp>='" . $returnValues[$returnRange]['start'] . "' AND answereddatestamp<='" . $returnValues[$returnRange]['end'] . "'";
+        return 'Questions ' . $range . ' ' . $count . '<br/><br/>' . $percent . '% correct.';
     }
 }

+ 149 - 110
answerChecker2.php

@@ -1,10 +1,24 @@
 <?php
 
-/*
- * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license
- * Click nbfs://nbhost/SystemFileSystem/Templates/Scripting/EmptyPHP.php to edit this template
+/**
+ * Checks submitted answers, updates the score and writes the report row.
+ *
+ * Changes from the current repo version:
+ *  - one correctness decision ($correct, from isCorrect()) now drives the
+ *    on-screen message, the score/minutes AND the report row. Before, the
+ *    message and score used a strict === comparison while the report used
+ *    isCorrect(), so "1.1" vs "1.10" showed as wrong but was logged correct
+ *  - anagrams: the old retrieve('tk_wordList','word') returned a single value
+ *    so findInList() looped over a string and never matched. Now an answer is
+ *    accepted if it uses exactly the same letters AND is in the word list
+ *  - 'rounding' had no case in the switch, so every rounding question hit
+ *    "An error has been detected"; added with its messages
+ *  - user-typed text is escaped before being shown
+ *  - updateScore(): a brand-new user (retrieve() returns false) no longer
+ *    increments keys on false or decrements null; user escaped in SQL
+ *  - getDifficulty(): MPREFIX instead of `e107.`, user escaped, no null result
+ *  - removed the broken, unused shortCode() and findInList()
  */
-
 class answerChecker2 {
 
     private $user;
@@ -14,7 +28,7 @@ class answerChecker2 {
         'subtraction' => ['Solve the following equation. {QUESTION}.', "Congratulations '{USER_ANSWER}' was correct for Solve the following equation. '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Solve the following equation in {QUESTION}'"],
         'multiplication' => ['Solve the following equation. {QUESTION}.', "Congratulations '{USER_ANSWER}' was correct for Solve the following equation. '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Solve the following equation in {QUESTION}'"],
         'division' => ['Solve the following equation. {QUESTION}.', "Congratulations '{USER_ANSWER}' was correct for Solve the following equation. '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Solve the following equation in {QUESTION}'"],
-        'rounding' => [],
+        'rounding' => ['Round to the nearest 5 cents. {QUESTION}', "Congratulations '{USER_ANSWER}' was correct for rounding to the nearest 5 cents '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Round {QUESTION} to the nearest 5 cents'"],
         'worded_add' => [],
         'worded_sub' => [],
         'worded_mult' => [],
@@ -44,6 +58,7 @@ class answerChecker2 {
             case 'subtraction':
             case 'multiplication':
             case 'division':
+            case 'rounding':
                 $this->checkGeneric($subject);
                 break;
             case 'worded_add':
@@ -69,7 +84,6 @@ class answerChecker2 {
             case 'nameBodyParts':
             case 'nameBodyOrgans':
             case 'basicFirstAid':
-
             default:
                 print_a('An error has been detected in the answer checker. Please report this and refresh the page.');
                 break;
@@ -80,97 +94,150 @@ class answerChecker2 {
      * Check the answer for anything basic
      */
     private function checkGeneric($subject) {
-
-        $usersAnswer = (string) filter_input(INPUT_POST, 'a1');
+        $usersAnswer = trim((string) filter_input(INPUT_POST, 'a1'));
         $answer = $this->loadPending();
         if ($answer === null) {
             return;
         }
 
-        if (is_array($answer['question'])) {
-            $answer['question'] = implode('', $answer['question']);
-        }
+        $correct = $this->isCorrect($answer['answer1'], $usersAnswer);
 
-        if (trim($usersAnswer) === trim((string) $answer['answer1'])) {
-            $text = str_replace(['{ANSWER}', '{USER_ANSWER}'], [$answer['answer1'], trim($usersAnswer)], $this->messages[$subject][1]) . "<br/>";
-            $this->updateScore(true);
-        } else {
-            $text = str_replace(['{ANSWER}', '{USER_ANSWER}', '{QUESTION}'], [$answer['answer1'], trim($usersAnswer), $answer['question']], $this->messages[$subject][2]) . "<br/>";
-            $this->updateScore(false);
-        }
-
-        $this->addToReport($answer['answer1'], $usersAnswer, $subject, str_replace('{ANSWER}', $answer['answer1'], str_replace('{QUESTION}', $answer['question'], $this->messages[$subject][0])),$this->isCorrect($answer['answer1'], $usersAnswer));
-        e107::getRender()->tablerender(ucfirst($subject) . ' answer check', $text);
+        $this->finishCheck($subject, $correct, $answer['answer1'], $usersAnswer, $this->questionText($answer, $subject));
     }
 
     /**
-     * Check the answer for anything basic
+     * Check an anagram answer: the exact word, or any other real word made
+     * from the same letters.
      */
     private function checkAnagrams() {
-
-        $usersAnswer = (string) filter_input(INPUT_POST, 'a1');
+        $usersAnswer = trim((string) filter_input(INPUT_POST, 'a1'));
         $answer = $this->loadPending();
         if ($answer === null) {
             return;
         }
 
-        if (is_array($answer['question'])) {
-            $answer['question'] = implode('', $answer['question']);
+        $correct = $this->isCorrect($answer['answer1'], $usersAnswer) || $this->isValidAnagram($usersAnswer, $answer['answer1']);
+
+        $this->finishCheck('anagrams', $correct, $answer['answer1'], $usersAnswer, $this->questionText($answer, 'anagrams'));
+    }
+
+    /**
+     * Check the answer for spelling
+     */
+    private function checkSpelling() {
+        $usersAnswer = trim((string) filter_input(INPUT_POST, 'a1'));
+        $answer = $this->loadPending();
+        if ($answer === null) {
+            return;
         }
 
-        $words = e107::getDb()->retrieve('tk_wordList', 'word');
+        $word = (string) $answer['word'];
+        $correct = $this->isCorrect($word, $usersAnswer);
 
-        if (trim($usersAnswer) === trim((string) $answer['answer1']) || $this->findInList($words, $usersAnswer)) {
-            $text = str_replace(['{ANSWER}', '{USER_ANSWER}'], [$answer['answer1'], trim($usersAnswer)], $this->messages['anagrams'][1]) . "<br/>";
-            $this->updateScore(true);
+        $safeAnswer = htmlspecialchars($usersAnswer);
+        $safeWord = htmlspecialchars($word);
+
+        if ($correct) {
+            $text = "Congratulations '" . $safeAnswer . "' was correct for 'spell the word " . $safeWord . "'<br/>";
         } else {
-            $text = str_replace(['{ANSWER}', '{USER_ANSWER}', '{QUESTION}'], [$answer['answer1'], trim($usersAnswer), $answer['question']], $this->messages['anagrams'][2]) . "<br/>";
-            $this->updateScore(false);
+            $text = "Sorry but '" . $safeAnswer . "' was incorrect it should have been '" . $safeWord . "' for 'spell the word " . $safeWord . "'<br/>";
         }
+        $this->updateScore($correct);
 
-        $this->addToReport($answer['answer1'], $usersAnswer, 'anagrams', str_replace('{ANSWER}', $answer['answer1'], str_replace('{QUESTION}', $answer['question'], $this->messages['anagrams'][0])),$this->isCorrect($answer['answer1'], $usersAnswer));
-        e107::getRender()->tablerender(ucfirst('anagrams') . ' answer check', $text);
+        $this->addToReport($word, $usersAnswer, 'spelling', "Spell the word " . $word, $correct);
+        e107::getRender()->tablerender('Spelling answer check', $text);
     }
 
-    private function findInList($list, $needle) {
-        foreach ($list as $listItem) {
-            if ($listItem == $needle) {
-                return true;
+    /**
+     * Shared tail of the generic and anagram checks: build the message, update
+     * the score, write the report row, render the result. $correct is the one
+     * decision used for all three.
+     */
+    private function finishCheck($subject, $correct, $expected, $usersAnswer, $question) {
+        $expected = (string) $expected;
+        $messages = $this->messages[$subject];
+
+        $safe = [
+            '{ANSWER}' => htmlspecialchars($expected),
+            '{USER_ANSWER}' => htmlspecialchars($usersAnswer),
+            '{QUESTION}' => htmlspecialchars($question),
+        ];
+
+        $text = strtr($correct ? $messages[1] : $messages[2], $safe) . "<br/>";
+        $this->updateScore($correct);
+
+        $questionString = strtr($messages[0], ['{ANSWER}' => $expected, '{QUESTION}' => $question]);
+        $this->addToReport($expected, $usersAnswer, $subject, $questionString, $correct);
+
+        e107::getRender()->tablerender(ucfirst($subject) . ' answer check', $text);
+    }
+
+    /**
+     * Question text for messages. Array questions (equations, number
+     * sequences, scrambled letters) are joined; a rounding question is just
+     * the amount.
+     */
+    private function questionText($answer, $subject) {
+        $question = isset($answer['question']) ? $answer['question'] : '';
+
+        if (is_array($question)) {
+            if ($subject === 'rounding') {
+                $question = array_slice($question, 0, 1);
             }
+            $question = implode('', $question);
         }
-        return false;
+
+        return (string) $question;
     }
 
     /**
-     * Check the answer for spelling
+     * Numbers compare numerically ("1.1" == "1.10"), everything else
+     * case-insensitively, after trimming.
      */
-    private function checkSpelling() {
-
-        $usersAnswer = (string) filter_input(INPUT_POST, 'a1');
-        $answer = $this->loadPending();
-        if ($answer === null) {
-            return;
+    private function isCorrect($expected, $given): bool {
+        $expected = trim((string) $expected);
+        $given = trim((string) $given);
+        if (is_numeric($expected) && is_numeric($given)) {
+            return abs((float) $expected - (float) $given) < 1e-9;
         }
+        return mb_strtolower($expected) === mb_strtolower($given);
+    }
 
-        if (trim($usersAnswer) === trim((string) $answer['word'])) {
-            $text = "Congratulations '" . trim($usersAnswer) . "' was correct for 'spell the word " . $answer['word'] . "'<br/>";
-            $this->updateScore(true);
-            //file_put_contents('debug_Spelling_data.txt','<->'.$answer['word'].'<->'.$usersAnswer.'<->True'."\n",FILE_APPEND);//=================================
-        } else {
-            $text = "Sorry but '" . trim($usersAnswer) . "' was incorrect it should have been '" . trim($answer['word']) . "' for 'spell the word " . $answer['word'] . "'<br/>";
-            $this->updateScore(false);
-            //file_put_contents('debug_Spelling_data.txt','<->'.$answer['word'].'<->'.$usersAnswer.'<->False'."\n",FILE_APPEND);//=================================
+    /**
+     * An alternative anagram answer is valid when it uses exactly the same
+     * letters as the original word and is itself in the word list.
+     */
+    private function isValidAnagram($given, $original) {
+        $given = trim((string) $given);
+        if ($given === '' || !$this->sameLetters($given, (string) $original)) {
+            return false;
         }
 
-        $this->addToReport($answer['word'], $usersAnswer, 'spelling', "Spell the word " . $answer['word'],$this->isCorrect($answer['word'], $usersAnswer));
-        e107::getRender()->tablerender('Spelling answer check', $text);
+        $db = e107::getDb();
+        return $db->count('tk_wordList', '(*)', "word = '" . $db->escape($given) . "'") > 0;
+    }
+
+    private function sameLetters($a, $b) {
+        $a = preg_split('//u', mb_strtolower($a), -1, PREG_SPLIT_NO_EMPTY);
+        $b = preg_split('//u', mb_strtolower($b), -1, PREG_SPLIT_NO_EMPTY);
+        sort($a);
+        sort($b);
+        return $a === $b;
     }
 
     /**
-     * Add or minus the score    
+     * Add or minus the score
      */
     private function updateScore($scorePoint) {
-        $currentScore = e107::getDb()->retrieve('timekeeperScore', 'timekeeper_lifetimecorrect, timekeeper_lifetimequestions, timekeeper_minutes', 'timekeeper_username="' . $this->user . '"');
+        $db = e107::getDb();
+        $user = $db->escape($this->user);
+
+        $currentScore = $db->retrieve('timekeeperScore', 'timekeeper_lifetimecorrect, timekeeper_lifetimequestions, timekeeper_minutes', 'timekeeper_username="' . $user . '"');
+
+        $isNew = !is_array($currentScore);
+        if ($isNew) {
+            $currentScore = ['timekeeper_lifetimecorrect' => 0, 'timekeeper_lifetimequestions' => 0, 'timekeeper_minutes' => 0];
+        }
 
         if ($scorePoint) {
             $currentScore['timekeeper_lifetimecorrect']++;
@@ -180,14 +247,14 @@ class answerChecker2 {
         }
         $currentScore['timekeeper_lifetimequestions']++;
 
-        if ($currentScore['timekeeper_lifetimequestions'] == 1) {
+        if ($isNew) {
             $currentScore['timekeeper_lastquestiondatestamp'] = time();
             $currentScore['timekeeper_yearlevel'] = 0;
             $currentScore['timekeeper_username'] = $this->user;
-            e107::getDb()->insert('timekeeperScore', $currentScore);
+            $db->insert('timekeeperScore', $currentScore);
         } else {
-            $currentScore['WHERE'] = 'timekeeper_username="' . $this->user . '"';
-            e107::getDb()->update('timekeeperScore', $currentScore);
+            $currentScore['WHERE'] = 'timekeeper_username="' . $user . '"';
+            $db->update('timekeeperScore', $currentScore);
         }
     }
 
@@ -197,11 +264,8 @@ class answerChecker2 {
     private function scaleTimeAddition() {
 //TODO look at this and fix it.
         $difficulty = $this->getDifficulty($this->user); //replace with db call
-        $maxDifficulty = 5000000; // 5 mil is max here
-        //$returnMinutes = 10 - ($difficulty / $maxDifficulty * 100) / 10;
-        $returnMinutes = (int) $difficulty / 10;
 
-        //print_a(['diffi'=>$difficulty,'fprint'=> round($difficulty),'retmins'=>round($returnMinutes)]);
+        $returnMinutes = (int) $difficulty / 10;
 
         $returnMinutes = round($returnMinutes);
 
@@ -212,32 +276,23 @@ class answerChecker2 {
     }
 
     /**
-     * Return users overall difficulty.
+     * Return users overall accuracy (percent) for the last month.
      */
     private function getDifficulty($user) {
-        $returnArray = [];
-        //$returnArray['totalQuestionCount'] = e107::getDb()->count("tkReport", "(id)", "username = '" . $user . "'");
-        e107::getDb()->gen("SELECT COUNT(id) as cntr, CAST((SUM(correct)/COUNT(id)*100) as DECIMAL(5,2)) as percent
-            FROM e107.`e107_tkReport` WHERE username = '" . $user . "'
-            AND answereddatestamp >= unix_timestamp(CURRENT_DATE() - INTERVAL 1 MONTH)
-            AND answereddatestamp <= unix_timestamp(CURRENT_DATE() + INTERVAL 1 DAY)");
-        while ($row = e107::getDb()->fetch()) {
-            $returnArray = array_merge($returnArray, $row);
-        }
-        return $returnArray['percent']; //($returnArray['totalQuestionCount']*$returnArray['percent']);
+        $db = e107::getDb();
+        $db->gen("SELECT CAST((SUM(correct)/COUNT(id)*100) AS DECIMAL(5,2)) AS percent
+            FROM `" . MPREFIX . "tkReport` WHERE username = '" . $db->escape($user) . "'
+            AND answereddatestamp >= UNIX_TIMESTAMP(CURRENT_DATE() - INTERVAL 1 MONTH)
+            AND answereddatestamp <= UNIX_TIMESTAMP(CURRENT_DATE() + INTERVAL 1 DAY)");
+
+        $row = $db->fetch();
+        return is_array($row) ? (float) $row['percent'] : 0;
     }
 
     /**
      * Add the last answered question to the report DB table.
      */
     private function addToReport($answer, $usersAnswer, $subject, $questionString, $isCorrect) {
-
-        if ($isCorrect) {
-            $correct = 1;
-        } else {
-            $correct = 0;
-        }
-
         $insert = [
             'username' => $this->user,
             'question' => $questionString,
@@ -245,39 +300,23 @@ class answerChecker2 {
             'userAnswer' => trim($usersAnswer),
             'answer' => trim($answer),
             'subject' => $subject,
-            'correct' => $correct
+            'correct' => $isCorrect ? 1 : 0
         ];
 
         e107::getDb()->insert('tkReport', $insert);
     }
 
     /**
-     * ShortCode engine
+     * Load the expected answer for this user's open question. Single use:
+     * a stale tab, a tampered token or a double-submit gets the message below.
      */
-    private function shortCode($keys, $message) {
-        $returnMessage = $message;
-        foreach ($keys as $key => $value) {
-            $returnMessage = $str;
-        }
-    }
-    
     private function loadPending(): ?array {
-    $token = filter_input(INPUT_POST, 'checkerData');
-    $answer = TkAnswerStore::consume($this->user, is_string($token) ? $token : null);
-    if ($answer === null) {
-        e107::getRender()->tablerender('Answer check',
-            'That question has expired or was already answered. Please answer the new question.');
-    }
-    return $answer;
-}
-
-private function isCorrect($expected, $given): bool {
-    $expected = trim((string) $expected);
-    $given = trim((string) $given);
-    if (is_numeric($expected) && is_numeric($given)) {
-        return abs((float) $expected - (float) $given) < 1e-9;
+        $token = filter_input(INPUT_POST, 'checkerData');
+        $answer = TkAnswerStore::consume($this->user, is_string($token) ? $token : null);
+        if ($answer === null) {
+            e107::getRender()->tablerender('Answer check',
+                    'That question has expired or was already answered. Please answer the new question.');
+        }
+        return $answer;
     }
-    return mb_strtolower($expected) === mb_strtolower($given);
-}
-
 }

+ 173 - 206
e_cron.php

@@ -7,9 +7,45 @@
  * Released under the terms and conditions of the
  * GNU General Public License (http://www.gnu.org/licenses/gpl.txt)
  *
+ * Daily TimeKeeper report emails.
+ *
+ * Changes from the current repo version:
+ *  - the shared DB connection is no longer closed ($sql->close()) between the
+ *    two students' reports, which would likely have broken the second one
+ *  - reporting window is explicit integer timestamps (yesterday, midnight to
+ *    midnight; see $windowStart/$windowEnd). The old FROM_UNIXTIME(col) >=
+ *    DATE_ADD(CURDATE(), INTERVAL -1 DAY) condition had no upper bound, could
+ *    overlap the previous email, and couldn't use an index
+ *  - "minutes studied" is now the sum of the gaps between answers, ignoring
+ *    breaks over $breakSeconds. It used last-minus-first, which counted a
+ *    morning and an evening session as ten hours, and on a day with no
+ *    answers produced about 166 million minutes
+ *  - the overall percentage is correct answers / questions, not the average
+ *    of the per-subject percentages (1 question at 100% outweighed 50 at 50%)
+ *  - questions, answers, subjects and names are HTML-escaped in the email
+ *  - correct/incorrect are text symbols (the <img> paths were relative, which
+ *    never worked in an email client)
+ *  - names and addresses are in one config block instead of repeated; the kid
+ *    is addressed by their own name
+ *  - `e107.e107_tkReport` -> MPREFIX, user escaped
  */
 class timekeeper_cron {
 
+    // Who gets which report. The parent address receives every report.
+    private $parentEmail = 'gregory@pfip.au';
+    private $parentName = 'Gregory';
+    private $children = [
+        'elliot' => 'elliot@pfip.au',
+        'silas' => 'silas@pfip.au',
+    ];
+    // Reporting window (strtotime strings). Default: yesterday, midnight to midnight.
+    // If you send the email in the evening and want "today so far", use
+    // 'today midnight' and 'tomorrow midnight' and adjust the wording below.
+    private $windowStart = 'yesterday midnight';
+    private $windowEnd = 'today midnight';
+    // A gap between two answers longer than this (seconds) counts as a break.
+    private $breakSeconds = 600;
+
     function config() { // Setup
         $cron = array();
 
@@ -17,253 +53,184 @@ class timekeeper_cron {
             'name' => "Send Reports", // Displayed in admin area. .
             'function' => "sendReportsEmail", // Name of the function which is defined below.
             'category' => 'mail', // Choose between: mail, user, content, notify, or backup
-            'description' => "Send Timekeeper reports out to receivers."  // Displayed in admin area.
+            'description' => "Send Timekeeper reports out to receivers." // Displayed in admin area.
         );
 
         return $cron;
     }
 
     function sendReportsEmail() {
+        require_once(e_HANDLER . "mail.php");
 
-        //$adminEmail = e107::getPref('siteadminemail');
-        //$adminName = e107::getPref('siteadmin');
+        foreach ($this->children as $user => $childEmail) {
+            $mailContent = $this->generateEmail($user);
 
-        require_once(e_HANDLER . "mail.php");
-        
-        $mailContent = $this->generateEmail('elliot');
-        
-        $eml = array(
-            'subject' => $mailContent['subject'],
-            'body' => $mailContent['message'],
-                'sender_email'=>'service@pfip.au'
+            $eml = array(
+                'subject' => $mailContent['subject'],
+                'body' => $mailContent['message'],
+                'sender_email' => 'service@pfip.au'
             );
-        
-        
-        foreach([$mailContent['recipients'],'elliot@pfip.au'] as $recipient)
-        {
-            e107::getEmail()->sendEmail($recipient, 'Gregory', $eml);
-        }
-        
-        //sendemail($mailContent['recipients'], $mailContent['subject'], $mailContent['message']);
-        
-        $mailContent = $this->generateEmail('silas');
-        
-        $eml = array(
-            'subject' => $mailContent['subject'],
-            'body' => $mailContent['message'],
-                'sender_email'=>'service@pfip.au'
-            );
-        foreach([$mailContent['recipients'],'silas@pfip.au'] as $recipient)
-        {
-            e107::getEmail()->sendEmail($recipient, 'Gregory', $eml);
+
+            e107::getEmail()->sendEmail($mailContent['recipients'], $this->parentName, $eml);
+            e107::getEmail()->sendEmail($childEmail, ucfirst($user), $eml);
         }
-        //sendemail($mailContent['recipients'], $mailContent['subject'], $mailContent['message']);
-      
     }
 
     public function generateEmail($user) {
-
-        //
-        //Get recipients and users here
-        //
-
-        $returnArray['subject'] = 'TimeKeeper Report for '.$user;
-        //$returnArray['recipients'] = 'pigs.fly.in.packs@gmail.com';
-        $returnArray['recipients'] = 'gregory@pfip.au';
+        $returnArray['subject'] = 'TimeKeeper Report for ' . $user;
+        $returnArray['recipients'] = $this->parentEmail;
 
         $sql = e107::getDb();
+        $start = (int) strtotime($this->windowStart);
+        $end = (int) strtotime($this->windowEnd);
 
-        $coverPageData = $this->coverPage($sql, $user);
-
-        $questionsPageData = $this->questionsTable($sql, $user);
-        
-        $returnArray['message'] = $this->template($coverPageData,$questionsPageData);
+        $coverPageData = $this->coverPage($sql, $user, $start, $end);
+        $questionsPageData = $this->questionsTable($sql, $user, $start, $end);
+        $returnArray['message'] = $this->template($coverPageData, $questionsPageData);
 
-        $sql->close();
-        
         return $returnArray;
     }
 
-    private function coverPage($sql, $user) {
-        
-            $sql->gen("SELECT `subject`, COUNT(`correct`) as total, CAST((SUM(correct)/COUNT(id)*100) as DECIMAL(5,2)) as percent
-            FROM e107.`e107_tkReport` WHERE FROM_UNIXTIME(`answereddatestamp`)>=DATE_ADD(CURDATE(), INTERVAL -1 DAY) AND `username` = '".$user."' GROUP BY (`subject`);");
-            
-            $Scores = [];
-            $cntr = 0;
-            $percentTotal = 0;
-            
-            while ($row = $sql->fetch()) {
-                
-                $Scores[] = ['subject'=>$row['subject'],'total'=>$row['total'],'percent'=>$row['percent']];
-                $percentTotal = $percentTotal + $row['percent'];
-                $cntr++;
-            }
-            
-            if(is_numeric($percentTotal) && is_numeric($cntr) && $percentTotal != 0 && $cntr != 0)
-            {
-                $returnPercentTotal = $percentTotal/$cntr;
-            }else{
-                $returnPercentTotal = 0;
-            }
-        
-        return ['user'=>$user,'TotalPercent'=>$returnPercentTotal,'Scores'=>$Scores];
+    private function coverPage($sql, $user, $start, $end) {
+        $sql->gen("SELECT `subject`, COUNT(`correct`) AS total, SUM(`correct`) AS num_correct,
+            CAST((SUM(correct)/COUNT(id)*100) AS DECIMAL(5,2)) AS percent
+            FROM `" . MPREFIX . "tkReport`
+            WHERE `answereddatestamp` >= $start AND `answereddatestamp` < $end
+            AND `username` = '" . $sql->escape($user) . "'
+            GROUP BY `subject` ORDER BY `subject`");
+
+        $scores = [];
+        $questions = 0;
+        $correct = 0;
+
+        while ($row = $sql->fetch()) {
+            $scores[] = ['subject' => $row['subject'], 'total' => (int) $row['total'], 'percent' => $row['percent']];
+            $questions += (int) $row['total'];
+            $correct += (int) $row['num_correct'];
+        }
+
+        $totalPercent = ($questions > 0) ? round($correct / $questions * 100, 1) : 0;
+
+        return ['user' => $user, 'TotalPercent' => $totalPercent, 'Questions' => $questions, 'Scores' => $scores];
     }
 
-    private function questionsTable($sql, $user) {
+    private function questionsTable($sql, $user, $start, $end) {
+        $sql->gen("SELECT `question`, `answereddatestamp`, `userAnswer`, `correct`, `subject`
+            FROM `" . MPREFIX . "tkReport`
+            WHERE username = '" . $sql->escape($user) . "'
+            AND `answereddatestamp` >= $start AND `answereddatestamp` < $end
+            ORDER BY subject, answereddatestamp");
 
-        $sql->gen("SELECT `question`,`answereddatestamp`,`userAnswer`,`correct`,`subject` FROM e107.e107_tkReport WHERE username='$user' 
-                AND FROM_UNIXTIME(`answereddatestamp`)>=DATE_ADD(CURDATE(), INTERVAL -1 DAY) ORDER BY subject;");
+        $questionsData = ['questions' => []];
+        $times = [];
 
-        $questionsData['questions'] = [];
-        $startTime = 9999999999;
-        $endTime = 0;
-        
         while ($row = $sql->fetch()) {
-            if(!isset($questionsData['questions'][$row["subject"]])) {
-                $questionsData['questions'][$row["subject"]] = [];
-            }
-            
-            $questionsData['questions'][$row["subject"]][] = ['question'=>$row["question"],'userAnswer'=>$row["userAnswer"],'correct'=>$row["correct"]];
-            
-            if($row["answereddatestamp"]<=$startTime) {
-                $startTime = $row["answereddatestamp"];
-            }
-            
-            if($row["answereddatestamp"]>=$endTime) {
-                $endTime = $row["answereddatestamp"];
-            }
+            $questionsData['questions'][$row['subject']][] = [
+                'question' => $row['question'],
+                'userAnswer' => $row['userAnswer'],
+                'correct' => $row['correct'],
+            ];
+            $times[] = (int) $row['answereddatestamp'];
+        }
+
+        // Active study time: gaps between answers, ignoring breaks.
+        sort($times);
+        $active = 0;
+        for ($i = 1; $i < count($times); $i++) {
+            $active += min($times[$i] - $times[$i - 1], $this->breakSeconds);
         }
-        
-        $questionsData['startTime'] = $startTime;
-        $questionsData['endTime'] = $endTime;
-        
+        $questionsData['activeSeconds'] = $active;
+
         return $questionsData;
     }
 
+    private function esc($value) {
+        return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
+    }
+
     private function correctOrNot($state) {
         if ($state) {
-            return '<img alt="Correct" src="' . e_IMAGE . 'admin_images/true_16.png"/>';
-        } else {
-            return '<img alt="Incorrect" src="' . e_IMAGE . 'admin_images/false_16.png"/>';
+            return '<span style="color:#33cc33" title="Correct">&#10004;</span>';
         }
+        return '<span style="color:#ff4444" title="Incorrect">&#10008;</span>';
     }
 
-private function template($coverPageData,$questionsPageData) {
-    $emailTemplate = '<!DOCTYPE html>
-<html>
-<head>
-<style>
-body {
-  background-color: #1a1a1a;
-  color: #ffffff;
-  font-family: Arial, sans-serif;
-}
+    private $css = 'body {
+        background-color: #1a1a1a;
+        color: #ffffff;
+        font-family: Arial, sans-serif;
+    }
+    #sum {
+        font-size: 32px;
+        text-align: center;
+        color: #ff9900;
+    }
+    #styTi {
+        font-size: 14px;
+        text-align: center;
+        color: #cccccc;
+    }
+    #coverP, #questions {
+        font-family: Arial, sans-serif;
+        border-collapse: collapse;
+        width: 100%;
+        margin-top: 20px;
+    }
+    #coverP td, #coverP th, #questions td, #questions th {
+        border: 1px solid #444;
+        padding: 12px;
+        text-align: left;
+    }
+    #coverP tr:nth-child(even), #questions tr:nth-child(even) {
+        background-color: #2a2a2a;
+    }
+    #coverP tr:hover, #questions tr:hover {
+        background-color: #333;
+    }
+    #coverP th, #questions th {
+        padding-top: 12px;
+        padding-bottom: 12px;
+        text-align: left;
+        background-color: #ff9900;
+        color: #1a1a1a;
+        font-weight: bold;
+    }
+    #coverP td, #questions td {
+        color: #ffffff;
+    }';
 
-#sum {
-  font-size: 32px;
-  text-align: center;
-  color: #ff9900;
-}
+    private function template($coverPageData, $questionsPageData) {
+        $user = $this->esc(ucfirst($coverPageData['user']));
 
-#styTi {
-  font-size: 14px;
-  text-align: center;
-  color: #cccccc;
-}
+        $emailTemplate = '<!DOCTYPE html><html><head><meta charset="utf-8"><style>' . $this->css . '</style></head><body>';
 
-#coverP, #questions {
-  font-family: Arial, sans-serif;
-  border-collapse: collapse;
-  width: 100%;
-  margin-top: 20px;
-}
+        if ($coverPageData['Questions'] === 0) {
+            $emailTemplate .= '<div id="sum">' . $user . ' did not answer any questions yesterday.</div></body></html>';
+            return $emailTemplate;
+        }
 
-#coverP td, #coverP th, #questions td, #questions th {
-  border: 1px solid #444;
-  padding: 12px;
-  text-align: left;
-}
+        $minutes = round($questionsPageData['activeSeconds'] / 60, 1);
 
-#coverP tr:nth-child(even), #questions tr:nth-child(even) {
-  background-color: #2a2a2a;
-}
+        $emailTemplate .= '<div id="sum">' . $user . '\'s overall score for yesterday is ' . $this->esc($coverPageData['TotalPercent']) . '%.</div>'
+                . '<div id="styTi">About ' . $this->esc($minutes) . ' minutes were spent studying yesterday (breaks over ' . (int) ($this->breakSeconds / 60) . ' minutes not counted).</div>'
+                . '<br/><table id="coverP"><tr><th>Subject</th><th>Count</th><th>Percentage</th></tr>';
 
-#coverP tr:hover, #questions tr:hover {
-  background-color: #333;
-}
+        foreach ($coverPageData['Scores'] as $score) {
+            $emailTemplate .= '<tr><td>' . $this->esc(ucfirst($score['subject'])) . '</td><td>' . (int) $score['total'] . '</td><td>' . $this->esc($score['percent']) . '%</td></tr>';
+        }
 
-#coverP th, #questions th {
-  padding-top: 12px;
-  padding-bottom: 12px;
-  text-align: left;
-  background-color: #ff9900;
-  color: #1a1a1a;
-  font-weight: bold;
-}
+        $emailTemplate .= '</table><br/><table id="questions"><tr><th>Question</th><th>Answered</th><th>Correct?</th></tr>';
 
-#coverP td, #questions td {
-  color: #ffffff;
-}
-</style>
-</head>
-<body>
-<div id="sum">'.ucfirst($coverPageData["user"]).'\'s overall score for yesterday is '.$coverPageData["TotalPercent"].'%.</div>
-<div id="styTi">'.round(abs($questionsPageData["endTime"] - $questionsPageData["startTime"]) / 60, 2)." minutes were spent studying today.".'</div>
-<br/>
-<table id="coverP">
-  <tr>
-    <th>Subject</th>
-    <th>Count</th>
-    <th>Percentage</th>
-  </tr>';
-  
-  foreach ($coverPageData["Scores"] as $Score) {
-    $emailTemplate .= '
-    <tr>
-      <td>'.ucfirst($Score["subject"]).'</td>
-      <td>'.$Score["total"].'</td>
-      <td>'.$Score["percent"].'%</td>
-    </tr>';
-  }
-  
-  $emailTemplate .= '
-</table>
-<br/>
-<table id="questions">
-  <tr>
-    <th>Question</th>
-    <th>Answered</th>
-    <th>Correct?</th>
-  </tr>';
-  
-  $subjectNow = '';
-  foreach ($questionsPageData["questions"] as $key => $questionSub) {
-    foreach ($questionSub as $question) {
-      if($subjectNow == '' || $subjectNow != $key) {
-        $subjectNow = $key;
-        $emailTemplate .= '
-        <tr>
-          <td colspan="3" style="text-align: center; font-size: 16px; background-color: #333; color: #ff9900;">'.ucfirst($subjectNow).'</td>
-        </tr>';
-      }
-      
-      $emailTemplate .= '
-      <tr>
-        <td>'.$question["question"].'</td>
-        <td>'.$question["userAnswer"].'</td>
-        <td>'.$this->correctOrNot($question["correct"]).'</td>
-      </tr>';
-    }
-  }
-  
-  $emailTemplate .= '
-</table>
-</body>
-</html>';
-  
-  return $emailTemplate;
-}
+        foreach ($questionsPageData['questions'] as $subject => $subjectQuestions) {
+            $emailTemplate .= '<tr><td colspan="3" style="text-align: center; font-size: 16px; background-color: #333; color: #ff9900;">' . $this->esc(ucfirst($subject)) . '</td></tr>';
+
+            foreach ($subjectQuestions as $question) {
+                $emailTemplate .= '<tr><td>' . $this->esc($question['question']) . '</td><td>' . $this->esc($question['userAnswer']) . '</td><td>' . $this->correctOrNot($question['correct']) . '</td></tr>';
+            }
+        }
+
+        $emailTemplate .= '</table></body></html>';
 
-    
+        return $emailTemplate;
+    }
 }

+ 106 - 61
questionGenerators/english/anagrams.php

@@ -1,101 +1,146 @@
 <?php
 
-/* 
- * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license
- * Click nbfs://nbhost/SystemFileSystem/Templates/Scripting/EmptyPHP.php to edit this template
+/**
+ * Anagram question generator.
+ *
+ * Changes from the original:
+ *  - word selection used `year >= difficulty`, i.e. beginners got every word
+ *    and strong players only the hardest; now `year <= difficulty` (same
+ *    direction as spelling2, and consistent with the min(...) caps below)
+ *  - falls back to any word when nothing exists at the user's level, and shows
+ *    a message instead of crashing when the word list is empty
+ *  - the scramble is never the original word (up to 10 reshuffles)
+ *  - multibyte-safe letter splitting, escaped output
+ *  - user escaped in SQL; hard-coded `e107.` database name replaced by MPREFIX
+ *  - only the data the checker needs is stored (server-side via TkAnswerStore)
+ *  - previous tkLastQuestion row removed before inserting the new one
  */
+class anagrams {
 
-class anagrams
-{
-
- private $user;
+    private $user;
 
     /**
-     * Setup new spelling engine
-     * 
-     * @param bool $user user to supply question for.
+     * @param string $user User to supply the question for.
      */
     public function __construct($user) {
         $this->user = $user;
     }
 
     public function getanagram() {
+        $year = max(1, (int) ceil($this->difficulty()));
+
+        $question = $this->pickWord($year);
+        if ($question === null) {
+            $question = $this->pickWord(13); // nothing at this level: fall back to any word
+        }
+        if ($question === null) {
+            e107::getRender()->tablerender('Anagrams', 'No anagram words are available yet.');
+            return;
+        }
+
+        $questionHtml = $this->anagramsHtml($question['word']);
+
+        $db = e107::getDb();
+        $db->delete('tkLastQuestion', "user = '" . $db->escape($this->user) . "'");
+        $db->insert('tkLastQuestion', ['user' => $this->user, 'LastQuestionHtml' => base64_encode($questionHtml)]);
 
-        $difficulty = $this->difficulty();
-
-        $question = e107::getDb()->retrieve("SELECT `word`,`usage`, IFNULL(`cnt`,'0') AS Ncnt FROM 
-            (SELECT `word`,`usage` FROM e107.e107_tk_wordList WHERE `year` >= '".ceil($difficulty)."' AND `usage` != '' AND CHAR_LENGTH(`word`)>3) AS wList LEFT JOIN 
-            (SELECT `answer`,COUNT(`answer`)AS `cnt` FROM e107.e107_tkReport 
-            WHERE `answereddatestamp` > unix_timestamp((NOW()) - INTERVAL 1 MONTH) AND `username` = '".$this->user."' 
-                AND `subject` = 'anagrams' GROUP BY `answer` ORDER BY COUNT(`answer`)) AS Ans
-            ON wList.`word` = Ans.`answer` ORDER BY IFNULL(`cnt`,'0'), RAND() LIMIT 1;");
-        
-        $questionHtml = $this->anagramsHtml($question);
-        
-        e107::getDb()->insert('tkLastQuestion', ['user'=>$this->user,'LastQuestionHtml'=>base64_encode($questionHtml)]);
-        
         e107::getRender()->tablerender('Anagrams', $questionHtml);
     }
 
     /**
-     * Create a spelling form using the supplied question.
-     * And write it into "$this->returnQuestion".
-     * 
-     * @param array() $data Question data to be used.
+     * Pick a word at or below $year, preferring words this user has answered
+     * least in the last month.
+     *
+     * @return array|null Row with `word`, or null when none found.
+     */
+    private function pickWord($year) {
+        $db = e107::getDb();
+        $row = $db->retrieve(
+                "SELECT `word`, IFNULL(`cnt`, 0) AS Ncnt FROM
+                (SELECT `word` FROM `" . MPREFIX . "tk_wordList`
+                 WHERE `year` <= " . (int) $year . " AND `usage` != '' AND CHAR_LENGTH(`word`) > 3) AS wList
+             LEFT JOIN
+                (SELECT `answer`, COUNT(`answer`) AS `cnt` FROM `" . MPREFIX . "tkReport`
+                 WHERE `answereddatestamp` > UNIX_TIMESTAMP(NOW() - INTERVAL 1 MONTH)
+                 AND `username` = '" . $db->escape($this->user) . "'
+                 AND `subject` = 'anagrams' GROUP BY `answer`) AS Ans
+             ON wList.`word` = Ans.`answer`
+             ORDER BY IFNULL(`cnt`, 0), RAND() LIMIT 1"
+        );
+
+        return (is_array($row) && !empty($row['word'])) ? $row : null;
+    }
+
+    /**
+     * Build the anagram form for $word. The expected answer is stored
+     * server-side; the form only carries a token.
      */
-    private function anagramsHtml($data) {
-        
+    private function anagramsHtml($word) {
+        $letters = preg_split('//u', $word, -1, PREG_SPLIT_NO_EMPTY);
+        $shuffled = $letters;
+        for ($i = 0; $i < 10; $i++) {
+            shuffle($shuffled);
+            if ($shuffled !== $letters) {
+                break;
+            }
+        }
+
+        $token = TkAnswerStore::issue($this->user, ['answer1' => $word, 'question' => $shuffled, 'type' => 'anagrams']);
+
         $frm = e107::getForm();
 
         $fString = $frm->open('questionForm', 'post', '', ['autocomplete' => 'off', 'class' => 'formclass']);
         $fString .= $frm->label('<h4>Anagrams</h4>') . "</br>\n";
         $fString .= $frm->label('Unjumble the letters to find the word.') . "\n";
         $fString .= '</br>';
-        
-        $data['answer1'] = $data['word'];
-        $data['question'] = str_split($data['word']);
-        shuffle($data['question']);
-        
-        foreach ($data['question'] as $seq) {
-            $fString .= $frm->label($seq . " &nbsp; ") . "\n";
+
+        foreach ($shuffled as $letter) {
+            $fString .= $frm->label(htmlspecialchars($letter) . " &nbsp; ") . "\n";
         }
-        
+
         $fString .= '</br></br>';
         $fString .= $frm->text('a1', '', 80, ['placeholder' => 'Write answer here!', 'autocomplete' => "off", 'spellcheck' => "false"]) . "\n";
-        $fString .= $frm->hidden('checkerData', TkAnswerStore::issue($this->user, $data), ['id' => 'checkerData']);
+        $fString .= $frm->hidden('checkerData', $token, ['id' => 'checkerData']);
         $fString .= $frm->hidden('subject', 'anagrams', ['id' => 'subject']);
         $fString .= '</br>' . "\n";
-        $fString .= e107::getForm()->button('Answer', 'Check answer') . "\n";
-        $fString .= e107::getForm()->close();
+        $fString .= $frm->button('Answer', 'Check answer') . "\n";
+        $fString .= $frm->close();
         $fString .= '</br></br>';
-        
+
         return $fString;
-        
     }
-    
+
     /**
-     * Return the difficulty based on previously answered questions.
+     * Difficulty (word year level 1-13) from the last month of answers.
+     * Capped while the user has few answers.
      *
-     * @return array['percent','count'] Difficulty 1-13, Number of questions answered.
+     * @return float
      */
     private function difficulty() {
+        $db = e107::getDb();
+        $stats = $db->retrieve(
+                "SELECT (SUM(correct)/COUNT(correct)*13) AS percent, COUNT(correct) AS count
+             FROM `" . MPREFIX . "tkReport`
+             WHERE answereddatestamp >= UNIX_TIMESTAMP(NOW() - INTERVAL 1 MONTH)
+             AND `username` = '" . $db->escape($this->user) . "' AND `subject` = 'anagrams'"
+        );
 
-        $stats = e107::getDb()->retrieve("SELECT (SUM(correct)/COUNT(correct)*13) as percent, COUNT(correct) as count
-            FROM e107.`e107_tkReport` WHERE 
-            answereddatestamp >= unix_timestamp((NOW()) - INTERVAL 1 MONTH)
-            AND `username` = '$this->user' AND `subject` = 'anagrams';"
-		  );
-        
-        if($stats["count"]>=30) {
-            return $stats["percent"];
-        } elseif($stats["count"]>=25) {
-            return min($stats["percent"],7);
-        } elseif($stats["count"]>=20) {
-            return min($stats["percent"],5);
-        } elseif($stats["count"]>=10) {
-            return min($stats["percent"],3);
-        } else {
+        if (!is_array($stats)) {
             return 1;
         }
+
+        $count = (int) $stats['count'];
+        $percent = (float) $stats['percent'];
+
+        if ($count >= 30) {
+            return $percent;
+        } elseif ($count >= 25) {
+            return min($percent, 7);
+        } elseif ($count >= 20) {
+            return min($percent, 5);
+        } elseif ($count >= 10) {
+            return min($percent, 3);
+        }
+        return 1;
     }
-}
+}

+ 155 - 62
questionGenerators/english/spelling2.php

@@ -1,111 +1,204 @@
 <?php
 
-/*
- * To change this license header, choose License Headers in Project Properties.
- * To change this template file, choose Tools | Templates
- * and open the template in the editor.
- */
-
 /**
- * Description of spelling
+ * Spelling question generator.
  *
- * @author gregory
+ * Changes from the original:
+ *  - the TTS request no longer goes through exec()/curl with text spliced into
+ *    a shell command. It is a normal HTTP POST with a json_encode()d body,
+ *    with a timeout, and the result is only kept if the server answered 200
+ *    with real audio (written to a temp file, then renamed). Previously a
+ *    failed request left a bad .mp3 that file_exists() trusted forever.
+ *  - the word/usage text goes to TTS as plain text (the old toHTML() turned
+ *    apostrophes and quotes into HTML entities)
+ *  - if audio can't be produced, the sentence is shown with the word blanked
+ *    out instead of an unplayable player
+ *  - <source> type is audio/mpeg (was audio/wav for an .mp3)
+ *  - audio path is absolute (no dependence on the working directory)
+ *  - user escaped in SQL; `e107.` database name replaced by MPREFIX
+ *  - falls back to any word when nothing exists at the user's level, and
+ *    shows a message instead of crashing when the word list is empty
+ *  - previous tkLastQuestion row removed before inserting the new one
  */
 class spelling2 {
 
+    // Text-to-speech server (OpenAI-compatible /v1/audio/speech endpoint).
+    const TTS_URL = 'http://192.168.1.252:8880/v1/audio/speech';
+    const TTS_TIMEOUT = 20; // seconds
+
     private $user;
 
     /**
-     * Setup new spelling engine
-     * 
-     * @param bool $user user to supply question for.
+     * @param string $user User to supply the question for.
      */
     public function __construct($user) {
         $this->user = $user;
     }
 
     public function getSpellingWord() {
+        $year = max(1, (int) ceil($this->difficulty()));
+
+        $question = $this->pickWord($year);
+        if ($question === null) {
+            $question = $this->pickWord(13); // nothing at this level: fall back to any word
+        }
+        if ($question === null) {
+            e107::getRender()->tablerender('Spelling', 'No spelling words are available yet.');
+            return;
+        }
 
-        $difficulty = $this->difficulty();
-        
-        $question = e107::getDb()->retrieve("SELECT `word`,`usage`, IFNULL(`cnt`,'0') AS Ncnt FROM 
-            (SELECT `word`,`usage` FROM e107.e107_tk_wordList WHERE `year` <= '".ceil($difficulty)."' AND `usage` != '') AS wList LEFT JOIN 
-            (SELECT `answer`,COUNT(`answer`)AS `cnt` FROM e107.e107_tkReport 
-            WHERE `answereddatestamp` > unix_timestamp((NOW()) - INTERVAL 1 MONTH) AND `username` = '".$this->user."' 
-                AND `subject` = 'spelling' GROUP BY `answer` ORDER BY COUNT(`answer`)) AS Ans
-            ON wList.`word` = Ans.`answer` ORDER BY IFNULL(`cnt`,'0'), RAND() LIMIT 1;");
-        
         $questionHtml = $this->spellingHtml($question);
-        
-        e107::getDb()->insert('tkLastQuestion', ['user'=>$this->user,'LastQuestionHtml'=>base64_encode($questionHtml)]);
-        
+
+        $db = e107::getDb();
+        $db->delete('tkLastQuestion', "user = '" . $db->escape($this->user) . "'");
+        $db->insert('tkLastQuestion', ['user' => $this->user, 'LastQuestionHtml' => base64_encode($questionHtml)]);
+
         e107::getRender()->tablerender('Spelling', $questionHtml);
     }
 
     /**
-     * Create a spelling form using the supplied question.
-     * And write it into "$this->returnQuestion".
-     * 
-     * @param array() $data Question data to be used.
+     * Pick a word at or below $year, preferring words this user has answered
+     * least in the last month.
+     *
+     * @return array|null Row with `word` and `usage`, or null when none found.
+     */
+    private function pickWord($year) {
+        $db = e107::getDb();
+        $row = $db->retrieve(
+                "SELECT `word`, `usage`, IFNULL(`cnt`, 0) AS Ncnt FROM
+                (SELECT `word`, `usage` FROM `" . MPREFIX . "tk_wordList`
+                 WHERE `year` <= " . (int) $year . " AND `usage` != '') AS wList
+             LEFT JOIN
+                (SELECT `answer`, COUNT(`answer`) AS `cnt` FROM `" . MPREFIX . "tkReport`
+                 WHERE `answereddatestamp` > UNIX_TIMESTAMP(NOW() - INTERVAL 1 MONTH)
+                 AND `username` = '" . $db->escape($this->user) . "'
+                 AND `subject` = 'spelling' GROUP BY `answer`) AS Ans
+             ON wList.`word` = Ans.`answer`
+             ORDER BY IFNULL(`cnt`, 0), RAND() LIMIT 1"
+        );
+
+        return (is_array($row) && !empty($row['word'])) ? $row : null;
+    }
+
+    /**
+     * Build the spelling form. The expected answer is stored server-side;
+     * the form only carries a token.
+     *
+     * @param array $data ['word' => .., 'usage' => ..]
      */
     private function spellingHtml($data) {
-        
-        $tp = e107::getParser();
         $frm = e107::getForm();
 
+        $token = TkAnswerStore::issue($this->user, ['word' => $data['word'], 'usage' => $data['usage']]);
+
         $fString = $frm->open('questionForm', 'post', '', ['autocomplete' => 'off', 'class' => 'formclass']);
         $fString .= $frm->label('<h4>Spelling</h4>') . "</br>\n";
         $fString .= $frm->label('Spell the word in the sentence.') . "\n";
         $fString .= '</br>';
 
-        //if (!file_exists('./audio/' . md5($data['word']) . '3v.wav')) {
-        //exec('./audio/flite-2.0.0-release/bin/flite -voice ./audio/voices/cmu_us_aew.flitevox -o "./audio/' . md5($data['word']) . '3v.wav" "Spell the word, ' . $tp->toHTML($data['word']) . '. ' . $tp->toHTML($data['usage']) . '"');
-        //}
-        
-        if (!file_exists('./audio/' . md5($data['word']) . '.mp3')) {
-            $voices = ['bf_emma','bf_isabella','bf_alice','bf_lily','bm_george','bm_lewis','bm_daniel','bm_fable'];
-            $voice = $voices[array_rand($voices,1)];
-            exec('curl http://192.168.1.252:8880/v1/audio/speech -H "Content-Type: application/json" -d \'{"model":"tts-1","input":"Spell the word, ' . $tp->toHTML($data['word']) . '. ' . $tp->toHTML($data['usage']) . '.","voice":"'.$voice.'"}\' --output ./audio/' . md5($data['word']) . '.mp3');
+        if ($this->ensureAudio($data)) {
+            $fString .= "<audio controls><source src='audio/" . md5($data['word']) . ".mp3' type='audio/mpeg'>Your browser does not support the audio element.</audio>\n";
+        } else {
+            // No audio available: show the sentence with the word hidden.
+            $blanked = preg_replace('/' . preg_quote($data['word'], '/') . '/iu', '_____', $data['usage']);
+            $fString .= '<p><em>Audio is unavailable right now.</em></p><p>' . htmlspecialchars((string) $blanked) . "</p>\n";
         }
 
-        $fString .= "<audio controls><source src='audio/" . md5($data['word']) . ".mp3' type='audio/wav'>Your browser does not support the audio element.</audio>\n";
-
         $fString .= '</br></br>';
         $fString .= $frm->text('a1', '', 80, ['placeholder' => 'Write answer here!', 'autocomplete' => "off", 'spellcheck' => "false"]) . "\n";
-        $fString .= $frm->hidden('checkerData', TkAnswerStore::issue($this->user, $data), ['id' => 'checkerData']);
+        $fString .= $frm->hidden('checkerData', $token, ['id' => 'checkerData']);
         $fString .= $frm->hidden('subject', 'spelling', ['id' => 'subject']);
         $fString .= '</br>' . "\n";
-        $fString .= e107::getForm()->button('Answer', 'Check answer') . "\n";
-        $fString .= e107::getForm()->close();
+        $fString .= $frm->button('Answer', 'Check answer') . "\n";
+        $fString .= $frm->close();
         $fString .= '</br></br>';
-        
+
         return $fString;
     }
-    
+
+    /**
+     * Make sure ../../audio/<md5(word)>.mp3 exists, generating it through the
+     * TTS server if needed.
+     *
+     * @return bool True when a usable audio file is in place.
+     */
+    private function ensureAudio($data) {
+        $dir = dirname(__DIR__, 2) . '/audio';
+        $file = $dir . '/' . md5($data['word']) . '.mp3';
+
+        if (is_file($file) && filesize($file) > 0) {
+            return true;
+        }
+        if (!is_dir($dir) || !is_writable($dir)) {
+            return false;
+        }
+
+        $voices = ['bf_emma', 'bf_isabella', 'bf_alice', 'bf_lily', 'bm_george', 'bm_lewis', 'bm_daniel', 'bm_fable'];
+
+        $body = json_encode([
+            'model' => 'tts-1',
+            'input' => 'Spell the word, ' . $data['word'] . '. ' . $data['usage'] . '.',
+            'voice' => $voices[array_rand($voices)],
+        ]);
+
+        $context = stream_context_create(['http' => [
+                'method' => 'POST',
+                'header' => "Content-Type: application/json\r\n",
+                'content' => $body,
+                'timeout' => self::TTS_TIMEOUT,
+                'ignore_errors' => true,
+        ]]);
+
+        $audio = @file_get_contents(self::TTS_URL, false, $context);
+        $status = isset($http_response_header[0]) ? $http_response_header[0] : '';
+
+        if ($audio === false || strlen($audio) < 100 || !preg_match('#^HTTP/\S+\s+200\b#', $status)) {
+            return false;
+        }
+
+        // Write to a temp file and rename so a half-written file is never served.
+        $tmp = $file . '.' . bin2hex(random_bytes(4)) . '.tmp';
+        if (file_put_contents($tmp, $audio) === false) {
+            return false;
+        }
+        if (!rename($tmp, $file)) {
+            @unlink($tmp);
+            return false;
+        }
+        return true;
+    }
+
     /**
-     * Return the difficulty based on previously answered questions.
+     * Difficulty (word year level 1-13) from the last month of answers.
+     * Capped while the user has few answers.
      *
-     * @return array['percent','count'] Difficulty 1-13, Number of questions answered.
+     * @return float
      */
     private function difficulty() {
+        $db = e107::getDb();
+        $stats = $db->retrieve(
+                "SELECT (SUM(correct)/COUNT(correct)*13) AS percent, COUNT(correct) AS count
+             FROM `" . MPREFIX . "tkReport`
+             WHERE answereddatestamp >= UNIX_TIMESTAMP(NOW() - INTERVAL 1 MONTH)
+             AND `username` = '" . $db->escape($this->user) . "' AND `subject` = 'spelling'"
+        );
 
-        $stats = e107::getDb()->retrieve("SELECT (SUM(correct)/COUNT(correct)*13) as percent, COUNT(correct) as count
-            FROM e107.`e107_tkReport` WHERE 
-            answereddatestamp >= unix_timestamp((NOW()) - INTERVAL 1 MONTH)
-            AND `username` = '$this->user' AND `subject` = 'spelling';"
-		  );
-        
-        if($stats["count"]>=300) {
-            return $stats["percent"];
-        } elseif($stats["count"]>=250) {
-            return min($stats["percent"],7);
-        } elseif($stats["count"]>=200) {
-            return min($stats["percent"],5);
-        } elseif($stats["count"]>=100) {
-            return min($stats["percent"],3);
-        } else {
+        if (!is_array($stats)) {
             return 1;
         }
-    }
 
+        $count = (int) $stats['count'];
+        $percent = (float) $stats['percent'];
+
+        if ($count >= 300) {
+            return $percent;
+        } elseif ($count >= 250) {
+            return min($percent, 7);
+        } elseif ($count >= 200) {
+            return min($percent, 5);
+        } elseif ($count >= 100) {
+            return min($percent, 3);
+        }
+        return 1;
+    }
 }

+ 104 - 93
questionGenerators/science/elements2.php

@@ -1,127 +1,140 @@
 <?php
 
-/*
- * To change this license header, choose License Headers in Project Properties.
- * To change this template file, choose Tools | Templates
- * and open the template in the editor.
- */
-
 /**
- * Description of elements
+ * Periodic table question generator.
  *
- * @author gregory
+ * Changes from the original:
+ *  - element index could reach 118 at 300+ answers (the table has indexes
+ *    0-117); now capped at the table size
+ *  - difficulty() always returns ['percent' => .., 'count' => ..] (it returned
+ *    int 1 for new users and the code then indexed it like an array)
+ *  - the question type is chosen without float arguments to rand()
+ *  - the stored `question` is now a readable string ("element 12, missing
+ *    symbol") instead of a nested array, which the checker imploded into
+ *    "Array" in its messages; the element data the form needs is stored
+ *    separately (server-side, via TkAnswerStore)
+ *  - periodicTable.json and the template are loaded by absolute path
+ *  - user escaped in SQL; `e107.` database name replaced by MPREFIX
+ *  - previous tkLastQuestion row removed before inserting the new one
  */
 class elements2 {
-   
+
     private $user;
 
+    /** Human wording for each element property, used in the checker's messages. */
+    private $labels = [
+        'symbol' => 'symbol',
+        'phase' => 'phase',
+        'name' => 'name',
+        'number' => 'atomic number',
+        'atomic_mass' => 'atomic mass',
+    ];
+
     /**
-     * Setup new numbers engine
-     * 
-     * @param bool $test Enable debug mode.
+     * @param string $user User to supply the question for.
      */
     public function __construct($user) {
-       
         $this->user = $user;
     }
 
     public function getElements() {
-
         $difficulty = $this->difficulty();
-        $switchValue = rand(0,(int)$difficulty['percent']/20);
-        $question = $this->elementSwitch($switchValue,$difficulty);
-        $questionHtml = $this->htmlify($question);
-        
-        e107::getDb()->insert('tkLastQuestion', ['user'=>$this->user,'LastQuestionHtml'=>base64_encode($questionHtml)]);
-        
-        e107::getRender()->tablerender('Elements', $questionHtml);
-
 
+        // 0..4, widening as accuracy improves (one more type per 20%).
+        $maxType = min(4, (int) floor($difficulty['percent'] / 20));
+        $question = $this->elementSwitch(mt_rand(0, $maxType), $difficulty);
 
+        if ($question === null) {
+            e107::getRender()->tablerender('Elements', 'The periodic table data could not be loaded.');
+            return;
+        }
 
+        $questionHtml = $this->htmlify($question);
 
+        $db = e107::getDb();
+        $db->delete('tkLastQuestion', "user = '" . $db->escape($this->user) . "'");
+        $db->insert('tkLastQuestion', ['user' => $this->user, 'LastQuestionHtml' => base64_encode($questionHtml)]);
 
-        
-        
-        
-        
-        
-        
-        
-        
+        e107::getRender()->tablerender('Elements', $questionHtml);
     }
-    
-    private function elementSwitch($switchValue,$difficulty) {
+
+    private function elementSwitch($switchValue, $difficulty) {
         switch ($switchValue) {
-            
             case 0:
-                return $this->elementValues('symbol',$difficulty);
-                break;
+                return $this->elementValues('symbol', $difficulty);
             case 1:
-                return $this->elementValues('phase',$difficulty);
-                break;
+                return $this->elementValues('phase', $difficulty);
             case 2:
-                return $this->elementValues('name',$difficulty);
-                break;
+                return $this->elementValues('name', $difficulty);
             case 3:
-                return $this->elementValues('number',$difficulty);
-                break;
-            case 4:
+                return $this->elementValues('number', $difficulty);
             default:
-                return $this->elementValues('atomic_mass',$difficulty);
-                break;
+                return $this->elementValues('atomic_mass', $difficulty);
         }
     }
 
     /**
-     * Return the difficulty based on previously answered questions.
+     * Difficulty based on the last month of answers.
      *
-     * @return int Difficulty as percent.
+     * @return array ['percent' => float 0-100, 'count' => int]
      */
     private function difficulty() {
-
-        $stats = e107::getDb()->retrieve("SELECT (SUM(correct)/COUNT(correct)*100) as percent,COUNT(correct) as count
-            FROM e107.`e107_tkReport` WHERE 
-            answereddatestamp >= unix_timestamp((NOW()) - INTERVAL 1 MONTH)
-            AND username = '$this->user' AND subject = 'elements';");
-        
-        if ($stats["count"]==0) {
-            return 1;
-        } else {
-            return $stats;
+        $db = e107::getDb();
+        $stats = $db->retrieve(
+                "SELECT (SUM(correct)/COUNT(correct)*100) AS percent, COUNT(correct) AS count
+             FROM `" . MPREFIX . "tkReport`
+             WHERE answereddatestamp >= UNIX_TIMESTAMP(NOW() - INTERVAL 1 MONTH)
+             AND username = '" . $db->escape($this->user) . "' AND subject = 'elements'"
+        );
+
+        if (!is_array($stats) || (int) $stats['count'] === 0) {
+            return ['percent' => 0, 'count' => 0];
         }
-     
+
+        return ['percent' => (float) $stats['percent'], 'count' => (int) $stats['count']];
     }
-    
-    private function elementValues($sign,$difficulty) {
-        
-        if($difficulty['count']>=300) {
-            $incrementalNumber = mt_rand(1, 100 );
-        }elseif($difficulty['count']>=200){
-            $incrementalNumber = mt_rand(1, 50 );
-        }elseif($difficulty['count']>=100){
-            $incrementalNumber = mt_rand(1, 20 );
-        }else{
+
+    private function loadElements() {
+        $path = dirname(__DIR__, 2) . '/resources/periodicTable.json';
+        $json = is_file($path) ? json_decode(file_get_contents($path), true) : null;
+
+        return (is_array($json) && !empty($json['elements'])) ? $json['elements'] : null;
+    }
+
+    private function elementValues($sign, $difficulty) {
+        $count = (int) $difficulty['count'];
+
+        if ($count >= 300) {
+            $incrementalNumber = mt_rand(1, 100);
+        } elseif ($count >= 200) {
+            $incrementalNumber = mt_rand(1, 50);
+        } elseif ($count >= 100) {
+            $incrementalNumber = mt_rand(1, 20);
+        } else {
             $incrementalNumber = mt_rand(1, 10);
         }
 
-        $elements = json_decode(file_get_contents('resources/periodicTable.json'), true)['elements'];
-        
-        $selectedElement = rand(0, (int) ($incrementalNumber * 1.18));
-        
-        $TempQuestion = [];
-        
-        $TempQuestion['element'] = $elements[$selectedElement];
-        $TempQuestion[] = $selectedElement;
-        $TempQuestion[] = $sign;
-         
-        
-        return [ 'answer1'=>$TempQuestion['element'][$sign], 'question'=>$TempQuestion, 'type'=>'elements'];
-        
+        $elements = $this->loadElements();
+        if ($elements === null) {
+            return null;
+        }
+
+        $selectedElement = mt_rand(0, min((int) ($incrementalNumber * 1.18), count($elements) - 1));
+        $element = $elements[$selectedElement];
+
+        return [
+            'answer1' => $element[$sign],
+            'question' => 'element ' . $element['number'] . ', missing ' . $this->labels[$sign],
+            'type' => 'elements',
+            'element' => $element,
+            'sign' => $sign,
+        ];
     }
 
     private function htmlify($question) {
-        
+        // Expected answer stays on the server; the form only carries a token.
+        $token = TkAnswerStore::issue($this->user, $question);
+
         $frm = e107::getForm();
         $tp = e107::getParser();
         $sc = e107::getScBatch('timekeeper', TRUE);
@@ -130,23 +143,21 @@ class elements2 {
         $fString .= $frm->label('<h4>Elements</h4>') . "</br>\n";
         $fString .= $frm->label('Periodic Table of Elements. Fill in the missing information.') . "\n";
         $fString .= '</br>';
-        
-        $elementData = $question['question']['element'];
-        $elementData[$question['question'][1]] = '???';
+
+        $elementData = $question['element'];
+        $elementData[$question['sign']] = '???';
         $sc->setVars($elementData);
-        $fString .= $tp->parseTemplate(file_get_contents('templates/elementTable.html'), true, $sc);
-        
+        $fString .= $tp->parseTemplate(file_get_contents(dirname(__DIR__, 2) . '/templates/elementTable.html'), true, $sc);
+
         $fString .= '</br></br>';
         $fString .= $frm->text('a1', '', 80, ['placeholder' => 'Write answer here!', 'autocomplete' => "off", 'spellcheck' => "false"]) . "\n";
-        $fString .= $frm->hidden('checkerData', TkAnswerStore::issue($this->user, $question), ['id' => 'checkerData']);
-        $fString .= $frm->hidden('subject', 'elements', ['id' => 'elements']);
+        $fString .= $frm->hidden('checkerData', $token, ['id' => 'checkerData']);
+        $fString .= $frm->hidden('subject', 'elements', ['id' => 'subject']);
         $fString .= '</br>' . "\n";
-        $fString .= e107::getForm()->button('Answer', 'Check answer') . "\n";
-        $fString .= e107::getForm()->close();
+        $fString .= $frm->button('Answer', 'Check answer') . "\n";
+        $fString .= $frm->close();
         $fString .= '</br></br>';
-        
+
         return $fString;
-        
     }
-
 }

+ 99 - 64
tk_stats_page.php

@@ -1,80 +1,115 @@
 <?php
 
+/**
+ * Kiosk-style score page: tk_stats_page.php?user=<name>
+ *
+ * Changes from the current repo version:
+ *  - ?user= must be a plain username; it is escaped for SQL and for HTML
+ *  - optional shared secret: if the plugin pref `stats_key` is set, the page
+ *    needs ?key=<that value>. Until it is set the page stays open to anyone,
+ *    as before (it shows a student's scores and remaining minutes)
+ *  - an unknown user no longer crashes (count(false) is fatal on PHP 8)
+ *  - one GROUP BY query replaces the hand-built UNION; no subjects no longer
+ *    produces invalid SQL or undefined-variable notices
+ *  - a student with exactly 1 answer today now also gets the "games enabled"
+ *    line (the singular branch skipped it)
+ *  - `e107.e107_tkReport` -> MPREFIX; removed the leftover print_r('hi')
+ *  - settings row: reads `username` / `minQuestionCount` as before and falls
+ *    back to the timekeeper_username / timekeeper_Min_Question_Count names
+ *    used by timekeeper_sql.php, until it's settled which one your table has
+ */
 if (!defined('e107_INIT')) {
     require_once('../../class2.php');
 }
-
 if (!e107::isInstalled('timekeeper')) {
     return '';
 }
 
-$sql = e107::getDb();
+// Optional shared secret (plugin pref `stats_key`).
+$requiredKey = (string) e107::getPlugPref('timekeeper', 'stats_key', '');
+if ($requiredKey !== '' && !hash_equals($requiredKey, (string) filter_input(INPUT_GET, 'key'))) {
+    http_response_code(403);
+    exit;
+}
+
+$userName = filter_input(INPUT_GET, 'user');
+if (!is_string($userName) || !preg_match('/^[A-Za-z0-9_.-]{1,50}$/', $userName)) {
+    exit;
+}
 
-$userName = filter_input(INPUT_GET, 'user',FILTER_SANITIZE_SPECIAL_CHARS);
-$ext = vartrue(filter_input(INPUT_GET, 'extended', FILTER_VALIDATE_BOOL ),false);
-$settingsHolder = $sql->retrieve('timekeeper', '*', 'username="' . $userName . '"');
+$sql = e107::getDb();
+$u = $sql->escape($userName);
+$reportTable = '`' . MPREFIX . 'tkReport`';
 
-if(count($settingsHolder)==0)
-{
+// Settings row (unknown users show nothing).
+$settings = $sql->retrieve('timekeeper', '*', 'username="' . $u . '"');
+if (!is_array($settings)) {
+    $settings = $sql->retrieve('timekeeper', '*', 'timekeeper_username="' . $u . '"');
+}
+if (!is_array($settings)) {
     exit;
 }
 
-        $sql->gen("SELECT DISTINCT subject FROM e107_tkReport WHERE `username` = '$userName'");
-
-        while ($row = $sql->fetch()) {
-            $subjects[] = $row['subject'];
-            
-        }
-
-        $first = true;
-        foreach ($subjects as $subject) {
-            if($selects!=''){$selects .= ' UNION ';}
-        $selects .= "(SELECT CAST((SUM(correct) / COUNT(id) * 100) AS DECIMAL (5 , 2 )) AS percent, COUNT(id) as `count`,'".ucfirst($subject)."' AS `subject`
-            FROM e107_tkReport WHERE `username` = '$userName' AND `subject` = '$subject')";
-            if($first){
-                $selects .= " as subjects";
-                $first = false;
-            }
-        }
-
-        $sql->gen("SELECT * FROM $selects;");
-
-        $text = '<div ><h1>Score for '.$userName.'</h1>';
-        
-        $text .= "<table style='width:100%;text-align:center;'>"
-                . "<thead><tr><td style='text-align:left'>Subject</td><td>Score</td><td>No.Questions</td></tr></thead>"
-                . "<tbody>";
-        while ($row = $sql->fetch()) {
-            if(is_numeric($row['count'])) {
-                $text .= '<tr><td style=\'text-align:left\'>' . $row['subject'] . '</td><td>' . $row['percent'] . '%</td><td>' . $row['count'] . '</td></tr>';
-            }
-        }
-        $sql->gen("SELECT COUNT(id) as cntr, CAST((SUM(correct)/COUNT(id)*100) as DECIMAL(5,2)) as percent
-            FROM e107.`e107_tkReport` WHERE username = '$userName'
-            AND answereddatestamp >= unix_timestamp(CURRENT_DATE())
-            AND answereddatestamp <= unix_timestamp(CURRENT_DATE() + INTERVAL 1 DAY)");
-        $text .= '</tbody></table>';
-        while ($row = $sql->fetch()) {
-            if((int)$row['cntr']==1) {
-                $text .= '<p>' . $row['cntr'] . ' question has been answered<br/>today with ' . $row['percent'] . '% accuracy.<p>';
-            }elseif(!(int)$row['cntr']==0) {
-                $text .= '<p>' . $row['cntr'] . ' questions have been answered<br/>today with ' . $row['percent'] . '% accuracy.<p><br/>';
-                if($settingsHolder['minQuestionCount']<=$row['cntr']) {
-                    $text .= '<label style="background: darkgreen;">Games enabled</label><br/>';
-                    $currentScore = e107::getDb()->retrieve('timekeeperScore', 'timekeeper_lifetimecorrect, timekeeper_lifetimequestions, timekeeper_minutes', 'timekeeper_username="' . $userName . '"');
-                    $text .= '<label>You have '.$currentScore['timekeeper_minutes'].' minutes remaining.</label>';
-                }else{
-                    $text .= '<label>Games disabled</label>';
-                }
-            }
-        }
-        
-        $text .= '</div>';
-
-if($ext)
-{
-    print_r('hi');
+$minQuestions = 0;
+foreach (['minQuestionCount', 'timekeeper_Min_Question_Count'] as $column) {
+    if (isset($settings[$column])) {
+        $minQuestions = (int) $settings[$column];
+        break;
+    }
+}
+
+// Score per subject, all time.
+$sql->gen("SELECT `subject`, COUNT(id) AS `count`,
+        CAST((SUM(correct) / COUNT(id) * 100) AS DECIMAL(5,2)) AS percent
+    FROM $reportTable WHERE `username` = '$u' GROUP BY `subject` ORDER BY `subject`");
+
+$subjectRows = [];
+while ($row = $sql->fetch()) {
+    $subjectRows[] = $row;
+}
+
+$safeUser = htmlspecialchars($userName, ENT_QUOTES, 'UTF-8');
+
+$text = '<div><h1>Score for ' . $safeUser . '</h1>';
+$text .= "<table style='width:100%;text-align:center;'>"
+        . "<thead><tr><td style='text-align:left'>Subject</td><td>Score</td><td>No.Questions</td></tr></thead>"
+        . "<tbody>";
+
+foreach ($subjectRows as $row) {
+    $text .= "<tr><td style='text-align:left'>" . htmlspecialchars(ucfirst($row['subject']), ENT_QUOTES, 'UTF-8') . '</td><td>' . htmlspecialchars($row['percent']) . '%</td><td>' . (int) $row['count'] . '</td></tr>';
+}
+
+$text .= '</tbody></table>';
+
+// Today.
+$sql->gen("SELECT COUNT(id) AS cntr, CAST((SUM(correct)/COUNT(id)*100) AS DECIMAL(5,2)) AS percent
+    FROM $reportTable WHERE username = '$u'
+    AND answereddatestamp >= UNIX_TIMESTAMP(CURRENT_DATE())
+    AND answereddatestamp <= UNIX_TIMESTAMP(CURRENT_DATE() + INTERVAL 1 DAY)");
+
+$today = $sql->fetch();
+$answeredToday = is_array($today) ? (int) $today['cntr'] : 0;
+
+if ($answeredToday > 0) {
+    $percent = htmlspecialchars($today['percent']);
+
+    if ($answeredToday === 1) {
+        $text .= '<p>1 question has been answered<br/>today with ' . $percent . '% accuracy.</p><br/>';
+    } else {
+        $text .= '<p>' . $answeredToday . ' questions have been answered<br/>today with ' . $percent . '% accuracy.</p><br/>';
+    }
+
+    if ($minQuestions <= $answeredToday) {
+        $text .= '<label style="background: darkgreen;">Games enabled</label><br/>';
+        $currentScore = $sql->retrieve('timekeeperScore', 'timekeeper_lifetimecorrect, timekeeper_lifetimequestions, timekeeper_minutes', 'timekeeper_username="' . $u . '"');
+        $minutes = is_array($currentScore) ? (int) $currentScore['timekeeper_minutes'] : 0;
+        $text .= '<label>You have ' . $minutes . ' minutes remaining.</label>';
+    } else {
+        $text .= '<label>Games disabled</label>';
+    }
 }
-        
-        echo '<head><meta http-equiv="refresh" content="60"></head><body style="background-color: #1a0000;color:white;">'.$text.'</body>';
 
+$text .= '</div>';
+
+echo '<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="60"></head>'
+ . '<body style="background-color: #1a0000;color:white;">' . $text . '</body></html>';