Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.15% covered (success)
93.15%
68 / 73
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
CirrusGeoFeature
93.15% covered (success)
93.15%
68 / 73
66.67% covered (warning)
66.67%
2 / 3
15.07
0.00% covered (danger)
0.00%
0 / 1
 parseGeoNearbyTitle
85.29% covered (warning)
85.29%
29 / 34
0.00% covered (danger)
0.00%
0 / 1
8.20
 parseGeoNearby
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
5
 parseDistance
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace GeoData\Search;
4
5use CirrusSearch\WarningCollector;
6use GeoData\Coord;
7use GeoData\GeoData;
8use GeoData\Globe;
9use MediaWiki\MediaWikiServices;
10
11/**
12 * Trait for geo based features.
13 */
14trait CirrusGeoFeature {
15    /** @var int Default radius, in meters */
16    private static $DEFAULT_RADIUS = 5000;
17
18    /**
19     * radius, if provided, must have either m or km suffix. Valid formats:
20     *   <title>
21     *   <radius>,<title>
22     *
23     * @param WarningCollector $warningCollector
24     * @param string $key Key used to trigger feature
25     * @param string $text user input to parse
26     * @return array{?Coord,int,int|string} Three member array with Coordinate object, integer radius
27     *  in meters, and page id to exclude from results.. When invalid the
28     *  Coordinate returned will be null.
29     */
30    public function parseGeoNearbyTitle( WarningCollector $warningCollector, $key, $text ): array {
31        $titleFactory = MediaWikiServices::getInstance()->getTitleFactory();
32        $title = $titleFactory->newFromText( $text );
33        if ( $title && $title->exists() ) {
34            // Default radius if not provided: 5km
35            $radius = self::$DEFAULT_RADIUS;
36        } else {
37            // If the provided value is not a title try to extract a radius prefix
38            // from the beginning. If $text has a valid radius prefix see if the
39            // remaining text is a valid title to use.
40            $pieces = explode( ',', $text, 2 );
41            if ( count( $pieces ) !== 2 ) {
42                $warningCollector->addWarning(
43                    "geodata-search-feature-invalid-coordinates",
44                    $key, $text
45                );
46                return [ null, 0, '' ];
47            }
48            $radius = self::parseDistance( $pieces[0] );
49            if ( $radius === null ) {
50                $warningCollector->addWarning(
51                    "geodata-search-feature-invalid-distance",
52                    $key, $pieces[0]
53                );
54                return [ null, 0, '' ];
55            }
56            $title = $titleFactory->newFromText( $pieces[1] );
57            if ( !$title || !$title->exists() ) {
58                $warningCollector->addWarning(
59                    "geodata-search-feature-unknown-title",
60                    $key, $pieces[1]
61                );
62                return [ null, 0, '' ];
63            }
64        }
65
66        $pageId = $title->getArticleID();
67        $coord = GeoData::getPageCoordinates( $pageId );
68        if ( !$coord ) {
69            $warningCollector->addWarning(
70                'geodata-search-feature-title-no-coordinates',
71                (string)$title
72            );
73            return [ null, 0, '' ];
74        }
75
76        return [ $coord, $radius, $pageId ];
77    }
78
79    /**
80     * radius, if provided, must have either m or km suffix. Latitude and longitude
81     * must be floats in the domain of [-90:90] for latitude and [-180,180] for
82     * longitude. Valid formats:
83     *   <lat>,<lon>
84     *   <radius>,<lat>,<lon>
85     *
86     * @param WarningCollector $warningCollector
87     * @param string $key
88     * @param string $text
89     * @return array{array{lat:float,lon:float,globe:string}|null,int} Two member array with
90     *  coordinates and integer radius in meters. When invalid the coordinates will be null.
91     */
92    public function parseGeoNearby(
93        WarningCollector $warningCollector,
94        $key,
95        $text
96    ) {
97        $pieces = explode( ',', $text, 3 );
98        // Default radius if not provided: 5km
99        $radius = self::$DEFAULT_RADIUS;
100        if ( count( $pieces ) === 3 ) {
101            $radius = self::parseDistance( $pieces[0] );
102            if ( $radius === null ) {
103                $warningCollector->addWarning(
104                    'geodata-search-feature-invalid-distance',
105                    $key, $pieces[0]
106                );
107                return [ null, 0 ];
108            }
109            [ , $lat, $lon ] = $pieces;
110        } elseif ( count( $pieces ) === 2 ) {
111            [ $lat, $lon ] = $pieces;
112        } else {
113            $warningCollector->addWarning(
114                'geodata-search-feature-invalid-coordinates',
115                $key, $text
116            );
117            return [ null, 0 ];
118        }
119
120        $globe = new Globe( Globe::EARTH );
121        if ( !$globe->coordinatesAreValid( $lat, $lon ) ) {
122            $warningCollector->addWarning(
123                'geodata-search-feature-invalid-coordinates',
124                $key, $text
125            );
126            return [ null, 0 ];
127        }
128
129        return [
130            [ 'lat' => floatval( $lat ), 'lon' => floatval( $lon ), 'globe' => $globe->getName() ],
131            $radius,
132        ];
133    }
134
135    /**
136     * @param string $distance
137     * @return int|null Parsed distance in meters, or null if unparsable
138     */
139    public static function parseDistance( $distance ) {
140        if ( !preg_match( '/^(\d+)(m|km|mi|ft|yd)$/', $distance, $matches ) ) {
141            return null;
142        }
143
144        $scale = [
145            'm' => 1,
146            'km' => 1000,
147            // Supported non-SI units, and their conversions, sourced from
148            // https://en.wikipedia.org/wiki/Unit_of_length#Imperial.2FUS
149            'mi' => 1609.344,
150            'ft' => 0.3048,
151            'yd' => 0.9144,
152        ];
153
154        return max( 10, (int)round( (int)$matches[1] * $scale[$matches[2]] ) );
155    }
156}