diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c417b388..510cd1d1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -43,7 +43,9 @@ "Bash(while read dir)", "Bash(MIX_ENV=dev mix run:*)", "Bash(mix deps.get:*)", - "Bash(ls:*)" + "Bash(ls:*)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:github.com)" ] }, "enableAllProjectMcpServers": false diff --git a/lib/music_library/artists/artist_info.ex b/lib/music_library/artists/artist_info.ex index 7b54b2de..986b8a5f 100644 --- a/lib/music_library/artists/artist_info.ex +++ b/lib/music_library/artists/artist_info.ex @@ -40,13 +40,7 @@ defmodule MusicLibrary.Artists.ArtistInfo do nil -> artist_info.musicbrainz_data["country"] end - %{name: area["name"] || "World", code: keep_alpha_2(country_code) || "XW"} - end - - defp keep_alpha_2(nil), do: nil - - defp keep_alpha_2(country_code) do - String.slice(country_code, 0..1) + %{name: area["name"] || "World", code: country_code || "XW"} end def extract_image(artist_info) when is_nil(artist_info.discogs_data) do diff --git a/lib/music_library/country.ex b/lib/music_library/country.ex new file mode 100644 index 00000000..998787f1 --- /dev/null +++ b/lib/music_library/country.ex @@ -0,0 +1,330 @@ +defmodule MusicLibrary.Country do + @moduledoc """ + Converts country codes to flag emojis. + + Supports alpha-2 (`"US"`), alpha-3 (`"USA"`), subdivision (`"GB-SCT"`), + and IETF language tag (`"en-US"`) formats. + """ + + @regional_indicator_offset 0x1F1E6 - ?a + @tag_offset 0xE0000 + @black_flag <<0x1F3F4::utf8>> + @cancel_tag <<0xE007F::utf8>> + + # ISO 3166-1 alpha-3 to alpha-2 mapping + @alpha3_to_alpha2 %{ + "ABW" => "AW", + "AFG" => "AF", + "AGO" => "AO", + "AIA" => "AI", + "ALA" => "AX", + "ALB" => "AL", + "AND" => "AD", + "ARE" => "AE", + "ARG" => "AR", + "ARM" => "AM", + "ASM" => "AS", + "ATA" => "AQ", + "ATF" => "TF", + "ATG" => "AG", + "AUS" => "AU", + "AUT" => "AT", + "AZE" => "AZ", + "BDI" => "BI", + "BEL" => "BE", + "BEN" => "BJ", + "BES" => "BQ", + "BFA" => "BF", + "BGD" => "BD", + "BGR" => "BG", + "BHR" => "BH", + "BHS" => "BS", + "BIH" => "BA", + "BLM" => "BL", + "BLR" => "BY", + "BLZ" => "BZ", + "BMU" => "BM", + "BOL" => "BO", + "BRA" => "BR", + "BRB" => "BB", + "BRN" => "BN", + "BTN" => "BT", + "BVT" => "BV", + "BWA" => "BW", + "CAF" => "CF", + "CAN" => "CA", + "CCK" => "CC", + "CHE" => "CH", + "CHL" => "CL", + "CHN" => "CN", + "CIV" => "CI", + "CMR" => "CM", + "COD" => "CD", + "COG" => "CG", + "COK" => "CK", + "COL" => "CO", + "COM" => "KM", + "CPV" => "CV", + "CRI" => "CR", + "CUB" => "CU", + "CUW" => "CW", + "CXR" => "CX", + "CYM" => "KY", + "CYP" => "CY", + "CZE" => "CZ", + "DEU" => "DE", + "DJI" => "DJ", + "DMA" => "DM", + "DNK" => "DK", + "DOM" => "DO", + "DZA" => "DZ", + "ECU" => "EC", + "EGY" => "EG", + "ERI" => "ER", + "ESH" => "EH", + "ESP" => "ES", + "EST" => "EE", + "ETH" => "ET", + "FIN" => "FI", + "FJI" => "FJ", + "FLK" => "FK", + "FRA" => "FR", + "FRO" => "FO", + "FSM" => "FM", + "GAB" => "GA", + "GBR" => "GB", + "GEO" => "GE", + "GGY" => "GG", + "GHA" => "GH", + "GIB" => "GI", + "GIN" => "GN", + "GLP" => "GP", + "GMB" => "GM", + "GNB" => "GW", + "GNQ" => "GQ", + "GRC" => "GR", + "GRD" => "GD", + "GRL" => "GL", + "GTM" => "GT", + "GUF" => "GF", + "GUM" => "GU", + "GUY" => "GY", + "HKG" => "HK", + "HMD" => "HM", + "HND" => "HN", + "HRV" => "HR", + "HTI" => "HT", + "HUN" => "HU", + "IDN" => "ID", + "IMN" => "IM", + "IND" => "IN", + "IOT" => "IO", + "IRL" => "IE", + "IRN" => "IR", + "IRQ" => "IQ", + "ISL" => "IS", + "ISR" => "IL", + "ITA" => "IT", + "JAM" => "JM", + "JEY" => "JE", + "JOR" => "JO", + "JPN" => "JP", + "KAZ" => "KZ", + "KEN" => "KE", + "KGZ" => "KG", + "KHM" => "KH", + "KIR" => "KI", + "KNA" => "KN", + "KOR" => "KR", + "KWT" => "KW", + "LAO" => "LA", + "LBN" => "LB", + "LBR" => "LR", + "LBY" => "LY", + "LCA" => "LC", + "LIE" => "LI", + "LKA" => "LK", + "LSO" => "LS", + "LTU" => "LT", + "LUX" => "LU", + "LVA" => "LV", + "MAC" => "MO", + "MAF" => "MF", + "MAR" => "MA", + "MCO" => "MC", + "MDA" => "MD", + "MDG" => "MG", + "MDV" => "MV", + "MEX" => "MX", + "MHL" => "MH", + "MKD" => "MK", + "MLI" => "ML", + "MLT" => "MT", + "MMR" => "MM", + "MNE" => "ME", + "MNG" => "MN", + "MNP" => "MP", + "MOZ" => "MZ", + "MRT" => "MR", + "MSR" => "MS", + "MTQ" => "MQ", + "MUS" => "MU", + "MWI" => "MW", + "MYS" => "MY", + "MYT" => "YT", + "NAM" => "NA", + "NCL" => "NC", + "NER" => "NE", + "NFK" => "NF", + "NGA" => "NG", + "NIC" => "NI", + "NIU" => "NU", + "NLD" => "NL", + "NOR" => "NO", + "NPL" => "NP", + "NRU" => "NR", + "NZL" => "NZ", + "OMN" => "OM", + "PAK" => "PK", + "PAN" => "PA", + "PCN" => "PN", + "PER" => "PE", + "PHL" => "PH", + "PLW" => "PW", + "PNG" => "PG", + "POL" => "PL", + "PRI" => "PR", + "PRK" => "KP", + "PRT" => "PT", + "PRY" => "PY", + "PSE" => "PS", + "PYF" => "PF", + "QAT" => "QA", + "REU" => "RE", + "ROU" => "RO", + "RUS" => "RU", + "RWA" => "RW", + "SAU" => "SA", + "SDN" => "SD", + "SEN" => "SN", + "SGP" => "SG", + "SGS" => "GS", + "SHN" => "SH", + "SJM" => "SJ", + "SLB" => "SB", + "SLE" => "SL", + "SLV" => "SV", + "SMR" => "SM", + "SOM" => "SO", + "SPM" => "PM", + "SRB" => "RS", + "SSD" => "SS", + "STP" => "ST", + "SUR" => "SR", + "SVK" => "SK", + "SVN" => "SI", + "SWE" => "SE", + "SWZ" => "SZ", + "SXM" => "SX", + "SYC" => "SC", + "SYR" => "SY", + "TCA" => "TC", + "TCD" => "TD", + "TGO" => "TG", + "THA" => "TH", + "TJK" => "TJ", + "TKL" => "TK", + "TKM" => "TM", + "TLS" => "TL", + "TON" => "TO", + "TTO" => "TT", + "TUN" => "TN", + "TUR" => "TR", + "TUV" => "TV", + "TWN" => "TW", + "TZA" => "TZ", + "UGA" => "UG", + "UKR" => "UA", + "UMI" => "UM", + "URY" => "UY", + "USA" => "US", + "UZB" => "UZ", + "VAT" => "VA", + "VCT" => "VC", + "VEN" => "VE", + "VGB" => "VG", + "VIR" => "VI", + "VNM" => "VN", + "VUT" => "VU", + "WLF" => "WF", + "WSM" => "WS", + "XKX" => "XK", + "YEM" => "YE", + "ZAF" => "ZA", + "ZMB" => "ZM", + "ZWE" => "ZW" + } + + @doc """ + Converts a country code to a flag emoji. + + Accepts alpha-2 (`"US"`), alpha-3 (`"USA"`), subdivision (`"GB-SCT"`), + and IETF language tag (`"en-US"`) formats. Case insensitive. + + ## Examples + + iex> MusicLibrary.Country.to_emoji("US") + "🇺🇸" + + iex> MusicLibrary.Country.to_emoji("GB-SCT") + "🏴󠁧󠁢󠁳󠁣󠁴󠁿" + + iex> MusicLibrary.Country.to_emoji("USA") + "🇺🇸" + + """ + @spec to_emoji(String.t()) :: String.t() + def to_emoji(code) when is_binary(code) do + code + |> normalize() + |> convert() + end + + defp normalize(code) do + code = String.upcase(code) + + cond do + String.contains?(code, "-") -> normalize_with_separator(code) + String.length(code) == 3 -> Map.get(@alpha3_to_alpha2, code, code) + true -> code + end + end + + defp normalize_with_separator(code) do + case String.split(code, "-") do + [country, subdivision] when byte_size(country) == 2 and byte_size(subdivision) > 2 -> + subdivision = if subdivision == "CYM", do: "WLS", else: subdivision + {:subdivision, country <> subdivision} + + parts -> + List.last(parts) + end + end + + defp convert({:subdivision, code}) do + tags = + code + |> String.downcase() + |> String.to_charlist() + |> Enum.map_join(fn char -> <> end) + + @black_flag <> tags <> @cancel_tag + end + + defp convert(code) when is_binary(code) do + code + |> String.downcase() + |> String.to_charlist() + |> Enum.map_join(fn char -> <> end) + end +end diff --git a/lib/music_library_web/components/record_components.ex b/lib/music_library_web/components/record_components.ex index 30a53fac..1f490237 100644 --- a/lib/music_library_web/components/record_components.ex +++ b/lib/music_library_web/components/record_components.ex @@ -3,6 +3,7 @@ defmodule MusicLibraryWeb.RecordComponents do alias MusicBrainz.ReleaseSearchResult alias MusicLibrary.Assets.Transform + alias MusicLibrary.Country alias MusicLibrary.Records alias Phoenix.LiveView.JS @@ -436,14 +437,7 @@ defmodule MusicLibraryWeb.RecordComponents do def country_label(nil), do: nil def country_label("XW"), do: "🌍" def country_label("XE"), do: "🇪🇺" - - def country_label(country_code) do - if flagmoji = Flagmojis.by_iso(country_code) do - flagmoji.emoji - else - country_code - end - end + def country_label(country_code), do: Country.to_emoji(country_code) attr :record, Records.Record, required: true diff --git a/test/music_library/country_test.exs b/test/music_library/country_test.exs new file mode 100644 index 00000000..8a3c1edb --- /dev/null +++ b/test/music_library/country_test.exs @@ -0,0 +1,46 @@ +defmodule MusicLibrary.CountryTest do + use ExUnit.Case, async: true + + alias MusicLibrary.Country + + describe "to_emoji/1" do + test "converts alpha-2 codes" do + assert Country.to_emoji("US") == "🇺🇸" + assert Country.to_emoji("PL") == "🇵🇱" + assert Country.to_emoji("GB") == "🇬🇧" + end + + test "is case insensitive" do + assert Country.to_emoji("us") == "🇺🇸" + assert Country.to_emoji("pl") == "🇵🇱" + end + + test "converts subdivision codes" do + assert Country.to_emoji("GB-SCT") == "🏴󠁧󠁢󠁳󠁣󠁴󠁿" + assert Country.to_emoji("GB-WLS") == "🏴󠁧󠁢󠁷󠁬󠁳󠁿" + assert Country.to_emoji("GB-ENG") == "🏴󠁧󠁢󠁥󠁮󠁧󠁿" + end + + test "maps GB-CYM to GB-WLS" do + assert Country.to_emoji("GB-CYM") == Country.to_emoji("GB-WLS") + end + + test "converts alpha-3 codes" do + assert Country.to_emoji("USA") == "🇺🇸" + assert Country.to_emoji("GBR") == "🇬🇧" + assert Country.to_emoji("POL") == "🇵🇱" + end + + test "converts IETF language tags" do + assert Country.to_emoji("en-US") == "🇺🇸" + assert Country.to_emoji("pl-PL") == "🇵🇱" + end + + test "matches Flagmojis output for all alpha-2 codes" do + for %Flagmojis.Flag{iso: iso, emoji: expected} <- Flagmojis.Data.all() do + assert Country.to_emoji(iso) == expected, + "mismatch for #{iso}: expected #{expected}, got #{Country.to_emoji(iso)}" + end + end + end +end