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