Преглед на файлове

Add flags question generator.

Gregory Huxley преди 4 дни
родител
ревизия
8756e92f47
променени са 6 файла, в които са добавени 530 реда и са изтрити 80 реда
  1. 15 1
      README.md
  2. 8 3
      TK2.php
  3. 43 2
      answerChecker2.php
  4. 73 0
      flag.php
  5. 197 0
      questionGenerators/geography/countryNames.php
  6. 194 74
      questionGenerators/geography/flags.php

+ 15 - 1
README.md

@@ -67,18 +67,31 @@ generator in `questionGenerators/`. A subject unlocks at a difficulty threshold:
 | addition | 10,000 |
 | addition | 10,000 |
 | subtraction | 20,000 |
 | subtraction | 20,000 |
 | rounding (money to the nearest 5 cents) | 50,000 |
 | rounding (money to the nearest 5 cents) | 50,000 |
+| flags (switched off until you turn it on) | 120,000 |
 | elements (periodic table) | 150,000 |
 | elements (periodic table) | 150,000 |
 | multiplication | 200,000 |
 | multiplication | 200,000 |
 | division | 300,000 |
 | division | 300,000 |
 | fractions (switched off until you turn it on) | 350,000 |
 | fractions (switched off until you turn it on) | 350,000 |
 | anagrams | 4,000,000 |
 | anagrams | 4,000,000 |
 
 
-Worded problems, fractions and the geography/history/health subjects exist or are stubbed but are switched
+The geography subjects other than flags, and the history/health ones, are stubbed but switched
 off (the `0` in `$subjectsList` in `TK2.php`). To switch one on, change that `0` to a `1`; to change
 off (the `0` in `$subjectsList` in `TK2.php`). To switch one on, change that `0` to a `1`; to change
 when a subject appears, change its number. Worded problems (`word_problems`) would unlock at 250,000 and use whole-number
 when a subject appears, change its number. Worded problems (`word_problems`) would unlock at 250,000 and use whole-number
 answers only: pillow cases, sponges, an elephant's dinner, sandwiches, marbles, a day at the beach,
 answers only: pillow cases, sponges, an elephant's dinner, sandwiches, marbles, a day at the beach,
 cupcakes and sugar bags. The numbers get bigger as the student's accuracy and number of answers grow.
 cupcakes and sugar bags. The numbers get bigger as the student's accuracy and number of answers grow.
 
 
