Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.71% covered (warning)
85.71%
150 / 175
61.54% covered (warning)
61.54%
8 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
CoordinatesParserFunction
85.71% covered (warning)
85.71%
150 / 175
61.54% covered (warning)
61.54%
8 / 13
92.84
0.00% covered (danger)
0.00%
0 / 1
 coordinates
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
5.00
 getLanguage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseArgs
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 addArg
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 applyCoord
44.44% covered (danger)
44.44%
8 / 18
0.00% covered (danger)
0.00%
0 / 1
12.17
 processArgs
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 applyTagArgs
70.27% covered (warning)
70.27%
26 / 37
0.00% covered (danger)
0.00%
0 / 1
24.59
 parseGeoHackArgs
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 parseDim
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 errorText
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 parseCoordinates
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 parseOneCoord
97.22% covered (success)
97.22%
35 / 36
0.00% covered (danger)
0.00%
0 / 1
17
 parseSuffix
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace GeoData;
4
5use Language;
6use MediaWiki\Parser\ParserOutput;
7use MediaWiki\Status\Status;
8use Parser;
9use PPFrame;
10use PPNode;
11
12/**
13 * Handler for the #coordinates parser function
14 */
15class CoordinatesParserFunction {
16    /**
17     * @var Parser used for processing the current coordinates() call
18     */
19    private $parser;
20
21    /**
22     * @var ParserOutput
23     */
24    private $output;
25
26    /** @var (string|true)[] */
27    private $named = [];
28    /** @var string[] */
29    private $unnamed = [];
30
31    /** @var Globe */
32    private $globe;
33
34    /**
35     * #coordinates parser function callback
36     *
37     * @param Parser $parser
38     * @param PPFrame $frame
39     * @param PPNode[] $args
40     * @return mixed
41     */
42    public function coordinates( Parser $parser, PPFrame $frame, array $args ) {
43        $this->parser = $parser;
44        $this->output = $parser->getOutput();
45
46        $this->unnamed = [];
47        $this->named = [];
48        $this->parseArgs( $frame, $args );
49        $this->processArgs();
50        $status = $this->parseCoordinates( $this->unnamed, $this->globe );
51        if ( $status->isGood() ) {
52            $coord = $status->value;
53            $status = $this->applyTagArgs( $coord );
54            if ( $status->isGood() ) {
55                $status = $this->applyCoord( $coord );
56                if ( $status->isGood() ) {
57                    return '';
58                }
59            }
60        }
61
62        $parser->addTrackingCategory( 'geodata-broken-tags-category' );
63        $errorText = $this->errorText( $status );
64        if ( $errorText === '' ) {
65            // Error that doesn't require a message,
66            return '';
67        }
68
69        return [ "<span class=\"error\">{$errorText}</span>", 'noparse' => false ];
70    }
71
72    /**
73     * @return Language Current parsing language
74     */
75    private function getLanguage() {
76        return $this->parser->getContentLanguage();
77    }
78
79    /**
80     * Parses parser function input
81     * @param PPFrame $frame
82     * @param PPNode[] $args
83     */
84    private function parseArgs( $frame, $args ) {
85        $first = trim( $frame->expand( array_shift( $args ) ) );
86        $this->addArg( $first );
87        foreach ( $args as $arg ) {
88            $bits = $arg->splitArg();
89            $value = trim( $frame->expand( $bits['value'] ) );
90            if ( $bits['index'] === '' ) {
91                $this->named[trim( $frame->expand( $bits['name'] ) )] = $value;
92            } else {
93                $this->addArg( $value );
94            }
95        }
96    }
97
98    /**
99     * Add an unnamed parameter to the list, turining it into a named one if needed
100     * @param string $value Parameter
101     */
102    private function addArg( $value ) {
103        $primary = $this->parser->getMagicWordFactory()->get( 'primary' );
104        if ( $primary->match( $value ) ) {
105            $this->named['primary'] = true;
106        } elseif ( preg_match( '/\S+?:\S*?([ _]+\S+?:\S*?)*/', $value ) ) {
107            $this->named['geohack'] = $value;
108        } elseif ( $value != '' ) {
109            $this->unnamed[] = $value;
110        }
111    }
112
113    /**
114     * Applies a coordinate to parser output
115     *
116     * @param Coord $coord
117     * @return Status whether save went OK
118     */
119    private function applyCoord( Coord $coord ) {
120        global $wgMaxCoordinatesPerPage;
121
122        $geoData = CoordinatesOutput::getOrBuildFromParserOutput( $this->output );
123        if ( $wgMaxCoordinatesPerPage >= 0 && $geoData->getCount() >= $wgMaxCoordinatesPerPage ) {
124            if ( $geoData->limitExceeded ) {
125                $geoData->setToParserOutput( $this->output );
126                return Status::newFatal( '' );
127            }
128            $geoData->limitExceeded = true;
129            $geoData->setToParserOutput( $this->output );
130            return Status::newFatal( 'geodata-limit-exceeded',
131                $this->getLanguage()->formatNum( $wgMaxCoordinatesPerPage )
132            );
133        }
134        if ( $coord->primary ) {
135            if ( $geoData->hasPrimary() ) {
136                $geoData->setToParserOutput( $this->output );
137                return Status::newFatal( 'geodata-multiple-primary' );
138            } else {
139                $geoData->addPrimary( $coord );
140            }
141        } else {
142            $geoData->addSecondary( $coord );
143        }
144        $geoData->setToParserOutput( $this->output );
145        return Status::newGood();
146    }
147
148    /**
149     * Merges parameters with decoded GeoHack data, sets default globe
150     */
151    private function processArgs() {
152        global $wgDefaultGlobe;
153        // fear not of overwriting the stuff we've just received from the geohack param,
154        // it has minimum precedence
155        if ( isset( $this->named['geohack'] ) ) {
156            $this->named = array_merge(
157                $this->parseGeoHackArgs( $this->named['geohack'] ), $this->named
158            );
159        }
160        $globe = ( isset( $this->named['globe'] ) && $this->named['globe'] )
161            ? $this->getLanguage()->lc( $this->named['globe'] )
162            : $wgDefaultGlobe;
163
164        $this->globe = new Globe( $globe );
165    }
166
167    /**
168     * @param Coord $coord
169     * @return Status
170     */
171    private function applyTagArgs( Coord $coord ) {
172        global $wgTypeToDim, $wgDefaultDim, $wgGeoDataWarningLevel;
173        $args = $this->named;
174        $coord->primary = isset( $args['primary'] );
175        if ( !$this->globe->isKnown() ) {
176            switch ( $wgGeoDataWarningLevel['unknown globe'] ) {
177                case 'fail':
178                    return Status::newFatal( 'geodata-bad-globe', $coord->globe );
179                case 'warn':
180                    $this->parser->addTrackingCategory( 'geodata-unknown-globe-category' );
181                    break;
182            }
183        }
184        $coord->dim = $wgDefaultDim;
185        if ( isset( $args['type'] ) ) {
186            $coord->type = mb_strtolower( preg_replace( '/\(.*?\).*$/', '', $args['type'] ) );
187            if ( isset( $wgTypeToDim[$coord->type] ) ) {
188                $coord->dim = $wgTypeToDim[$coord->type];
189            } else {
190                switch ( $wgGeoDataWarningLevel['unknown type'] ) {
191                    case 'fail':
192                        return Status::newFatal( 'geodata-bad-type', $coord->type );
193                    case 'warn':
194                        $this->parser->addTrackingCategory( 'geodata-unknown-type-category' );
195                        break;
196                }
197            }
198        }
199        if ( isset( $args['scale'] ) && is_numeric( $args['scale'] ) && $args['scale'] > 0 ) {
200            $coord->dim = intval( (int)$args['scale'] / 10 );
201        }
202        if ( isset( $args['dim'] ) ) {
203            $dim = $this->parseDim( $args['dim'] );
204            if ( $dim !== false ) {
205                $coord->dim = intval( $dim );
206            }
207        }
208        $coord->name = $args['name'] ?? null;
209        if ( isset( $args['region'] ) ) {
210            $code = strtoupper( $args['region'] );
211            if ( preg_match( '/^([A-Z]{2})(?:-([A-Z0-9]{1,3}))?$/', $code, $m ) ) {
212                $coord->country = $m[1];
213                $coord->region = $m[2] ?? null;
214            } else {
215                if ( $wgGeoDataWarningLevel['invalid region'] == 'fail' ) {
216                    return Status::newFatal( 'geodata-bad-region', $args['region'] );
217                } elseif ( $wgGeoDataWarningLevel['invalid region'] == 'warn' ) {
218                    $this->parser->addTrackingCategory( 'geodata-unknown-region-category' );
219                }
220            }
221        }
222        return Status::newGood();
223    }
224
225    /**
226     * @param string $str
227     * @return string[]
228     */
229    private function parseGeoHackArgs( $str ) {
230        $result = [];
231        // per GeoHack docs, spaces and underscores are equivalent
232        $str = str_replace( '_', ' ', $str );
233        foreach ( explode( ' ', $str ) as $arg ) {
234            $keyVal = explode( ':', $arg, 2 );
235            if ( isset( $keyVal[1] ) ) {
236                $result[$keyVal[0]] = $keyVal[1];
237            }
238        }
239        return $result;
240    }
241
242    /**
243     * @param string|int $str
244     * @return string|int|false
245     */
246    private function parseDim( $str ) {
247        if ( is_numeric( $str ) ) {
248            return $str > 0 ? $str : false;
249        }
250        if ( !preg_match( '/^(\d+)(km|m)$/i', $str, $m ) ) {
251            return false;
252        }
253        if ( strtolower( $m[2] ) == 'km' ) {
254            return (int)$m[1] * 1000;
255        }
256        return $m[1];
257    }
258
259    /**
260     * Returns wikitext of status error message in content language
261     *
262     * @param Status $s
263     * @return string
264     */
265    private function errorText( Status $s ) {
266        $errors = array_merge( $s->getErrorsArray(), $s->getWarningsArray() );
267        if ( $errors === [] ) {
268            return '';
269        }
270        $err = $errors[0];
271        $message = array_shift( $err );
272        if ( $message === '' ) {
273            return '';
274        }
275        return wfMessage( $message )->params( $err )->inContentLanguage()->plain();
276    }
277
278    /**
279     * Parses coordinates
280     * See https://en.wikipedia.org/wiki/Template:Coord for sample inputs
281     *
282     * @param string[] $parts Array of coordinate components
283     * @param Globe $globe Globe these coordinates belong to
284     * @return Status Operation status, in case of success its value is a Coord object
285     */
286    private function parseCoordinates( $parts, Globe $globe ) {
287        $latSuffixes = [ 'N' => 1, 'S' => -1 ];
288        $lonSuffixes = [ 'E' => $globe->getEastSign(), 'W' => -$globe->getEastSign() ];
289
290        $count = count( $parts );
291        if ( !is_array( $parts ) || $count < 2 || $count > 8 || ( $count % 2 ) ) {
292            return Status::newFatal( 'geodata-bad-input' );
293        }
294        [ $latArr, $lonArr ] = array_chunk( $parts, $count / 2 );
295
296        $lat = $this->parseOneCoord( $latArr, -90, 90, $latSuffixes );
297        if ( $lat === false ) {
298            return Status::newFatal( 'geodata-bad-latitude' );
299        }
300
301        $lon = $this->parseOneCoord( $lonArr,
302            $globe->getMinLongitude(),
303            $globe->getMaxLongitude(),
304            $lonSuffixes
305        );
306        if ( $lon === false ) {
307            return Status::newFatal( 'geodata-bad-longitude' );
308        }
309        return Status::newGood( new Coord( (float)$lat, (float)$lon, $globe->getName() ) );
310    }
311
312    /**
313     * @param string[] $parts
314     * @param float $min
315     * @param float $max
316     * @param int[] $suffixes
317     * @return float|false
318     */
319    private function parseOneCoord( $parts, $min, $max, $suffixes ) {
320        $count = count( $parts );
321        $multiplier = 1;
322        $value = 0;
323        $alreadyFractional = false;
324
325        $currentMin = $min;
326        $currentMax = $max;
327
328        $language = $this->getLanguage();
329        for ( $i = 0; $i < $count; $i++ ) {
330            $part = $parts[$i];
331            if ( $i > 0 && $i == $count - 1 ) {
332                $suffix = self::parseSuffix( $part, $suffixes );
333                if ( $suffix ) {
334                    if ( $value < 0 ) {
335                        // "-60°S sounds weird, doesn't it?
336                        return false;
337                    }
338                    $value *= $suffix;
339                    break;
340                } elseif ( $i == 3 ) {
341                    return false;
342                }
343            }
344            // 20° 15.5' 20" is wrong
345            if ( $alreadyFractional && $part ) {
346                return false;
347            }
348            if ( !is_numeric( $part ) ) {
349                $part = $language->parseFormattedNumber( $part );
350            }
351
352            if ( !is_numeric( $part )
353                 || $part < $currentMin
354                 || $part > $currentMax ) {
355                return false;
356            }
357            // Use these limits in the next iteration
358            $currentMin = 0;
359            $currentMax = 59.99999999;
360
361            $alreadyFractional = $part != intval( $part );
362            $value += (float)$part * $multiplier * Math::sign( $value );
363            $multiplier /= 60;
364        }
365        if ( $min == 0 && $value < 0 ) {
366            $value = $max + $value;
367        }
368        if ( $value < $min || $value > $max ) {
369            return false;
370        }
371        return (float)$value;
372    }
373
374    /**
375     * Parses coordinate suffix such as N, S, E or W
376     *
377     * @param string $str String to test
378     * @param int[] $suffixes
379     * @return int Sign modifier or 0 if not a suffix
380     */
381    private function parseSuffix( $str, array $suffixes ) {
382        $str = $this->getLanguage()->uc( trim( $str ) );
383        return $suffixes[$str] ?? 0;
384    }
385}