[calling code, trunk prefix, minimum and maximum * digits of the national number]. An empty trunk means none (a leading 0 is still dropped, as no such * number starts with 0); Italy keeps its leading 0, which is part of the number. The maximum only decides * whether digits typed without "+" already carry the country code (see from_national()). */ const COUNTRIES = array( 'AT' => array( '43', '0', 7, 11 ), 'BE' => array( '32', '0', 8, 9 ), 'BY' => array( '375', '80', 9, 9 ), 'CA' => array( '1', '1', 10, 10 ), 'CH' => array( '41', '0', 9, 9 ), 'CZ' => array( '420', '', 9, 9 ), 'DE' => array( '49', '0', 6, 11 ), 'DK' => array( '45', '', 8, 8 ), 'EE' => array( '372', '', 7, 8 ), 'ES' => array( '34', '', 9, 9 ), 'FI' => array( '358', '0', 5, 10 ), 'FR' => array( '33', '0', 9, 9 ), 'GB' => array( '44', '0', 10, 10 ), 'HU' => array( '36', '06', 8, 9 ), 'IE' => array( '353', '0', 7, 9 ), 'IT' => array( '39', '', 6, 11 ), 'LT' => array( '370', '8', 8, 8 ), 'LV' => array( '371', '', 8, 8 ), 'NL' => array( '31', '0', 9, 9 ), 'NO' => array( '47', '', 8, 8 ), 'PL' => array( '48', '', 9, 9 ), 'PT' => array( '351', '', 9, 9 ), 'RO' => array( '40', '0', 9, 9 ), 'SE' => array( '46', '0', 7, 10 ), 'SK' => array( '421', '0', 9, 9 ), 'UA' => array( '380', '0', 9, 9 ), 'US' => array( '1', '1', 10, 10 ), ); /** * Calling codes where a leading 0 belongs to the number. */ const KEEPS_LEADING_ZERO = array( '39' ); /** * Normalise a phone number to E.164. * * @param string $raw Number as typed. * @param string $default_country_code Calling code for national numbers, e.g. "48" or "+48". * @return string|null E.164 number, or null when it cannot be a phone number. */ public static function normalize( string $raw, string $default_country_code ): ?string { $cleaned = (string) preg_replace( '/[^\d+]/', '', $raw ); $digits = (string) preg_replace( '/\D/', '', $cleaned ); $country = (string) preg_replace( '/\D/', '', $default_country_code ); if ( '' !== $cleaned && '+' === $cleaned[0] ) { $international = $digits; } elseif ( 0 === strncmp( $digits, '00', 2 ) ) { $international = substr( $digits, 2 ); } else { $international = self::from_national( $digits, $country ); } if ( null === $international || ! self::plausible( $international ) ) { return null; } return '+' . $international; } /** * Calling code used for numbers typed without one ("48" unless filtered). * * @return string Digits only. */ public static function default_country_code(): string { /** * Filters the calling code added to phone numbers typed without one. * * @param string $code Calling code, e.g. "48". */ $code = (string) apply_filters( 'smsportal_default_country_code', self::DEFAULT_COUNTRY_CODE ); return (string) preg_replace( '/\D/', '', $code ); } /** * Number with its middle hidden, for lists: "+48600123201" → "+48 600 *** 201". * A sender that is not a number (e.g. "ORANGE") is returned as it is. * * @param string $phone Number (E.164 or digits). * @return string */ public static function mask( string $phone ): string { $compact = (string) preg_replace( '/[\s\-().]/', '', $phone ); if ( 1 !== preg_match( '/^(\+?)(\d+)$/', $compact, $m ) ) { return $phone; } $code = '' === $m[1] ? '' : self::code_prefix( $m[2] ); $rest = (string) substr( $m[2], strlen( $code ) ); $prefix = '' === $code ? '' : '+' . $code . ' '; $shown = min( self::MASK_SHOWN, intdiv( strlen( $rest ) - self::MASK_HIDDEN, 2 ) ); if ( $shown < 1 ) { return '***'; } return $prefix . substr( $rest, 0, $shown ) . ' *** ' . substr( $rest, -$shown ); } /** * Calling code at the start of international digits (a known one, else the first two digits). * * @param string $digits International digits without "+". * @return string */ private static function code_prefix( string $digits ): string { foreach ( array( 3, 2, 1 ) as $length ) { $code = substr( $digits, 0, $length ); if ( self::is_known_code( $code ) ) { return $code; } } return substr( $digits, 0, 2 ); } /** * Calling code of a country. * * @param string $country ISO 3166-1 alpha-2 code, e.g. "PL". * @return string|null Digits, e.g. "48", or null for a country not in the list. */ public static function calling_code( string $country ): ?string { $rules = self::COUNTRIES[ strtoupper( trim( $country ) ) ] ?? null; return null === $rules ? null : $rules[0]; } /** * International digits from a national number. * * @param string $digits Digits as typed. * @param string $country Calling code digits. * @return string|null International digits, or null when there is nothing to work with. */ private static function from_national( string $digits, string $country ): ?string { if ( '' === $country || '' === $digits ) { return null; } $rules = self::rules( $country ); $national = self::without_trunk( $digits, $country, $rules ); // Already carries the country code, only the "+" is missing ("48600100200"): only when the digits are // too many for a national number, so a national number that happens to start with the code (Italian // mobiles 39x) stays national. if ( strlen( $national ) > $rules['max'] && 0 === strncmp( $national, $country, strlen( $country ) ) && strlen( $national ) - strlen( $country ) >= $rules['min'] ) { return $national; } return strlen( $national ) >= $rules['min'] ? $country . $national : null; } /** * National number without the trunk prefix (or without leading zeros where numbers never start with 0). * * @param string $digits National digits. * @param string $country Calling code digits. * @param array{trunk:string,min:int,max:int} $rules Country rules. * @return string */ private static function without_trunk( string $digits, string $country, array $rules ): string { $trunk = $rules['trunk']; if ( '' !== $trunk && 0 === strncmp( $digits, $trunk, strlen( $trunk ) ) && strlen( $digits ) - strlen( $trunk ) >= $rules['min'] ) { return substr( $digits, strlen( $trunk ) ); } return in_array( $country, self::KEEPS_LEADING_ZERO, true ) ? $digits : ltrim( $digits, '0' ); } /** * Whether international digits can be a number: at most 15 digits, and for a known country at least its * minimum national length (otherwise at least 9 digits in all). * * @param string $international International digits. * @return bool */ private static function plausible( string $international ): bool { $length = strlen( $international ); if ( $length > self::MAX_DIGITS ) { return false; } foreach ( array( 3, 2, 1 ) as $code_length ) { $code = substr( $international, 0, $code_length ); if ( self::is_known_code( $code ) ) { return $length - $code_length >= self::rules( $code )['min']; } } return $length >= self::MIN_DIGITS; } /** * Trunk prefix and minimum/maximum national length of a calling code (defaults for codes not in the list). * * @param string $code Calling code digits. * @return array{trunk:string,min:int,max:int} */ private static function rules( string $code ): array { foreach ( self::COUNTRIES as $rules ) { if ( $rules[0] === $code ) { return array( 'trunk' => $rules[1], 'min' => $rules[2], 'max' => $rules[3], ); } } // Unknown country: no maximum known, the code prefix alone decides (the old rule). return array( 'trunk' => '', 'min' => self::MIN_DIGITS, 'max' => 0, ); } /** * Whether a calling code is in the list. * * @param string $code Calling code digits. * @return bool */ private static function is_known_code( string $code ): bool { foreach ( self::COUNTRIES as $rules ) { if ( $rules[0] === $code ) { return true; } } return false; } }