+**Flags** (`flags`, unlocking at 120,000 once switched on) show a flag from `resources/flags/` and the
+student types the country's name. A country is asked only if `tk_countries` has its `isoCode` (the
+3-letter code that names the flag file, e.g. `AUS` for `AUS.svg`) and the flag file exists. The
+`difficulty` column (1 easy ... 3 hard in the current data) sets the order: a new student gets the
+easiest 15% and the list opens up as their accuracy grows. Countries asked least this month come first.
+The page shows the flag through `flag.php`, which sends the flag for the student's open question
+without naming the file, so the answer isn't in the address. Names are checked by
+`questionGenerators/geography/countryNames.php`: capitals, accents and punctuation don't matter,
+alternative names are accepted (USA, Holland, Ivory Coast, Burma, DR Congo ...), one slipped letter in a
+long name is forgiven, and the name of a different country never is. Two rows with the same `isoCode`
+(Bosnia and Herzegovina) are asked once.
+
 **Fractions** (`fractions`, unlocking at 350,000 once switched on) use the `fraction/` folder, a copy
 **Fractions** (`fractions`, unlocking at 350,000 once switched on) use the `fraction/` folder, a copy
 of the open-source `phospr/fraction` library (MIT licence, lightly modified). The questions are
 of the open-source `phospr/fraction` library (MIT licence, lightly modified). The questions are
 simplifying, adding and subtracting (same denominator, then one a multiple of the other, then
 simplifying, adding and subtracting (same denominator, then one a multiple of the other, then
@@ -113,6 +126,7 @@ allowed. The server uses today's free minutes first, then earned minutes (only i
 | `tk_stats_page.php?user=NAME&key=KEY` | anyone with the key (anyone at all if no key is set) | Per-subject scores and minutes left; refreshes every minute |
 | `tk_stats_page.php?user=NAME&key=KEY` | anyone with the key (anyone at all if no key is set) | Per-subject scores and minutes left; refreshes every minute |
 | `timekeeperClientServices/timer.php?user=NAME&game=PROCESS` | the enforcement client | Minutes remaining; `&peek=1` doesn't use a minute |
 | `timekeeperClientServices/timer.php?user=NAME&game=PROCESS` | the enforcement client | Minutes remaining; `&peek=1` doesn't use a minute |
 | `timekeeperClientServices/games.json` | the enforcement client | Process names to treat as games |
 | `timekeeperClientServices/games.json` | the enforcement client | Process names to treat as games |
+| `flag.php` | the student's own browser | The flag for their open flags question (logged in only) |
 
 
 ## Admin area
 ## Admin area
 
 

+ 8 - 3
TK2.php

@@ -26,6 +26,7 @@
  *  - someone with neither the student nor the teacher class now sees a
  *  - someone with neither the student nor the teacher class now sees a
  *    message instead of an empty page
  *    message instead of an empty page
  *  - division and rounding are switched on (rounding at 50,000, division at 300,000)
  *  - division and rounding are switched on (rounding at 50,000, division at 300,000)
+ *  - new flags subject (switched off: see $subjectsList)
  *  - new fractions subject (switched off: see $subjectsList)
  *  - new fractions subject (switched off: see $subjectsList)
  *  - new word_problems subject (switched off: see $subjectsList); the old
  *  - new word_problems subject (switched off: see $subjectsList); the old
  *    worded_* subjects were mapped to the spelling generator and are gone
  *    worded_* subjects were mapped to the spelling generator and are gone
@@ -43,7 +44,7 @@ class TK2 {
             ['multiplication', 1, 200000], // Numeracy times'
             ['multiplication', 1, 200000], // Numeracy times'
             ['division', 1, 300000], // Numeracy deviding
             ['division', 1, 300000], // Numeracy deviding
             ['rounding', 1, 50000], // Numeracy rounding (money to the nearest 5 cents)
             ['rounding', 1, 50000], // Numeracy rounding (money to the nearest 5 cents)
-            ['word_problems', 1, 250000], // Worded maths problems: change the 0 to a 1 to switch them on
+            ['word_problems', 1, 300000], // Worded maths problems: change the 0 to a 1 to switch them on
             ['fractions', 1, 350000], // Fractions: change the 0 to a 1 to switch them on
             ['fractions', 1, 350000], // Fractions: change the 0 to a 1 to switch them on
         ],
         ],
         // English
         // English
@@ -59,7 +60,7 @@ class TK2 {
         // Geography
         // Geography
         [['continents', 0, 0], // Point to the continent
         [['continents', 0, 0], // Point to the continent
             ['countries', 0, 0], // Point to the country
             ['countries', 0, 0], // Point to the country
-            ['flags', 0, 0], // Name the country by the flag picture
+            ['flags', 1, 120000], // Name the country by the flag picture: change the 0 to a 1 to switch it on
             ['capitalCities', 0, 0], // Name the capital for a country
             ['capitalCities', 0, 0], // Name the capital for a country
         ],
         ],
         // History
         // History
@@ -198,10 +199,14 @@ class TK2 {
                 $questionGen = new elements2($this->data['user']);
                 $questionGen = new elements2($this->data['user']);
                 $questionGen->getElements();
                 $questionGen->getElements();
                 break;
                 break;
+            case 'flags':
+                require_once __DIR__ . '/questionGenerators/geography/flags.php';
+                $questionGen = new flags($this->data['user']);
+                $questionGen->getFlag();
+                break;
             case 'generalKnowledge':
             case 'generalKnowledge':
             case 'continents':
             case 'continents':
             case 'countries':
             case 'countries':
-            case 'flags':
             case 'capitalCities':
             case 'capitalCities':
             case 'artists':
             case 'artists':
             case 'malaySpelling':
             case 'malaySpelling':

+ 43 - 2
answerChecker2.php

@@ -19,6 +19,8 @@
  *  - getDifficulty(): MPREFIX instead of `e107.`, user escaped, no null result
  *  - getDifficulty(): MPREFIX instead of `e107.`, user escaped, no null result
  *  - removed the broken, unused shortCode() and findInList()
  *  - removed the broken, unused shortCode() and findInList()
  *  - a wrong answer takes a minute off but never below zero
  *  - a wrong answer takes a minute off but never below zero
+ *  - flags: the typed name is checked with CountryNames (alternative names such as USA or
+ *    Holland, accents and punctuation ignored, one slipped letter forgiven)
  *  - fractions: the answer may be typed as 3/4, a mixed number (1 1/2), a whole number
  *  - fractions: the answer may be typed as 3/4, a mixed number (1 1/2), a whole number
  *    or an exact decimal (0.75); it must be in its simplest form, and a right value that
  *    or an exact decimal (0.75); it must be in its simplest form, and a right value that
  *    isn't simplified (or isn't a mixed number when one was asked for) is marked wrong
  *    isn't simplified (or isn't a mixed number when one was asked for) is marked wrong
@@ -44,7 +46,7 @@ class answerChecker2 {
         'elements' => ['Fill in the missing element information. {QUESTION}', "Congratulations '{USER_ANSWER}' was correct for Fill in the missing element information '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Fill in the missing element information in {QUESTION}'"],
         'elements' => ['Fill in the missing element information. {QUESTION}', "Congratulations '{USER_ANSWER}' was correct for Fill in the missing element information '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Fill in the missing element information in {QUESTION}'"],
         'continents' => [],
         'continents' => [],
         'countries' => [],
         'countries' => [],
-        'flags' => [],
+        'flags' => ['Flags: {QUESTION}', "Congratulations '{USER_ANSWER}' was correct for '{QUESTION}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for '{QUESTION}'"],
         'capitalCities' => [],
         'capitalCities' => [],
         'artists' => [],
         'artists' => [],
         'malaySpelling' => [],
         'malaySpelling' => [],
@@ -77,13 +79,15 @@ class answerChecker2 {
             case 'fractions':
             case 'fractions':
                 $this->checkFractions();
                 $this->checkFractions();
                 break;
                 break;
+            case 'flags':
+                $this->checkFlags();
+                break;
             case 'generalKnowledge':
             case 'generalKnowledge':
             case 'elements':
             case 'elements':
                 $this->checkGeneric('elements');
                 $this->checkGeneric('elements');
                 break;
                 break;
             case 'continents':
             case 'continents':
             case 'countries':
             case 'countries':
-            case 'flags':
             case 'capitalCities':
             case 'capitalCities':
             case 'artists':
             case 'artists':
             case 'malaySpelling':
             case 'malaySpelling':
@@ -128,6 +132,41 @@ class answerChecker2 {
         $this->finishCheck('anagrams', $correct, $answer['answer1'], $usersAnswer, $this->questionText($answer, 'anagrams'));
         $this->finishCheck('anagrams', $correct, $answer['answer1'], $usersAnswer, $this->questionText($answer, 'anagrams'));
     }
     }
 
 
+    /**
+     * Check a flags answer: the student types the country's name. Alternative
+     * names (USA, Holland, Ivory Coast ...), accents and punctuation don't
+     * matter and a single slipped letter in a long name is forgiven, but the
+     * name of a different country is never accepted.
+     */
+    private function checkFlags() {
+        $usersAnswer = trim((string) filter_input(INPUT_POST, 'a1'));
+        $answer = $this->loadPending();
+        if ($answer === null) {
+            return;
+        }
+
+        require_once __DIR__ . '/questionGenerators/geography/countryNames.php';
+
+        $correct = CountryNames::matches((string) $answer['answer1'], $usersAnswer, $this->allCountryNames());
+
+        $this->finishCheck('flags', $correct, $answer['answer1'], $usersAnswer, $this->questionText($answer, 'flags'));
+    }
+
+    /**
+     * Every country name in tk_countries.
+     *
+     * @return string[]
+     */
+    private function allCountryNames() {
+        $db = e107::getDb();
+        $names = [];
+        $db->gen("SELECT `country` FROM `" . MPREFIX . "tk_countries`");
+        while ($row = $db->fetch()) {
+            $names[] = $row['country'];
+        }
+        return $names;
+    }
+
     /**
     /**
      * Check a fractions answer. Any correct form is accepted (3/4, a mixed
      * Check a fractions answer. Any correct form is accepted (3/4, a mixed
      * number like 1 1/2, a whole number, an exact decimal like 0.75) but it has
      * number like 1 1/2, a whole number, an exact decimal like 0.75) but it has
@@ -364,6 +403,8 @@ class answerChecker2 {
                     return 'You found <b>' . htmlspecialchars($usersAnswer) . '</b>. I was thinking of <b>' . $e . '</b>, but that is a real word too!';
                     return 'You found <b>' . htmlspecialchars($usersAnswer) . '</b>. I was thinking of <b>' . $e . '</b>, but that is a real word too!';
                 }
                 }
                 return 'You unjumbled it: <b>' . $e . '</b>!';
                 return 'You unjumbled it: <b>' . $e . '</b>!';
+            case 'flags':
+                return 'That is the flag of <b>' . $e . '</b>!';
             case 'fractions':
             case 'fractions':
                 return '<b>' . htmlspecialchars($question) . ' = ' . $e . '</b>';
                 return '<b>' . htmlspecialchars($question) . ' = ' . $e . '</b>';
             case 'word_problems':
             case 'word_problems':

+ 73 - 0
flag.php

@@ -0,0 +1,73 @@
+
+<?php
+
+/**
+ * Sends the flag for the logged-in student's open flags question.
+ *
+ * The question page shows <img src="flag.php?r=...">. Linking the file
+ * directly (resources/flags/AUS.svg) would put the answer's ISO code in the
+ * page for anyone who looks at the image address. This looks the flag up
+ * from the question stored for the student (TkAnswerStore / tkPendingAnswer)
+ * and sends it without saying which file it was.
+ *
+ * Only a logged-in user gets anything, and only the flag of their own open
+ * question: the file name comes from the stored question, never from the URL,
+ * and has to match a strict pattern and exist in resources/flags.
+ *
+ * SVGs are cleaned of comments and <title>/<desc>/<script> before they are
+ * sent (a title can name the country), and are sent with a content security
+ * policy that stops scripts if someone opens the address directly.
+ */
+if (!defined('e107_INIT')) {
+    require_once('../../class2.php');
+}
+
+if (!e107::isInstalled('timekeeper') || !USER) {
+    http_response_code(403);
+    exit;
+}
+
+$db = e107::getDb();
+$row = $db->retrieve('tkPendingAnswer', 'payload', "username = '" . $db->escape(e107::getUser()->getLoginName()) . "'");
+if (is_array($row)) {
+    $row = isset($row['payload']) ? $row['payload'] : reset($row);
+}
+$payload = is_string($row) ? json_decode($row, true) : null;
+
+$file = (is_array($payload) && isset($payload['flag'])) ? (string) $payload['flag'] : '';
+if (!preg_match('/^[A-Za-z]{3}\.(svg|png)$/', $file)) {
+    http_response_code(404);
+    exit;
+}
+
+$path = __DIR__ . '/resources/flags/' . $file;
+if (!is_file($path)) {
+    http_response_code(404);
+    exit;
+}
+
+while (ob_get_level() > 0) {
+    ob_end_clean();
+}
+
+header('Cache-Control: private, no-store');
+header('X-Content-Type-Options: nosniff');
+
+if (substr($file, -4) === '.svg') {
+    $svg = (string) file_get_contents($path);
+    foreach (['#<!--.*?-->#s', '#<title\b.*?</title>#is', '#<desc\b.*?</desc>#is', '#<script\b.*?</script>#is'] as $pattern) {
+        $clean = preg_replace($pattern, '', $svg);
+        if ($clean !== null) {
+            $svg = $clean;
+        }
+    }
+    header('Content-Type: image/svg+xml');
+    header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox");
+    header('Content-Length: ' . strlen($svg));
+    echo $svg;
+} else {
+    header('Content-Type: image/png');
+    header('Content-Length: ' . filesize($path));
+    readfile($path);
+}
+exit;

+ 197 - 0
questionGenerators/geography/countryNames.php

@@ -0,0 +1,197 @@
+
+<?php
+
+/**
+ * Decides whether what a student typed is the name of a country.
+ *
+ * Names in tk_countries come in the style of "Congo, Democratic Republic of
+ * the" and "Vatican City (Holy See)", which nobody types. So every country
+ * name is expanded into the forms a student might reasonably use:
+ *
+ *  - capitals, accents, punctuation, HTML entities (e107 stores an apostrophe as
+ *    &#039;), "&" / "and", "St" / "Saint" and a leading "the" are ignored
+ *  - "Congo, Republic of the" also accepts "Republic of the Congo"
+ *  - "Vatican City (Holy See)" also accepts "Vatican City" and "Holy See"
+ *  - well-known alternative names are accepted (USA, Holland, Ivory Coast,
+ *    Burma, Czech Republic ...): see $aliasGroups
+ *  - one slipped letter is forgiven in names of 8 or more letters
+ *    ("Kyrgystan"), but never when what was typed is exactly the name of a
+ *    different country ("Dominica" for the Dominican Republic), or is just as
+ *    close to a different country ("Sorth Korea")
+ */
+class CountryNames {
+
+    private static $translit = [
+        'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a', 'ā' => 'a', 'ă' => 'a', 'ą' => 'a',
+        'ç' => 'c', 'ć' => 'c', 'č' => 'c', 'ď' => 'd', 'đ' => 'd', 'ð' => 'd',
+        'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e', 'ē' => 'e', 'ė' => 'e', 'ę' => 'e', 'ě' => 'e',
+        'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i', 'ī' => 'i', 'ı' => 'i',
+        'ł' => 'l', 'ñ' => 'n', 'ń' => 'n', 'ň' => 'n',
+        'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o', 'ø' => 'o', 'ō' => 'o', 'ő' => 'o',
+        'ř' => 'r', 'ś' => 's', 'š' => 's', 'ş' => 's', 'ș' => 's', 'ť' => 't', 'ț' => 't',
+        'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u', 'ū' => 'u', 'ů' => 'u', 'ű' => 'u',
+        'ý' => 'y', 'ÿ' => 'y', 'ž' => 'z', 'ź' => 'z', 'ż' => 'z',
+        'ß' => 'ss', 'æ' => 'ae', 'œ' => 'oe', 'þ' => 'th',
+    ];
+
+    /**
+     * Alternative names, already in normalized form (see normalize()). If a
+     * country's name is in a group, every name in the group is accepted.
+     * Only names that belong to exactly one country belong here: "congo" and
+     * "korea" are left out on purpose.
+     */
+    private static $aliasGroups = [
+        ['united states of america', 'united states', 'usa', 'u s a', 'us', 'u s', 'america'],
+        ['united kingdom', 'uk', 'u k', 'great britain', 'britain'],
+        ['czechia', 'czech republic'],
+        ['myanmar', 'burma'],
+        ['timor leste', 'east timor'],
+        ['cote d ivoire', 'ivory coast'],
+        ['eswatini', 'swaziland'],
+        ['north macedonia', 'macedonia'],
+        ['cabo verde', 'cape verde'],
+        ['turkey', 'turkiye'],
+        ['netherlands', 'holland'],
+        ['democratic republic of the congo', 'democratic republic of congo', 'dr congo', 'drc', 'congo kinshasa'],
+        ['republic of the congo', 'republic of congo', 'congo brazzaville'],
+        ['south korea', 'republic of korea', 'korea south'],
+        ['north korea', 'democratic peoples republic of korea', 'korea north', 'dprk'],
+        ['russia', 'russian federation'],
+        ['vatican city', 'vatican', 'holy see', 'vatican city state'],
+        ['micronesia', 'federated states of micronesia'],
+        ['brunei', 'brunei darussalam'],
+        ['laos', 'lao peoples democratic republic'],
+        ['syria', 'syrian arab republic'],
+        ['vietnam', 'viet nam'],
+        ['iran', 'islamic republic of iran'],
+        ['bolivia', 'plurinational state of bolivia'],
+        ['venezuela', 'bolivarian republic of venezuela'],
+        ['tanzania', 'united republic of tanzania'],
+        ['moldova', 'republic of moldova'],
+        ['palestine', 'state of palestine'],
+        ['kosovo', 'republic of kosovo'],
+        ['sao tome and principe', 'sao tome'],
+        ['trinidad and tobago', 'trinidad'],
+        ['antigua and barbuda', 'antigua'],
+        ['bosnia and herzegovina', 'bosnia', 'herzegovina'],
+        ['united arab emirates', 'uae'],
+        ['saint kitts and nevis', 'saint kitts'],
+        ['saint vincent and the grenadines', 'saint vincent'],
+    ];
+
+    /**
+     * Lower case, accents removed, punctuation turned into single spaces.
+     */
+    public static function normalize($name) {
+        // e107 stores an apostrophe as &#039; ("Cote d&#039;Ivoire"), so decode first.
+        $s = html_entity_decode((string) $name, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+        $s = mb_strtolower($s, 'UTF-8');
+        $s = strtr($s, self::$translit);
+        $s = str_replace('&', ' and ', $s);
+        $s = preg_replace('/[^a-z0-9]+/', ' ', $s);
+        $s = trim(preg_replace('/\s+/', ' ', $s));
+        $s = preg_replace('/^the /', '', $s);
+        $s = preg_replace('/\bst\b/', 'saint', $s);
+        return $s;
+    }
+
+    /**
+     * Every normalized form accepted for a country name.
+     *
+     * @return string[]
+     */
+    public static function forms($name) {
+        $name = (string) $name;
+
+        $variants = [$name];
+        if (preg_match('/^(.*?)\s*\((.*)\)\s*$/u', $name, $m)) {
+            $variants[] = $m[1];
+            $variants[] = $m[2];
+        }
+        foreach ($variants as $variant) {
+            if (strpos($variant, ',') !== false) {
+                $parts = array_map('trim', explode(',', $variant, 2));
+                $variants[] = $parts[1] . ' ' . $parts[0]; // "Congo, Republic of the" -> "Republic of the Congo"
+            }
+        }
+
+        $forms = [];
+        foreach ($variants as $variant) {
+            $form = self::normalize($variant);
+            if ($form !== '') {
+                $forms[$form] = true;
+            }
+        }
+
+        foreach (array_keys($forms) as $form) {
+            foreach (self::$aliasGroups as $group) {
+                if (in_array((string) $form, $group, true)) {
+                    foreach ($group as $member) {
+                        $forms[$member] = true;
+                    }
+                }
+            }
+        }
+
+        return array_map('strval', array_keys($forms));
+    }
+
+    /**
+     * Is $typed a correct name for the country called $expected?
+     *
+     * @param string   $expected The country's name as stored (tk_countries.country).
+     * @param string   $typed    What the student typed.
+     * @param string[] $allNames Every country name, so another country's name is never accepted.
+     * @return bool
+     */
+    public static function matches($expected, $typed, array $allNames = []) {
+        $typedForm = self::normalize($typed);
+        if ($typedForm === '' || strlen($typedForm) > 60) {
+            return false;
+        }
+
+        $accepted = self::forms($expected);
+        if (in_array($typedForm, $accepted, true)) {
+            return true;
+        }
+
+        // The other countries (everything that isn't the same country as $expected).
+        $others = [];
+        foreach ($allNames as $other) {
+            $otherForms = self::forms($other);
+            if (array_intersect($accepted, $otherForms)) {
+                continue; // the same country
+            }
+            $others[] = $otherForms;
+        }
+
+        foreach ($others as $otherForms) {
+            if (in_array($typedForm, $otherForms, true)) {
+                return false; // the name of a different country
+            }
+        }
+
+        // One slipped letter in a long name is forgiven...
+        $slip = false;
+        foreach ($accepted as $form) {
+            if (strlen($form) >= 8 && levenshtein($typedForm, $form) <= 1) {
+                $slip = true;
+                break;
+            }
+        }
+        if (!$slip) {
+            return false;
+        }
+
+        // ...unless it is just as close to a different country ("Sorth Korea").
+        foreach ($others as $otherForms) {
+            foreach ($otherForms as $form) {
+                if (strlen($form) >= 8 && levenshtein($typedForm, $form) <= 1) {
+                    return false;
+                }
+            }
+        }
+
+        return true;
+    }
+}

+ 194 - 74
questionGenerators/geography/flags.php

@@ -1,101 +1,221 @@
 <?php
 <?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
+/**
+ * Flags ("flags"): show a flag, the student types the country's name.
+ *
+ * Replaces the placeholder that was a copy of the anagram generator.
+ *
+ *  - The flags come from resources/flags/<ISO alpha-3 code>.svg (or .png). A
+ *    country is only asked if it is in tk_countries with its isoCode filled
+ *    in AND there is a flag file for that code.
+ *  - The flag is not linked by file name: the page shows flag.php, which sends
+ *    the flag for the student's open question. A name like AUS.svg in the page
+ *    would give the answer away.
+ *  - Easy countries first: tk_countries.difficulty (1 = easy ... 10 = hard)
+ *    decides the order. At level 0 a student gets the easiest 15% of the
+ *    countries, and the pool grows to all of them as their accuracy grows
+ *    (scaled up over their first 30 answers). Within the same difficulty the
+ *    order is shuffled differently for each student.
+ *  - Countries the student has been asked least this month come first.
+ *  - Names are decoded from e107's HTML entities, and a code that has two rows
+ *    (Bosnia and Herzegovina) is asked once.
+ *
+ * The expected answer is stored server-side (TkAnswerStore); the checker
+ * (answerChecker2::checkFlags) accepts alternative names such as "USA" or
+ * "Holland" through CountryNames.
  */
  */
+class flags {
 
 
-class continents
-{
+    const SUBJECT = 'flags';
 
 
- private $user;
+    private $user;
+    private $level = 0; // 0-100: how much of the country list to use
 
 
     /**
     /**
-     * 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) {
     public function __construct($user) {
         $this->user = $user;
         $this->user = $user;
     }
     }
 
 
-    public function getgeography() {
-
-        $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);
+    /**
+     * Make a question, show it, and remember it as the student's open question.
+     */
+    public function getFlag() {
+        $problem = $this->getProblem();
+
+        if ($problem === null) {
+            e107::getRender()->tablerender('Flags', 'There are no flags to ask about yet. Please refresh the page.');
+            return;
+        }
+
+        $questionHtml = $this->htmlify($problem);
+
+        $db = e107::getDb();
+        $db->delete('tkLastQuestion', "user = '" . $db->escape($this->user) . "'");
+        $db->insert('tkLastQuestion', ['user' => $this->user, 'LastQuestionHtml' => base64_encode($questionHtml)]);
+
+        e107::getRender()->tablerender('Flags', $questionHtml);
     }
     }
 
 
     /**
     /**
-     * Create a spelling form using the supplied question.
-     * And write it into "$this->returnQuestion".
-     * 
-     * @param array() $data Question data to be used.
+     * Choose a flag without showing it.
+     *
+     * @return array|null ['answer1' => country name, 'question' => string, 'type' => 'flags',
+     *                     'flag' => file name, 'iso' => code], or null when no flag can be asked.
      */
      */
-    private function anagramsHtml($data) {
-        
-        $frm = e107::getForm();
+    public function getProblem() {
+        $this->level = $this->difficulty();
 
 
-        $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";
+        $country = $this->pickCountry($this->flagFiles());
+        if ($country === null) {
+            return null;
         }
         }
-        
-        $fString .= '</br></br>';
-        $fString .= $frm->text('a1', '', 80, ['placeholder' => 'Write answer here!', 'autocomplete' => "off", 'spellcheck' => "false"]) . "\n";
-        $fString .= $frm->hidden('checkerData', base64_encode(json_encode($data)), ['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 .= '</br></br>';
-        
-        return $fString;
-        
+
+        return [
+            'answer1' => $country['country'],
+            'question' => 'Name this flag (' . $country['iso'] . ')',
+            'type' => self::SUBJECT,
+            'flag' => $country['file'],
+            'iso' => $country['iso'],
+        ];
     }
     }
-    
+
     /**
     /**
-     * Return the difficulty based on previously answered questions.
+     * How much of the country list to use, 0-100: last month's accuracy, scaled
+     * up over the first 30 answers.
      *
      *
-     * @return array['percent','count'] Difficulty 1-13, Number of questions answered.
+     * @return float
      */
      */
     private function difficulty() {
     private function difficulty() {
+        $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 = '" . self::SUBJECT . "'"
+        );
 
 
-        $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` = 'continents';"
-		  );
-        
-        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 {
-            return 1;
+        if (!is_array($stats) || (int) $stats['count'] === 0) {
+            return 0.0;
         }
         }
+
+        $sample = min(1.0, ((int) $stats['count']) / 30);
+        return max(0.0, min(100.0, (float) $stats['percent'])) * $sample;
+    }
+
+    /**
+     * The flag files that exist: ['AUS' => 'AUS.svg', ...]
+     */
+    private function flagFiles() {
+        $dir = dirname(__DIR__, 2) . '/resources/flags';
+        $files = [];
+
+        foreach (['svg', 'png'] as $extension) {
+            $found = glob($dir . '/*.' . $extension);
+            foreach (($found ? $found : []) as $path) {
+                $code = strtoupper(pathinfo($path, PATHINFO_FILENAME));
+                if (preg_match('/^[A-Z]{3}$/', $code) && !isset($files[$code])) {
+                    $files[$code] = basename($path);
+                }
+            }
+        }
+
+        return $files;
+    }
+
+    /**
+     * @param array $files ['AUS' => 'AUS.svg', ...]
+     * @return array|null ['country', 'iso', 'file', 'difficulty']
+     */
+    private function pickCountry(array $files) {
+        $db = e107::getDb();
+
+        $seen = [];
+        // Countries that have both an ISO code and a flag file, one row per code
+        // (the first by id: the table has two rows, Bosnia and Herzegovina, for BIH).
+        $countries = [];
+        $db->gen("SELECT `country`, `isoCode`, `difficulty` FROM `" . MPREFIX . "tk_countries` WHERE `isoCode` != '' ORDER BY `id`");
+        while ($row = $db->fetch()) {
+            $code = strtoupper(trim($row['isoCode']));
+            // e107 stores an apostrophe as &#039; ("Cote d&#039;Ivoire"): show and compare the real name.
+            $name = trim(html_entity_decode($row['country'], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
+            if ($name !== '' && isset($files[$code]) && !isset($seen[$code])) {
+                $seen[$code] = true;
+                $countries[] = [
+                    'country' => $name,
+                    'iso' => $code,
+                    'file' => $files[$code],
+                    'difficulty' => (int) $row['difficulty'],
+                    'order' => crc32($this->user . '|' . $code),
+                ];
+            }
+        }
+        if (count($countries) < 4) {
+            return null;
+        }
+
+        // How often each has been asked this month.
+        $asked = [];
+        $db->gen("SELECT `answer`, COUNT(*) AS cnt FROM `" . MPREFIX . "tkReport`
+            WHERE `username` = '" . $db->escape($this->user) . "' AND `subject` = '" . self::SUBJECT . "'
+            AND `answereddatestamp` >= UNIX_TIMESTAMP(NOW() - INTERVAL 1 MONTH) GROUP BY `answer`");
+        while ($row = $db->fetch()) {
+            $asked[$row['answer']] = (int) $row['cnt'];
+        }
+
+        // Easiest first; the same difficulty in an order of this student's own.
+        usort($countries, function ($a, $b) {
+            if ($a['difficulty'] !== $b['difficulty']) {
+                return $a['difficulty'] <=> $b['difficulty'];
+            }
+            return $a['order'] <=> $b['order'];
+        });
+
+        $total = count($countries);
+        $size = (int) ceil($total * (0.15 + 0.85 * $this->level / 100));
+        $size = max(min($total, 8), min($total, $size));
+        $pool = array_slice($countries, 0, $size);
+
+        // The least-asked countries in the pool, one at random.
+        $fewest = null;
+        foreach ($pool as $country) {
+            $count = isset($asked[$country['country']]) ? $asked[$country['country']] : 0;
+            if ($fewest === null || $count < $fewest) {
+                $fewest = $count;
+            }
+        }
+        $candidates = [];
+        foreach ($pool as $country) {
+            $count = isset($asked[$country['country']]) ? $asked[$country['country']] : 0;
+            if ($count === $fewest) {
+                $candidates[] = $country;
+            }
+        }
+
+        return $candidates[array_rand($candidates)];
+    }
+
+    private function htmlify($problem) {
+        // Expected answer stays on the server; the form only carries a token.
+        $token = TkAnswerStore::issue($this->user, $problem);
+
+        $frm = e107::getForm();
+
+        $fString = $frm->open('questionForm', 'post', '', ['autocomplete' => 'off', 'class' => 'formclass']);
+        $fString .= $frm->label('<h4>Flags</h4>') . "</br>\n";
+        $fString .= $frm->label('Which country has this flag? Type its name.') . "\n";
+        $fString .= '</br>';
+        // flag.php sends the flag for this student's open question (no file name, so no clue in the page).
+        $fString .= '<p><img src="flag.php?r=' . bin2hex(random_bytes(4)) . '" alt="A flag" style="width:360px;max-width:100%;height:auto;border:1px solid #999;"></p>';
+        $fString .= $frm->text('a1', '', 60, ['placeholder' => 'Write the country here!', 'autocomplete' => "off", 'spellcheck' => "false"]) . "\n";
+        $fString .= $frm->hidden('checkerData', $token, ['id' => 'checkerData']);
+        $fString .= $frm->hidden('subject', self::SUBJECT, ['id' => 'subject']);
+        $fString .= '</br>' . "\n";
+        $fString .= $frm->button('Answer', 'Check answer') . "\n";
+        $fString .= $frm->close();
+        $fString .= '</br></br>';
+
+        return $fString;
     }
     }
 }
 }