Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.00% covered (warning)
80.00%
48 / 60
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
MapTagArgumentValidator
80.00% covered (warning)
80.00%
48 / 60
33.33% covered (danger)
33.33%
3 / 9
43.25
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 parseArgs
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
15
 parseGroups
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 hasCoordinates
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 usesAutoPosition
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValidLanguageCode
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 getLanguageCodeWithDefaultFallback
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 setFirstMarkerProperties
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 getTextWithFallback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace Kartographer\Tag;
4
5use Language;
6use MediaWiki\Config\Config;
7use MediaWiki\Languages\LanguageNameUtils;
8use StatusValue;
9use stdClass;
10
11/**
12 * Validator and preprocessor for all arguments that can be used in <mapframe> and <maplink> tags.
13 * As of now this class intentionally knows everything about both tags.
14 *
15 * @license MIT
16 */
17class MapTagArgumentValidator {
18
19    public StatusValue $status;
20    private Tag $args;
21    private Config $config;
22    private Language $defaultLanguage;
23    private ?LanguageNameUtils $languageCodeValidator;
24
25    public ?float $lat;
26    public ?float $lon;
27    /** @var int|null Typically a number from 0 to 19 */
28    public ?int $zoom;
29    /** @var string One of "osm-intl" or "osm" */
30    public string $mapStyle;
31    /** @var string|null Number of pixels (without a unit) or "full" */
32    public ?string $width = null;
33    public ?int $height;
34    /** @var string One of "left", "center", "right", or "none" */
35    public string $align;
36    public bool $frameless;
37    public string $cssClass;
38    /** @var string|null Language code as specified by the user, null if none or invalid */
39    public ?string $specifiedLangCode = null;
40    public ?string $text;
41    private ?string $fallbackText = null;
42    public string $firstMarkerColor = '';
43
44    /**
45     * @var string|null Currently parsed group identifier from the group="…" attribute. Only allowed
46     *  in â€¦WikivoyageMode. Otherwise a private, auto-generated identifier starting with "_".
47     */
48    public ?string $groupId = null;
49    /** @var string[] List of group identifiers to show */
50    public array $showGroups = [];
51
52    /**
53     * @param string $tag Tag name, e.g. "maplink"
54     * @param array<string,string> $args
55     * @param Config $config
56     * @param Language $defaultLanguage
57     * @param LanguageNameUtils|null $languageCodeValidator
58     */
59    public function __construct(
60        string $tag,
61        array $args,
62        Config $config,
63        Language $defaultLanguage,
64        LanguageNameUtils $languageCodeValidator = null
65    ) {
66        $this->status = StatusValue::newGood();
67        $this->args = new Tag( $tag, $args, $this->status );
68        $this->config = $config;
69        $this->defaultLanguage = $defaultLanguage;
70        $this->languageCodeValidator = $languageCodeValidator;
71
72        $this->parseArgs();
73        if ( $config->get( 'KartographerWikivoyageMode' ) ) {
74            $this->parseGroups();
75        }
76    }
77
78    private function parseArgs(): void {
79        // Required arguments
80        if ( $this->args->name === LegacyMapFrame::TAG ) {
81            foreach ( [ 'width', 'height' ] as $required ) {
82                if ( !$this->args->has( $required ) ) {
83                    $this->status->fatal( 'kartographer-error-missing-attr', $required );
84                }
85            }
86
87            // @todo: should these have defaults?
88            $this->width = $this->args->getString( 'width', '/^(\d+|([1-9]\d?|100)%|full)$/' );
89            $this->height = $this->args->getInt( 'height' );
90
91            // @todo: deprecate old syntax completely
92            if ( $this->width && str_ends_with( $this->width, '%' ) ) {
93                $this->width = $this->width === '100%' ? 'full' : '300';
94            }
95        }
96
97        // Arguments valid for both <mapframe> and <maplink>
98        $this->lat = $this->args->getFloat( 'latitude' );
99        $this->lon = $this->args->getFloat( 'longitude' );
100        if ( $this->status->isOK() && ( ( $this->lat === null ) xor ( $this->lon === null ) ) ) {
101            $this->lat = null;
102            $this->lon = null;
103            $this->status->fatal( 'kartographer-error-latlon' );
104        }
105
106        $this->zoom = $this->args->getInt( 'zoom' );
107        $regexp = '/^(' . implode( '|', $this->config->get( 'KartographerStyles' ) ) . ')$/';
108        $defaultStyle = $this->config->get( 'KartographerDfltStyle' );
109        $this->mapStyle = $this->args->getString( 'mapstyle', $regexp ) ?? $defaultStyle;
110        $this->text = $this->args->getString( 'text' );
111
112        $lang = $this->args->getString( 'lang' );
113        // If the specified language code is invalid, behave as if no language was specified
114        if ( $lang && $this->isValidLanguageCode( $lang ) ) {
115            $this->specifiedLangCode = $lang;
116        }
117
118        // Arguments valid only for one of the two tags, but all optional anyway
119        if ( $this->width === 'full' ) {
120            $this->align = 'none';
121        } elseif ( $this->width !== null ) {
122            $defaultAlign = $this->defaultLanguage->alignEnd();
123            $this->align = $this->args->getString( 'align', '/^(left|center|right)$/' ) ?? $defaultAlign;
124        }
125        $this->frameless = ( $this->text === null || $this->text === '' ) &&
126            $this->args->getString( 'frameless' ) !== null;
127        $this->cssClass = $this->args->getString( 'class', '/^([a-z][\w-]*)?$/i' ) ?? '';
128    }
129
130    private function parseGroups(): void {
131        $this->groupId = $this->args->getString( 'group', '/^[\w ]+$/u' );
132
133        $show = $this->args->getString( 'show', '/^([\w ]+(\s*,\s*+[\w ]+)*)?$/u' );
134        if ( $show ) {
135            $this->showGroups = array_map( 'trim', explode( ',', $show ) );
136        }
137
138        // Make sure the current group is shown for this map, even if there is no geojson
139        // Private group will be added during the save, as it requires hash calculation
140        if ( $this->groupId !== null ) {
141            $this->showGroups[] = $this->groupId;
142        }
143
144        // Make sure there are no group name duplicates
145        $this->showGroups = array_values( array_unique( $this->showGroups ) );
146    }
147
148    /**
149     * @return bool If a complete pair of coordinates is given, e.g. to render a <maplink> label
150     */
151    public function hasCoordinates(): bool {
152        return $this->lat !== null && $this->lon !== null;
153    }
154
155    /**
156     * @return bool If the map relies on Kartotherian's auto-position feature (extracting a bounding
157     *  box from the GeoJSON) instead of the arguments alone (coordinates and zoom)
158     */
159    public function usesAutoPosition(): bool {
160        return $this->zoom === null || !$this->hasCoordinates();
161    }
162
163    private function isValidLanguageCode( string $code ): bool {
164        return $code === 'local' ||
165            // Everything is valid without a validator, should only be used in test scenarios
166            !$this->languageCodeValidator ||
167            $this->languageCodeValidator->isKnownLanguageTag( $code );
168    }
169
170    public function getLanguageCodeWithDefaultFallback(): string {
171        return $this->specifiedLangCode ??
172            ( $this->config->get( 'KartographerUsePageLanguage' ) ?
173                $this->defaultLanguage->getCode() :
174                'local'
175            );
176    }
177
178    public function setFirstMarkerProperties( ?string $fallbackText, stdClass $properties ): void {
179        $this->fallbackText = $fallbackText;
180
181        if ( $this->config->get( 'KartographerUseMarkerStyle' ) &&
182            isset( $properties->{'marker-color'} ) &&
183            // JsonSchema already validates this value for us, however this regex will also fail
184            // if the color is invalid
185            preg_match( '/^#?((?:[\da-f]{3}){1,2})$/i', $properties->{'marker-color'}, $m )
186        ) {
187            // Simplestyle allows colors "with or without the # prefix". Enforce it here.
188            $this->firstMarkerColor = '#' . $m[1];
189        }
190    }
191
192    public function getTextWithFallback(): ?string {
193        return $this->text ?? $this->fallbackText;
194    }
195
196}