Map country codes instead of guessing

This commit is contained in:
Eugene Burmakin 2025-06-30 22:08:34 +02:00
parent cabd63344a
commit d10ca668a9
8 changed files with 756 additions and 13 deletions

View file

@ -0,0 +1,397 @@
# frozen_string_literal: true
class Countries::IsoCodeMapper
# Comprehensive country data with name, ISO codes, and flag emoji
# Based on ISO 3166-1 standard
COUNTRIES = {
'AF' => { name: 'Afghanistan', iso2: 'AF', iso3: 'AFG', flag: '🇦🇫' },
'AL' => { name: 'Albania', iso2: 'AL', iso3: 'ALB', flag: '🇦🇱' },
'DZ' => { name: 'Algeria', iso2: 'DZ', iso3: 'DZA', flag: '🇩🇿' },
'AS' => { name: 'American Samoa', iso2: 'AS', iso3: 'ASM', flag: '🇦🇸' },
'AD' => { name: 'Andorra', iso2: 'AD', iso3: 'AND', flag: '🇦🇩' },
'AO' => { name: 'Angola', iso2: 'AO', iso3: 'AGO', flag: '🇦🇴' },
'AI' => { name: 'Anguilla', iso2: 'AI', iso3: 'AIA', flag: '🇦🇮' },
'AQ' => { name: 'Antarctica', iso2: 'AQ', iso3: 'ATA', flag: '🇦🇶' },
'AG' => { name: 'Antigua and Barbuda', iso2: 'AG', iso3: 'ATG', flag: '🇦🇬' },
'AR' => { name: 'Argentina', iso2: 'AR', iso3: 'ARG', flag: '🇦🇷' },
'AM' => { name: 'Armenia', iso2: 'AM', iso3: 'ARM', flag: '🇦🇲' },
'AW' => { name: 'Aruba', iso2: 'AW', iso3: 'ABW', flag: '🇦🇼' },
'AU' => { name: 'Australia', iso2: 'AU', iso3: 'AUS', flag: '🇦🇺' },
'AT' => { name: 'Austria', iso2: 'AT', iso3: 'AUT', flag: '🇦🇹' },
'AZ' => { name: 'Azerbaijan', iso2: 'AZ', iso3: 'AZE', flag: '🇦🇿' },
'BS' => { name: 'Bahamas', iso2: 'BS', iso3: 'BHS', flag: '🇧🇸' },
'BH' => { name: 'Bahrain', iso2: 'BH', iso3: 'BHR', flag: '🇧🇭' },
'BD' => { name: 'Bangladesh', iso2: 'BD', iso3: 'BGD', flag: '🇧🇩' },
'BB' => { name: 'Barbados', iso2: 'BB', iso3: 'BRB', flag: '🇧🇧' },
'BY' => { name: 'Belarus', iso2: 'BY', iso3: 'BLR', flag: '🇧🇾' },
'BE' => { name: 'Belgium', iso2: 'BE', iso3: 'BEL', flag: '🇧🇪' },
'BZ' => { name: 'Belize', iso2: 'BZ', iso3: 'BLZ', flag: '🇧🇿' },
'BJ' => { name: 'Benin', iso2: 'BJ', iso3: 'BEN', flag: '🇧🇯' },
'BM' => { name: 'Bermuda', iso2: 'BM', iso3: 'BMU', flag: '🇧🇲' },
'BT' => { name: 'Bhutan', iso2: 'BT', iso3: 'BTN', flag: '🇧🇹' },
'BO' => { name: 'Bolivia', iso2: 'BO', iso3: 'BOL', flag: '🇧🇴' },
'BA' => { name: 'Bosnia and Herzegovina', iso2: 'BA', iso3: 'BIH', flag: '🇧🇦' },
'BW' => { name: 'Botswana', iso2: 'BW', iso3: 'BWA', flag: '🇧🇼' },
'BR' => { name: 'Brazil', iso2: 'BR', iso3: 'BRA', flag: '🇧🇷' },
'BN' => { name: 'Brunei Darussalam', iso2: 'BN', iso3: 'BRN', flag: '🇧🇳' },
'BG' => { name: 'Bulgaria', iso2: 'BG', iso3: 'BGR', flag: '🇧🇬' },
'BF' => { name: 'Burkina Faso', iso2: 'BF', iso3: 'BFA', flag: '🇧🇫' },
'BI' => { name: 'Burundi', iso2: 'BI', iso3: 'BDI', flag: '🇧🇮' },
'KH' => { name: 'Cambodia', iso2: 'KH', iso3: 'KHM', flag: '🇰🇭' },
'CM' => { name: 'Cameroon', iso2: 'CM', iso3: 'CMR', flag: '🇨🇲' },
'CA' => { name: 'Canada', iso2: 'CA', iso3: 'CAN', flag: '🇨🇦' },
'CV' => { name: 'Cape Verde', iso2: 'CV', iso3: 'CPV', flag: '🇨🇻' },
'KY' => { name: 'Cayman Islands', iso2: 'KY', iso3: 'CYM', flag: '🇰🇾' },
'CF' => { name: 'Central African Republic', iso2: 'CF', iso3: 'CAF', flag: '🇨🇫' },
'TD' => { name: 'Chad', iso2: 'TD', iso3: 'TCD', flag: '🇹🇩' },
'CL' => { name: 'Chile', iso2: 'CL', iso3: 'CHL', flag: '🇨🇱' },
'CN' => { name: 'China', iso2: 'CN', iso3: 'CHN', flag: '🇨🇳' },
'CO' => { name: 'Colombia', iso2: 'CO', iso3: 'COL', flag: '🇨🇴' },
'KM' => { name: 'Comoros', iso2: 'KM', iso3: 'COM', flag: '🇰🇲' },
'CG' => { name: 'Congo', iso2: 'CG', iso3: 'COG', flag: '🇨🇬' },
'CD' => { name: 'Congo, Democratic Republic of the', iso2: 'CD', iso3: 'COD', flag: '🇨🇩' },
'CK' => { name: 'Cook Islands', iso2: 'CK', iso3: 'COK', flag: '🇨🇰' },
'CR' => { name: 'Costa Rica', iso2: 'CR', iso3: 'CRI', flag: '🇨🇷' },
'CI' => { name: 'Côte d\'Ivoire', iso2: 'CI', iso3: 'CIV', flag: '🇨🇮' },
'HR' => { name: 'Croatia', iso2: 'HR', iso3: 'HRV', flag: '🇭🇷' },
'CU' => { name: 'Cuba', iso2: 'CU', iso3: 'CUB', flag: '🇨🇺' },
'CY' => { name: 'Cyprus', iso2: 'CY', iso3: 'CYP', flag: '🇨🇾' },
'CZ' => { name: 'Czech Republic', iso2: 'CZ', iso3: 'CZE', flag: '🇨🇿' },
'DK' => { name: 'Denmark', iso2: 'DK', iso3: 'DNK', flag: '🇩🇰' },
'DJ' => { name: 'Djibouti', iso2: 'DJ', iso3: 'DJI', flag: '🇩🇯' },
'DM' => { name: 'Dominica', iso2: 'DM', iso3: 'DMA', flag: '🇩🇲' },
'DO' => { name: 'Dominican Republic', iso2: 'DO', iso3: 'DOM', flag: '🇩🇴' },
'EC' => { name: 'Ecuador', iso2: 'EC', iso3: 'ECU', flag: '🇪🇨' },
'EG' => { name: 'Egypt', iso2: 'EG', iso3: 'EGY', flag: '🇪🇬' },
'SV' => { name: 'El Salvador', iso2: 'SV', iso3: 'SLV', flag: '🇸🇻' },
'GQ' => { name: 'Equatorial Guinea', iso2: 'GQ', iso3: 'GNQ', flag: '🇬🇶' },
'ER' => { name: 'Eritrea', iso2: 'ER', iso3: 'ERI', flag: '🇪🇷' },
'EE' => { name: 'Estonia', iso2: 'EE', iso3: 'EST', flag: '🇪🇪' },
'ET' => { name: 'Ethiopia', iso2: 'ET', iso3: 'ETH', flag: '🇪🇹' },
'FK' => { name: 'Falkland Islands (Malvinas)', iso2: 'FK', iso3: 'FLK', flag: '🇫🇰' },
'FO' => { name: 'Faroe Islands', iso2: 'FO', iso3: 'FRO', flag: '🇫🇴' },
'FJ' => { name: 'Fiji', iso2: 'FJ', iso3: 'FJI', flag: '🇫🇯' },
'FI' => { name: 'Finland', iso2: 'FI', iso3: 'FIN', flag: '🇫🇮' },
'FR' => { name: 'France', iso2: 'FR', iso3: 'FRA', flag: '🇫🇷' },
'GF' => { name: 'French Guiana', iso2: 'GF', iso3: 'GUF', flag: '🇬🇫' },
'PF' => { name: 'French Polynesia', iso2: 'PF', iso3: 'PYF', flag: '🇵🇫' },
'GA' => { name: 'Gabon', iso2: 'GA', iso3: 'GAB', flag: '🇬🇦' },
'GM' => { name: 'Gambia', iso2: 'GM', iso3: 'GMB', flag: '🇬🇲' },
'GE' => { name: 'Georgia', iso2: 'GE', iso3: 'GEO', flag: '🇬🇪' },
'DE' => { name: 'Germany', iso2: 'DE', iso3: 'DEU', flag: '🇩🇪' },
'GH' => { name: 'Ghana', iso2: 'GH', iso3: 'GHA', flag: '🇬🇭' },
'GI' => { name: 'Gibraltar', iso2: 'GI', iso3: 'GIB', flag: '🇬🇮' },
'GR' => { name: 'Greece', iso2: 'GR', iso3: 'GRC', flag: '🇬🇷' },
'GL' => { name: 'Greenland', iso2: 'GL', iso3: 'GRL', flag: '🇬🇱' },
'GD' => { name: 'Grenada', iso2: 'GD', iso3: 'GRD', flag: '🇬🇩' },
'GP' => { name: 'Guadeloupe', iso2: 'GP', iso3: 'GLP', flag: '🇬🇵' },
'GU' => { name: 'Guam', iso2: 'GU', iso3: 'GUM', flag: '🇬🇺' },
'GT' => { name: 'Guatemala', iso2: 'GT', iso3: 'GTM', flag: '🇬🇹' },
'GG' => { name: 'Guernsey', iso2: 'GG', iso3: 'GGY', flag: '🇬🇬' },
'GN' => { name: 'Guinea', iso2: 'GN', iso3: 'GIN', flag: '🇬🇳' },
'GW' => { name: 'Guinea-Bissau', iso2: 'GW', iso3: 'GNB', flag: '🇬🇼' },
'GY' => { name: 'Guyana', iso2: 'GY', iso3: 'GUY', flag: '🇬🇾' },
'HT' => { name: 'Haiti', iso2: 'HT', iso3: 'HTI', flag: '🇭🇹' },
'VA' => { name: 'Holy See (Vatican City State)', iso2: 'VA', iso3: 'VAT', flag: '🇻🇦' },
'HN' => { name: 'Honduras', iso2: 'HN', iso3: 'HND', flag: '🇭🇳' },
'HK' => { name: 'Hong Kong', iso2: 'HK', iso3: 'HKG', flag: '🇭🇰' },
'HU' => { name: 'Hungary', iso2: 'HU', iso3: 'HUN', flag: '🇭🇺' },
'IS' => { name: 'Iceland', iso2: 'IS', iso3: 'ISL', flag: '🇮🇸' },
'IN' => { name: 'India', iso2: 'IN', iso3: 'IND', flag: '🇮🇳' },
'ID' => { name: 'Indonesia', iso2: 'ID', iso3: 'IDN', flag: '🇮🇩' },
'IR' => { name: 'Iran, Islamic Republic of', iso2: 'IR', iso3: 'IRN', flag: '🇮🇷' },
'IQ' => { name: 'Iraq', iso2: 'IQ', iso3: 'IRQ', flag: '🇮🇶' },
'IE' => { name: 'Ireland', iso2: 'IE', iso3: 'IRL', flag: '🇮🇪' },
'IM' => { name: 'Isle of Man', iso2: 'IM', iso3: 'IMN', flag: '🇮🇲' },
'IL' => { name: 'Israel', iso2: 'IL', iso3: 'ISR', flag: '🇮🇱' },
'IT' => { name: 'Italy', iso2: 'IT', iso3: 'ITA', flag: '🇮🇹' },
'JM' => { name: 'Jamaica', iso2: 'JM', iso3: 'JAM', flag: '🇯🇲' },
'JP' => { name: 'Japan', iso2: 'JP', iso3: 'JPN', flag: '🇯🇵' },
'JE' => { name: 'Jersey', iso2: 'JE', iso3: 'JEY', flag: '🇯🇪' },
'JO' => { name: 'Jordan', iso2: 'JO', iso3: 'JOR', flag: '🇯🇴' },
'KZ' => { name: 'Kazakhstan', iso2: 'KZ', iso3: 'KAZ', flag: '🇰🇿' },
'KE' => { name: 'Kenya', iso2: 'KE', iso3: 'KEN', flag: '🇰🇪' },
'KI' => { name: 'Kiribati', iso2: 'KI', iso3: 'KIR', flag: '🇰🇮' },
'KP' => { name: 'Korea, Democratic People\'s Republic of', iso2: 'KP', iso3: 'PRK', flag: '🇰🇵' },
'KR' => { name: 'Korea, Republic of', iso2: 'KR', iso3: 'KOR', flag: '🇰🇷' },
'KW' => { name: 'Kuwait', iso2: 'KW', iso3: 'KWT', flag: '🇰🇼' },
'KG' => { name: 'Kyrgyzstan', iso2: 'KG', iso3: 'KGZ', flag: '🇰🇬' },
'LA' => { name: 'Lao People\'s Democratic Republic', iso2: 'LA', iso3: 'LAO', flag: '🇱🇦' },
'LV' => { name: 'Latvia', iso2: 'LV', iso3: 'LVA', flag: '🇱🇻' },
'LB' => { name: 'Lebanon', iso2: 'LB', iso3: 'LBN', flag: '🇱🇧' },
'LS' => { name: 'Lesotho', iso2: 'LS', iso3: 'LSO', flag: '🇱🇸' },
'LR' => { name: 'Liberia', iso2: 'LR', iso3: 'LBR', flag: '🇱🇷' },
'LY' => { name: 'Libya', iso2: 'LY', iso3: 'LBY', flag: '🇱🇾' },
'LI' => { name: 'Liechtenstein', iso2: 'LI', iso3: 'LIE', flag: '🇱🇮' },
'LT' => { name: 'Lithuania', iso2: 'LT', iso3: 'LTU', flag: '🇱🇹' },
'LU' => { name: 'Luxembourg', iso2: 'LU', iso3: 'LUX', flag: '🇱🇺' },
'MO' => { name: 'Macao', iso2: 'MO', iso3: 'MAC', flag: '🇲🇴' },
'MK' => { name: 'North Macedonia', iso2: 'MK', iso3: 'MKD', flag: '🇲🇰' },
'MG' => { name: 'Madagascar', iso2: 'MG', iso3: 'MDG', flag: '🇲🇬' },
'MW' => { name: 'Malawi', iso2: 'MW', iso3: 'MWI', flag: '🇲🇼' },
'MY' => { name: 'Malaysia', iso2: 'MY', iso3: 'MYS', flag: '🇲🇾' },
'MV' => { name: 'Maldives', iso2: 'MV', iso3: 'MDV', flag: '🇲🇻' },
'ML' => { name: 'Mali', iso2: 'ML', iso3: 'MLI', flag: '🇲🇱' },
'MT' => { name: 'Malta', iso2: 'MT', iso3: 'MLT', flag: '🇲🇹' },
'MH' => { name: 'Marshall Islands', iso2: 'MH', iso3: 'MHL', flag: '🇲🇭' },
'MQ' => { name: 'Martinique', iso2: 'MQ', iso3: 'MTQ', flag: '🇲🇶' },
'MR' => { name: 'Mauritania', iso2: 'MR', iso3: 'MRT', flag: '🇲🇷' },
'MU' => { name: 'Mauritius', iso2: 'MU', iso3: 'MUS', flag: '🇲🇺' },
'YT' => { name: 'Mayotte', iso2: 'YT', iso3: 'MYT', flag: '🇾🇹' },
'MX' => { name: 'Mexico', iso2: 'MX', iso3: 'MEX', flag: '🇲🇽' },
'FM' => { name: 'Micronesia, Federated States of', iso2: 'FM', iso3: 'FSM', flag: '🇫🇲' },
'MD' => { name: 'Moldova, Republic of', iso2: 'MD', iso3: 'MDA', flag: '🇲🇩' },
'MC' => { name: 'Monaco', iso2: 'MC', iso3: 'MCO', flag: '🇲🇨' },
'MN' => { name: 'Mongolia', iso2: 'MN', iso3: 'MNG', flag: '🇲🇳' },
'ME' => { name: 'Montenegro', iso2: 'ME', iso3: 'MNE', flag: '🇲🇪' },
'MS' => { name: 'Montserrat', iso2: 'MS', iso3: 'MSR', flag: '🇲🇸' },
'MA' => { name: 'Morocco', iso2: 'MA', iso3: 'MAR', flag: '🇲🇦' },
'MZ' => { name: 'Mozambique', iso2: 'MZ', iso3: 'MOZ', flag: '🇲🇿' },
'MM' => { name: 'Myanmar', iso2: 'MM', iso3: 'MMR', flag: '🇲🇲' },
'NA' => { name: 'Namibia', iso2: 'NA', iso3: 'NAM', flag: '🇳🇦' },
'NR' => { name: 'Nauru', iso2: 'NR', iso3: 'NRU', flag: '🇳🇷' },
'NP' => { name: 'Nepal', iso2: 'NP', iso3: 'NPL', flag: '🇳🇵' },
'NL' => { name: 'Netherlands', iso2: 'NL', iso3: 'NLD', flag: '🇳🇱' },
'NC' => { name: 'New Caledonia', iso2: 'NC', iso3: 'NCL', flag: '🇳🇨' },
'NZ' => { name: 'New Zealand', iso2: 'NZ', iso3: 'NZL', flag: '🇳🇿' },
'NI' => { name: 'Nicaragua', iso2: 'NI', iso3: 'NIC', flag: '🇳🇮' },
'NE' => { name: 'Niger', iso2: 'NE', iso3: 'NER', flag: '🇳🇪' },
'NG' => { name: 'Nigeria', iso2: 'NG', iso3: 'NGA', flag: '🇳🇬' },
'NU' => { name: 'Niue', iso2: 'NU', iso3: 'NIU', flag: '🇳🇺' },
'NF' => { name: 'Norfolk Island', iso2: 'NF', iso3: 'NFK', flag: '🇳🇫' },
'MP' => { name: 'Northern Mariana Islands', iso2: 'MP', iso3: 'MNP', flag: '🇲🇵' },
'NO' => { name: 'Norway', iso2: 'NO', iso3: 'NOR', flag: '🇳🇴' },
'OM' => { name: 'Oman', iso2: 'OM', iso3: 'OMN', flag: '🇴🇲' },
'PK' => { name: 'Pakistan', iso2: 'PK', iso3: 'PAK', flag: '🇵🇰' },
'PW' => { name: 'Palau', iso2: 'PW', iso3: 'PLW', flag: '🇵🇼' },
'PS' => { name: 'Palestine, State of', iso2: 'PS', iso3: 'PSE', flag: '🇵🇸' },
'PA' => { name: 'Panama', iso2: 'PA', iso3: 'PAN', flag: '🇵🇦' },
'PG' => { name: 'Papua New Guinea', iso2: 'PG', iso3: 'PNG', flag: '🇵🇬' },
'PY' => { name: 'Paraguay', iso2: 'PY', iso3: 'PRY', flag: '🇵🇾' },
'PE' => { name: 'Peru', iso2: 'PE', iso3: 'PER', flag: '🇵🇪' },
'PH' => { name: 'Philippines', iso2: 'PH', iso3: 'PHL', flag: '🇵🇭' },
'PN' => { name: 'Pitcairn', iso2: 'PN', iso3: 'PCN', flag: '🇵🇳' },
'PL' => { name: 'Poland', iso2: 'PL', iso3: 'POL', flag: '🇵🇱' },
'PT' => { name: 'Portugal', iso2: 'PT', iso3: 'PRT', flag: '🇵🇹' },
'PR' => { name: 'Puerto Rico', iso2: 'PR', iso3: 'PRI', flag: '🇵🇷' },
'QA' => { name: 'Qatar', iso2: 'QA', iso3: 'QAT', flag: '🇶🇦' },
'RE' => { name: 'Réunion', iso2: 'RE', iso3: 'REU', flag: '🇷🇪' },
'RO' => { name: 'Romania', iso2: 'RO', iso3: 'ROU', flag: '🇷🇴' },
'RU' => { name: 'Russian Federation', iso2: 'RU', iso3: 'RUS', flag: '🇷🇺' },
'RW' => { name: 'Rwanda', iso2: 'RW', iso3: 'RWA', flag: '🇷🇼' },
'BL' => { name: 'Saint Barthélemy', iso2: 'BL', iso3: 'BLM', flag: '🇧🇱' },
'SH' => { name: 'Saint Helena, Ascension and Tristan da Cunha', iso2: 'SH', iso3: 'SHN', flag: '🇸🇭' },
'KN' => { name: 'Saint Kitts and Nevis', iso2: 'KN', iso3: 'KNA', flag: '🇰🇳' },
'LC' => { name: 'Saint Lucia', iso2: 'LC', iso3: 'LCA', flag: '🇱🇨' },
'MF' => { name: 'Saint Martin (French part)', iso2: 'MF', iso3: 'MAF', flag: '🇲🇫' },
'PM' => { name: 'Saint Pierre and Miquelon', iso2: 'PM', iso3: 'SPM', flag: '🇵🇲' },
'VC' => { name: 'Saint Vincent and the Grenadines', iso2: 'VC', iso3: 'VCT', flag: '🇻🇨' },
'WS' => { name: 'Samoa', iso2: 'WS', iso3: 'WSM', flag: '🇼🇸' },
'SM' => { name: 'San Marino', iso2: 'SM', iso3: 'SMR', flag: '🇸🇲' },
'ST' => { name: 'Sao Tome and Principe', iso2: 'ST', iso3: 'STP', flag: '🇸🇹' },
'SA' => { name: 'Saudi Arabia', iso2: 'SA', iso3: 'SAU', flag: '🇸🇦' },
'SN' => { name: 'Senegal', iso2: 'SN', iso3: 'SEN', flag: '🇸🇳' },
'RS' => { name: 'Serbia', iso2: 'RS', iso3: 'SRB', flag: '🇷🇸' },
'SC' => { name: 'Seychelles', iso2: 'SC', iso3: 'SYC', flag: '🇸🇨' },
'SL' => { name: 'Sierra Leone', iso2: 'SL', iso3: 'SLE', flag: '🇸🇱' },
'SG' => { name: 'Singapore', iso2: 'SG', iso3: 'SGP', flag: '🇸🇬' },
'SX' => { name: 'Sint Maarten (Dutch part)', iso2: 'SX', iso3: 'SXM', flag: '🇸🇽' },
'SK' => { name: 'Slovakia', iso2: 'SK', iso3: 'SVK', flag: '🇸🇰' },
'SI' => { name: 'Slovenia', iso2: 'SI', iso3: 'SVN', flag: '🇸🇮' },
'SB' => { name: 'Solomon Islands', iso2: 'SB', iso3: 'SLB', flag: '🇸🇧' },
'SO' => { name: 'Somalia', iso2: 'SO', iso3: 'SOM', flag: '🇸🇴' },
'ZA' => { name: 'South Africa', iso2: 'ZA', iso3: 'ZAF', flag: '🇿🇦' },
'GS' => { name: 'South Georgia and the South Sandwich Islands', iso2: 'GS', iso3: 'SGS', flag: '🇬🇸' },
'SS' => { name: 'South Sudan', iso2: 'SS', iso3: 'SSD', flag: '🇸🇸' },
'ES' => { name: 'Spain', iso2: 'ES', iso3: 'ESP', flag: '🇪🇸' },
'LK' => { name: 'Sri Lanka', iso2: 'LK', iso3: 'LKA', flag: '🇱🇰' },
'SD' => { name: 'Sudan', iso2: 'SD', iso3: 'SDN', flag: '🇸🇩' },
'SR' => { name: 'Suriname', iso2: 'SR', iso3: 'SUR', flag: '🇸🇷' },
'SJ' => { name: 'Svalbard and Jan Mayen', iso2: 'SJ', iso3: 'SJM', flag: '🇸🇯' },
'SE' => { name: 'Sweden', iso2: 'SE', iso3: 'SWE', flag: '🇸🇪' },
'CH' => { name: 'Switzerland', iso2: 'CH', iso3: 'CHE', flag: '🇨🇭' },
'SY' => { name: 'Syrian Arab Republic', iso2: 'SY', iso3: 'SYR', flag: '🇸🇾' },
'TW' => { name: 'Taiwan, Province of China', iso2: 'TW', iso3: 'TWN', flag: '🇹🇼' },
'TJ' => { name: 'Tajikistan', iso2: 'TJ', iso3: 'TJK', flag: '🇹🇯' },
'TZ' => { name: 'Tanzania, United Republic of', iso2: 'TZ', iso3: 'TZA', flag: '🇹🇿' },
'TH' => { name: 'Thailand', iso2: 'TH', iso3: 'THA', flag: '🇹🇭' },
'TL' => { name: 'Timor-Leste', iso2: 'TL', iso3: 'TLS', flag: '🇹🇱' },
'TG' => { name: 'Togo', iso2: 'TG', iso3: 'TGO', flag: '🇹🇬' },
'TK' => { name: 'Tokelau', iso2: 'TK', iso3: 'TKL', flag: '🇹🇰' },
'TO' => { name: 'Tonga', iso2: 'TO', iso3: 'TON', flag: '🇹🇴' },
'TT' => { name: 'Trinidad and Tobago', iso2: 'TT', iso3: 'TTO', flag: '🇹🇹' },
'TN' => { name: 'Tunisia', iso2: 'TN', iso3: 'TUN', flag: '🇹🇳' },
'TR' => { name: 'Turkey', iso2: 'TR', iso3: 'TUR', flag: '🇹🇷' },
'TM' => { name: 'Turkmenistan', iso2: 'TM', iso3: 'TKM', flag: '🇹🇲' },
'TC' => { name: 'Turks and Caicos Islands', iso2: 'TC', iso3: 'TCA', flag: '🇹🇨' },
'TV' => { name: 'Tuvalu', iso2: 'TV', iso3: 'TUV', flag: '🇹🇻' },
'UG' => { name: 'Uganda', iso2: 'UG', iso3: 'UGA', flag: '🇺🇬' },
'UA' => { name: 'Ukraine', iso2: 'UA', iso3: 'UKR', flag: '🇺🇦' },
'AE' => { name: 'United Arab Emirates', iso2: 'AE', iso3: 'ARE', flag: '🇦🇪' },
'GB' => { name: 'United Kingdom', iso2: 'GB', iso3: 'GBR', flag: '🇬🇧' },
'US' => { name: 'United States', iso2: 'US', iso3: 'USA', flag: '🇺🇸' },
'UM' => { name: 'United States Minor Outlying Islands', iso2: 'UM', iso3: 'UMI', flag: '🇺🇲' },
'UY' => { name: 'Uruguay', iso2: 'UY', iso3: 'URY', flag: '🇺🇾' },
'UZ' => { name: 'Uzbekistan', iso2: 'UZ', iso3: 'UZB', flag: '🇺🇿' },
'VU' => { name: 'Vanuatu', iso2: 'VU', iso3: 'VUT', flag: '🇻🇺' },
'VE' => { name: 'Venezuela, Bolivarian Republic of', iso2: 'VE', iso3: 'VEN', flag: '🇻🇪' },
'VN' => { name: 'Viet Nam', iso2: 'VN', iso3: 'VNM', flag: '🇻🇳' },
'VG' => { name: 'Virgin Islands, British', iso2: 'VG', iso3: 'VGB', flag: '🇻🇬' },
'VI' => { name: 'Virgin Islands, U.S.', iso2: 'VI', iso3: 'VIR', flag: '🇻🇮' },
'WF' => { name: 'Wallis and Futuna', iso2: 'WF', iso3: 'WLF', flag: '🇼🇫' },
'EH' => { name: 'Western Sahara', iso2: 'EH', iso3: 'ESH', flag: '🇪🇭' },
'YE' => { name: 'Yemen', iso2: 'YE', iso3: 'YEM', flag: '🇾🇪' },
'ZM' => { name: 'Zambia', iso2: 'ZM', iso3: 'ZMB', flag: '🇿🇲' },
'ZW' => { name: 'Zimbabwe', iso2: 'ZW', iso3: 'ZWE', flag: '🇿🇼' }
}.freeze
# Country name aliases and variations for better matching
COUNTRY_ALIASES = {
'Russia' => 'Russian Federation',
'South Korea' => 'Korea, Republic of',
'North Korea' => 'Korea, Democratic People\'s Republic of',
'United States of America' => 'United States',
'USA' => 'United States',
'UK' => 'United Kingdom',
'Britain' => 'United Kingdom',
'Great Britain' => 'United Kingdom',
'England' => 'United Kingdom',
'Scotland' => 'United Kingdom',
'Wales' => 'United Kingdom',
'Northern Ireland' => 'United Kingdom',
'Macedonia' => 'North Macedonia',
'Czech Republic' => 'Czech Republic',
'Czechia' => 'Czech Republic',
'Vatican' => 'Holy See (Vatican City State)',
'Vatican City' => 'Holy See (Vatican City State)',
'Taiwan' => 'Taiwan, Province of China',
'Hong Kong SAR' => 'Hong Kong',
'Macao SAR' => 'Macao',
'Moldova' => 'Moldova, Republic of',
'Bolivia' => 'Bolivia',
'Venezuela' => 'Venezuela, Bolivarian Republic of',
'Iran' => 'Iran, Islamic Republic of',
'Syria' => 'Syrian Arab Republic',
'Tanzania' => 'Tanzania, United Republic of',
'Laos' => 'Lao People\'s Democratic Republic',
'Vietnam' => 'Viet Nam',
'Palestine' => 'Palestine, State of',
'Congo' => 'Congo',
'Democratic Republic of Congo' => 'Congo, Democratic Republic of the',
'DRC' => 'Congo, Democratic Republic of the',
'Ivory Coast' => 'Côte d\'Ivoire',
'Cape Verde' => 'Cape Verde',
'East Timor' => 'Timor-Leste',
'Burma' => 'Myanmar',
'Swaziland' => 'Eswatini'
}.freeze
def self.iso_a3_from_a2(iso_a2)
return nil if iso_a2.blank?
country_data = COUNTRIES[iso_a2.upcase]
country_data&.dig(:iso3)
end
def self.iso_codes_from_country_name(country_name)
return [nil, nil] if country_name.blank?
# Try exact match first
country_data = find_country_by_name(country_name)
return [country_data[:iso2], country_data[:iso3]] if country_data
# Try aliases
standard_name = COUNTRY_ALIASES[country_name]
if standard_name
country_data = find_country_by_name(standard_name)
return [country_data[:iso2], country_data[:iso3]] if country_data
end
# Try case-insensitive match
country_data = COUNTRIES.values.find { |data| data[:name].downcase == country_name.downcase }
return [country_data[:iso2], country_data[:iso3]] if country_data
# Try partial match (country name contains or is contained in a known name)
country_data = COUNTRIES.values.find do |data|
data[:name].downcase.include?(country_name.downcase) ||
country_name.downcase.include?(data[:name].downcase)
end
return [country_data[:iso2], country_data[:iso3]] if country_data
# No match found
[nil, nil]
end
def self.fallback_codes_from_country_name(country_name)
return [nil, nil] if country_name.blank?
# First try to find proper ISO codes from country name
iso_a2, iso_a3 = iso_codes_from_country_name(country_name)
return [iso_a2, iso_a3] if iso_a2 && iso_a3
# Only use character-based fallback as a last resort
# This is still not ideal but better than nothing
fallback_a2 = country_name[0..1].upcase
fallback_a3 = country_name[0..2].upcase
[fallback_a2, fallback_a3]
end
def self.standardize_country_name(country_name)
return nil if country_name.blank?
# Try exact match first
country_data = find_country_by_name(country_name)
return country_data[:name] if country_data
# Try aliases
standard_name = COUNTRY_ALIASES[country_name]
return standard_name if standard_name
# Try case-insensitive match
country_data = COUNTRIES.values.find { |data| data[:name].downcase == country_name.downcase }
return country_data[:name] if country_data
# Try partial match
country_data = COUNTRIES.values.find do |data|
data[:name].downcase.include?(country_name.downcase) ||
country_name.downcase.include?(data[:name].downcase)
end
return country_data[:name] if country_data
nil
end
def self.country_flag(iso_a2)
return nil if iso_a2.blank?
country_data = COUNTRIES[iso_a2.upcase]
country_data&.dig(:flag)
end
def self.country_by_iso2(iso_a2)
return nil if iso_a2.blank?
COUNTRIES[iso_a2.upcase]
end
def self.country_by_name(country_name)
return nil if country_name.blank?
find_country_by_name(country_name) ||
find_country_by_name(COUNTRY_ALIASES[country_name]) ||
COUNTRIES.values.find { |data| data[:name].downcase == country_name.downcase }
end
def self.all_countries
COUNTRIES.values
end
private
def self.find_country_by_name(name)
return nil if name.blank?
COUNTRIES.values.find { |data| data[:name] == name }
end
end

