countryNames.php 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. <?php
  2. /**
  3. * Decides whether what a student typed is the name of a country.
  4. *
  5. * Names in tk_countries come in the style of "Congo, Democratic Republic of
  6. * the" and "Vatican City (Holy See)", which nobody types. So every country
  7. * name is expanded into the forms a student might reasonably use:
  8. *
  9. * - capitals, accents, punctuation, HTML entities (e107 stores an apostrophe as
  10. * &#039;), "&" / "and", "St" / "Saint" and a leading "the" are ignored
  11. * - "Congo, Republic of the" also accepts "Republic of the Congo"
  12. * - "Vatican City (Holy See)" also accepts "Vatican City" and "Holy See"
  13. * - well-known alternative names are accepted (USA, Holland, Ivory Coast,
  14. * Burma, Czech Republic ...): see $aliasGroups
  15. * - one slipped letter is forgiven in names of 8 or more letters
  16. * ("Kyrgystan"), but never when what was typed is exactly the name of a
  17. * different country ("Dominica" for the Dominican Republic), or is just as
  18. * close to a different country ("Sorth Korea")
  19. */
  20. class CountryNames {
  21. private static $translit = [
  22. 'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a', 'ā' => 'a', 'ă' => 'a', 'ą' => 'a',
  23. 'ç' => 'c', 'ć' => 'c', 'č' => 'c', 'ď' => 'd', 'đ' => 'd', 'ð' => 'd',
  24. 'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e', 'ē' => 'e', 'ė' => 'e', 'ę' => 'e', 'ě' => 'e',
  25. 'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i', 'ī' => 'i', 'ı' => 'i',
  26. 'ł' => 'l', 'ñ' => 'n', 'ń' => 'n', 'ň' => 'n',
  27. 'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o', 'ø' => 'o', 'ō' => 'o', 'ő' => 'o',
  28. 'ř' => 'r', 'ś' => 's', 'š' => 's', 'ş' => 's', 'ș' => 's', 'ť' => 't', 'ț' => 't',
  29. 'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u', 'ū' => 'u', 'ů' => 'u', 'ű' => 'u',
  30. 'ý' => 'y', 'ÿ' => 'y', 'ž' => 'z', 'ź' => 'z', 'ż' => 'z',
  31. 'ß' => 'ss', 'æ' => 'ae', 'œ' => 'oe', 'þ' => 'th',
  32. ];
  33. /**
  34. * Alternative names, already in normalized form (see normalize()). If a
  35. * country's name is in a group, every name in the group is accepted.
  36. * Only names that belong to exactly one country belong here: "congo" and
  37. * "korea" are left out on purpose.
  38. */
  39. private static $aliasGroups = [
  40. ['united states of america', 'united states', 'usa', 'u s a', 'us', 'u s', 'america'],
  41. ['united kingdom', 'uk', 'u k', 'great britain', 'britain'],
  42. ['czechia', 'czech republic'],
  43. ['myanmar', 'burma'],
  44. ['timor leste', 'east timor'],
  45. ['cote d ivoire', 'ivory coast'],
  46. ['eswatini', 'swaziland'],
  47. ['north macedonia', 'macedonia'],
  48. ['cabo verde', 'cape verde'],
  49. ['turkey', 'turkiye'],
  50. ['netherlands', 'holland'],
  51. ['democratic republic of the congo', 'democratic republic of congo', 'dr congo', 'drc', 'congo kinshasa'],
  52. ['republic of the congo', 'republic of congo', 'congo brazzaville'],
  53. ['south korea', 'republic of korea', 'korea south'],
  54. ['north korea', 'democratic peoples republic of korea', 'korea north', 'dprk'],
  55. ['russia', 'russian federation'],
  56. ['vatican city', 'vatican', 'holy see', 'vatican city state'],
  57. ['micronesia', 'federated states of micronesia'],
  58. ['brunei', 'brunei darussalam'],
  59. ['laos', 'lao peoples democratic republic'],
  60. ['syria', 'syrian arab republic'],
  61. ['vietnam', 'viet nam'],
  62. ['iran', 'islamic republic of iran'],
  63. ['bolivia', 'plurinational state of bolivia'],
  64. ['venezuela', 'bolivarian republic of venezuela'],
  65. ['tanzania', 'united republic of tanzania'],
  66. ['moldova', 'republic of moldova'],
  67. ['palestine', 'state of palestine'],
  68. ['kosovo', 'republic of kosovo'],
  69. ['sao tome and principe', 'sao tome'],
  70. ['trinidad and tobago', 'trinidad'],
  71. ['antigua and barbuda', 'antigua'],
  72. ['bosnia and herzegovina', 'bosnia', 'herzegovina'],
  73. ['united arab emirates', 'uae'],
  74. ['saint kitts and nevis', 'saint kitts'],
  75. ['saint vincent and the grenadines', 'saint vincent'],
  76. ];
  77. /**
  78. * Lower case, accents removed, punctuation turned into single spaces.
  79. */
  80. public static function normalize($name) {
  81. // e107 stores an apostrophe as &#039; ("Cote d&#039;Ivoire"), so decode first.
  82. $s = html_entity_decode((string) $name, ENT_QUOTES | ENT_HTML5, 'UTF-8');
  83. $s = mb_strtolower($s, 'UTF-8');
  84. $s = strtr($s, self::$translit);
  85. $s = str_replace('&', ' and ', $s);
  86. $s = preg_replace('/[^a-z0-9]+/', ' ', $s);
  87. $s = trim(preg_replace('/\s+/', ' ', $s));
  88. $s = preg_replace('/^the /', '', $s);
  89. $s = preg_replace('/\bst\b/', 'saint', $s);
  90. return $s;
  91. }
  92. /**
  93. * Every normalized form accepted for a country name.
  94. *
  95. * @return string[]
  96. */
  97. public static function forms($name) {
  98. $name = (string) $name;
  99. $variants = [$name];
  100. if (preg_match('/^(.*?)\s*\((.*)\)\s*$/u', $name, $m)) {
  101. $variants[] = $m[1];
  102. $variants[] = $m[2];
  103. }
  104. foreach ($variants as $variant) {
  105. if (strpos($variant, ',') !== false) {
  106. $parts = array_map('trim', explode(',', $variant, 2));
  107. $variants[] = $parts[1] . ' ' . $parts[0]; // "Congo, Republic of the" -> "Republic of the Congo"
  108. }
  109. }
  110. $forms = [];
  111. foreach ($variants as $variant) {
  112. $form = self::normalize($variant);
  113. if ($form !== '') {
  114. $forms[$form] = true;
  115. }
  116. }
  117. foreach (array_keys($forms) as $form) {
  118. foreach (self::$aliasGroups as $group) {
  119. if (in_array((string) $form, $group, true)) {
  120. foreach ($group as $member) {
  121. $forms[$member] = true;
  122. }
  123. }
  124. }
  125. }
  126. return array_map('strval', array_keys($forms));
  127. }
  128. /**
  129. * Is $typed a correct name for the country called $expected?
  130. *
  131. * @param string $expected The country's name as stored (tk_countries.country).
  132. * @param string $typed What the student typed.
  133. * @param string[] $allNames Every country name, so another country's name is never accepted.
  134. * @return bool
  135. */
  136. public static function matches($expected, $typed, array $allNames = []) {
  137. $typedForm = self::normalize($typed);
  138. if ($typedForm === '' || strlen($typedForm) > 60) {
  139. return false;
  140. }
  141. $accepted = self::forms($expected);
  142. if (in_array($typedForm, $accepted, true)) {
  143. return true;
  144. }
  145. // The other countries (everything that isn't the same country as $expected).
  146. $others = [];
  147. foreach ($allNames as $other) {
  148. $otherForms = self::forms($other);
  149. if (array_intersect($accepted, $otherForms)) {
  150. continue; // the same country
  151. }
  152. $others[] = $otherForms;
  153. }
  154. foreach ($others as $otherForms) {
  155. if (in_array($typedForm, $otherForms, true)) {
  156. return false; // the name of a different country
  157. }
  158. }
  159. // One slipped letter in a long name is forgiven...
  160. $slip = false;
  161. foreach ($accepted as $form) {
  162. if (strlen($form) >= 8 && levenshtein($typedForm, $form) <= 1) {
  163. $slip = true;
  164. break;
  165. }
  166. }
  167. if (!$slip) {
  168. return false;
  169. }
  170. // ...unless it is just as close to a different country ("Sorth Korea").
  171. foreach ($others as $otherForms) {
  172. foreach ($otherForms as $form) {
  173. if (strlen($form) >= 8 && levenshtein($typedForm, $form) <= 1) {
  174. return false;
  175. }
  176. }
  177. }
  178. return true;
  179. }
  180. }