Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.79% covered (warning)
85.79%
157 / 183
61.54% covered (warning)
61.54%
8 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
CoordinatesParserFunction
85.79% covered (warning)
85.79%
157 / 183
61.54% covered (warning)
61.54%
8 / 13
92.57
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
50.00% covered (danger)
50.00%
10 / 20
0.00% covered (danger)
0.00%
0 / 1
10.50
 processArgs
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 applyTagArgs
72.09% covered (warning)
72.09%
31 / 43
0.00% covered (danger)
0.00%
0 / 1
23.28
 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\MediaWikiServices;
7use MediaWiki\Parser\Parser;
8use MediaWiki\Parser\ParserOutput;
9use MediaWiki\Status\Status;
10use PPFrame;
11use PPNode;
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 $parser;
21    private ParserOutput $output;
22
23    /** @var (string|true)[] */
24    private $named = [];
25    /** @var string[] */
26    private $unnamed = [];
27
28    /** @var Globe */
29    private $globe;
30
31    /**
32     * #coordinates parser function callback
33     *
34     * @param Parser $parser
35     * @param PPFrame $frame
36     * @param PPNode[] $args
37     * @return mixed
38     */
39    public function coordinates( Parser $parser, PPFrame $frame, array $args ) {
40        $this->parser = $parser;
41        $this->output = $parser->getOutput();
42
43        $this->unnamed = [];
44        $this->named = [];
45        $this->parseArgs( $frame, $args );
46        $this->processArgs();
47        $status = $this->parseCoordinates( $this->unnamed, $this->globe );
48        if ( $status->isGood() ) {
49            $coord = $status->value;
50            $status = $this->applyTagArgs( $coord );
51            if ( $status->isGood() ) {
52                $status = $this->applyCoord( $coord );
53                if ( $status->isGood() ) {
54                    return '';
55                }
56            }
57        }
58
59        $parser->addTrackingCategory( 'geodata-broken-tags-category' );
60        $errorText = $this->errorText( $status );
61        if ( $errorText === '' ) {
62            // Error that doesn't require a message,
63            return '';
64        }
65
66        return [ "<span class=\"error\">{$errorText}</span>", 'noparse' => false ];
67    }
68
69    /**
70     * @return Language Current parsing language
71     */
72    private function getLanguage() {
73        return $this->parser->getContentLanguage();
74    }
75
76    /**
77     * Parses parser function input
78     * @param PPFrame $frame
79     * @param PPNode[] $args
80     */
81    private function parseArgs( $frame, $args ) {
82        $first = trim( $frame->expand( array_shift( $args ) ) );
83        $this->addArg( $first );
84        foreach ( $args as $arg ) {
85            $bits = $arg->splitArg();
86            $value = trim( $frame->expand( $bits['value'] ) );
87            if ( $bits['index'] === '' ) {
88                $this->named[trim( $frame->expand( $bits['name'] ) )] = $value;
89            } else {
90                $this->addArg( $value );
91            }
92        }
93    }
94
95    /**
96     * Add an unnamed parameter to the list, turining it into a named one if needed
97     * @param string $value Parameter
98     */
99    private function addArg( $value ) {
100        $primary = $this->parser->getMagicWordFactory()->get( 'primary' );
101        if ( $primary->match( $value ) ) {
102            $this->named['primary'] = true;
103        } elseif ( preg_match( '/\S+?:\S*?([ _]+\S+?:\S*?)*/', $value ) ) {
104            $this->named['geohack'] = $value;
105        } elseif ( $value != '' ) {
106            $this->unnamed[] = $value;
107        }
108    }
109
110    /**
111     * Applies a coordinate to parser output
112     *
113     * @param Coord $coord
114     * @return Status whether save went OK
115     */
116    private function applyCoord( Coord $coord ) {
117        $config = MediaWikiServices::getInstance()->getMainConfig();
118        $maxCoordinatesPerPage = $config->get( 'MaxCoordinatesPerPage' );
119        $geoData = CoordinatesOutput::getOrBuildFromParserOutput( $this->output );
120        if ( $maxCoordinatesPerPage >= 0 && $geoData->getCount() >= $maxCoordinatesPerPage ) {
121            if ( $geoData->limitExceeded ) {
122                $geoData->setToParserOutput( $this->output );
123                return Status::newFatal( '' );
124            }
125            $geoData->limitExceeded = true;
126            $geoData->setToParserOutput( $this->output );
127            return Status::newFatal( 'geodata-limit-exceeded',
128                $this->getLanguage()->formatNum( $maxCoordinatesPerPage )
129            );
130        }
131        if ( $coord->primary ) {
132            if ( $geoData->hasPrimary() ) {
133                $geoData->setToParserOutput( $this->output );
134                return Status::newFatal( 'geodata-multiple-primary' );
135            } else {
136                $geoData->addPrimary( $coord );
137            }
138        } else {
139            $geoData->addSecondary( $coord );
140        }
141        $geoData->setToParserOutput( $this->output );
142        return Status::newGood();
143    }
144
145    /**
146     * Merges parameters with decoded GeoHack data, sets default globe
147     */
148    private function processArgs(): void {
149        // fear not of overwriting the stuff we've just received from the geohack param,
150        // it has minimum precedence
151        if ( isset( $this->named['geohack'] ) ) {
152            $this->named = array_merge(
153                $this->parseGeoHackArgs( $this->named['geohack'] ), $this->named
154            );
155        }
156        $globe = ( isset( $this->named['globe'] ) && $this->named['globe'] )
157            ? $this->getLanguage()->lc( $this->named['globe'] )
158            : Globe::EARTH;
159
160        $this->globe = new Globe( $globe );
161    }
162
163    /**
164     * @param Coord $coord
165     * @return Status
166     */
167    private function applyTagArgs( Coord $coord ) {
168        $config = MediaWikiServices::getInstance()->getMainConfig();
169        $typeToDim = $config->get( 'TypeToDim' );
170        $defaultDim = $config->get( 'DefaultDim' );
171        $geoDataWarningLevel = $config->get( 'GeoDataWarningLevel' );
172
173        $args = $this->named;
174        $coord->primary = isset( $args['primary'] );
175        if ( !$this->globe->isKnown() ) {
176            switch ( $geoDataWarningLevel['unknown globe'] ?? null ) {
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 = $defaultDim;
185        if ( isset( $args['type'] ) ) {
186            $coord->type = mb_strtolower( preg_replace( '/\(.*?\).*$/', '', $args['type'] ) );
187            if ( isset( $typeToDim[$coord->type] ) ) {
188                $coord->dim = $typeToDim[$coord->type];
189            } else {
190                switch ( $geoDataWarningLevel['unknown type'] ?? null ) {
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                switch ( $geoDataWarningLevel['invalid region'] ?? null ) {
216                    case 'fail':
217                        return Status::newFatal( 'geodata-bad-region', $args['region'] );
218                    case 'warn':
219                        $this->parser->addTrackingCategory( 'geodata-unknown-region-category' );
220                        break;
221                }
222            }
223        }
224        return Status::newGood();
225    }
226
227    /**
228     * @param string $str
229     * @return array<string,string>
230     */
231    private function parseGeoHackArgs( $str ): array {
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 ): string {
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        [ $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 array<string,int> $suffixes
319     * @return float|false
320     */
321    private function parseOneCoord( $parts, $min, $max, array $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;
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 array<string,int> $suffixes
381     * @return int Sign modifier or 0 if not a suffix
382     */
383    private function parseSuffix( $str, array $suffixes ): int {
384        $str = $this->getLanguage()->uc( trim( $str ) );
385        return $suffixes[$str] ?? 0;
386    }
387}