View file

@ -21,7 +21,7 @@ class Imports::Create
create_import_failed_notification(import, user, e)
ensure
import.update!(status: :completed) if import.completed?
import.update!(status: :completed) if import.processing?
end
private

View file

@ -24,8 +24,9 @@ class ReverseGeocoding::Points::FetchData
return if response.blank? || response.data['error'].present?
country_record = Country.find_or_create_by(name: response.country) do |country|
country.iso_a2 = response.country[0..1].upcase if response.country
country.iso_a3 = response.country[0..2].upcase if response.country
iso_a2, iso_a3 = extract_iso_codes(response)
country.iso_a2 = iso_a2
country.iso_a3 = iso_a3
country.geom = "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 1, 0 0)))"
end if response.country
@ -36,4 +37,19 @@ class ReverseGeocoding::Points::FetchData
reverse_geocoded_at: Time.current
)
end
def extract_iso_codes(response)
# First, try to get the ISO A2 code from the Geocoder response
iso_a2 = response.data.dig('properties', 'countrycode')&.upcase
if iso_a2.present?
# If we have a valid ISO A2 code, get the corresponding ISO A3 code
iso_a3 = Countries::IsoCodeMapper.iso_a3_from_a2(iso_a2)
return [iso_a2, iso_a3] if iso_a3.present?
end
# If no valid ISO code from Geocoder, try to match the country name
# This will return proper ISO codes if the country name is recognized
Countries::IsoCodeMapper.fallback_codes_from_country_name(response.country)
end
end

