diff --git a/Readme.md b/Readme.md index e268ac8..a100989 100644 --- a/Readme.md +++ b/Readme.md @@ -146,8 +146,9 @@ These fields in `countries.json` come from Google’s libphonenumber metadata: - `nationalPrefix`: what callers dial before a number inside the country, such as `0` in Malawi or `8` in Russia. Some countries, like Italy and Côte d’Ivoire, have none: their leading `0` is part of the number - `numberPattern`: the shape of a valid number, used to tell a leading `0` or `8` that belongs to the number from one that doesn’t - `mainCountryForCode`: which country to use when several share a dialling code +- `minLocalDigits`, `maxLocalDigits` and `localDigitLengths`, for African countries only so far: how many digits can follow the dialling code. Malawi, for example, has 7-digit landlines and 9-digit mobiles, so `localDigitLengths` is `[7, 9]` and an 8-digit number is rejected. These cover every kind of number libphonenumber lists, including toll-free and premium-rate lines, so a real number is never rejected for its length -To refresh them, pin the libphonenumber release in `internal/cmd/gencountries/main.go` and run this from the repository root: +To refresh them, pin the libphonenumber release in `internal/cmd/gencountries/main.go` and run this from the repository root. It also rewrites `testdata/libphonenumber_examples.json`, libphonenumber’s example landline and mobile number for each of those African countries, which the tests check: ```bash go run ./internal/cmd/gencountries @@ -172,6 +173,7 @@ type Country struct { MainCountryForCode bool // True for the country returned when several share a dialing code MinLocalDigits int // Fewest digits after the dialing code MaxLocalDigits int // Most digits after the dialing code + LocalDigitLengths []int // Every allowed digit count after the dialing code, when known (e.g., [7, 9]) } type Normalizer struct { diff --git a/countries.json b/countries.json index d1febd8..b705448 100644 --- a/countries.json +++ b/countries.json @@ -32,8 +32,12 @@ "internationalPrefix": "00", "nationalPrefix": "0", "numberPattern": "(?:[1-4]|[5-79]\\d|80)\\d{7}", - "minLocalDigits": 9, - "maxLocalDigits": 9 + "minLocalDigits": 8, + "maxLocalDigits": 9, + "localDigitLengths": [ + 8, + 9 + ] }, { "name": "American Samoa", @@ -69,7 +73,10 @@ "nationalPrefix": "", "numberPattern": "[29]\\d{8}", "minLocalDigits": 9, - "maxLocalDigits": 9 + "maxLocalDigits": 9, + "localDigitLengths": [ + 9 + ] }, { "name": "Anguilla", @@ -274,7 +281,11 @@ "nationalPrefix": "", "numberPattern": "(?:01\\d|8)\\d{7}", "minLocalDigits": 8, - "maxLocalDigits": 8 + "maxLocalDigits": 10, + "localDigitLengths": [ + 8, + 10 + ] }, { "name": "Bermuda", @@ -346,7 +357,12 @@ "nationalPrefix": "", "numberPattern": "(?:0800|(?:[37]|800)\\d)\\d{6}|(?:[2-6]\\d|90)\\d{5}", "minLocalDigits": 7, - "maxLocalDigits": 8 + "maxLocalDigits": 10, + "localDigitLengths": [ + 7, + 8, + 10 + ] }, { "name": "Bouvet Island", @@ -382,7 +398,10 @@ "nationalPrefix": "", "numberPattern": "3\\d{6}", "minLocalDigits": 7, - "maxLocalDigits": 7 + "maxLocalDigits": 7, + "localDigitLengths": [ + 7 + ] }, { "name": "Brunei Darussalam", @@ -418,7 +437,10 @@ "nationalPrefix": "", "numberPattern": "[024-7]\\d{7}", "minLocalDigits": 8, - "maxLocalDigits": 8 + "maxLocalDigits": 8, + "localDigitLengths": [ + 8 + ] }, { "name": "Burundi", @@ -430,7 +452,10 @@ "nationalPrefix": "", "numberPattern": "(?:[267]\\d|31)\\d{6}", "minLocalDigits": 8, - "maxLocalDigits": 8 + "maxLocalDigits": 8, + "localDigitLengths": [ + 8 + ] }, { "name": "Cambodia", @@ -453,8 +478,12 @@ "internationalPrefix": "00", "nationalPrefix": "", "numberPattern": "[26]\\d{8}|88\\d{6,7}", - "minLocalDigits": 9, - "maxLocalDigits": 9 + "minLocalDigits": 8, + "maxLocalDigits": 9, + "localDigitLengths": [ + 8, + 9 + ] }, { "name": "Canada", @@ -478,7 +507,10 @@ "nationalPrefix": "", "numberPattern": "(?:[2-59]\\d\\d|800)\\d{4}", "minLocalDigits": 7, - "maxLocalDigits": 7 + "maxLocalDigits": 7, + "localDigitLengths": [ + 7 + ] }, { "name": "Cayman Islands", @@ -502,7 +534,10 @@ "nationalPrefix": "", "numberPattern": "8776\\d{4}|(?:[27]\\d|61)\\d{6}", "minLocalDigits": 8, - "maxLocalDigits": 8 + "maxLocalDigits": 8, + "localDigitLengths": [ + 8 + ] }, { "name": "Chad", @@ -514,7 +549,10 @@ "nationalPrefix": "", "numberPattern": "(?:22|[3689]\\d|77)\\d{6}", "minLocalDigits": 8, - "maxLocalDigits": 8 + "maxLocalDigits": 8, + "localDigitLengths": [ + 8 + ] }, { "name": "Chile", @@ -586,7 +624,10 @@ "nationalPrefix": "", "numberPattern": "[3478]\\d{6}", "minLocalDigits": 7, - "maxLocalDigits": 7 + "maxLocalDigits": 7, + "localDigitLengths": [ + 7 + ] }, { "name": "Congo", @@ -598,7 +639,10 @@ "nationalPrefix": "", "numberPattern": "222\\d{6}|(?:0\\d|80)\\d{7}", "minLocalDigits": 9, - "maxLocalDigits": 9 + "maxLocalDigits": 9, + "localDigitLengths": [ + 9 + ] }, { "name": "Democratic Republic of the Congo", @@ -609,8 +653,14 @@ "internationalPrefix": "00", "nationalPrefix": "0", "numberPattern": "(?:(?:[189]|5\\d)\\d|2)\\d{7}|[1-68]\\d{6}", - "minLocalDigits": 9, - "maxLocalDigits": 9 + "minLocalDigits": 7, + "maxLocalDigits": 10, + "localDigitLengths": [ + 7, + 8, + 9, + 10 + ] }, { "name": "Cook Islands", @@ -707,7 +757,10 @@ "nationalPrefix": "", "numberPattern": "[02]\\d{9}", "minLocalDigits": 10, - "maxLocalDigits": 10 + "maxLocalDigits": 10, + "localDigitLengths": [ + 10 + ] }, { "name": "Denmark", @@ -731,7 +784,10 @@ "nationalPrefix": "", "numberPattern": "(?:2\\d|77)\\d{6}", "minLocalDigits": 8, - "maxLocalDigits": 8 + "maxLocalDigits": 8, + "localDigitLengths": [ + 8 + ] }, { "name": "Dominica", @@ -778,8 +834,13 @@ "internationalPrefix": "00", "nationalPrefix": "0", "numberPattern": "[189]\\d{8,9}|[24-6]\\d{8}|[135]\\d{7}", - "minLocalDigits": 10, - "maxLocalDigits": 10 + "minLocalDigits": 8, + "maxLocalDigits": 10, + "localDigitLengths": [ + 8, + 9, + 10 + ] }, { "name": "El Salvador", @@ -803,7 +864,10 @@ "nationalPrefix": "", "numberPattern": "222\\d{6}|(?:3\\d|55|[89]0)\\d{7}", "minLocalDigits": 9, - "maxLocalDigits": 9 + "maxLocalDigits": 9, + "localDigitLengths": [ + 9 + ] }, { "name": "Eritrea", @@ -815,7 +879,10 @@ "nationalPrefix": "0", "numberPattern": "[178]\\d{6}", "minLocalDigits": 7, - "maxLocalDigits": 7 + "maxLocalDigits": 7, + "localDigitLengths": [ + 7 + ] }, { "name": "Estonia", @@ -839,7 +906,10 @@ "nationalPrefix": "0", "numberPattern": "(?:11|[2-57-9]\\d)\\d{7}", "minLocalDigits": 9, - "maxLocalDigits": 9 + "maxLocalDigits": 9, + "localDigitLengths": [ + 9 + ] }, { "name": "Falkland Islands (Malvinas)", @@ -949,7 +1019,11 @@ "nationalPrefix": "", "numberPattern": "(?:[067]\\d|11)\\d{6}|[2-7]\\d{6}", "minLocalDigits": 7, - "maxLocalDigits": 8 + "maxLocalDigits": 8, + "localDigitLengths": [ + 7, + 8 + ] }, { "name": "Gambia", @@ -961,7 +1035,11 @@ "nationalPrefix": "", "numberPattern": "[48]\\d{8}|[2-9]\\d{6}", "minLocalDigits": 7, - "maxLocalDigits": 7 + "maxLocalDigits": 9, + "localDigitLengths": [ + 7, + 9 + ] }, { "name": "Georgia", @@ -996,8 +1074,12 @@ "internationalPrefix": "00", "nationalPrefix": "0", "numberPattern": "[235]\\d{8}|800\\d{5,6}", - "minLocalDigits": 9, - "maxLocalDigits": 9 + "minLocalDigits": 8, + "maxLocalDigits": 9, + "localDigitLengths": [ + 8, + 9 + ] }, { "name": "Gibraltar", @@ -1105,8 +1187,12 @@ "internationalPrefix": "00", "nationalPrefix": "", "numberPattern": "722\\d{6}|(?:3|6\\d)\\d{7}", - "minLocalDigits": 9, - "maxLocalDigits": 9 + "minLocalDigits": 8, + "maxLocalDigits": 9, + "localDigitLengths": [ + 8, + 9 + ] }, { "name": "Guinea-Bissau", @@ -1118,7 +1204,11 @@ "nationalPrefix": "", "numberPattern": "[49]\\d{8}|4\\d{6}", "minLocalDigits": 7, - "maxLocalDigits": 7 + "maxLocalDigits": 9, + "localDigitLengths": [ + 7, + 9 + ] }, { "name": "Guyana", @@ -1382,8 +1472,14 @@ "internationalPrefix": "000", "nationalPrefix": "0", "numberPattern": "(?:[17]\\d\\d|900)\\d{6}|(?:2|80)0\\d{6,7}|[4-6]\\d{6,8}", - "minLocalDigits": 9, - "maxLocalDigits": 9 + "minLocalDigits": 7, + "maxLocalDigits": 10, + "localDigitLengths": [ + 7, + 8, + 9, + 10 + ] }, { "name": "Kiribati", @@ -1491,7 +1587,10 @@ "nationalPrefix": "", "numberPattern": "(?:[256]\\d\\d|800)\\d{5}", "minLocalDigits": 8, - "maxLocalDigits": 8 + "maxLocalDigits": 8, + "localDigitLengths": [ + 8 + ] }, { "name": "Liberia", @@ -1503,7 +1602,12 @@ "nationalPrefix": "0", "numberPattern": "(?:[2457]\\d|33|88)\\d{7}|(?:2\\d|[4-6])\\d{6}", "minLocalDigits": 7, - "maxLocalDigits": 8 + "maxLocalDigits": 9, + "localDigitLengths": [ + 7, + 8, + 9 + ] }, { "name": "Libya", @@ -1515,7 +1619,10 @@ "nationalPrefix": "0", "numberPattern": "[2-9]\\d{8}", "minLocalDigits": 9, - "maxLocalDigits": 10 + "maxLocalDigits": 9, + "localDigitLengths": [ + 9 + ] }, { "name": "Liechtenstein", @@ -1587,7 +1694,10 @@ "nationalPrefix": "0", "numberPattern": "[23]\\d{8}", "minLocalDigits": 9, - "maxLocalDigits": 9 + "maxLocalDigits": 9, + "localDigitLengths": [ + 9 + ] }, { "name": "Malawi", @@ -1598,8 +1708,12 @@ "internationalPrefix": "00", "nationalPrefix": "0", "numberPattern": "(?:[1289]\\d|31|77)\\d{7}|1\\d{6}", - "minLocalDigits": 9, - "maxLocalDigits": 9 + "minLocalDigits": 7, + "maxLocalDigits": 9, + "localDigitLengths": [ + 7, + 9 + ] }, { "name": "Malaysia", @@ -1635,7 +1749,10 @@ "nationalPrefix": "", "numberPattern": "[24-9]\\d{7}", "minLocalDigits": 8, - "maxLocalDigits": 8 + "maxLocalDigits": 8, + "localDigitLengths": [ + 8 + ] }, { "name": "Malta", @@ -1683,7 +1800,10 @@ "nationalPrefix": "", "numberPattern": "(?:[2-4]\\d\\d|800)\\d{5}", "minLocalDigits": 8, - "maxLocalDigits": 8 + "maxLocalDigits": 8, + "localDigitLengths": [ + 8 + ] }, { "name": "Mauritius", @@ -1694,8 +1814,13 @@ "internationalPrefix": "0(?:0|[24-7]0|3[03])", "nationalPrefix": "", "numberPattern": "(?:[57]|8\\d\\d)\\d{7}|[2-468]\\d{6}", - "minLocalDigits": 8, - "maxLocalDigits": 8 + "minLocalDigits": 7, + "maxLocalDigits": 10, + "localDigitLengths": [ + 7, + 8, + 10 + ] }, { "name": "Mayotte", @@ -1707,7 +1832,10 @@ "nationalPrefix": "0", "numberPattern": "(?:639\\d|7093)\\d{5}|(?:26|80|9\\d)\\d{7}", "minLocalDigits": 9, - "maxLocalDigits": 9 + "maxLocalDigits": 9, + "localDigitLengths": [ + 9 + ] }, { "name": "Mexico", @@ -1804,7 +1932,10 @@ "numberPattern": "[5-8]\\d{8}", "mainCountryForCode": true, "minLocalDigits": 9, - "maxLocalDigits": 9 + "maxLocalDigits": 9, + "localDigitLengths": [ + 9 + ] }, { "name": "Mozambique", @@ -1815,8 +1946,12 @@ "internationalPrefix": "00", "nationalPrefix": "", "numberPattern": "(?:2|8\\d)\\d{7}", - "minLocalDigits": 9, - "maxLocalDigits": 9 + "minLocalDigits": 8, + "maxLocalDigits": 9, + "localDigitLengths": [ + 8, + 9 + ] }, { "name": "Myanmar", @@ -1839,8 +1974,12 @@ "internationalPrefix": "00", "nationalPrefix": "0", "numberPattern": "[68]\\d{7,8}", - "minLocalDigits": 9, - "maxLocalDigits": 9 + "minLocalDigits": 8, + "maxLocalDigits": 9, + "localDigitLengths": [ + 8, + 9 + ] }, { "name": "Nauru", @@ -1924,7 +2063,10 @@ "nationalPrefix": "", "numberPattern": "[027-9]\\d{7}", "minLocalDigits": 8, - "maxLocalDigits": 8 + "maxLocalDigits": 8, + "localDigitLengths": [ + 8 + ] }, { "name": "Nigeria", @@ -1935,8 +2077,15 @@ "internationalPrefix": "009", "nationalPrefix": "0", "numberPattern": "(?:20|9\\d)\\d{8}|[78]\\d{9,13}", - "minLocalDigits": 7, - "maxLocalDigits": 10 + "minLocalDigits": 10, + "maxLocalDigits": 14, + "localDigitLengths": [ + 10, + 11, + 12, + 13, + 14 + ] }, { "name": "Niue", @@ -2190,8 +2339,12 @@ "internationalPrefix": "00", "nationalPrefix": "0", "numberPattern": "(?:06|[27]\\d\\d|[89]00)\\d{6}", - "minLocalDigits": 9, - "maxLocalDigits": 9 + "minLocalDigits": 8, + "maxLocalDigits": 9, + "localDigitLengths": [ + 8, + 9 + ] }, { "name": "Reunion", @@ -2204,7 +2357,10 @@ "numberPattern": "709\\d{6}|(?:26|[689]\\d)\\d{7}", "mainCountryForCode": true, "minLocalDigits": 9, - "maxLocalDigits": 9 + "maxLocalDigits": 9, + "localDigitLengths": [ + 9 + ] }, { "name": "Saint Barthelemy", @@ -2229,7 +2385,11 @@ "numberPattern": "(?:[256]\\d|8)\\d{3}", "mainCountryForCode": true, "minLocalDigits": 4, - "maxLocalDigits": 4 + "maxLocalDigits": 5, + "localDigitLengths": [ + 4, + 5 + ] }, { "name": "Saint Kitts and Nevis", @@ -2325,7 +2485,10 @@ "nationalPrefix": "", "numberPattern": "(?:22|9\\d)\\d{5}", "minLocalDigits": 7, - "maxLocalDigits": 7 + "maxLocalDigits": 7, + "localDigitLengths": [ + 7 + ] }, { "name": "Saudi Arabia", @@ -2349,7 +2512,10 @@ "nationalPrefix": "", "numberPattern": "(?:[378]\\d|93)\\d{7}", "minLocalDigits": 9, - "maxLocalDigits": 9 + "maxLocalDigits": 9, + "localDigitLengths": [ + 9 + ] }, { "name": "Serbia", @@ -2373,7 +2539,10 @@ "nationalPrefix": "", "numberPattern": "(?:[2489]\\d|64)\\d{5}", "minLocalDigits": 7, - "maxLocalDigits": 7 + "maxLocalDigits": 7, + "localDigitLengths": [ + 7 + ] }, { "name": "Sierra Leone", @@ -2385,7 +2554,10 @@ "nationalPrefix": "0", "numberPattern": "(?:[237-9]\\d|66)\\d{6}", "minLocalDigits": 8, - "maxLocalDigits": 8 + "maxLocalDigits": 8, + "localDigitLengths": [ + 8 + ] }, { "name": "Singapore", @@ -2456,8 +2628,14 @@ "internationalPrefix": "00", "nationalPrefix": "0", "numberPattern": "[346-9]\\d{8}|[12679]\\d{7}|[1-5]\\d{6}|[1348]\\d{5}", - "minLocalDigits": 8, - "maxLocalDigits": 9 + "minLocalDigits": 6, + "maxLocalDigits": 9, + "localDigitLengths": [ + 6, + 7, + 8, + 9 + ] }, { "name": "South Africa", @@ -2468,8 +2646,16 @@ "internationalPrefix": "00", "nationalPrefix": "0", "numberPattern": "[1-79]\\d{8}|8\\d{4,9}", - "minLocalDigits": 9, - "maxLocalDigits": 9 + "minLocalDigits": 5, + "maxLocalDigits": 10, + "localDigitLengths": [ + 5, + 6, + 7, + 8, + 9, + 10 + ] }, { "name": "South Georgia and the South Sandwich Islands", @@ -2493,7 +2679,10 @@ "nationalPrefix": "0", "numberPattern": "[19]\\d{8}", "minLocalDigits": 9, - "maxLocalDigits": 9 + "maxLocalDigits": 9, + "localDigitLengths": [ + 9 + ] }, { "name": "Spain", @@ -2529,7 +2718,10 @@ "nationalPrefix": "0", "numberPattern": "[19]\\d{8}", "minLocalDigits": 9, - "maxLocalDigits": 9 + "maxLocalDigits": 9, + "localDigitLengths": [ + 9 + ] }, { "name": "Suriname", @@ -2565,7 +2757,11 @@ "nationalPrefix": "", "numberPattern": "0800\\d{4}|(?:[237]\\d|900)\\d{6}", "minLocalDigits": 8, - "maxLocalDigits": 8 + "maxLocalDigits": 9, + "localDigitLengths": [ + 8, + 9 + ] }, { "name": "Sweden", @@ -2637,7 +2833,10 @@ "nationalPrefix": "0", "numberPattern": "(?:[25-8]\\d|41|90)\\d{7}", "minLocalDigits": 9, - "maxLocalDigits": 9 + "maxLocalDigits": 9, + "localDigitLengths": [ + 9 + ] }, { "name": "Thailand", @@ -2673,7 +2872,10 @@ "nationalPrefix": "", "numberPattern": "[279]\\d{7}", "minLocalDigits": 8, - "maxLocalDigits": 8 + "maxLocalDigits": 8, + "localDigitLengths": [ + 8 + ] }, { "name": "Tokelau", @@ -2721,7 +2923,10 @@ "nationalPrefix": "", "numberPattern": "[2-57-9]\\d{7}", "minLocalDigits": 8, - "maxLocalDigits": 8 + "maxLocalDigits": 8, + "localDigitLengths": [ + 8 + ] }, { "name": "Turkey", @@ -2781,7 +2986,10 @@ "nationalPrefix": "0", "numberPattern": "800\\d{6}|(?:[29]0|[347]\\d)\\d{7}", "minLocalDigits": 9, - "maxLocalDigits": 9 + "maxLocalDigits": 9, + "localDigitLengths": [ + 9 + ] }, { "name": "Ukraine", @@ -2951,7 +3159,10 @@ "nationalPrefix": "0", "numberPattern": "[5-8]\\d{8}", "minLocalDigits": 9, - "maxLocalDigits": 9 + "maxLocalDigits": 9, + "localDigitLengths": [ + 9 + ] }, { "name": "Yemen", @@ -2975,7 +3186,10 @@ "nationalPrefix": "0", "numberPattern": "800\\d{6}|(?:21|[579]\\d|63)\\d{7}", "minLocalDigits": 9, - "maxLocalDigits": 9 + "maxLocalDigits": 9, + "localDigitLengths": [ + 9 + ] }, { "name": "Zimbabwe", @@ -2986,7 +3200,12 @@ "internationalPrefix": "00", "nationalPrefix": "0", "numberPattern": "(?:13|8\\d{4})\\d{5}|[235-8]\\d{8}|[23568]\\d{6}", - "minLocalDigits": 9, - "maxLocalDigits": 9 + "minLocalDigits": 7, + "maxLocalDigits": 10, + "localDigitLengths": [ + 7, + 9, + 10 + ] } ] diff --git a/country.go b/country.go index 899f5ce..11981d9 100644 --- a/country.go +++ b/country.go @@ -3,6 +3,7 @@ package sanja import ( "fmt" "regexp" + "slices" "strings" ) @@ -19,6 +20,7 @@ type Country struct { MainCountryForCode bool `json:"mainCountryForCode"` MinLocalDigits int `json:"minLocalDigits"` MaxLocalDigits int `json:"maxLocalDigits"` + LocalDigitLengths []int `json:"localDigitLengths"` internationalPrefixPattern *regexp.Regexp numberPattern *regexp.Regexp @@ -95,6 +97,11 @@ func checkLocalDigits(e164 string, country *Country) error { ErrInvalidPhoneNumber, country.Name, country.MaxLocalDigits, len(local)) } + if len(country.LocalDigitLengths) > 0 && !slices.Contains(country.LocalDigitLengths, len(local)) { + return fmt.Errorf("%w: %s allows only %v local digits, got %d", + ErrInvalidPhoneNumber, country.Name, country.LocalDigitLengths, len(local)) + } + return nil } diff --git a/internal/cmd/gencountries/main.go b/internal/cmd/gencountries/main.go index 93a7c42..c85d931 100644 --- a/internal/cmd/gencountries/main.go +++ b/internal/cmd/gencountries/main.go @@ -10,18 +10,58 @@ import ( "log" "net/http" "os" + "slices" + "strconv" "strings" ) const defaultMetadataURL = "https://raw.githubusercontent.com/google/libphonenumber/v9.0.40/resources/PhoneNumberMetadata.xml" +var countriesWithLibphonenumberLengths = []string{ + "AO", "BF", "BI", "BJ", "BW", "CD", "CF", "CG", "CI", "CM", "CV", "DJ", "DZ", "EG", "EH", + "ER", "ET", "GA", "GH", "GM", "GN", "GQ", "GW", "IO", "KE", "KM", "LR", "LS", "LY", "MA", + "MG", "ML", "MR", "MU", "MW", "MZ", "NA", "NE", "NG", "RE", "RW", "SC", "SD", "SH", "SL", + "SN", "SO", "SS", "ST", "SZ", "TD", "TG", "TN", "TZ", "UG", "YT", "ZA", "ZM", "ZW", +} + +type numberType struct { + PossibleLengths struct { + National string `xml:"national,attr"` + } `xml:"possibleLengths"` + ExampleNumber string `xml:"exampleNumber"` +} + type territory struct { - ID string `xml:"id,attr"` - CountryCode string `xml:"countryCode,attr"` - MainCountryForCode bool `xml:"mainCountryForCode,attr"` - InternationalPrefix string `xml:"internationalPrefix,attr"` - NationalPrefix string `xml:"nationalPrefix,attr"` - NumberPattern string `xml:"generalDesc>nationalNumberPattern"` + ID string `xml:"id,attr"` + CountryCode string `xml:"countryCode,attr"` + MainCountryForCode bool `xml:"mainCountryForCode,attr"` + InternationalPrefix string `xml:"internationalPrefix,attr"` + NationalPrefix string `xml:"nationalPrefix,attr"` + NumberPattern string `xml:"generalDesc>nationalNumberPattern"` + FixedLine numberType `xml:"fixedLine"` + Mobile numberType `xml:"mobile"` + TollFree numberType `xml:"tollFree"` + PremiumRate numberType `xml:"premiumRate"` + SharedCost numberType `xml:"sharedCost"` + PersonalNumber numberType `xml:"personalNumber"` + VoIP numberType `xml:"voip"` + Pager numberType `xml:"pager"` + UAN numberType `xml:"uan"` + Voicemail numberType `xml:"voicemail"` +} + +func (t territory) numberTypes() []numberType { + return []numberType{ + t.FixedLine, t.Mobile, t.TollFree, t.PremiumRate, t.SharedCost, + t.PersonalNumber, t.VoIP, t.Pager, t.UAN, t.Voicemail, + } +} + +type example struct { + A2 string `json:"a2"` + CountryCode string `json:"countryCode"` + Type string `json:"type"` + Number string `json:"number"` } type metadata struct { @@ -40,11 +80,13 @@ type country struct { MainCountryForCode bool `json:"mainCountryForCode,omitempty"` MinLocalDigits int `json:"minLocalDigits"` MaxLocalDigits int `json:"maxLocalDigits"` + LocalDigitLengths []int `json:"localDigitLengths,omitempty"` } func main() { metadataSource := flag.String("metadata", defaultMetadataURL, "libphonenumber PhoneNumberMetadata.xml, as a URL or a file path") countriesPath := flag.String("countries", "countries.json", "countries file to update in place") + examplesPath := flag.String("examples", "testdata/libphonenumber_examples.json", "file to write example numbers to, for tests") flag.Parse() territories, err := loadTerritories(*metadataSource) @@ -82,12 +124,104 @@ func main() { onlyOneInLibphonenumber := territoriesPerCode[t.CountryCode] == 1 sharedInOurData := countriesPerCode[t.CountryCode] > 1 countries[i].MainCountryForCode = t.MainCountryForCode || (onlyOneInLibphonenumber && sharedInOurData) + + if !slices.Contains(countriesWithLibphonenumberLengths, t.ID) { + continue + } + + lengths, err := possibleLengths(t) + if err != nil { + log.Fatalf("%s: %v", t.ID, err) + } + + countries[i].MinLocalDigits = slices.Min(lengths) + countries[i].MaxLocalDigits = slices.Max(lengths) + countries[i].LocalDigitLengths = lengths } - err = writeCountries(*countriesPath, countries) + err = writeJSON(*countriesPath, countries) if err != nil { log.Fatal(err) } + + err = writeJSON(*examplesPath, examples(territories)) + if err != nil { + log.Fatal(err) + } +} + +func possibleLengths(t territory) ([]int, error) { + var lengths []int + for _, numberType := range t.numberTypes() { + typeLengths, err := parseLengths(numberType.PossibleLengths.National) + if err != nil { + return nil, err + } + + lengths = append(lengths, typeLengths...) + } + + if len(lengths) == 0 { + return nil, fmt.Errorf("no possible lengths") + } + + slices.Sort(lengths) + + return slices.Compact(lengths), nil +} + +func parseLengths(spec string) ([]int, error) { + if spec == "" { + return nil, nil + } + + var lengths []int + for _, part := range strings.Split(spec, ",") { + bounds := strings.Split(strings.Trim(part, "[]"), "-") + + low, err := strconv.Atoi(bounds[0]) + if err != nil { + return nil, fmt.Errorf("possible lengths %q: %w", spec, err) + } + + high, err := strconv.Atoi(bounds[len(bounds)-1]) + if err != nil { + return nil, fmt.Errorf("possible lengths %q: %w", spec, err) + } + + for length := low; length <= high; length++ { + lengths = append(lengths, length) + } + } + + return lengths, nil +} + +func examples(territories map[string]territory) []example { + var result []example + for _, a2 := range countriesWithLibphonenumberLengths { + t := territories[a2] + for _, typed := range []struct { + name string + numberType numberType + }{ + {name: "fixedLine", numberType: t.FixedLine}, + {name: "mobile", numberType: t.Mobile}, + } { + if typed.numberType.ExampleNumber == "" { + continue + } + + result = append(result, example{ + A2: a2, + CountryCode: t.CountryCode, + Type: typed.name, + Number: typed.numberType.ExampleNumber, + }) + } + } + + return result } func loadTerritories(source string) (map[string]territory, error) { @@ -143,13 +277,13 @@ func readCountries(path string) ([]country, error) { return countries, nil } -func writeCountries(path string, countries []country) error { +func writeJSON(path string, value any) error { var buffer bytes.Buffer encoder := json.NewEncoder(&buffer) encoder.SetEscapeHTML(false) encoder.SetIndent("", " ") - err := encoder.Encode(countries) + err := encoder.Encode(value) if err != nil { return err } diff --git a/normalizer_test.go b/normalizer_test.go index 5520c89..bb0b276 100644 --- a/normalizer_test.go +++ b/normalizer_test.go @@ -1,6 +1,8 @@ package sanja import ( + "encoding/json" + "os" "testing" "github.com/stretchr/testify/assert" @@ -533,7 +535,11 @@ func TestNormalizeAndValidate(t *testing.T) { {name: "local number", defaultCountry: "MW", input: "0886392814", expected: "+265886392814"}, {name: "international number", defaultCountry: "MW", input: "+447911123456", expected: "+447911123456"}, {name: "international prefix", defaultCountry: "MW", input: "00447911123456", expected: "+447911123456"}, - {name: "one digit short with plus", defaultCountry: "MW", input: "+2659912345", expectedErr: ErrInvalidPhoneNumber}, + {name: "one digit short with plus", defaultCountry: "MW", input: "+26599123456", expectedErr: ErrInvalidPhoneNumber}, + {name: "Malawi landline length", defaultCountry: "MW", input: "01234567", expected: "+2651234567"}, + {name: "Malawi has no 8-digit numbers", defaultCountry: "MW", input: "+26512345678", expectedErr: ErrInvalidPhoneNumber}, + {name: "Benin 10-digit number", defaultCountry: "BJ", input: "0195123456", expected: "+2290195123456"}, + {name: "Benin has no 9-digit numbers", defaultCountry: "BJ", input: "+229019512345", expectedErr: ErrInvalidPhoneNumber}, {name: "one digit short local", defaultCountry: "MW", input: "099123456", expectedErr: ErrInvalidPhoneNumber}, {name: "one digit too many", defaultCountry: "MW", input: "+2658863928140", expectedErr: ErrInvalidPhoneNumber}, {name: "short number", defaultCountry: "MW", input: "123", expectedErr: ErrInvalidPhoneNumber}, @@ -594,3 +600,33 @@ func BenchmarkNormalizeBulk(b *testing.B) { norm.NormalizeBulk(phoneNumbers) } } + +func TestLibphonenumberExamples(t *testing.T) { + data, err := os.ReadFile("testdata/libphonenumber_examples.json") + require.NoError(t, err) + + var examples []struct { + A2 string `json:"a2"` + CountryCode string `json:"countryCode"` + Type string `json:"type"` + Number string `json:"number"` + } + err = json.Unmarshal(data, &examples) + require.NoError(t, err) + require.NotEmpty(t, examples) + + for _, example := range examples { + t.Run(example.A2+" "+example.Type, func(t *testing.T) { + norm := mustNormalizer(t, example.A2) + expected := "+" + example.CountryCode + example.Number + + local, err := norm.NormalizeAndValidate(norm.defaultCountry.NationalPrefix + example.Number) + require.NoError(t, err) + assert.Equal(t, expected, local) + + international, err := norm.NormalizeAndValidate(expected) + require.NoError(t, err) + assert.Equal(t, expected, international) + }) + } +} diff --git a/testdata/libphonenumber_examples.json b/testdata/libphonenumber_examples.json new file mode 100644 index 0000000..cc85644 --- /dev/null +++ b/testdata/libphonenumber_examples.json @@ -0,0 +1,710 @@ +[ + { + "a2": "AO", + "countryCode": "244", + "type": "fixedLine", + "number": "222123456" + }, + { + "a2": "AO", + "countryCode": "244", + "type": "mobile", + "number": "923123456" + }, + { + "a2": "BF", + "countryCode": "226", + "type": "fixedLine", + "number": "20491234" + }, + { + "a2": "BF", + "countryCode": "226", + "type": "mobile", + "number": "70123456" + }, + { + "a2": "BI", + "countryCode": "257", + "type": "fixedLine", + "number": "22201234" + }, + { + "a2": "BI", + "countryCode": "257", + "type": "mobile", + "number": "79561234" + }, + { + "a2": "BJ", + "countryCode": "229", + "type": "fixedLine", + "number": "0120211234" + }, + { + "a2": "BJ", + "countryCode": "229", + "type": "mobile", + "number": "0195123456" + }, + { + "a2": "BW", + "countryCode": "267", + "type": "fixedLine", + "number": "2401234" + }, + { + "a2": "BW", + "countryCode": "267", + "type": "mobile", + "number": "71123456" + }, + { + "a2": "CD", + "countryCode": "243", + "type": "fixedLine", + "number": "1234567" + }, + { + "a2": "CD", + "countryCode": "243", + "type": "mobile", + "number": "991234567" + }, + { + "a2": "CF", + "countryCode": "236", + "type": "fixedLine", + "number": "21612345" + }, + { + "a2": "CF", + "countryCode": "236", + "type": "mobile", + "number": "70012345" + }, + { + "a2": "CG", + "countryCode": "242", + "type": "fixedLine", + "number": "222123456" + }, + { + "a2": "CG", + "countryCode": "242", + "type": "mobile", + "number": "061234567" + }, + { + "a2": "CI", + "countryCode": "225", + "type": "fixedLine", + "number": "2123456789" + }, + { + "a2": "CI", + "countryCode": "225", + "type": "mobile", + "number": "0123456789" + }, + { + "a2": "CM", + "countryCode": "237", + "type": "fixedLine", + "number": "222123456" + }, + { + "a2": "CM", + "countryCode": "237", + "type": "mobile", + "number": "671234567" + }, + { + "a2": "CV", + "countryCode": "238", + "type": "fixedLine", + "number": "2211234" + }, + { + "a2": "CV", + "countryCode": "238", + "type": "mobile", + "number": "9911234" + }, + { + "a2": "DJ", + "countryCode": "253", + "type": "fixedLine", + "number": "21360003" + }, + { + "a2": "DJ", + "countryCode": "253", + "type": "mobile", + "number": "77831001" + }, + { + "a2": "DZ", + "countryCode": "213", + "type": "fixedLine", + "number": "12345678" + }, + { + "a2": "DZ", + "countryCode": "213", + "type": "mobile", + "number": "551234567" + }, + { + "a2": "EG", + "countryCode": "20", + "type": "fixedLine", + "number": "234567890" + }, + { + "a2": "EG", + "countryCode": "20", + "type": "mobile", + "number": "1001234567" + }, + { + "a2": "EH", + "countryCode": "212", + "type": "fixedLine", + "number": "528812345" + }, + { + "a2": "EH", + "countryCode": "212", + "type": "mobile", + "number": "650123456" + }, + { + "a2": "ER", + "countryCode": "291", + "type": "fixedLine", + "number": "8370362" + }, + { + "a2": "ER", + "countryCode": "291", + "type": "mobile", + "number": "7123456" + }, + { + "a2": "ET", + "countryCode": "251", + "type": "fixedLine", + "number": "111112345" + }, + { + "a2": "ET", + "countryCode": "251", + "type": "mobile", + "number": "911234567" + }, + { + "a2": "GA", + "countryCode": "241", + "type": "fixedLine", + "number": "01441234" + }, + { + "a2": "GA", + "countryCode": "241", + "type": "mobile", + "number": "06031234" + }, + { + "a2": "GH", + "countryCode": "233", + "type": "fixedLine", + "number": "302345678" + }, + { + "a2": "GH", + "countryCode": "233", + "type": "mobile", + "number": "231234567" + }, + { + "a2": "GM", + "countryCode": "220", + "type": "fixedLine", + "number": "5661234" + }, + { + "a2": "GM", + "countryCode": "220", + "type": "mobile", + "number": "3012345" + }, + { + "a2": "GN", + "countryCode": "224", + "type": "fixedLine", + "number": "30241234" + }, + { + "a2": "GN", + "countryCode": "224", + "type": "mobile", + "number": "601123456" + }, + { + "a2": "GQ", + "countryCode": "240", + "type": "fixedLine", + "number": "333091234" + }, + { + "a2": "GQ", + "countryCode": "240", + "type": "mobile", + "number": "222123456" + }, + { + "a2": "GW", + "countryCode": "245", + "type": "fixedLine", + "number": "443201234" + }, + { + "a2": "GW", + "countryCode": "245", + "type": "mobile", + "number": "955012345" + }, + { + "a2": "IO", + "countryCode": "246", + "type": "fixedLine", + "number": "3709100" + }, + { + "a2": "IO", + "countryCode": "246", + "type": "mobile", + "number": "3801234" + }, + { + "a2": "KE", + "countryCode": "254", + "type": "fixedLine", + "number": "202012345" + }, + { + "a2": "KE", + "countryCode": "254", + "type": "mobile", + "number": "712123456" + }, + { + "a2": "KM", + "countryCode": "269", + "type": "fixedLine", + "number": "7712345" + }, + { + "a2": "KM", + "countryCode": "269", + "type": "mobile", + "number": "3212345" + }, + { + "a2": "LR", + "countryCode": "231", + "type": "fixedLine", + "number": "21234567" + }, + { + "a2": "LR", + "countryCode": "231", + "type": "mobile", + "number": "770123456" + }, + { + "a2": "LS", + "countryCode": "266", + "type": "fixedLine", + "number": "22123456" + }, + { + "a2": "LS", + "countryCode": "266", + "type": "mobile", + "number": "50123456" + }, + { + "a2": "LY", + "countryCode": "218", + "type": "fixedLine", + "number": "212345678" + }, + { + "a2": "LY", + "countryCode": "218", + "type": "mobile", + "number": "912345678" + }, + { + "a2": "MA", + "countryCode": "212", + "type": "fixedLine", + "number": "520123456" + }, + { + "a2": "MA", + "countryCode": "212", + "type": "mobile", + "number": "650123456" + }, + { + "a2": "MG", + "countryCode": "261", + "type": "fixedLine", + "number": "202123456" + }, + { + "a2": "MG", + "countryCode": "261", + "type": "mobile", + "number": "321234567" + }, + { + "a2": "ML", + "countryCode": "223", + "type": "fixedLine", + "number": "20212345" + }, + { + "a2": "ML", + "countryCode": "223", + "type": "mobile", + "number": "65012345" + }, + { + "a2": "MR", + "countryCode": "222", + "type": "fixedLine", + "number": "35123456" + }, + { + "a2": "MR", + "countryCode": "222", + "type": "mobile", + "number": "22123456" + }, + { + "a2": "MU", + "countryCode": "230", + "type": "fixedLine", + "number": "54480123" + }, + { + "a2": "MU", + "countryCode": "230", + "type": "mobile", + "number": "52512345" + }, + { + "a2": "MW", + "countryCode": "265", + "type": "fixedLine", + "number": "1234567" + }, + { + "a2": "MW", + "countryCode": "265", + "type": "mobile", + "number": "991234567" + }, + { + "a2": "MZ", + "countryCode": "258", + "type": "fixedLine", + "number": "21123456" + }, + { + "a2": "MZ", + "countryCode": "258", + "type": "mobile", + "number": "821234567" + }, + { + "a2": "NA", + "countryCode": "264", + "type": "fixedLine", + "number": "61221234" + }, + { + "a2": "NA", + "countryCode": "264", + "type": "mobile", + "number": "811234567" + }, + { + "a2": "NE", + "countryCode": "227", + "type": "fixedLine", + "number": "20201234" + }, + { + "a2": "NE", + "countryCode": "227", + "type": "mobile", + "number": "93123456" + }, + { + "a2": "NG", + "countryCode": "234", + "type": "fixedLine", + "number": "2033123456" + }, + { + "a2": "NG", + "countryCode": "234", + "type": "mobile", + "number": "8021234567" + }, + { + "a2": "RE", + "countryCode": "262", + "type": "fixedLine", + "number": "262161234" + }, + { + "a2": "RE", + "countryCode": "262", + "type": "mobile", + "number": "692123456" + }, + { + "a2": "RW", + "countryCode": "250", + "type": "fixedLine", + "number": "250123456" + }, + { + "a2": "RW", + "countryCode": "250", + "type": "mobile", + "number": "720123456" + }, + { + "a2": "SC", + "countryCode": "248", + "type": "fixedLine", + "number": "4217123" + }, + { + "a2": "SC", + "countryCode": "248", + "type": "mobile", + "number": "2510123" + }, + { + "a2": "SD", + "countryCode": "249", + "type": "fixedLine", + "number": "153123456" + }, + { + "a2": "SD", + "countryCode": "249", + "type": "mobile", + "number": "911231234" + }, + { + "a2": "SH", + "countryCode": "290", + "type": "fixedLine", + "number": "22158" + }, + { + "a2": "SH", + "countryCode": "290", + "type": "mobile", + "number": "51234" + }, + { + "a2": "SL", + "countryCode": "232", + "type": "fixedLine", + "number": "22221234" + }, + { + "a2": "SL", + "countryCode": "232", + "type": "mobile", + "number": "25123456" + }, + { + "a2": "SN", + "countryCode": "221", + "type": "fixedLine", + "number": "301012345" + }, + { + "a2": "SN", + "countryCode": "221", + "type": "mobile", + "number": "701234567" + }, + { + "a2": "SO", + "countryCode": "252", + "type": "fixedLine", + "number": "4012345" + }, + { + "a2": "SO", + "countryCode": "252", + "type": "mobile", + "number": "71123456" + }, + { + "a2": "SS", + "countryCode": "211", + "type": "fixedLine", + "number": "181234567" + }, + { + "a2": "SS", + "countryCode": "211", + "type": "mobile", + "number": "977123456" + }, + { + "a2": "ST", + "countryCode": "239", + "type": "fixedLine", + "number": "2221234" + }, + { + "a2": "ST", + "countryCode": "239", + "type": "mobile", + "number": "9812345" + }, + { + "a2": "SZ", + "countryCode": "268", + "type": "fixedLine", + "number": "22171234" + }, + { + "a2": "SZ", + "countryCode": "268", + "type": "mobile", + "number": "76123456" + }, + { + "a2": "TD", + "countryCode": "235", + "type": "fixedLine", + "number": "22501234" + }, + { + "a2": "TD", + "countryCode": "235", + "type": "mobile", + "number": "63012345" + }, + { + "a2": "TG", + "countryCode": "228", + "type": "fixedLine", + "number": "22212345" + }, + { + "a2": "TG", + "countryCode": "228", + "type": "mobile", + "number": "90112345" + }, + { + "a2": "TN", + "countryCode": "216", + "type": "fixedLine", + "number": "30010123" + }, + { + "a2": "TN", + "countryCode": "216", + "type": "mobile", + "number": "20123456" + }, + { + "a2": "TZ", + "countryCode": "255", + "type": "fixedLine", + "number": "222345678" + }, + { + "a2": "TZ", + "countryCode": "255", + "type": "mobile", + "number": "621234567" + }, + { + "a2": "UG", + "countryCode": "256", + "type": "fixedLine", + "number": "312345678" + }, + { + "a2": "UG", + "countryCode": "256", + "type": "mobile", + "number": "712345678" + }, + { + "a2": "YT", + "countryCode": "262", + "type": "fixedLine", + "number": "269601234" + }, + { + "a2": "YT", + "countryCode": "262", + "type": "mobile", + "number": "639012345" + }, + { + "a2": "ZA", + "countryCode": "27", + "type": "fixedLine", + "number": "101234567" + }, + { + "a2": "ZA", + "countryCode": "27", + "type": "mobile", + "number": "711234567" + }, + { + "a2": "ZM", + "countryCode": "260", + "type": "fixedLine", + "number": "211234567" + }, + { + "a2": "ZM", + "countryCode": "260", + "type": "mobile", + "number": "955123456" + }, + { + "a2": "ZW", + "countryCode": "263", + "type": "fixedLine", + "number": "1312345" + }, + { + "a2": "ZW", + "countryCode": "263", + "type": "mobile", + "number": "712345678" + } +]