self::MAX_LENGTH ) { return ''; } $s = (string) preg_replace( '/[\p{Cf}\p{Zs}\p{Pd}\s.()\/\x{FF08}\x{FF09}\x{FF0E}\x{FF0F}]+/u', '', $raw ); $s = str_replace( "\u{FF0B}", '+', self::ascii_digits( $s ) ); return preg_match( '/^\+?\d{1,20}$/', $s ) ? $s : ''; } /** * The number in E.164 (+12015550123), or '' when it is not a valid number. * * @param mixed $raw Input. * @param string $country Country of a national number (upper-case code), '' if unknown. * @return string */ public static function e164( $raw, $country = '' ) { $s = self::clean( $raw ); $country = strtoupper( (string) $country ); $country = preg_match( '/^[A-Z]{2}$/', $country ) ? $country : ''; if ( '' === $s ) { return ''; } if ( '+' !== $s[0] && '' === $country ) { if ( 0 !== strpos( $s, '00' ) ) { return ''; } $s = '+' . substr( $s, 2 ); } $util = self::util(); if ( ! $util ) { return self::basic_e164( $s, $country ); } $found = self::parse_valid( $util, $s, '' !== $country ? $country : 'ZZ' ); foreach ( self::readings( $util, $s, $country ) as $kind => $other ) { $number = self::parse_valid( $util, $other, 'ZZ' ); if ( ! $number ) { continue; } if ( ! $found || ( 'code' === $kind && self::is_mobile( $util, $number ) && ! self::is_mobile( $util, $found ) ) ) { $found = $number; break; } } return $found ? $util->format( $found, PhoneNumberFormat::E164 ) : ''; } /** * Other international readings of a cleaned number: 00 as +, the country code typed without +, old Mexican mobile prefixes. * * @param PhoneNumberUtil $util Library. * @param string $s Cleaned number. * @param string $country Country code or ''. * @return array Kind => number with +. */ private static function readings( $util, $s, $country ) { $out = array(); if ( '+' !== $s[0] ) { $code = '' !== $country ? (string) $util->getCountryCodeForRegion( $country ) : '0'; if ( 0 === strpos( $s, '00' ) ) { $out['intl'] = '+' . substr( $s, 2 ); } elseif ( '0' !== $code && 0 === strpos( $s, $code ) ) { $out['code'] = '+' . $s; } if ( 'MX' === $country && preg_match( '/^(?:04[45]|01)(\d{10})$/', $s, $m ) ) { $out['legacy'] = '+52' . $m[1]; } } foreach ( array( $s ) + $out as $value ) { if ( preg_match( '/^(?:\+|00)521(\d{10})$/', $value, $m ) ) { $out['legacy'] = '+52' . $m[1]; } } return $out; } /** * Parsed number when it is valid, otherwise null. * * @param PhoneNumberUtil $util Library. * @param string $s Number. * @param string $country Region for national numbers. * @return PhoneNumber|null */ private static function parse_valid( $util, $s, $country ) { try { $number = $util->parse( $s, $country ); return $util->isValidNumber( $number ) ? $number : null; } catch ( \Exception $e ) { return null; } } /** * Whether a number is a mobile number (or can be one). * * @param PhoneNumberUtil $util Library. * @param PhoneNumber $number Number. * @return bool */ private static function is_mobile( $util, $number ) { return in_array( $util->getNumberType( $number ), array( PhoneNumberType::MOBILE, PhoneNumberType::FIXED_LINE_OR_MOBILE ), true ); } /** * Whether the input is a valid number. * * @param mixed $raw Input. * @param string $country Country of a national number. * @return bool */ public static function is_valid( $raw, $country = '' ) { return '' !== self::e164( $raw, $country ); } /** * Why a number is not valid: too_short, too_long or invalid; '' when it is valid. * * @param mixed $raw Input. * @param string $country Country of a national number. * @return string */ public static function reason( $raw, $country = '' ) { if ( self::is_valid( $raw, $country ) ) { return ''; } $s = self::clean( $raw ); $country = strtoupper( (string) $country ); $country = preg_match( '/^[A-Z]{2}$/', $country ) ? $country : ''; if ( '' === $s ) { return 'invalid'; } if ( '+' !== $s[0] && 0 === strpos( $s, '00' ) ) { $s = '+' . substr( $s, 2 ); } elseif ( '+' !== $s[0] && '' === $country ) { return 'invalid'; } $util = self::util(); if ( ! $util ) { $digits = strlen( ltrim( $s, '+0' ) ); return $digits < 8 ? 'too_short' : ( $digits > 15 ? 'too_long' : 'invalid' ); } try { $result = $util->isPossibleNumberWithReason( $util->parse( $s, '' !== $country ? $country : 'ZZ' ) ); } catch ( NumberParseException $e ) { $error = $e->getErrorType(); if ( NumberParseException::TOO_SHORT_NSN === $error || NumberParseException::TOO_SHORT_AFTER_IDD === $error ) { return 'too_short'; } return NumberParseException::TOO_LONG === $error ? 'too_long' : 'invalid'; } if ( ValidationResult::TOO_SHORT === $result ) { return 'too_short'; } return ValidationResult::TOO_LONG === $result ? 'too_long' : self::length_reason( $s, $country ); } /** * Too short or too long where the library can't tell (countries with numbers of different lengths): * a valid number appears when digits are added or removed at the end. * * @param string $s Cleaned number (digits, optional leading +). * @param string $country Country code or ''. * @return string too_short, too_long or invalid. */ private static function length_reason( $s, $country ) { if ( ! preg_match( '/^\+?\d+$/', $s ) ) { return 'invalid'; } $plus = '+' === $s[0] ? '+' : ''; $digits = ltrim( $s, '+' ); for ( $add = 1; $add <= 8; $add++ ) { foreach ( array( '5', '0' ) as $digit ) { if ( '' !== self::e164( $plus . $digits . str_repeat( $digit, $add ), $country ) ) { return 'too_short'; } } } $length = strlen( $digits ); for ( $cut = 1; $cut <= 6 && $length - $cut >= 5; $cut++ ) { if ( '' !== self::e164( $plus . substr( $digits, 0, -$cut ), $country ) ) { return 'too_long'; } } return 'invalid'; } /** * Types of an E.164 number (mobile, fixed_line, …); empty when unknown or without the library. * * @param string $e164 Number. * @return array */ public static function types( $e164 ) { $util = self::util(); if ( ! $util || ! preg_match( '/^\+\d{6,15}$/', (string) $e164 ) ) { return array(); } try { $type = $util->getNumberType( $util->parse( (string) $e164, 'ZZ' ) ); } catch ( \Exception $e ) { return array(); } $names = array( PhoneNumberType::FIXED_LINE => 'fixed_line', PhoneNumberType::MOBILE => 'mobile', PhoneNumberType::TOLL_FREE => 'toll_free', PhoneNumberType::PREMIUM_RATE => 'premium_rate', PhoneNumberType::SHARED_COST => 'shared_cost', PhoneNumberType::VOIP => 'voip', PhoneNumberType::PERSONAL_NUMBER => 'personal_number', PhoneNumberType::PAGER => 'pager', PhoneNumberType::UAN => 'uan', PhoneNumberType::VOICEMAIL => 'voicemail', ); if ( PhoneNumberType::FIXED_LINE_OR_MOBILE === $type ) { return array( 'fixed_line', 'mobile' ); } return isset( $names[ $type ] ) ? array( $names[ $type ] ) : array(); } /** * Whether an E.164 number has one of the types; an empty list or an unknown type passes. * * @param string $e164 Number. * @param array $allowed Types. * @return bool */ public static function type_allowed( $e164, array $allowed ) { if ( ! $allowed ) { return true; } $types = self::types( $e164 ); return ! $types || (bool) array_intersect( $types, $allowed ); } /** * Country of an E.164 number (upper-case code), '' if unknown or shared by several countries. * * @param string $e164 Number. * @return string */ public static function country_of( $e164 ) { $countries = self::countries_of( $e164 ); return 1 === count( $countries ) ? $countries[0] : ''; } /** * Countries an E.164 number can belong to: one with the library, every country with that calling code without it. * * @param string $e164 Number. * @return array Upper-case codes known to WooCommerce. */ public static function countries_of( $e164 ) { $e164 = (string) $e164; $known = Countries::all_codes(); if ( ! preg_match( '/^\+[1-9]\d{5,14}$/', $e164 ) || ! $known ) { return array(); } $util = self::util(); if ( $util ) { try { $region = (string) $util->getRegionCodeForNumber( $util->parse( $e164, 'ZZ' ) ); } catch ( \Exception $e ) { $region = ''; } return isset( $known[ $region ] ) ? array( $region ) : array(); } $digits = substr( $e164, 1 ); $best = 0; $out = array(); foreach ( array_keys( $known ) as $code ) { foreach ( (array) WC()->countries->get_country_calling_code( $code ) as $calling ) { $calling = preg_replace( '/\D/', '', (string) $calling ); if ( '' === $calling || 0 !== strpos( $digits, $calling ) || strlen( $calling ) < $best ) { continue; } if ( strlen( $calling ) > $best ) { $best = strlen( $calling ); $out = array(); } $out[] = (string) $code; } } return array_values( array_unique( $out ) ); } /** * Readable international format (+43 660 1234567); the input unchanged when it is not E.164. * * @param string $e164 Number. * @return string */ public static function display( $e164 ) { $e164 = (string) $e164; $util = self::util(); if ( ! $util || ! preg_match( '/^\+\d{6,15}$/', $e164 ) ) { return $e164; } try { return $util->format( $util->parse( $e164, 'ZZ' ), PhoneNumberFormat::INTERNATIONAL ); } catch ( \Exception $e ) { return $e164; } } /** * National form of an E.164 number, for example 0660 1234567; '' without the library or for an invalid value. * * @param string $e164 Number. * @return string */ public static function national( $e164 ) { $e164 = (string) $e164; $util = self::util(); if ( ! $util || ! preg_match( '/^\+\d{6,15}$/', $e164 ) ) { return ''; } try { return $util->format( $util->parse( $e164, 'ZZ' ), PhoneNumberFormat::NATIONAL ); } catch ( \Exception $e ) { return ''; } } /** * Example number of a country in E.164 (a mobile number when the country has one); '' when unknown. * * @param string $country Upper-case country code. * @return string */ public static function example( $country ) { $util = self::util(); $country = strtoupper( (string) $country ); if ( ! $util || ! preg_match( '/^[A-Z]{2}$/', $country ) ) { return ''; } $number = $util->getExampleNumberForType( $country, PhoneNumberType::MOBILE ); if ( ! $number ) { $number = $util->getExampleNumber( $country ); } return $number ? $util->format( $number, PhoneNumberFormat::E164 ) : ''; } /** * Readable form of a saved number for a place; the saved value is not changed. * * @param string $saved Saved number (E.164 when it was valid). * @param string $place Where it is shown. * @return string */ public static function shown( $saved, $place ) { $shown = self::display( (string) $saved ); /** * Readable form of a saved phone number. The saved value is not changed. * * @param string $shown Number as shown (international format with spaces). * @param string $saved Saved number, E.164 when it was valid. * @param string $place Where it is shown: admin_order. */ $filtered = apply_filters( 'dialtidy_display_phone', $shown, (string) $saved, (string) $place ); if ( $filtered === $shown || ! is_string( $filtered ) ) { return $shown; } $filtered = trim( wp_strip_all_tags( $filtered ) ); return '' !== $filtered ? $filtered : $shown; } /** * Whether the full library runs here (it needs mbstring). * * @return bool */ public static function full() { return (bool) self::util(); } /** * Library instance or false. * * @return PhoneNumberUtil|false */ private static function util() { if ( null === self::$util ) { self::$util = false; if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) && function_exists( 'mb_strtoupper' ) ) { spl_autoload_register( array( __CLASS__, 'autoload' ) ); self::$util = PhoneNumberUtil::getInstance(); } } return self::$util; } /** * Loads the bundled library classes. * * @param string $class_name Class. */ public static function autoload( $class_name ) { $prefix = 'Dialtidy\\Vendor\\libphonenumber\\'; if ( 0 !== strpos( $class_name, $prefix ) ) { return; } $file = DIALTIDY_PATH . 'vendor/libphonenumber/' . str_replace( '\\', '/', substr( $class_name, strlen( $prefix ) ) ) . '.php'; if ( preg_match( '#^[A-Za-z0-9_/]+$#', substr( $class_name, strlen( $prefix ) ) ) && is_readable( $file ) ) { require_once $file; } } /** * Check without the library: country calling code and 8 to 15 digits. * * @param string $s Cleaned number. * @param string $country Country code or ''. * @return string */ private static function basic_e164( $s, $country ) { if ( '+' === $s[0] ) { $digits = substr( $s, 1 ); } elseif ( 0 === strpos( $s, '00' ) ) { $digits = substr( $s, 2 ); } else { $code = function_exists( 'WC' ) && WC()->countries ? WC()->countries->get_country_calling_code( $country ) : ''; $code = is_array( $code ) ? (string) reset( $code ) : (string) $code; $code = ltrim( $code, '+' ); if ( ! preg_match( '/^\d{1,4}$/', $code ) ) { return ''; } // The national 0 is dropped after the country code, except where it is part of the number (Italy, San Marino, Vatican City). $digits = $code . ( in_array( strtoupper( (string) $country ), array( 'IT', 'SM', 'VA' ), true ) ? $s : ltrim( $s, '0' ) ); } return preg_match( '/^[1-9]\d{7,14}$/', $digits ) ? '+' . $digits : ''; } /** * Replaces decimal digits of other scripts with ASCII digits. * * @param string $s Text. * @return string */ private static function ascii_digits( $s ) { return (string) preg_replace_callback( '/\p{Nd}/u', static function ( $m ) { $cp = self::code_point( $m[0] ); if ( $cp < 128 ) { return $m[0]; } foreach ( self::DIGIT_ZEROS as $zero ) { if ( $cp >= $zero && $cp <= $zero + 9 ) { return (string) ( $cp - $zero ); } } return 'x'; }, $s ); } /** * Unicode code point of one UTF-8 character. * * @param string $c Character. * @return int */ private static function code_point( $c ) { $b = array_values( (array) unpack( 'C*', $c ) ); $n = count( $b ); if ( 1 === $n ) { return (int) $b[0]; } if ( 2 === $n ) { return ( ( $b[0] & 0x1F ) << 6 ) | ( $b[1] & 0x3F ); } if ( 3 === $n ) { return ( ( $b[0] & 0x0F ) << 12 ) | ( ( $b[1] & 0x3F ) << 6 ) | ( $b[2] & 0x3F ); } return ( ( $b[0] & 0x07 ) << 18 ) | ( ( $b[1] & 0x3F ) << 12 ) | ( ( $b[2] & 0x3F ) << 6 ) | ( $b[3] & 0x3F ); } /** * Length in characters. * * @param string $s Text. * @return int */ private static function length( $s ) { return (int) preg_match_all( '/./us', $s ); } }