diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5dec1cb..a861901 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,10 @@ Changelog ========= +1.0.5 (Unreleased) +------------------ +- Fix: England no longer displays as "United Kingdom" with the Union Jack — England, Scotland and Wales now use their own flags (Unicode subdivision tag sequences) with their proper names; Kosovo (KVX) resolves to its localized name and flag; Northern Ireland falls back to a neutral initials badge (it has no emoji flag). + 1.0.4 (June 3,2026) ------------------- - Chore: bump minVersion to 1.18 diff --git a/module.json b/module.json index 07856af..98962d7 100644 --- a/module.json +++ b/module.json @@ -10,7 +10,7 @@ "world cup", "leaderboard" ], - "version": "1.0.4", + "version": "1.0.5", "humhub": { "minVersion": "1.18" }, diff --git a/services/TeamNameLocalizer.php b/services/TeamNameLocalizer.php index 288d7ea..7e38888 100644 --- a/services/TeamNameLocalizer.php +++ b/services/TeamNameLocalizer.php @@ -9,19 +9,33 @@ * country code simply fall back to the stored name. * * Adapters may store the country code as ISO-3166-1 alpha-2 (mock), alpha-3 - * (some football-data fields), or 3-letter international codes (`GER`, `SUI`, - * `KSA`). `normalizeToIso2()` collapses all three onto a canonical alpha-2. + * (some football-data fields), or 3-letter FIFA codes (`GER`, `SUI`, `KSA`). + * `normalizeToIso2()` collapses all three onto a canonical alpha-2. + * + * FIFA members without an ISO 3166-1 identity get special treatment: the + * British home nations (England, Scotland, Wales) are ISO 3166-2 + * subdivisions of GB whose flags are Unicode tag sequences (see + * `flagCodepoints()`), and Kosovo maps to the user-assigned-but-universal + * `XK`. Their names fall back to the stored team name where ICU can't help + * (PHP's intl exposes territory names only, not subdivision names). */ final class TeamNameLocalizer { /** - * 3-letter (international / ISO-3) → ISO-2 alias map for nations the - * module is likely to see. Codes already in alpha-2 are not listed — they - * pass through unchanged. + * 3-letter (FIFA / ISO-3) → ISO-2 alias map for nations the module is + * likely to see. Codes already in alpha-2 are not listed — they pass + * through unchanged. Where FIFA and ISO-3 disagree (GER/DEU, SUI/CHE, …) + * both spellings are present. + * + * Deliberately NOT mapped: ENG/SCO/WAL (subdivision flags, see + * SUBDIVISION_FLAGS) and NIR (no ISO code and no emoji flag — Unicode + * declined the politically contested Ulster Banner — so Northern Ireland + * intentionally falls through to the neutral initials badge). */ private const ISO3_TO_ISO2 = [ 'ARG' => 'AR', 'FRA' => 'FR', 'BRA' => 'BR', 'ESP' => 'ES', - 'ENG' => 'GB', 'GBR' => 'GB', + 'GBR' => 'GB', + 'KVX' => 'XK', // Kosovo: FIFA KVX → user-assigned ISO-2, resolved by ICU/CLDR 'POR' => 'PT', 'PRT' => 'PT', 'NED' => 'NL', 'NLD' => 'NL', 'BEL' => 'BE', @@ -83,6 +97,19 @@ final class TeamNameLocalizer 'CUW' => 'CW', ]; + /** + * FIFA codes of teams that are ISO 3166-2 *subdivisions* (the British + * home nations), keyed to the lowercase tag-letter part of their Unicode + * tag-sequence flag (🏴 + tag letters + cancel tag). They have no ISO + * 3166-1 code, so `normalizeToIso2()` keeps returning null for them — + * names fall back to the stored team name, only the flag is resolvable. + */ + private const SUBDIVISION_FLAGS = [ + 'ENG' => 'gbeng', + 'SCO' => 'gbsct', + 'WAL' => 'gbwls', + ]; + /** * Returns the canonical ISO-3166-1 alpha-2 code for whatever variant the * adapter wrote in, or null if the input doesn't look like a country code @@ -103,6 +130,57 @@ public static function normalizeToIso2(?string $code): ?string return self::ISO3_TO_ISO2[$code] ?? null; } + /** + * Returns the Unicode codepoint sequence of the team's flag emoji, or + * null when no flag exists for the code. Two shapes: + * - countries: a regional-indicator pair (e.g. DE → U+1F1E9 U+1F1EA); + * - home nations (ENG/SCO/WAL): a tag sequence — black flag, one tag + * character per subdivision letter, cancel tag. + * + * The sequence doubles as the Twemoji asset filename (codepoints in + * lowercase hex, joined by "-"), so emoji rendering and SVG lookup can't + * drift apart. + * + * @return int[]|null + */ + public static function flagCodepoints(?string $code): ?array + { + $raw = strtoupper(trim((string) $code)); + if (isset(self::SUBDIVISION_FLAGS[$raw])) { + $codepoints = [0x1F3F4]; // 🏴 waving black flag + foreach (str_split(self::SUBDIVISION_FLAGS[$raw]) as $letter) { + $codepoints[] = 0xE0000 + ord($letter); // tag character + } + $codepoints[] = 0xE007F; // cancel tag + return $codepoints; + } + + $iso2 = self::normalizeToIso2($code); + if ($iso2 === null) { + return null; + } + return [ + 0x1F1E6 + ord($iso2[0]) - 65, + 0x1F1E6 + ord($iso2[1]) - 65, + ]; + } + + /** + * Returns the team's flag as an emoji string, or null when no flag + * exists for the code (clubs, unknown codes, Northern Ireland). + */ + public static function flagEmoji(?string $code): ?string + { + $codepoints = self::flagCodepoints($code); + if ($codepoints === null) { + return null; + } + return implode('', array_map( + static fn(int $codepoint): string => mb_chr($codepoint, 'UTF-8'), + $codepoints, + )); + } + /** * Returns a localized name for the team (e.g. "Deutschland" / "Germany" * / "Brésil") via Intl. Falls back to `$fallbackName` whenever: diff --git a/tests/codeception/unit/TeamNameLocalizerTest.php b/tests/codeception/unit/TeamNameLocalizerTest.php index 6111636..7252213 100644 --- a/tests/codeception/unit/TeamNameLocalizerTest.php +++ b/tests/codeception/unit/TeamNameLocalizerTest.php @@ -38,6 +38,56 @@ public function testNormalizeToIso2ReturnsNullForUnknownInputs(): void $this->assertNull(TeamNameLocalizer::normalizeToIso2('TOOLONG')); } + public function testNormalizeToIso2HandlesNonIsoFifaMembers(): void + { + $this->assertSame('XK', TeamNameLocalizer::normalizeToIso2('KVX'), 'Kosovo → user-assigned XK'); + // The home nations are ISO 3166-2 subdivisions, not countries — they + // must NOT resolve to GB (England is not the United Kingdom). + $this->assertNull(TeamNameLocalizer::normalizeToIso2('ENG')); + $this->assertNull(TeamNameLocalizer::normalizeToIso2('SCO')); + $this->assertNull(TeamNameLocalizer::normalizeToIso2('WAL')); + $this->assertNull(TeamNameLocalizer::normalizeToIso2('NIR')); + // The UK itself keeps resolving (e.g. an Olympic "Team GB"). + $this->assertSame('GB', TeamNameLocalizer::normalizeToIso2('GBR')); + } + + public function testFlagCodepointsForCountries(): void + { + $this->assertSame([0x1F1E9, 0x1F1EA], TeamNameLocalizer::flagCodepoints('DE')); + $this->assertSame([0x1F1E9, 0x1F1EA], TeamNameLocalizer::flagCodepoints('GER'), 'FIFA code resolves too'); + $this->assertSame([0x1F1FD, 0x1F1F0], TeamNameLocalizer::flagCodepoints('KVX'), 'Kosovo 🇽🇰'); + $this->assertNull(TeamNameLocalizer::flagCodepoints(null)); + $this->assertNull(TeamNameLocalizer::flagCodepoints('XYZ')); + } + + public function testFlagCodepointsForHomeNations(): void + { + $this->assertSame( + [0x1F3F4, 0xE0067, 0xE0062, 0xE0065, 0xE006E, 0xE0067, 0xE007F], + TeamNameLocalizer::flagCodepoints('ENG'), + 'England tag sequence', + ); + $this->assertSame( + [0x1F3F4, 0xE0067, 0xE0062, 0xE0073, 0xE0063, 0xE0074, 0xE007F], + TeamNameLocalizer::flagCodepoints('SCO'), + 'Scotland tag sequence', + ); + $this->assertSame( + [0x1F3F4, 0xE0067, 0xE0062, 0xE0077, 0xE006C, 0xE0073, 0xE007F], + TeamNameLocalizer::flagCodepoints('WAL'), + 'Wales tag sequence', + ); + $this->assertNull(TeamNameLocalizer::flagCodepoints('NIR'), 'Northern Ireland has no emoji flag'); + } + + public function testFlagEmoji(): void + { + $this->assertSame('🇫🇷', TeamNameLocalizer::flagEmoji('FRA')); + $this->assertSame('🏴󠁧󠁢󠁥󠁮󠁧󠁿', TeamNameLocalizer::flagEmoji('ENG')); + $this->assertNull(TeamNameLocalizer::flagEmoji('NIR')); + $this->assertNull(TeamNameLocalizer::flagEmoji(null)); + } + public function testLocalizeTranslatesViaIntl(): void { if (!class_exists(Locale::class)) { @@ -58,4 +108,18 @@ public function testLocalizeFallsBackToStoredNameWhenCodeUnknown(): void $this->assertSame('FC Bayern', TeamNameLocalizer::localize('', 'FC Bayern', 'de')); $this->assertSame('Mystery Club', TeamNameLocalizer::localize('XYZ', 'Mystery Club', 'de')); } + + public function testLocalizeHomeNationsAndKosovo(): void + { + if (!class_exists(Locale::class)) { + $this->markTestSkipped('Intl extension not available'); + } + // England must NOT become "Vereinigtes Königreich" — no ISO country, + // so the stored name passes through on every UI language. + $this->assertSame('England', TeamNameLocalizer::localize('ENG', 'England', 'de')); + $this->assertSame('Scotland', TeamNameLocalizer::localize('SCO', 'Scotland', 'fr')); + $this->assertSame('Northern Ireland', TeamNameLocalizer::localize('NIR', 'Northern Ireland', 'de')); + // Kosovo resolves via the user-assigned XK, which CLDR localizes. + $this->assertSame('Kosovo', TeamNameLocalizer::localize('KVX', 'Kosovo', 'de')); + } } diff --git a/views/competition/_team_badge.php b/views/competition/_team_badge.php index 4676d22..57c906c 100644 --- a/views/competition/_team_badge.php +++ b/views/competition/_team_badge.php @@ -10,11 +10,7 @@ $flag = null; if (!$logo && $team) { - $iso2 = \humhub\modules\kickoff\services\TeamNameLocalizer::normalizeToIso2($team->country_code); - if ($iso2 !== null) { - $flag = mb_chr(0x1F1E6 + ord($iso2[0]) - 65, 'UTF-8') - . mb_chr(0x1F1E6 + ord($iso2[1]) - 65, 'UTF-8'); - } + $flag = \humhub\modules\kickoff\services\TeamNameLocalizer::flagEmoji($team->country_code); } if ($short !== null) {