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