View file

@ -206,8 +206,8 @@ class Users::ImportData::Points
def create_missing_country(country_info)
Country.find_or_create_by(name: country_info['name']) do |new_country|
new_country.iso_a2 = country_info['iso_a2'] || country_info['name'][0..1].upcase
new_country.iso_a3 = country_info['iso_a3'] || country_info['name'][0..2].upcase
new_country.iso_a2 = country_info['iso_a2'] || Countries::IsoCodeMapper.fallback_codes_from_country_name(country_info['name'])[0]
new_country.iso_a3 = country_info['iso_a3'] || Countries::IsoCodeMapper.fallback_codes_from_country_name(country_info['name'])[1]
new_country.geom = "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 1, 0 0)))" # Default geometry
end
rescue StandardError => e

View file

@ -42,8 +42,9 @@ FactoryBot.define do
if evaluator.country.is_a?(String)
# Set both the country string attribute and the Country association
country_obj = Country.find_or_create_by(name: evaluator.country) do |country|
country.iso_a2 = evaluator.country[0..1].upcase
country.iso_a3 = evaluator.country[0..2].upcase
iso_a2, iso_a3 = Countries::IsoCodeMapper.fallback_codes_from_country_name(evaluator.country)
country.iso_a2 = iso_a2
country.iso_a3 = iso_a3
country.geom = "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 1, 0 0)))"
end
point.update_columns(
@ -95,8 +96,9 @@ FactoryBot.define do
unless point.read_attribute(:country)
country_name = FFaker::Address.country
country_obj = Country.find_or_create_by(name: country_name) do |country|
country.iso_a2 = country_name[0..1].upcase
country.iso_a3 = country_name[0..2].upcase
iso_a2, iso_a3 = Countries::IsoCodeMapper.fallback_codes_from_country_name(country_name)
country.iso_a2 = iso_a2
country.iso_a3 = iso_a3
country.geom = "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 1, 0 0)))"
end
point.write_attribute(:country, country_name) # Set the string attribute directly

View file

@ -0,0 +1,245 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Countries::IsoCodeMapper do
describe '.iso_a3_from_a2' do
it 'returns correct ISO A3 code for valid ISO A2 code' do
expect(described_class.iso_a3_from_a2('DE')).to eq('DEU')
expect(described_class.iso_a3_from_a2('US')).to eq('USA')
expect(described_class.iso_a3_from_a2('GB')).to eq('GBR')
end
it 'handles lowercase input' do
expect(described_class.iso_a3_from_a2('de')).to eq('DEU')
end
it 'returns nil for invalid ISO A2 code' do
expect(described_class.iso_a3_from_a2('XX')).to be_nil
expect(described_class.iso_a3_from_a2('')).to be_nil
expect(described_class.iso_a3_from_a2(nil)).to be_nil
end
end
describe '.iso_codes_from_country_name' do
it 'returns correct ISO codes for exact country name match' do
iso_a2, iso_a3 = described_class.iso_codes_from_country_name('Germany')
expect(iso_a2).to eq('DE')
expect(iso_a3).to eq('DEU')
end
it 'returns correct ISO codes for country name aliases' do
iso_a2, iso_a3 = described_class.iso_codes_from_country_name('Russia')
expect(iso_a2).to eq('RU')
expect(iso_a3).to eq('RUS')
iso_a2, iso_a3 = described_class.iso_codes_from_country_name('USA')
expect(iso_a2).to eq('US')
expect(iso_a3).to eq('USA')
end
it 'handles case-insensitive matching' do
iso_a2, iso_a3 = described_class.iso_codes_from_country_name('GERMANY')
expect(iso_a2).to eq('DE')
expect(iso_a3).to eq('DEU')
iso_a2, iso_a3 = described_class.iso_codes_from_country_name('germany')
expect(iso_a2).to eq('DE')
expect(iso_a3).to eq('DEU')
end
it 'handles partial matching' do
# This should find "United States" when searching for "United States of America"
iso_a2, iso_a3 = described_class.iso_codes_from_country_name('United States of America')
expect(iso_a2).to eq('US')
expect(iso_a3).to eq('USA')
end
it 'returns nil for unknown country names' do
iso_a2, iso_a3 = described_class.iso_codes_from_country_name('Atlantis')
expect(iso_a2).to be_nil
expect(iso_a3).to be_nil
end
it 'returns nil for blank input' do
iso_a2, iso_a3 = described_class.iso_codes_from_country_name('')
expect(iso_a2).to be_nil
expect(iso_a3).to be_nil
iso_a2, iso_a3 = described_class.iso_codes_from_country_name(nil)
expect(iso_a2).to be_nil
expect(iso_a3).to be_nil
end
end
describe '.fallback_codes_from_country_name' do
it 'returns proper ISO codes when country name is recognized' do
iso_a2, iso_a3 = described_class.fallback_codes_from_country_name('Germany')
expect(iso_a2).to eq('DE')
expect(iso_a3).to eq('DEU')
end
it 'falls back to character-based codes for unknown countries' do
iso_a2, iso_a3 = described_class.fallback_codes_from_country_name('Atlantis')
expect(iso_a2).to eq('AT')
expect(iso_a3).to eq('ATL')
end
it 'returns nil for blank input' do
iso_a2, iso_a3 = described_class.fallback_codes_from_country_name('')
expect(iso_a2).to be_nil
expect(iso_a3).to be_nil
iso_a2, iso_a3 = described_class.fallback_codes_from_country_name(nil)
expect(iso_a2).to be_nil
expect(iso_a3).to be_nil
end
end
describe '.standardize_country_name' do
it 'returns standard name for exact match' do
expect(described_class.standardize_country_name('Germany')).to eq('Germany')
end
it 'returns standard name for aliases' do
expect(described_class.standardize_country_name('Russia')).to eq('Russian Federation')
expect(described_class.standardize_country_name('USA')).to eq('United States')
end
it 'handles case-insensitive matching' do
expect(described_class.standardize_country_name('GERMANY')).to eq('Germany')
expect(described_class.standardize_country_name('germany')).to eq('Germany')
end
it 'returns nil for unknown country names' do
expect(described_class.standardize_country_name('Atlantis')).to be_nil
end
it 'returns nil for blank input' do
expect(described_class.standardize_country_name('')).to be_nil
expect(described_class.standardize_country_name(nil)).to be_nil
end
end
describe '.country_flag' do
it 'returns correct flag emoji for valid ISO A2 code' do
expect(described_class.country_flag('DE')).to eq('🇩🇪')
expect(described_class.country_flag('US')).to eq('🇺🇸')
expect(described_class.country_flag('GB')).to eq('🇬🇧')
end
it 'handles lowercase input' do
expect(described_class.country_flag('de')).to eq('🇩🇪')
end
it 'returns nil for invalid ISO A2 code' do
expect(described_class.country_flag('XX')).to be_nil
expect(described_class.country_flag('')).to be_nil
expect(described_class.country_flag(nil)).to be_nil
end
end
describe '.country_by_iso2' do
it 'returns complete country data for valid ISO A2 code' do
country = described_class.country_by_iso2('DE')
expect(country).to include(
name: 'Germany',
iso2: 'DE',
iso3: 'DEU',
flag: '🇩🇪'
)
end
it 'handles lowercase input' do
country = described_class.country_by_iso2('de')
expect(country[:name]).to eq('Germany')
end
it 'returns nil for invalid ISO A2 code' do
expect(described_class.country_by_iso2('XX')).to be_nil
expect(described_class.country_by_iso2('')).to be_nil
expect(described_class.country_by_iso2(nil)).to be_nil
end
end
describe '.country_by_name' do
it 'returns complete country data for exact name match' do
country = described_class.country_by_name('Germany')
expect(country).to include(
name: 'Germany',
iso2: 'DE',
iso3: 'DEU',
flag: '🇩🇪'
)
end
it 'returns country data for aliases' do
country = described_class.country_by_name('Russia')
expect(country).to include(
name: 'Russian Federation',
iso2: 'RU',
iso3: 'RUS',
flag: '🇷🇺'
)
end
it 'handles case-insensitive matching' do
country = described_class.country_by_name('GERMANY')
expect(country[:name]).to eq('Germany')
end
it 'returns nil for unknown country names' do
expect(described_class.country_by_name('Atlantis')).to be_nil
end
it 'returns nil for blank input' do
expect(described_class.country_by_name('')).to be_nil
expect(described_class.country_by_name(nil)).to be_nil
end
end
describe '.all_countries' do
it 'returns all country data' do
countries = described_class.all_countries
expect(countries).to be_an(Array)
expect(countries.size).to be > 190 # There are 195+ countries
# Check that each country has required fields
countries.each do |country|
expect(country).to have_key(:name)
expect(country).to have_key(:iso2)
expect(country).to have_key(:iso3)
expect(country).to have_key(:flag)
end
end
it 'includes expected countries' do
countries = described_class.all_countries
country_names = countries.map { |c| c[:name] }
expect(country_names).to include('Germany')
expect(country_names).to include('United States')
expect(country_names).to include('United Kingdom')
expect(country_names).to include('Russian Federation')
end
end
describe 'data integrity' do
it 'has consistent data structure' do
described_class.all_countries.each do |country|
expect(country[:iso2]).to match(/\A[A-Z]{2}\z/)
expect(country[:iso3]).to match(/\A[A-Z]{3}\z/)
expect(country[:name]).to be_present
expect(country[:flag]).to be_present
end
end
it 'has unique ISO codes' do
iso2_codes = described_class.all_countries.map { |c| c[:iso2] }
iso3_codes = described_class.all_countries.map { |c| c[:iso3] }
expect(iso2_codes.uniq.size).to eq(iso2_codes.size)
expect(iso3_codes.uniq.size).to eq(iso3_codes.size)
end
end
end

View file

@ -7,6 +7,38 @@ RSpec.describe Imports::Create do
let(:service) { described_class.new(user, import) }
describe '#call' do
describe 'status transitions' do
let(:import) { create(:import, source: 'owntracks', status: 'created') }
let(:file_path) { Rails.root.join('spec/fixtures/files/owntracks/2024-03.rec') }
before do
import.file.attach(io: File.open(file_path), filename: '2024-03.rec', content_type: 'application/octet-stream')
end
it 'sets status to processing at start' do
service.call
expect(import.reload.status).to eq('processing').or eq('completed')
end
context 'when import succeeds' do
it 'sets status to completed' do
service.call
expect(import.reload.status).to eq('completed')
end
end
context 'when import fails' do
before do
allow(OwnTracks::Importer).to receive(:new).with(import, user.id).and_raise(StandardError)
end
it 'sets status to failed' do
service.call
expect(import.reload.status).to eq('failed')
end
end
end
context 'when source is google_semantic_history' do
let(:import) { create(:import, source: 'google_semantic_history') }
let(:file_path) { Rails.root.join('spec/fixtures/files/google/semantic_history.json') }

View file

@ -11,7 +11,14 @@ RSpec.describe ReverseGeocoding::Points::FetchData do
before do
allow(Geocoder).to receive(:search).and_return(
[
double(city: 'City', country: 'Country', data: { 'address' => 'Address' })
double(
city: 'Berlin',
country: 'Germany',
data: {
'address' => 'Address',
'properties' => { 'countrycode' => 'DE' }
}
)
]
)
end
@ -19,12 +26,23 @@ RSpec.describe ReverseGeocoding::Points::FetchData do
context 'when point does not have city and country' do
it 'updates point with city and country' do
expect { fetch_data }.to change { point.reload.city }
.from(nil).to('City')
.from(nil).to('Berlin')
.and change { point.reload.country_id }.from(nil).to(be_present)
end
it 'creates country with correct ISO codes' do
fetch_data
country = point.reload.country
expect(country.name).to eq('Germany')
expect(country.iso_a2).to eq('DE')
expect(country.iso_a3).to eq('DEU')
end
it 'updates point with geodata' do
expect { fetch_data }.to change { point.reload.geodata }.from({}).to('address' => 'Address')
expect { fetch_data }.to change { point.reload.geodata }.from({}).to(
'address' => 'Address',
'properties' => { 'countrycode' => 'DE' }
)
end
it 'calls Geocoder' do
@ -40,7 +58,15 @@ RSpec.describe ReverseGeocoding::Points::FetchData do
before do
allow(Geocoder).to receive(:search).and_return(
[double(geodata: { 'address' => 'Address' }, city: 'City', country: 'Country')]
[double(
geodata: { 'address' => 'Address' },
city: 'Berlin',
country: 'Germany',
data: {
'address' => 'Address',
'properties' => { 'countrycode' => 'DE' }
}
)]
)
end
@ -56,6 +82,31 @@ RSpec.describe ReverseGeocoding::Points::FetchData do
end
end
context 'when Geocoder returns country name without ISO code' do
before do
allow(Geocoder).to receive(:search).and_return(
[
double(
city: 'Paris',
country: 'France',
data: {
'address' => 'Address',
'properties' => { 'city' => 'Paris' } # No countrycode property
}
)
]
)
end
it 'creates country with correct ISO codes from country name mapping' do
fetch_data
country = point.reload.country
expect(country.name).to eq('France')
expect(country.iso_a2).to eq('FR')
expect(country.iso_a3).to eq('FRA')
end
end
context 'when Geocoder returns an error' do
before do
allow(Geocoder).to receive(:search).and_return([double(city: nil, country: nil, data: { 'error' => 'Error' })])