*/ private static $lists = array(); /** * Hooks. */ public function register() { add_action( self::UPDATE_HOOK, array( __CLASS__, 'update' ) ); add_action( Installer::CLEANUP_HOOK, array( __CLASS__, 'maybe_update' ) ); add_action( 'update_option_' . Settings::OPTION, array( __CLASS__, 'settings_saved' ), 10, 2 ); } /** * Country of the site: the shop's country, else the one of the phone numbers. * * @return string */ public static function site_country() { if ( function_exists( 'WC' ) && WC()->countries ) { $country = Geo::code( (string) WC()->countries->get_base_country() ); if ( '' !== $country ) { return $country; } } return Geo::code( (string) ( Settings::get( 'phone_country' ) ? Settings::get( 'phone_country' ) : Phone::default_country() ) ); } /** * Countries with a list, and how each one is doing. * * @return array */ public static function countries() { $state = get_option( self::STATE, array() ); return is_array( $state ) ? $state : array(); } /** * Fetches the list of the site's country when cities were just turned on, removes all lists * when they were turned off. * * @param mixed $old_value Settings before. * @param mixed $value Settings after. */ public static function settings_saved( $old_value, $value ) { $was = ! empty( $old_value['geo_cities'] ); $now = ! empty( $value['geo_cities'] ); if ( $now && ! $was ) { self::request( self::site_country() ); } elseif ( ! $now && $was ) { self::remove(); } } /** * Asks for the list of a country, downloaded in the background. * * @param string $country Country code. */ public static function request( $country ) { $country = Geo::code( $country ); $known = self::countries(); if ( '' === $country || ! Settings::get( 'geo_cities' ) || isset( $known[ $country ] ) || count( $known ) >= self::MAX_COUNTRIES ) { return; } $known[ $country ] = array( 'built' => 0, 'error' => '', ); update_option( self::STATE, $known, false ); if ( ! wp_next_scheduled( self::UPDATE_HOOK ) ) { wp_schedule_single_event( time() + 10, self::UPDATE_HOOK ); } } /** * Replaces lists that are a few months old, and fetches the one of the site's country. */ public static function maybe_update() { if ( ! Settings::get( 'geo_cities' ) ) { return; } self::request( self::site_country() ); foreach ( self::countries() as $state ) { if ( time() - (int) $state['built'] > self::MAX_AGE && ! wp_next_scheduled( self::UPDATE_HOOK ) ) { wp_schedule_single_event( time() + 10, self::UPDATE_HOOK ); } } } /** * Downloads the lists that are missing or old, then places the recent leads again. */ public static function update() { if ( ! Settings::get( 'geo_cities' ) || get_transient( 'dealroot_postcodes_lock' ) ) { return; } set_transient( 'dealroot_postcodes_lock', 1, 10 * MINUTE_IN_SECONDS ); $fetched = array(); foreach ( self::countries() as $country => $state ) { if ( ( ! self::ready( $country ) || time() - (int) $state['built'] > self::MAX_AGE ) && self::download( $country ) ) { $fetched[] = $country; } } delete_transient( 'dealroot_postcodes_lock' ); if ( $fetched ) { Lead_Place::refine( $fetched ); } } /** * Removes every list. */ public static function remove() { wp_clear_scheduled_hook( self::UPDATE_HOOK ); foreach ( array_keys( self::countries() ) as $country ) { $path = self::path( $country ); if ( '' !== $path && file_exists( $path ) ) { wp_delete_file( $path ); } } delete_option( self::STATE ); self::$lists = array(); } /** * Whether the list of a country is there. * * @param string $country Country code. * @return bool */ public static function ready( $country ) { $path = self::path( $country ); return '' !== $path && is_readable( $path ); } /** * Place of a postcode, or of a town when the postcode is not known. * * @param string $country Country code. * @param string $postcode Postcode as typed, may be empty. * @param string $town Town as typed, may be empty. * @return array{city: string, lat: float, lon: float}|null */ public static function find( $country, $postcode, $town = '' ) { $list = self::load( $country ); if ( ! $list ) { return null; } foreach ( self::keys( $country, $postcode ) as $key ) { if ( isset( $list['p'][ $key ] ) ) { return self::place( $list['p'][ $key ] ); } } $town = self::town_key( $town ); if ( '' !== $town && isset( $list['n'][ $town ] ) ) { return self::place( $list['n'][ $town ] ); } return null; } /** * Ways a postcode may be written in the list, most exact first: "1011 AB" is listed as * 1011 in the Netherlands, "SW1A 1AA" as SW1A in Britain, "1010" stays 1010 in Austria. * * @param string $country Country code. * @param string $postcode Postcode as typed. * @return string[] */ private static function keys( $country, $postcode ) { $code = strtoupper( trim( (string) preg_replace( '/^[A-Z]{1,2}\s*-\s*(?=\d)/i', '', trim( (string) $postcode ) ) ) ); if ( '' === $code ) { return array(); } $keys = array( (string) preg_replace( '/\s+/', '', $code ) ); if ( preg_match( '/^(\d{4})\s*[A-Z]{2}$/', $code, $match ) ) { $keys[] = $match[1]; } if ( preg_match( '/^([A-Z]{1,2}\d[A-Z\d]?)\s*\d[A-Z]{2}$/', $code, $match ) ) { $keys[] = $match[1]; } // A German postcode written with four digits, e.g. "1067" for 01067. if ( 'DE' === $country && preg_match( '/^\d{4}$/', $keys[0] ) ) { $keys[] = '0' . $keys[0]; } return array_unique( $keys ); } /** * A town as a key of the list. * * @param string $town Town. * @return string */ private static function town_key( $town ) { $town = trim( (string) preg_replace( '/\s*\(.*\)$/', '', (string) $town ) ); return '' === $town ? '' : mb_strtolower( $town ); } /** * Place of a list entry. * * @param array $entry Latitude, longitude and name. * @return array */ private static function place( array $entry ) { return array( 'city' => (string) $entry[2], 'lat' => (float) $entry[0], 'lon' => (float) $entry[1], ); } /** * Reads the list of a country. * * @param string $country Country code. * @return array|false */ private static function load( $country ) { $country = Geo::code( $country ); if ( '' === $country ) { return false; } if ( ! isset( self::$lists[ $country ] ) ) { $path = self::path( $country ); $data = '' !== $path && is_readable( $path ) ? json_decode( (string) file_get_contents( $path ), true ) : null; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- A local file of the plugin. self::$lists[ $country ] = is_array( $data ) && isset( $data['p'], $data['n'] ) ? $data : false; } return self::$lists[ $country ]; } /** * Path of the list of a country. * * @param string $country Country code. * @return string */ private static function path( $country ) { $uploads = wp_upload_dir( null, false ); $country = Geo::code( $country ); return empty( $uploads['basedir'] ) || '' === $country ? '' : trailingslashit( $uploads['basedir'] ) . 'dealroot/postcodes-' . strtolower( $country ) . '.json'; } /** * Downloads the list of a country and keeps, for each postcode and each town, the middle of * its places. * * @param string $country Country code. * @return bool */ private static function download( $country ) { require_once ABSPATH . 'wp-admin/includes/file.php'; $path = self::path( $country ); if ( '' === $path || ! wp_mkdir_p( dirname( $path ) ) ) { return self::failed( $country, __( 'The folder for the database could not be created in the uploads folder.', 'dealroot' ) ); } /** * Filters the address of the postcode list of a country. * * @param string $url Address of a ZIP file in the format of GeoNames. * @param string $country Country code. */ $url = (string) apply_filters( 'dealroot_postcodes_url', sprintf( self::SOURCE, $country ), $country ); $download = download_url( $url, 120 ); if ( is_wp_error( $download ) ) { /* translators: %s: error message */ return self::failed( $country, sprintf( __( 'The postcode list could not be downloaded: %s', 'dealroot' ), $download->get_error_message() ) ); } $text = self::unzip( $download, $country . '.txt' ); wp_delete_file( $download ); $sums = array( 'p' => array(), 'n' => array(), ); foreach ( preg_split( '/\r?\n/', (string) $text ) as $line ) { $cols = explode( "\t", $line ); if ( count( $cols ) < 11 || ! is_numeric( $cols[9] ) || ! is_numeric( $cols[10] ) ) { continue; } $name = trim( (string) preg_replace( '/\s*\(.*\)$/', '', $cols[2] ) ); $keys = array( 'p' => strtoupper( (string) preg_replace( '/\s+/', '', $cols[1] ) ), 'n' => mb_strtolower( $name ), ); foreach ( $keys as $kind => $key ) { if ( '' === $key ) { continue; } $lat = (float) $cols[9]; $lon = (float) $cols[10]; if ( ! isset( $sums[ $kind ][ $key ] ) ) { $sums[ $kind ][ $key ] = array( 0.0, 0.0, $name, 0, $lat, $lat, $lon, $lon ); } $sums[ $kind ][ $key ][0] += $lat; $sums[ $kind ][ $key ][1] += $lon; ++$sums[ $kind ][ $key ][3]; $sums[ $kind ][ $key ][4] = min( $sums[ $kind ][ $key ][4], $lat ); $sums[ $kind ][ $key ][5] = max( $sums[ $kind ][ $key ][5], $lat ); $sums[ $kind ][ $key ][6] = min( $sums[ $kind ][ $key ][6], $lon ); $sums[ $kind ][ $key ][7] = max( $sums[ $kind ][ $key ][7], $lon ); } } if ( ! $sums['p'] ) { return self::failed( $country, __( 'The postcode list could not be read.', 'dealroot' ) ); } $list = array( 'p' => array(), 'n' => array(), ); foreach ( $sums as $kind => $entries ) { foreach ( $entries as $key => $sum ) { // A name that several towns far apart share, such as Neustadt, says nothing. if ( 'n' === $kind && ( $sum[5] - $sum[4] > 0.6 || $sum[7] - $sum[6] > 0.6 ) ) { continue; } $list[ $kind ][ $key ] = array( round( $sum[0] / $sum[3], 3 ), round( $sum[1] / $sum[3], 3 ), $sum[2] ); } } $written = file_put_contents( $path, wp_json_encode( $list, JSON_UNESCAPED_UNICODE ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Written by cron, where WP_Filesystem may need credentials. if ( ! $written ) { return self::failed( $country, __( 'The postcode list could not be saved.', 'dealroot' ) ); } $state = self::countries(); $state[ $country ] = array( 'built' => time(), 'error' => '', ); update_option( self::STATE, $state, false ); unset( self::$lists[ $country ] ); return true; } /** * One file of a ZIP archive. * * @param string $archive ZIP file. * @param string $name Name of the file inside. * @return string */ private static function unzip( $archive, $name ) { if ( class_exists( '\ZipArchive' ) ) { $zip = new \ZipArchive(); if ( true === $zip->open( $archive ) ) { $text = $zip->getFromName( $name ); $zip->close(); return false === $text ? '' : $text; } return ''; } // Without the zip extension, WordPress unpacks the archive with its own library. global $wp_filesystem; $folder = get_temp_dir() . 'dealroot-postcodes-' . wp_generate_password( 8, false ); if ( ! $wp_filesystem && ! WP_Filesystem() ) { return ''; } $done = unzip_file( $archive, $folder ); $text = ! is_wp_error( $done ) && $wp_filesystem->exists( $folder . '/' . $name ) ? (string) $wp_filesystem->get_contents( $folder . '/' . $name ) : ''; $wp_filesystem->delete( $folder, true ); return $text; } /** * Keeps the reason a download failed. * * @param string $country Country code. * @param string $message Reason. * @return bool False. */ private static function failed( $country, $message ) { $state = self::countries(); $state[ $country ] = array( 'built' => (int) ( $state[ $country ]['built'] ?? 0 ), 'error' => $message, ); update_option( self::STATE, $state, false ); return false; } }