WordPress.org

Making WordPress.org

Ticket #2823: 2823.diff

File 2823.diff, 33.2 KB (added by dd32, 4 years ago)
  • 1.0/index.php

    function send_response( $response, $ttl 
    147147
    148148        echo wp_json_encode( $response );
    149149}
    150150
    151151/**
    152152 * Guess the location based on a city inside the given input
    153153 *
    154154 * @param string $location_name
    155155 * @param string $timezone
    156156 * @param string $country_code
    157157 *
    158158 * @return false|object false on failure; an object on success
    159159 */
    160160function guess_location_from_city( $location_name, $timezone, $country_code ) {
    161161        $guess = guess_location_from_geonames( $location_name, $timezone, $country_code );
    162         $location_word_count = str_word_count( $location_name );
    163         $location_name_parts = explode( ' ', $location_name );
     162        if ( $guess ) {
     163                return $guess;
     164        }
    164165
    165166        /*
    166167         * Multi-word queries may contain cities, regions, and countries, so try to extract just the city
    167168         *
    168169         * This won't work for most ideographic languages, because they don't use the space character as a word
    169          * delimiter. That's ok, though, because `guess_ideographic_location_from_geonames()` should cover those
    170          * cases.
     170         * delimiter.
    171171         */
     172        $location_name_parts = preg_split( '/\s+/u', $location_name );
     173        $location_word_count = count( $location_name_parts );
     174
    172175        if ( ! $guess && $location_word_count >= 2 ) {
    173176                // Catch input like "Portland Maine"
    174                 $guess = guess_location_from_geonames( $location_name_parts[0], $timezone, $country_code );
     177                $guess = guess_location_from_geonames( $location_name_parts[0], $timezone, $country_code, $wildcard = false );
    175178        }
    176179
    177180        if ( ! $guess && $location_word_count >= 3 ) {
    178181                // Catch input like "Sao Paulo Brazil"
    179182                $city_name = sprintf( '%s %s', $location_name_parts[0], $location_name_parts[1] );
    180                 $guess     = guess_location_from_geonames( $city_name, $timezone, $country_code );
    181         }
    182 
    183         // Normalize all errors to boolean false for consistency
    184         if ( empty ( $guess ) ) {
    185                 $guess = false;
     183                $guess     = guess_location_from_geonames( $city_name, $timezone, $country_code, $wildcard = false );
    186184        }
    187185
    188186        return $guess;
    189187}
    190188
    191189/**
    192190 * Look for the given location in the Geonames database
    193191 *
    194192 * @param string $location_name
    195193 * @param string $timezone
    196194 * @param string $country
    197195 *
    198196 * @return stdClass|null
    199197 */
    200 function guess_location_from_geonames( $location_name, $timezone, $country ) {
     198function guess_location_from_geonames( $location_name, $timezone, $country, $wildcard = true ) {
    201199        global $wpdb;
    202200        // Look for a location that matches the name.
    203201        // The FIELD() orderings give preference to rows that match the country and/or timezone, without excluding rows that don't match.
    204202        // And we sort by population desc, assuming that the biggest matching location is the most likely one.
    205203
    206         // Strip all quotes from the search query, and then enclose it in double quotes, to force an exact literal search
    207         $quoted_location_name = sprintf(
    208                 '"%s"',
    209                 strtr( $location_name, [ '"' => '', "'" => '' ] )
    210         );
    211 
     204        // Exact match
    212205        $row = $wpdb->get_row( $wpdb->prepare( "
    213206                SELECT name, latitude, longitude, country
    214                 FROM geoname
     207                FROM geoname_summary
    215208                WHERE
    216                         MATCH( name, asciiname, alternatenames )
    217                         AGAINST( %s IN BOOLEAN MODE )
     209                        name = %s
    218210                ORDER BY
    219211                        FIELD( %s, country  ) DESC,
    220212                        FIELD( %s, timezone ) DESC,
    221213                        population DESC
    222214                LIMIT 1",
    223                 $quoted_location_name,
     215                $location_name,
    224216                $country,
    225217                $timezone
    226218        ) );
    227219
    228         if ( ! is_a( $row, 'stdClass' ) && 'ASCII' !== mb_detect_encoding( $location_name ) ) {
    229                 $row = guess_location_from_geonames_fallback( $location_name, $country, $timezone, 'exact', 'ideographic' );
    230         }
     220        // Wildcard match
     221        if ( ! $row && $wildcard && 'ASCII' !== mb_detect_encoding( $location_name ) ) {
     222                $row = $wpdb->get_row( $wpdb->prepare( "
     223                        SELECT name, latitude, longitude, country
     224                        FROM geoname_summary
     225                        WHERE
     226                                name LIKE %s
     227                        ORDER BY
     228                                FIELD( %s, country  ) DESC,
     229                                FIELD( %s, timezone ) DESC,
     230                                population DESC
     231                        LIMIT 1",
     232                        $location_name . '%',
     233                        $country,
     234                        $timezone
     235                ) );
     236        }
     237
     238        // Suffix the "State", good in some countries (western countries) horrible in others (where geonames data is not as complete, or  region names are similar (but not quite the same) to city names)
     239        // LEFT JOIN admin1codes ac ON gs.statecode = ac.code
     240        // if ( $row->state && $row->state != $row->name && $row->name NOT CONTAINED WITHIN $row->state? ) {
     241        //      $row->name .= ', ' . $row->state;
     242        // }
    231243
    232244        return $row;
    233245}
    234246
    235247/**
    236  * Look for the given location in the Geonames database using a LIKE query
    237  *
    238  * This is a fallback for situations where the full-text search in `guess_location_from_geonames()` resulted
    239  * in a false-negative.
    240  *
    241  * One situation where this happens is with queries in ideographic languages, because MySQL < 5.7.6 doesn't
    242  * support full-text searches for them, because it can't determine where the word boundaries are.
    243  * See https://dev.mysql.com/doc/refman/5.7/en/fulltext-restrictions.html
    244  *
    245  * There are also edge cases where the exact query doesn't exist in the database, but a loose LIKE query will find
    246  * a similar alternate, like `Osakashi`.
    247  *
    248  * @param string $location_name
    249  * @param string $country
    250  * @param string $timezone
    251  * @param string $mode          'exact' to only return exact matches from the database;
    252  *                              'loose' to return any match. This has a high chance of false positives.
    253  * @param string $restrict_counties 'ideographic' to only search in countries where ideographic languages are common;
    254  *                                  'none' to search all countries
    255  *
    256  * @return stdClass|null
    257  */
    258 function guess_location_from_geonames_fallback( $location_name, $country, $timezone, $mode = 'exact', $restrict_counties = 'ideographic' ) {
    259         global $wpdb;
    260 
    261         $where = $ideographic_countries = $ideographic_country_placeholders = '';
    262 
    263         /*
    264          * The name is wrapped in commas in order to ensure that we're only matching the exact location, which is
    265          * delimited by commas. Otherwise, there would be false positives in situations where `$location_name`
    266          * appears in other rows, which happens sometimes.
    267          *
    268          * Because this will only match entries that are prefixed _and_ postfixed with a comma, it will never match the
    269          * first and last entries in the column. That's ok, though, because the first entry is often an airport code
    270          * in English, which is shorter than `ft_min_word_len` anyway. The last entry is often ideographic, so it'd be nice
    271          * to match it, but this is good enough for now.
    272          */
    273         $escaped_location_name = sprintf(
    274                 'loose' === $mode ? '%%%s%%' : '%%,%s,%%',
    275                 $wpdb->esc_like( $location_name )
    276         );
    277 
    278         $prepare_args = array( $escaped_location_name, $country, $timezone );
    279 
    280         if ( 'ideographic' == $restrict_counties ) {
    281                 $ideographic_countries            = get_ideographic_counties();
    282                 $ideographic_country_placeholders = get_prepare_placeholders( count( $ideographic_countries ), '%s' );
    283 
    284                 $where .= "country IN ( $ideographic_country_placeholders ) AND";
    285 
    286                 $prepare_args = array_merge( $ideographic_countries, $prepare_args );
    287         }
    288 
    289         /*
    290          * REPLACE() is used because sometimes the `alternatenames` column contains entries where the `asciiname` is
    291          * prefixed to an ideographic name; for example: `,Karachi - كراچى,`
    292          *
    293          * If that prefix is not removed, then the LIKE query will fail in those cases, because
    294          * `$escaped_location_name` is wrapped in commas.
    295          *
    296          * The query is restricted to countries where ideographic languages are common, in order to avoid a full-table
    297          * scan.
    298          */
    299         $query = "
    300                 SELECT name, latitude, longitude, country
    301                 FROM `geoname`
    302                 WHERE
    303                         $where
    304                         REPLACE( alternatenames, CONCAT( asciiname, ' - ' ), '' ) LIKE %s
    305                 ORDER BY
    306                         FIELD( %s, country  ) DESC,
    307                         FIELD( %s, timezone ) DESC,
    308                         population DESC
    309                 LIMIT 1";
    310 
    311         $prepared_query = $wpdb->prepare( $query, $prepare_args );
    312 
    313         return $wpdb->get_row( $prepared_query );
    314 }
    315 
    316 /**
    317  * Get an array of countries where ideographic languages are common
    318  *
    319  * Derived from https://en.wikipedia.org/wiki/List_of_writing_systems#List_of_writing_scripts_by_adoption
    320  *
    321  * @todo Some of these individual countries may be able to be removed, to further narrow the rows that need to be
    322  *       scanned by `guess_ideographic_location_from_geonames()`. Some of the entire categories could possibly be
    323  *       removed too, but let's err on the side of caution for now.
    324  */
    325 function get_ideographic_counties() {
    326         $middle_east  = array( 'AE', 'BH', 'CY', 'EG', 'IL', 'IR', 'IQ', 'JO', 'KW', 'LB', 'OM', 'PS', 'QA', 'SA', 'SY', 'TR', 'YE' );
    327         $north_africa = array( 'DZ', 'EH', 'EG', 'LY', 'MA', 'SD', 'SS', 'TN' );
    328 
    329         $abjad_countries       = array_merge( $middle_east, $north_africa, array( 'CN', 'IL', 'IN', 'MY', 'PK' ) );
    330         $abugida_countries     = array( 'BD', 'BT', 'ER', 'ET', 'ID', 'IN', 'KH', 'LA', 'LK', 'MV', 'MY', 'MU', 'MM', 'NP', 'PK', 'SG', 'TH' );
    331         $logographic_countries = array( 'CN', 'JP', 'KR', 'MY', 'SG');
    332 
    333         $all_ideographic_countries = array_merge( $abjad_countries, $abugida_countries, $logographic_countries );
    334 
    335         return array_unique( $all_ideographic_countries );
    336 }
    337 
    338 /**
    339  * Build a string of placeholders to pass to `WPDB::prepare()`
    340  *
    341  * Sometimes it's convenient to be able to generate placeholders for `prepare()` dynamically. For example, when
    342  * looping through a multi-dimensional array where the sub-arrays have distinct counts; or when the total
    343  * number of items is too large to conveniently count by hand.
    344  *
    345  * See https://iandunn.name/2016/03/31/generating-dynamic-placeholders-for-wpdb-prepare/
    346  *
    347  * @param int    $number The number of placeholders needed
    348  * @param string $format An sprintf()-like format accepted by WPDB::prepare()
    349  *
    350  * @return string
    351  */
    352 function get_prepare_placeholders( $number, $format ) {
    353         return implode( ', ', array_fill( 0, $number, $format ) );
    354 }
    355 
    356 /**
    357248 * Determine a location for the given IPv4 address
    358249 *
    359250 * NOTE: The location that is found here cannot be returned to the client.
    360251 *       See `rebuild_location_from_geonames()`.
    361252 *
    362253 * @todo - Add support for IPv6 addresses. Otherwise, this will quickly lose effectiveness. As of March 2017, IPv6
    363254 *         adoption is at 16% globally and rising relatively fast. Some countries are as high as 30%.
    364255 *         See https://www.google.com/intl/en/ipv6/statistics.html#tab=ipv6-adoption for current stats.
    365256 *
    366257 * @todo - Core sends anonymized IPs like `2a03:2880:2110:df07::`, so make sure those work when implementing IPv6
    367258 *
    368259 * @param string $dotted_ip
    369260 *
    370261 * @return null|object `null` on failure; an object on success
    371262 */
    372263function guess_location_from_ip( $dotted_ip ) {
    373264        global $wpdb;
    374265
    375266        $long_ip = ip2long( $dotted_ip );
    376         if ( $long_ip === false )
    377                 return;
     267        if ( $long_ip === false ) {
     268                return false;
     269        }
    378270
    379271        $row = $wpdb->get_row( $wpdb->prepare( "
    380272                SELECT ip_city, ip_latitude, ip_longitude, country_short
    381273                FROM ip2location
    382274                WHERE ip_to >= %d
    383275                ORDER BY ip_to ASC
    384276                LIMIT 1",
    385277                $long_ip
    386278        ) );
    387279
    388280        // Unknown location:
    389281        if ( ! $row || '-' == $row->country_short ) {
    390                 return;
     282                return false;
    391283        }
    392284
    393285        return $row;
    394286}
    395287
    396288/**
    397289 * Rebuild the location given to the client from the event source data
    398290 *
    399291 * We cannot publicly expose location data that we retrieve from the `ip2location` database, because that would
    400292 * violate their licensing terms. We can only use the information internally, for the purposes of completing the
    401293 * program's business logic (determining nearby events).
    402294 *
    403295 * Once we have nearby events, though, we can take advantage of the data that's available in the `wporg_events` table.
    404296 * That table contains the locations details for the event's venue, which was sourced from the respective APIs
    405297 * (WordCamp.org, Meetup.com, etc). We can return the venue's location data without violating any terms.
    function get_location( $args = array() ) 
    447339        if ( isset( $args['country'] ) ) {
    448340                $location = array(
    449341                        'country' => $args['country'],
    450342                );
    451343        }
    452344
    453345        $country_code = get_country_code_from_locale( $args['locale'] ?? '' );
    454346
    455347        // Coordinates provided
    456348        if (
    457349                ! $location && (
    458350                        ! empty( $args['latitude'] )  && is_numeric( $args['latitude'] ) &&
    459351                        ! empty( $args['longitude'] ) && is_numeric( $args['longitude'] )
    460352                )
    461353        ) {
    462                 $city = get_city_from_coordinates( $args['latitude'], $args['longitude'] );
    463 
    464354                $location = array(
    465                         'description' => $city ? $city : "{$args['latitude']}, {$args['longitude']}",
     355                        'description' => false,
    466356                        'latitude'    => $args['latitude'],
    467357                        'longitude'   => $args['longitude']
    468358                );
    469359        }
    470360
    471361        // City was provided by the user:
    472362        if ( ! $location && isset( $args['location_name'] ) ) {
    473363                $guess = guess_location_from_city( $args['location_name'], $args['timezone'] ?? '', $country_code  );
    474364
    475365                if ( $guess ) {
    476366                        $location = array(
    477367                                'description' => $guess->name,
    478368                                'latitude' => $guess->latitude,
    479369                                'longitude' => $guess->longitude,
    480370                                'country' => $guess->country,
    481371                        );
    482372                } else {
    483373                        $guess = guess_location_from_country( $args['location_name'] );
    484374
    485                         if ( ! $location && $guess ) {
     375                        if ( $guess ) {
    486376                                $location = array(
    487377                                        'country'     => $guess['country_short'],
    488378                                        'description' => $guess['country_long'],
    489379                                );
    490380                        }
    491381                }
    492382        }
    493383
    494         /*
    495          * If all else fails, cast a wide net and try to find something before giving up, even
    496          * if the chance of success if lower than normal. Returning false is guaranteed failure, so this improves things
    497          * even if it only works 10% of the time.
    498          *
    499          * This must be done as the very last thing before giving up, because the likelihood of false positives is high.
    500          */
    501         if ( ! $location && isset( $args['location_name'] ) ) {
    502                 if ( 'ASCII' === mb_detect_encoding( $args['location_name'] ) ) {
    503                         $guess = guess_location_from_geonames_fallback( $args['location_name'], $country_code, $args['timezone'] ?? '', 'loose', 'none' );
    504                 } else {
    505                         $guess = guess_location_from_geonames_fallback( $args['location_name'], $country_code, $args['timezone'] ?? '', 'loose', 'ideographic' );
    506                 }
    507 
    508                 if ( $guess ) {
    509                         $location = array(
    510                                 'description' => $guess->name,
    511                                 'latitude'    => $guess->latitude,
    512                                 'longitude'   => $guess->longitude,
    513                                 'country'     => $guess->country,
    514                         );
    515                 }
    516         }
    517 
    518384        if ( ! $location ) {
    519385                if ( isset( $args['location_name'] ) || isset( $args['ip'] ) || ! empty( $args['latitude'] ) || ! empty( $args['longitude'] ) ) {
    520386                        // If any of these are specified, and no localitity was guessed based on the above checks, bail with no location.
    521387                        $location = false;
    522388                } else {
    523389                        // No specific location details.
    524390                        $location = array();
    525391                }
    526392        }
    527393
    528394        // IP:
    529395        if ( ! $location && isset( $args['ip'] ) && ! isset( $args['location_name'] ) ) {
    530396                $guess = guess_location_from_ip( $args['ip'] );
    531397
    532398                if ( $guess ) {
    function get_country_code_from_locale( $ 
    572438 *
    573439 * This isn't perfect because some of the country names in the database are in a format that regular
    574440 * people wouldn't type -- e.g., "Venezuela, Bolvarian Republic Of" -- but this will still match a
    575441 * majority of them.
    576442 *
    577443 * Currently, this only works with English names because that's the only data we have.
    578444 *
    579445 * @param string $location_name
    580446 *
    581447 * @return false|array false on failure; an array with country details on success
    582448 */
    583449function guess_location_from_country( $location_name ) {
    584450        // Check if they entered only the country name, e.g. "Germany" or "New Zealand"
    585451        $country             = get_country_from_name( $location_name );
    586452        $location_word_count = str_word_count( $location_name );
    587         $location_name_parts = explode( ' ', $location_name );
    588         $valid_country_codes = get_valid_country_codes();
     453        $location_name_parts = preg_split( '/\s+/u', $location_name );
    589454
    590455        /*
    591456         * Multi-word queries may contain cities, regions, and countries, so try to extract just the country
    592457         */
    593458        if ( ! $country && $location_word_count >= 2 ) {
    594459                // Catch input like "Vancouver Canada"
    595460                $country_id   = $location_name_parts[ $location_word_count - 1 ];
    596461                $country      = get_country_from_name( $country_id );
    597462        }
    598463
    599464        if ( ! $country && $location_word_count >= 3 ) {
    600465                // Catch input like "Santiago De Los Caballeros, Dominican Republic"
    601466                $country_name = sprintf(
    602467                        '%s %s',
    603468                        $location_name_parts[ $location_word_count - 2 ],
    function guess_location_from_country( $l 
    609474        if ( ! $country && $location_word_count >= 4 ) {
    610475                // Catch input like "Kaga-Bandoro, Central African Republic"
    611476                $country_name = sprintf(
    612477                        '%s %s %s',
    613478                        $location_name_parts[ $location_word_count - 3 ],
    614479                        $location_name_parts[ $location_word_count - 2 ],
    615480                        $location_name_parts[ $location_word_count - 1 ]
    616481                );
    617482                $country = get_country_from_name( $country_name );
    618483        }
    619484
    620485        return $country;
    621486}
    622487
    623488/**
    624  * Get a list of valid country codes
    625  *
    626  * @return array
    627  */
    628 function get_valid_country_codes() {
    629         global $wpdb;
    630 
    631         return $wpdb->get_col( "SELECT DISTINCT country FROM geoname" );
    632 }
    633 
    634 /**
    635489 * Get the country that corresponds to the given country name
    636490 *
    637491 * @param string $country_name
    638492 *
    639493 * @return false|array false on failure; an array with country details on success
    640494 */
    641495function get_country_from_name( $country_name ) {
    642496        global $wpdb;
    643497
    644         $country = $wpdb->get_row( $wpdb->prepare( "
    645                 SELECT country_short, country_long
    646                 FROM ip2location
    647                 WHERE
    648                         country_long  = %s OR
    649                         country_short = %s
    650                 LIMIT 1",
    651                 $country_name,
    652                 $country_name
    653         ), 'ARRAY_A' );
    654 
    655         // Convert all errors to boolean false for consistency
    656         if ( empty( $country ) ) {
    657                 $country = false;
     498        $field = 'name';
     499        if ( strlen( $country_name ) == 2 ) {
     500                $field = 'country';
    658501        }
    659502
    660         return $country;
    661 }
    662 
    663 /**
    664  * Get the name of the city that's closest to the given coordinates
    665  *
    666  * @todo - This can probably be optimized by SELECT'ing from a derived table of the closest rows, instead of the
    667  *         entire table, similar to the technique described at
    668  *         http://www.techfounder.net/2009/02/02/selecting-closest-values-in-mysql/
    669  *         There's only 140k rows in the table, though, so this is performant for now.
    670  *
    671  * NOTE: If this causes any performance issues, it's possible that it could be removed entirely. The Core client
    672  *       saves the location locally, so it could display that instead of using this. However, there were some
    673  *       edge cases early in development that caused us to add this. I don't remember what they were, though, and
    674  *       didn't properly document them in r5128. So, if we ever want to attempt removing this, we'll need to test
    675  *       for unintended side effects. The Core client would need to be updated to display the saved location, so
    676  *       removing this would probably require creating a new version of the endpoint, and leaving this version for
    677  *       older installs.
    678  *
    679  * @param float $latitude
    680  * @param float $longitude
    681  *
    682  * @return false|string
    683  */
    684 function get_city_from_coordinates( $latitude, $longitude ) {
    685         global $wpdb;
    686 
    687         $results = $wpdb->get_col( $wpdb->prepare( "
     503        return $wpdb->get_row( $wpdb->prepare( "
    688504                SELECT
    689                         name,
    690                         ABS( %f - latitude  ) AS latitude_distance,
    691                         ABS( %f - longitude ) AS longitude_distance
    692                 FROM geoname
    693                 HAVING
    694                         latitude_distance  < 0.3 AND    -- 0.3 degrees is about 30 miles
    695                         longitude_distance < 0.3
    696                 ORDER by latitude_distance ASC, longitude_distance ASC
     505                        country as country_short,
     506                        name as country_long
     507                FROM countrycodes
     508                WHERE
     509                        $field = %s
    697510                LIMIT 1",
    698                 $latitude,
    699                 $longitude
    700         ) );
    701 
    702         return isset( $results[0] ) ? $results[0] : false;
     511                $country_name
     512        ), 'ARRAY_A' );
    703513}
    704514
    705515function get_events( $args = array() ) {
    706516        global $wpdb, $cache_life, $cache_group;
    707517
    708518        // Sort to ensure consistent cache keys.
    709519        ksort( $args );
    710520
    711521        // number should be between 0 and 100, with a default of 10.
    712522        $args['number'] = $args['number'] ?? 10;
    713523        $args['number'] = max( 0, min( $args['number'], 100 ) );
    714524
    715525        $cache_key = 'events:' . md5( serialize( $args ) );
    716526        if ( false !== ( $data = wp_cache_get( $cache_key, $cache_group ) ) ) {
    717527                return $data;
  • 1.0/tests/test-index.php

     
    11<?php
    22
    33namespace Dotorg\API\Events;
    44
    55if ( 'cli' !== php_sapi_name() ) {
    66        die();
    77}
    88
     9// For query time stats
     10define( 'SAVEQUERIES', true );
     11
    912/**
    1013 * Main entry point
    1114 */
    1215function run_tests() {
     16        global $wpdb;
    1317        define( 'RUNNING_TESTS', true );
    1418        require_once( dirname( __DIR__ ) . '/index.php' );
    1519
    1620        $failed = 0;
    1721        $failed += test_get_location();
    18         $failed += test_get_city_from_coordinates();
    1922
     23        printf(
     24                "\nTook %f seconds for %d queries (%f/q)",
     25                $sum = array_sum( array_column( $wpdb->queries, 1 ) ),
     26                $count = count( $wpdb->queries ),
     27                $sum/$count
     28        );
    2029        printf( "\n\nFinished running all tests. %d failed.\n", $failed );
     30
    2131}
    2232
    2333/**
    2434 * Output the results of an individual test
    2535 *
    2636 * @param int   $case_id
    2737 * @param bool  $passed
    2838 * @param mixed $expected_result
    2939 * @param mixed $actual_result
    3040 */
    3141function output_results( $case_id, $passed, $expected_result, $actual_result ) {
    3242        printf(
    3343                "\n* %s: %s",
    3444                $case_id,
    3545                $passed ? 'PASSED' : '_FAILED_'
    function get_location_test_cases() { 
    131141                /*
    132142                 * The country name, locale, and timezone are given
    133143                 */
    134144                'country-exonym-1-word' => array(
    135145                        'input' => array(
    136146                                'location_name' => 'Indonesia',
    137147                                'locale'        => 'id_ID',
    138148                                'timezone'      => 'Asia/Jakarta',
    139149                        ),
    140150                        'expected' => array(
    141151                                'country' => 'ID',
    142152                                'description' => 'indonesia',
    143153                        ),
    144154                ),
    145155
    146                 /*
    147                  * This is matching a city inside the country before it the country searches run, but that's ok since it's
    148                  * good enough for our use cases
    149                  */
    150156                'country-exonym-2-words' => array(
    151157                        'input' => array(
    152158                                'location_name' => 'Bosnia and Herzegovina',
    153159                                'locale'        => 'bs_BA',
    154160                                'timezone'      => 'Europe/Sarajevo',
    155161                        ),
    156162                        'expected' => array(
    157                                 'description' => 'pale',
    158                                 'latitude'    => '43.817',
    159                                 'longitude'   => '18.569',
    160                                 'country'     => 'BA'
     163                                'country' => 'BA',
     164                                'description' => 'bosnia and herzegovina',
    161165                        ),
    162166                ),
    163167
    164168
    165169                /*
    166170                 * A location couldn't be found
    167171                 */
    168172                'city-invalid-private-ip' => array(
    169173                        'input' => array(
    170174                                'location_name' => 'Rivendell',
    171175                                'ip'            => '127.0.0.1'
    172176                        ),
    173177                        'expected' => false,
    174178                ),
    175179
    function get_location_test_cases() { 
    265269                        'expected' => array(
    266270                                'description' => 'sydney',
    267271                                'latitude'    => '-33.868',
    268272                                'longitude'   => '151.207',
    269273                                'country'     => 'AU',
    270274                        ),
    271275                ),
    272276
    273277                'city-south-america' => array(
    274278                        'input' => array(
    275279                                'location_name' => 'Sao Paulo',
    276280                                'locale'        => 'pt_BR',
    277281                                'timezone'      => 'America/Sao_Paulo',
    278282                        ),
    279283                        'expected' => array(
    280                                 'description' => 'são paulo',
     284                                'description' => 'sao paulo',
    281285                                'latitude'    => '-23.548',
    282286                                'longitude'   => '-46.636',
    283287                                'country'     => 'BR',
    284288                        ),
    285289                ),
    286290
    287291                // Users will often type them without the dash, bypassing an exact match
    288292                'city-with-dashes-in-formal-name' => array(
    289293                        'input' => array(
    290294                                'location_name' => 'Osakashi',
    291295                                'locale'        => 'ja',
    292296                                'timezone'      => 'Asia/Tokyo',
    293297                        ),
    294298                        'expected' => array(
    295                                 'description' => 'osaka',
     299                                'description' => 'osakashi',
    296300                                'latitude'    => '34.694',
    297301                                'longitude'   => '135.502',
    298302                                'country'     => 'JP',
    299303                        ),
    300304                ),
    301305
    302306                // If a location is provided, the fallback search should be attempted before an IP search
    303307                'fallback-with-public-ip' => array(
    304308                        'input' => array(
    305309                                'location_name' => 'Osakashi',
    306310                                'locale'        => 'ja',
    307311                                'timezone'      => 'Asia/Tokyo',
    308312                                'ip'            => '153.163.68.148', // Tokyo
    309313                        ),
    310314                        'expected' => array(
    311                                 'description' => 'osaka',
     315                                'description' => 'osakashi',
    312316                                'latitude'    => '34.694',
    313317                                'longitude'   => '135.502',
    314318                                'country'     => 'JP',
    315319                        ),
    316320                ),
    317321
    318322                'city-with-apostrophe-in-formal-name' => array(
    319323                        'input' => array(
    320324                                'location_name' => "Coeur d'Alene",
    321325                                'locale'        => 'en_US',
    322326                                'timezone'      => 'America/Los_Angeles',
    323327                        ),
    324328                        'expected' => array(
    325329                                'description' => "coeur d'alene",
    326330                                'latitude'    => '47.678',
    function get_location_test_cases() { 
    343347                        'expected' => array(
    344348                                'description' => "doña ana",
    345349                                'latitude'    => '32.390',
    346350                                'longitude'   => '-106.814',
    347351                                'country'     => 'US',
    348352                        ),
    349353                ),
    350354
    351355                'city-with-diacritics-in-formal-name-but-not-in-query' => array(
    352356                        'input' => array(
    353357                                'location_name' => "Dona Ana",
    354358                                'locale'        => 'en_US',
    355359                                'timezone'      => 'America/Denver',
    356360                        ),
    357361                        'expected' => array(
    358                                 'description' => "doña ana",
     362                                'description' => "dona ana",
    359363                                'latitude'    => '32.390',
    360364                                'longitude'   => '-106.814',
    361365                                'country'     => 'US',
    362366                        ),
    363367                ),
    364368
    365369                'city-with-period-in-query' => array(
    366370                        'input' => array(
    367371                                'location_name' => "St. Louis",
    368372                                'locale'        => 'en_US',
    369373                                'timezone'      => 'America/Chicago',
    370374                        ),
    371375                        'expected' => array(
    372376                                'description' => "st. louis",
    373377                                'latitude'    => '38.627',
    374378                                'longitude'   => '-90.198',
    375379                                'country'     => 'US',
    376380                        ),
    377381                ),
    378382
    379383                'city-with-period-in-formal-name-but-not-in-query' => array(
    380384                        'input' => array(
    381385                                'location_name' => "St Louis",
    382386                                'locale'        => 'en_US',
    383387                                'timezone'      => 'America/Chicago',
    384388                        ),
    385389                        'expected' => array(
    386                                 'description' => "st. louis",
     390                                'description' => "st louis",
    387391                                'latitude'    => '38.627',
    388392                                'longitude'   => '-90.198',
    389393                                'country'     => 'US',
    390394                        ),
    391395                ),
    392396
    393397                /*
    394398                 * The city endonym, locale, and timezone are given
    395399                 *
    396400                 * @todo
    397401                 * This is currently failling. A query from PHP shows row id 2220957 has "Yaound?" instead of
    398402                 * "Yaoundé", but it's correct in the database itself.
    399403                 */
    400404                 'city-endonym-accents-africa' => array(
    401405                        'input' => array(
    function get_location_test_cases() { 
    406410                        'expected' => array(
    407411                                'description' => 'yaoundé',
    408412                                'latitude'    => '3.867',
    409413                                'longitude'   => '11.517',
    410414                                'country'     => 'CM',
    411415                        ),
    412416                ),
    413417
    414418                'city-endonym-non-latin-africa' => array(
    415419                        'input' => array(
    416420                                'location_name' => 'አዲስ አበ',
    417421                                'locale'        => 'am',
    418422                                'timezone'      => 'Africa/Addis_Ababa',
    419423                        ),
    420424                        'expected' => array(
    421                                 'description' => 'addis ababa',
     425                                'description' => 'አዲስ አበባ',
    422426                                'latitude'    => '9.025',
    423427                                'longitude'   => '38.747',
    424428                                'country'     => 'ET',
    425429                        ),
    426430                ),
    427431
    428432                'city-endonym-ideographic-asia1' => array(
    429433                        'input' => array(
    430434                                'location_name' => '白浜町宇佐崎南',
    431435                                'locale'        => 'ja',
    432436                                'timezone'      => 'Asia/Tokyo',
    433437                        ),
    434438                        'expected' => array(
    435                                 'description' => 'shirahamachō-usazakiminami',
     439                                'description' => '白浜町宇佐崎南',
    436440                                'latitude'    => '34.783',
    437441                                'longitude'   => '134.717',
    438442                                'country'     => 'JP',
    439443                        ),
    440444                ),
    441445
    442446                'city-endonym-ideographic-asia2' => array(
    443447                        'input' => array(
    444448                                'location_name' => 'تهران',
    445449                                'locale'        => 'fa_IR',
    446450                                'timezone'      => 'Asia/Tehran',
    447451                        ),
    448452                        'expected' => array(
    449                                 'description' => 'tehran',
     453                                'description' => 'تهران',
    450454                                'latitude'    => '35.694',
    451455                                'longitude'   => '51.422',
    452456                                'country'     => 'IR',
    453457                        ),
    454458                ),
    455459
    456460                'city-endonym-ideographic-asia3' => array(
    457461                        'input' => array(
    458462                                'location_name' => 'كراچى',
    459463                                'locale'        => 'ur',
    460464                                'timezone'      => 'Asia/Karachi',
    461465                        ),
    462466                        'expected' => array(
    463                                 'description' => 'karachi',
     467                                'description' => 'كراچى',
    464468                                'latitude'    => '24.906',
    465469                                'longitude'   => '67.082',
    466470                                'country'     => 'PK',
    467471                        ),
    468472                ),
    469473
    470474                'city-endonym-ideographic-asia4' => array(
    471475                        'input' => array(
    472476                                'location_name' => '京都',
    473477                                'locale'        => 'ja',
    474478                                'timezone'      => 'Asia/Tokyo',
    475479                        ),
    476480                        'expected' => array(
    477                                 'description' => 'kyoto',
     481                                'description' => '京都',
    478482                                'latitude'    => '35.021',
    479483                                'longitude'   => '135.754',
    480484                                'country'     => 'JP',
    481485                        ),
    482486                ),
    483487
    484488                'city-endonym-ideographic-asia5' => array(
    485489                        'input' => array(
    486490                                'location_name' => '東京',
    487491                                'locale'        => 'ja',
    488492                                'timezone'      => 'Asia/Tokyo',
    489493                        ),
    490494                        'expected' => array(
    491                                 'description' => 'tokyo',
     495                                'description' => '東京',
    492496                                'latitude'    => '35.690',
    493497                                'longitude'   => '139.692',
    494498                                'country'     => 'JP',
    495499                        ),
    496500                ),
    497501
    498502                // The database only has 大阪市 ("Osaka-shi"), not 大阪 ("Osaka"), so an exact match will for 大阪 will fail
    499503                'city-endonym-ideographic-municipal-unit-asia' => array(
    500504                        'input' => array(
    501505                                'location_name' => '大阪',
    502506                                'locale'        => 'ja',
    503507                                'timezone'      => 'Asia/Tokyo',
    504508                        ),
    505509                        'expected' => array(
    506                                 'description' => 'osaka',
     510                                'description' => '大阪市',
    507511                                'latitude'    => '34.694',
    508512                                'longitude'   => '135.502',
    509513                                'country'     => 'JP',
    510514                        ),
    511515                ),
    512516
    513517                'city-endonym-europe' => array(
    514518                        'input' => array(
    515519                                'location_name' => 'Wien',
    516520                                'locale'        => 'de_DE',
    517521                                'timezone'      => 'Europe/Berlin',
    518522                        ),
    519523                        'expected' => array(
    520                                 'description' => 'vienna',
     524                                'description' => 'wien',
    521525                                'latitude'    => '48.208',
    522526                                'longitude'   => '16.372',
    523527                                'country'     => 'AT',
    524528                        ),
    525529                ),
    526530
    527531                'city-endonym-europe2' => array(
    528532                        'input' => array(
    529533                                'location_name' => 'Москва',
    530534                                'locale'        => 'ru_RU',
    531535                                'timezone'      => 'Europe/Moscow',
    532536                        ),
    533537                        'expected' => array(
    534                                 'description' => 'moscow',
     538                                'description' => 'Москва',
    535539                                'latitude'    => '55.752',
    536540                                'longitude'   => '37.616',
    537541                                'country'     => 'RU',
    538542                        ),
    539543                ),
    540544
    541545                'city-endonym-accents-north-america' => array(
    542546                        'input' => array(
    543547                                'location_name' => 'Ciudad de México',
    544548                                'locale'        => 'en_MX',
    545549                                'timezone'      => 'America/Mexico_City',
    546550                        ),
    547551                        'expected' => array(
    548                                 'description' => 'mexico city',
     552                                'description' => 'ciudad de méxico',
    549553                                'latitude'    => '19.428',
    550554                                'longitude'   => '-99.128',
    551555                                'country'     => 'MX',
    552556                        ),
    553557                ),
    554558
    555559                'city-endonym-accents-oceania' => array(
    556560                        'input' => array(
    557561                                'location_name' => 'Hagåtña',
    558562                                'locale'        => 'en_US',
    559563                                'timezone'      => 'Pacific/Guam',
    560564                        ),
    561565                        'expected' => array(
    562566                                'description' => 'hagåtña',
    563567                                'latitude'    => '13.476',
    function get_location_test_cases() { 
    674678                        ),
    675679                ),
    676680
    677681                /*
    678682                 * Coordinates should take precedence over IP addresses
    679683                 */
    680684                'coordinates-over-ip-us' => array(
    681685                        'input' => array(
    682686                                'latitude'  => '47.6062100',
    683687                                'longitude' => '-122.3320700',
    684688                                'ip'        => '192.0.70.251',  // San Francisco, USA
    685689                                'timezone'  => 'America/Los_Angeles',
    686690                                'locale'    => 'en_US',
    687691                        ),
    688692                        'expected' => array(
    689                                 'description' => 'seattle',
     693                                'description' => false,
    690694                                'latitude'    => '47.606',
    691695                                'longitude'   => '-122.332',
    692696                        ),
    693697                ),
    694698
    695699                'coordinates-over-ip-africa' => array(
    696700                        'input' => array(
    697701                                'latitude'  => '-19.634233',
    698702                                'longitude' => '17.331767',
    699703                                'ip'        => '41.190.96.5',   // Tsumeb, Namibia
    700704                                'timezone'  => 'Africa/Windhoek',
    701705                                'locale'    => 'af',
    702706                        ),
    703707                        'expected' => array(
    704                                 'description' => 'otavi',
     708                                'description' => false,
    705709                                'latitude'    => '-19.634',
    706710                                'longitude'   => '17.332',
    707711                        ),
    708712                ),
    709713
    710714                /*
    711715                 * Only the IP is given
    712716                 */
    713717                'ip-africa' => array(
    714718                        'input' => array( 'ip' => '41.191.232.22' ),
    715719                        'expected' => array(
    716720                                'description' => 'harare',
    717721                                'latitude'    => '-17.829',
    718722                                'longitude'   => '31.054',
    719723                                'country'     => 'ZW',
    function get_location_test_cases() { 
    768772                'ip-south-america' => array(
    769773                        'input' => array( 'ip' => '181.66.32.136' ),
    770774                        'expected' => array(
    771775                                'description' => 'lima',
    772776                                'latitude'    => '-12.043',
    773777                                'longitude'   => '-77.028',
    774778                                'country'     => 'PE',
    775779                                'internal'    => true,
    776780                        ),
    777781                ),
    778782        );
    779783
    780784         return $cases;
    781785}
    782786
    783 /**
    784  * Test `get_city_from_coordinates()`
    785  *
    786  * @todo This can probably be refactored along with test_get_location() into a more abstract/DRY general-purpose
    787  *       test runner.
    788  *
    789  * @return bool The number of failures
    790  */
    791 function test_get_city_from_coordinates() {
    792         $failed = 0;
    793         $cases  = get_city_from_coordinates_test_cases();
    794 
    795         printf( "\n\nRunning %d city from coordinate tests\n", count( $cases ) );
    796 
    797         foreach ( $cases as $case_id => $case ) {
    798                 $case['input'] = add_cachebusting_parameter( $case['input'] );
    799                 $actual_result = get_city_from_coordinates( $case['input']['latitude'], $case['input']['longitude'] );
    800                 $passed        = $case['expected'] === $actual_result;
    801 
    802                 output_results( $case_id, $passed, $case['expected'], $actual_result );
    803 
    804                 if ( ! $passed ) {
    805                         $failed++;
    806                 }
    807         }
    808 
    809         return $failed;
    810 }
    811 
    812 /**
    813  * Get the cases for testing `get_city_from_coordinates()`
    814  *
    815  * @return array
    816  */
    817 function get_city_from_coordinates_test_cases() {
    818          $cases = array(
    819                 'lower-latitude-higher-longitude' => array(
    820                         'input' => array(
    821                                 'latitude'  => '60.199',
    822                                 'longitude' => '24.660'
    823                         ),
    824                         'expected' => 'Espoo',
    825                 ),
    826 
    827                 'higher-latitude-lower-longitude' => array(
    828                         'input' => array(
    829                                 'latitude'  => '22.000',
    830                                 'longitude' => '95.900'
    831                         ),
    832                         'expected' => 'Mandalay',
    833                 ),
    834 
    835                 'middle-of-no-and-where' => array(
    836                         'input' => array(
    837                                 'latitude'  => '-23.121',
    838                                 'longitude' => '125.071'
    839                         ),
    840                         'expected' => false,
    841                 ),
    842         );
    843 
    844         return $cases;
    845 }
    846 
    847787run_tests();