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\GeoData;
7use GeoData\Globe;
8use MediaWiki\Config\Config;
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 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        $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 Config $config
88     * @param string $key
89     * @param string $text
90     * @return array Two member array with Coordinate object, and integer radius
91     *  in meters. When invalid the Coordinate returned will be null.
92     */
93    public function parseGeoNearby(
94        WarningCollector $warningCollector,
95        Config $config,
96        $key,
97        $text
98    ) {
99        $pieces = explode( ',', $text, 3 );
100        // Default radius if not provided: 5km
101        $radius = self::$DEFAULT_RADIUS;
102        if ( count( $pieces ) === 3 ) {
103            $radius = self::parseDistance( $pieces[0] );
104            if ( $radius === null ) {
105                $warningCollector->addWarning(
106                    'geodata-search-feature-invalid-distance',
107                    $key, $pieces[0]
108                );
109                return [ null, 0 ];
110            }
111            [ , $lat, $lon ] = $pieces;
112        } elseif ( count( $pieces ) === 2 ) {
113            [ $lat, $lon ] = $pieces;
114        } else {
115            $warningCollector->addWarning(
116                'geodata-search-feature-invalid-coordinates',
117                $key, $text
118            );
119            return [ null, 0 ];
120        }
121
122        $globe = new Globe( $config->get( 'DefaultGlobe' ) );
123        if ( !$globe->coordinatesAreValid( $lat, $lon ) ) {
124            $warningCollector->addWarning(
125                'geodata-search-feature-invalid-coordinates',
126                $key, $text
127            );
128            return [ null, 0 ];
129        }
130
131        return [
132            [ 'lat' => floatval( $lat ), 'lon' => floatval( $lon ), 'globe' => $globe->getName() ],
133            $radius,
134        ];
135    }
136
137    /**
138     * @param string $distance
139     * @return int|null Parsed distance in meters, or null if unparsable
140     */
141    public static function parseDistance( $distance ) {
142        if ( !preg_match( '/^(\d+)(m|km|mi|ft|yd)$/', $distance, $matches ) ) {
143            return null;
144        }
145
146        $scale = [
147            'm' => 1,
148            'km' => 1000,
149            // Supported non-SI units, and their conversions, sourced from
150            // https://en.wikipedia.org/wiki/Unit_of_length#Imperial.2FUS
151            'mi' => 1609.344,
152            'ft' => 0.3048,
153            'yd' => 0.9144,
154        ];
155
156        return max( 10, (int)round( (int)$matches[1] * $scale[$matches[2]] ) );
157    }
158}