Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
41.75% covered (danger)
41.75%
119 / 285
35.71% covered (danger)
35.71%
5 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChessBrowser
41.75% covered (danger)
41.75%
119 / 285
35.71% covered (danger)
35.71%
5 / 14
608.06
0.00% covered (danger)
0.00%
0 / 1
 newGame
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 assertValidPGN
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 decideResultString
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 createBoard
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
4
 newPosition
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
6
 assertValidFEN
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 parseArguments
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 generatePieces
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 getLocalizedLabels
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 getLocalizedLegendLabels
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
6
 getMoveSet
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
42
 getVariationSet
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 getMetadata
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 createPiece
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2/**
3 * This file is a part of ChessBrowser.
4 *
5 * ChessBrowser is free software: you can redistribute it and/or modify
6 *  it under the terms of the GNU General Public License as published by
7 *  the Free Software Foundation, either version 3 of the License, or
8 *  (at your option) any later version.
9 *
10 *  This program is distributed in the hope that it will be useful,
11 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
12 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 *  GNU General Public License for more details.
14 *
15 *  You should have received a copy of the GNU General Public License
16 *  along with this program.  If not, see <https://www.gnu.org/licenses/>.
17 *
18 * @file ChessBrowser
19 * @ingroup ChessBrowser
20 * @author Wugapodes
21 */
22
23namespace MediaWiki\Extension\ChessBrowser;
24
25use Exception;
26use MediaWiki\Extension\ChessBrowser\PgnParser\FenParser0x88;
27use MediaWiki\Html\TemplateParser;
28use MediaWiki\Parser\Parser;
29use PPFrame;
30
31class ChessBrowser {
32    /**
33     * @since 0.1.0
34     * @param string $input The wikitext placed between pgn tags
35     * @param array $args Arguments passed as xml attributes
36     * @param Parser $parser The MediaWiki parser object
37     * @param PPFrame $frame Parent frame, provides context of the tage placement
38     * @return array
39     */
40    public static function newGame( $input, array $args, Parser $parser, PPFrame $frame ): array {
41        try {
42            self::assertValidPGN( $input );
43
44            $out = $parser->getOutput();
45            // Get number of games so div id property is unique
46            $gameNum = $out->getExtensionData( 'ChessViewerNumGames' ) ?? 0;
47            $gameNum++;
48
49            $board = self::createBoard( $input, $gameNum, $args );
50
51            // Set after the parsing, etc. in case there is an error
52            // Set variable so resource loader knows whether to send javascript
53            $out->setExtensionData( 'ChessViewerTrigger', 'true' );
54            // Increment number of games
55            $out->setExtensionData( 'ChessViewerNumGames', $gameNum );
56
57            return [ $board, "markerType" => "nowiki" ];
58        } catch ( Exception $e ) {
59            wfDebugLog(
60                'ChessBrowser',
61                'Unable to create a game: ' . $e
62            );
63            $parser->addTrackingCategory( 'chessbrowser-invalid-category' );
64            $message = wfMessage( 'chessbrowser-invalid-message' )->escaped();
65            return [ $message ];
66        }
67    }
68
69    /**
70     * Check if tag cotains valid input format PGN
71     *
72     * The input PGN is checked by various regular expressions to get a rough
73     *  sense of whether the PGN is valid before devoting the parsing resources.
74     *
75     * The validation is a series of filters which remove sections that the
76     *  parser can understand. The PGN is valid if the entire input gets
77     *  filtered and is invalid if there is some part of the string that
78     *  remains after the filtering.
79     *
80     * @param string $input
81     * @throws ChessBrowserException if invalid
82     */
83    private static function assertValidPGN( string $input ) {
84        // Validate escaped lines (PGN Standard 6) identified by a % sign at
85        //  start of line and ignoring all following text on the line. A %
86        //  anywhere else but the start of line has no meaning.
87        $escapedLine = '/^%.*?\n/m';
88        $input = preg_replace( $escapedLine, "", $input );
89
90        // Validate tag pairs (PGN Standard 8.1). Composed of four tokens:
91        //   left bracket, ie: [
92        //   symbol token, a sequence comprising only alphanumeric chars and underscore
93        //   string token, a symbol token delimited by double quotes
94        //   right bracket, ie: ]
95        // Input format allows any ammount of whitespace to separate these tokens.
96        $tagPairs = '/\[\s*[a-zA-Z0-9_]+\s*".*"\s*\]/';
97        $input = preg_replace( $tagPairs, "", $input );
98
99        // Validate comments (PGN Standard 5) and move variations (idem 8.2.5).
100        //   Inline comments are delimited by braces
101        //   Rest-of-line comments are delimited by ; and a newline, EOL, or EOF
102        //   Variations are delimited by parentheses
103        //
104        // Single line or else multi-line comments fail, see T370499
105        $annotations = '/({.*?}|\(.*?\)|;.*?(\n|$|\Z))/s';
106        $input = preg_replace( $annotations, "", $input );
107
108        // Validate Numeric Annotation Glyphs (NAGs; PGN Standard 10). Composed of:
109        //   a leading dollar sign ($)
110        //   a non-negative decimal integer between 0 and 255
111        // We do not check that the NAG is proper, only that it has the same number of
112        //  digits as a proper NAG, i.e., not greater than a thousand.
113        $NAGs = '/\$\d{1,3}/';
114        $input = preg_replace( $NAGs, "", $input );
115
116        // Validate game termination markers (PGN Standard 8.2.6). One of four values:
117        //   1-0
118        //   0-1
119        //   1/2-1/2
120        //   *
121        // We allow a great deal of leeway in the separator accepting any non-alphanumeric
122        //  value (\W). This is to accomodate en- or em-dashes, periods, spaces, etc
123        //  without a complex pre-processing overhead.
124        // The game will not validate if there is more than one termination marker.
125        $termCount = 0;
126        $limit = -1;
127        $termination = '/(1\W0|0\W1|1\/2\W1\/2|\*)/';
128        $input = preg_replace( $termination, "", $input, $limit, $termCount );
129        if ( $termCount > 1 ) {
130            throw new ChessBrowserException( 'Too many termination tokens.' );
131        }
132
133        // Validate move number indicators (PGN Standard 8.2.2) if they exist. Move
134        // numbers are composed of:
135        //   a leading non-alphanumeric character (\W) which terminates the previous token
136        //   one or more digits (\d+)
137        //   any amount of whitespace (\s*)
138        //   any number of periods (\.*)
139        // Input PGN format is extremely forgiving with regards to the last two criteria
140        $moveNumbers = '/\W\d+\s*\.*/';
141        // Make sure SAN starts with a space so that first move number is matched
142        $input = " " . $input;
143        $input = preg_replace( $moveNumbers, "", $input );
144
145        // Validate standard algebraic notation (SAN; PGN Standard 8.2.3). As these denote
146        // moves on a chess board, their composition is restricted to:
147        //   files (a-h)
148        //   ranks (1-8)
149        //   the capture indicator (x)
150        //   piece indicators (BNRKQ)
151        //   promotion indictor (=)
152        //   check (+) and checkmate(#)
153        //   components of a castling marker (O-O and O-O-O)
154        // The minimum length of a SAN token is 2 characters, denoting a pawn move.
155        // The theoretical maximum is 7, but we do not check the upper bound.
156        $SAN = '/[a-hxOBNRKQ1-8=+#\-]{2,}/';
157        $input = preg_replace( $SAN, "", $input );
158
159        // Moves may be annotated by some number of glyphs. Check all known by NagTable.
160        $glyphs = array_values( NagTable::MAP );
161        $input = str_replace( $glyphs, "", $input );
162
163        // If the PGN is valid, we should have either an empty string or a string containing
164        // only white space. If, after removing the white space, we have anything left in
165        // the string, we know that the PGN is not valid.
166        $whitespace = '/\s+/';
167        $input = preg_replace( $whitespace, "", $input );
168
169        if ( strlen( $input ) > 0 ) {
170            throw new ChessBrowserException( 'Invalid PGN.' );
171        }
172    }
173
174    /**
175     * Check tag pair result against notation-end result
176     *
177     * @param string $input The text inside the pgn tag
178     * @param array $metadata Dictionary of tag pair key-values
179     * @return string
180     * @throws ChessBrowserException if Tag Pair result and movetext termination string are both valid but don't match.
181     */
182    private static function decideResultString( string $input, array $metadata ): string {
183        $terminationRegex = '/(1\W0|0\W1|1\/2\W1\/2|\*)/';
184        $tagResult = $metadata['result'];
185        $validTagPairResult = ( preg_match( $terminationRegex, $tagResult, $tagResultToken ) === 1 );
186        // Termination markers can appear all over the place in the input text like
187        // in variations or comments, so preg_match can have false positives. The real
188        // term marker should be at the end of the game, so checking the last 7 chars
189        // (the longest term marker of 1/2-1/2) should avoid false positives.
190        $endOfMovetext = substr( $input, -7 );
191        $validMovetextTerm = ( preg_match( $terminationRegex, $endOfMovetext, $movetextTermToken ) === 1 );
192
193        if ( $validTagPairResult && $validMovetextTerm ) {
194            // Check that they match
195            if ( strcmp( $tagResultToken[1], $movetextTermToken[1] ) !== 0 ) {
196                $errorMsg = 'Invalid PGN: Result tag and game termination marker do not match.';
197                throw new ChessBrowserException( $errorMsg );
198            }
199            // Go with the movetext token
200            return $movetextTermToken[1];
201        } elseif ( $validTagPairResult ) {
202            return $tagResultToken[1];
203        } elseif ( $validMovetextTerm ) {
204            return $movetextTermToken[1];
205        } else {
206            // Don't fail, just print nothing, TODO add tracking cat
207            return '';
208        }
209    }
210
211    /**
212     * Handle creating the board to show
213     *
214     * @param string $input
215     * @param int $gameNum
216     * @param array $args The XML arguments from MediaWiki
217     * @return string
218     * @throws ChessBrowserException
219     */
220    private static function createBoard( string $input, int $gameNum, array $args ): string {
221        $attr = self::parseArguments( $args );
222        $swap = $attr['side'] === 'black';
223        $initialPosition = $attr['ply'];
224
225        // Initialize parsers
226        $chessParser = new ChessParser( $input );
227
228        $chessObject = $chessParser->createOutputJson();
229        $annotationObject = $chessObject['variations'];
230        unset( $chessObject['variations'] );
231        $chessObject['init'] = $initialPosition;
232        if ( !$chessObject['boards'][0] ) {
233            throw new ChessBrowserException( 'No board available' );
234        }
235
236        // Set up template arguments
237        $templateParser = new TemplateParser( __DIR__ . '/../templates' );
238        $templateParser->enableRecursivePartials( true );
239        $templateArgs = [
240            'data-chess' => json_encode( $chessObject ),
241            'data-chess-annotations' => json_encode( $annotationObject ),
242            'div-number' => $gameNum,
243            // The JS toggles a class to flip games, so unlike FEN we only need
244            // to add the class in order to flip the board to black's view.
245            // Include notransition class so that readers don't get FOUC and
246            // watch all the boards spin on load
247            'swap' => $swap ? ' pgn-flip notransition' : '',
248            'move-set' => self::getMoveSet( $chessObject, $annotationObject ),
249            'piece-set' => self::generatePieces( $chessObject['boards'][0] )
250        ];
251        $localizedLabels = self::getLocalizedLabels();
252        $metadata = self::getMetadata( $chessObject['metadata'] );
253        $resultString = self::decideResultString( $input, $metadata );
254        if ( strcmp( $resultString, '' ) !== 0 ) {
255            $templateArgs['movetext-result'] = $resultString;
256        }
257        $templateArgs = array_merge( $templateArgs, $localizedLabels, $metadata );
258        $game = $templateParser->processTemplate(
259            'ChessGame',
260            $templateArgs
261        );
262        return $game;
263    }
264
265    /**
266     * @since 0.3.0
267     * @param string $input The wikitext placed between fen tags
268     * @param array $args Arguments passed as xml attributes
269     * @param Parser $parser The MediaWiki parser object
270     * @param PPFrame $frame Parent frame, provides context of the tage placement
271     * @return array
272     */
273    public static function newPosition( string $input, array $args, Parser $parser, PPFrame $frame ): array {
274        try {
275            $attr = self::parseArguments( $args );
276            $swap = $attr['side'] === 'black';
277
278            $input = trim( $input );
279            self::assertValidFEN( $input );
280            $fenParser = new FenParser0x88( $input );
281            $fenOut = $fenParser->getFen();
282
283            // Set up template arguments
284            $templateParser = new TemplateParser( __DIR__ . '/../templates' );
285            $templateArgs = [
286                'data-chess' => json_encode( $fenOut ),
287                'piece-set' => self::generatePieces( $fenOut, $swap )
288            ];
289            $localizedLegendLabels = self::getLocalizedLegendLabels( $swap );
290            $templateArgs = array_merge( $templateArgs, $localizedLegendLabels );
291            $board = $templateParser->processTemplate(
292                'ChessBoard',
293                $templateArgs
294            );
295            $parser->getOutput()->setExtensionData( 'ChessViewerFEN', 'true' );
296            return [ $board, 'markerType' => 'nowiki' ];
297        } catch ( Exception $e ) {
298            wfDebugLog(
299                'ChessBrowser',
300                'Unable to create a game: ' . $e
301            );
302            $parser->addTrackingCategory( 'chessbrowser-invalid-category' );
303            $message = wfMessage( 'chessbrowser-invalid-message' )->escaped();
304            return [ $message ];
305        }
306    }
307
308    /**
309     * Check if tag contains valid input format FEN
310     *
311     * The input string is checked with a regex to make sure we only have the expected
312     * characters and spacing of FEN. We do not check if it is a valid game.
313     *
314     * @param string $fenInput
315     * @throws ChessBrowserException if invalid
316     */
317    private static function assertValidFEN( string $fenInput ) {
318        $fenRegex = '/^([prnbqk1-8]{1,8}\/){7}[prnbqk1-8]{1,8}\s[wb]\s([kq]{1,4}|-)\s([abcdefgh][36]|-)\s\d+\s\d+/i';
319        $valid = preg_match( $fenRegex, $fenInput );
320        if ( $valid !== 1 ) {
321            throw new ChessBrowserException( 'Invalid FEN.' );
322        }
323    }
324
325    /**
326     * Return associative array with argument defaults
327     *
328     * @param array $args Arguments passed as xml attributes through MediaWiki parser
329     * @return array
330     */
331    public static function parseArguments( array $args ): array {
332        $attr = [
333            'side' => 'white',
334            'ply' => 1
335        ];
336        foreach ( $args as $name => $value ) {
337            if ( array_key_exists( $name, $attr ) ) {
338                $attr[$name] = $value;
339            }
340        }
341        if ( !in_array( $attr['side'], [ 'white', 'black' ] ) ) {
342            $attr['side'] = 'white';
343        }
344        // Ensure that an integer is always returned
345        $attr['ply'] = (int)$attr['ply'];
346        // Setting display to 0 results in the last ply being displayed, not
347        // the initial board state which is counterintuitive. Rewrite 0 to 1
348        // to prevent this from happening.
349        // TODO: Add some kind of warning about this behavior or fix it in JS
350        if ( $attr['ply'] === 0 ) {
351            $attr['ply'] = 1;
352        }
353        return $attr;
354    }
355
356    /**
357     * Create array of mustache arguments for chess-piece.mustache from a given FEN string
358     * @since 0.2.0
359     * @param string $fen
360     * @param bool $swap Display from black's perspective if true
361     * @return array
362     */
363    public static function generatePieces( $fen, $swap = false ): array {
364        $pieceArray = [];
365        $rankIndex = 7;
366        $fileIndex = 0;
367        $fenArray = str_split( $fen );
368        foreach ( $fenArray as $fenChar ) {
369            if ( is_numeric( $fenChar ) ) {
370                $fileIndex += $fenChar;
371            } elseif ( $fenChar === '/' ) {
372                $rankIndex--;
373                $fileIndex = 0;
374            } else {
375                if ( $fileIndex > 7 ) {
376                    continue;
377                }
378                if ( $swap ) {
379                    $piece = self::createPiece( $fenChar, 7 - $rankIndex, $fileIndex );
380                } else {
381                    $piece = self::createPiece( $fenChar, $rankIndex, $fileIndex );
382                }
383
384                $pieceArray[] = $piece;
385                $fileIndex++;
386            }
387        }
388        return $pieceArray;
389    }
390
391    /**
392     * Retrieve the interface text for the correct locale
393     * @since 0.2.0
394     * @param bool $swap
395     * @return array
396     */
397    public static function getLocalizedLabels( bool $swap = false ): array {
398        $legend = self::getLocalizedLegendLabels( $swap );
399        $other = [
400            'expand-button' => wfMessage( 'chessbrowser-expand-button' )->text(),
401            'game-detail' => wfMessage( 'chessbrowser-game-detail' )->text(),
402            'event-label' => wfMessage( 'chessbrowser-event-label' )->text(),
403            'site-label' => wfMessage( 'chessbrowser-site-label' )->text(),
404            'date-label' => wfMessage( 'chessbrowser-date-label' )->text(),
405            'round-label' => wfMessage( 'chessbrowser-round-label' )->text(),
406            'white-label' => wfMessage( 'chessbrowser-white-label' )->text(),
407            'black-label' => wfMessage( 'chessbrowser-black-label' )->text(),
408            'result-label' => wfMessage( 'chessbrowser-result-label' )->text(),
409            'notations-label' => wfMessage( 'chessbrowser-notations-label' )->text(),
410            'no-javascript' => wfMessage( 'chessbrowser-no-javascript' )->text()
411        ];
412        $allLabels = array_merge( $legend, $other );
413        return $allLabels;
414    }
415
416    /**
417     * Retrieve the interface text for the correct locale for the legend only
418     * @since 0.3.0
419     * @param bool $swap
420     * @return array
421     */
422    private static function getLocalizedLegendLabels( bool $swap ): array {
423        if ( $swap ) {
424            $ranks = [
425                'rank-8' => wfMessage( 'chessbrowser-first-rank' )->text(),
426                'rank-7' => wfMessage( 'chessbrowser-second-rank' )->text(),
427                'rank-6' => wfMessage( 'chessbrowser-third-rank' )->text(),
428                'rank-5' => wfMessage( 'chessbrowser-fourth-rank' )->text(),
429                'rank-4' => wfMessage( 'chessbrowser-fifth-rank' )->text(),
430                'rank-3' => wfMessage( 'chessbrowser-sixth-rank' )->text(),
431                'rank-2' => wfMessage( 'chessbrowser-seventh-rank' )->text(),
432                'rank-1' => wfMessage( 'chessbrowser-eighth-rank' )->text(),
433            ];
434        } else {
435            $ranks = [
436                'rank-1' => wfMessage( 'chessbrowser-first-rank' )->text(),
437                'rank-2' => wfMessage( 'chessbrowser-second-rank' )->text(),
438                'rank-3' => wfMessage( 'chessbrowser-third-rank' )->text(),
439                'rank-4' => wfMessage( 'chessbrowser-fourth-rank' )->text(),
440                'rank-5' => wfMessage( 'chessbrowser-fifth-rank' )->text(),
441                'rank-6' => wfMessage( 'chessbrowser-sixth-rank' )->text(),
442                'rank-7' => wfMessage( 'chessbrowser-seventh-rank' )->text(),
443                'rank-8' => wfMessage( 'chessbrowser-eighth-rank' )->text(),
444            ];
445        }
446        $files = [
447            'a' => wfMessage( 'chessbrowser-a-file' )->text(),
448            'b' => wfMessage( 'chessbrowser-b-file' )->text(),
449            'c' => wfMessage( 'chessbrowser-c-file' )->text(),
450            'd' => wfMessage( 'chessbrowser-d-file' )->text(),
451            'e' => wfMessage( 'chessbrowser-e-file' )->text(),
452            'f' => wfMessage( 'chessbrowser-f-file' )->text(),
453            'g' => wfMessage( 'chessbrowser-g-file' )->text(),
454            'h' => wfMessage( 'chessbrowser-h-file' )->text(),
455        ];
456
457        return array_merge( $ranks, $files );
458    }
459
460    /**
461     * Create array of mustache arguments for move-span.mustache from a given
462     * array of ply tokens.
463     * @since 0.2.0
464     * @param array $gameObject Game representation loaded into data-chess
465     *   and output from ChessParser::createOutputJson
466     * @param array $annotationObject representation loaded into data-chess-annotations
467     * @return array
468     */
469    public static function getMoveSet( $gameObject, $annotationObject ): array {
470        $tokens = $gameObject['tokens'];
471        $plys = $gameObject['plys'];
472        $moveSet = [];
473        $variationIndices = array_map(
474            static function ( $x ) {
475                return $x[0];
476            },
477            $annotationObject
478        );
479        foreach ( $tokens as $i => $token ) {
480            $span = [
481                'step-link' => false,
482                'annotations' => []
483            ];
484            if ( in_array( $i, $variationIndices ) ) {
485                $span['variations'] = [];
486                $j = array_search( $i, $variationIndices );
487                $variationList = $annotationObject[$j][1];
488                foreach ( $variationList as $variation ) {
489                    $span['variations'][] = [
490                        'debug' => json_encode( $variation ),
491                        'variation-moves' => self::getVariationSet( $variation, $i )
492                    ];
493                    // $span['variation-set'][] = self::getVariationSet( $variation, [], $i );
494                }
495            }
496            $ply = $plys[$i];
497            $comment = $ply[2][2];
498            if ( $comment !== null ) {
499                $span['annotations'][] = [ 'comment' => $comment ];
500            }
501            if ( $i % 2 === 0 ) {
502                $moveNumber = ( $i / 2 ) + 1;
503                $span['step-link'] = true;
504                $span['move-number'] = $moveNumber;
505            }
506            $plyNumber = $i + 1;
507            $span['move-token'] = $token;
508            $span['move-ply'] = $plyNumber;
509            $moveSet[] = $span;
510        }
511
512        return $moveSet;
513    }
514
515    /**
516     * Create template parameters for move variation strings
517     * @param array $variation Object listing tokens, boards, and plys for
518     *   the variation moves.
519     * @param int $index The ply of the parent move
520     * @return array
521     */
522    public static function getVariationSet( $variation, $index ) {
523        $tokens = $variation['tokens'];
524        $plys = $variation['plys'];
525        $spanList = [];
526        foreach ( $tokens as $i => $token ) {
527            $span = [
528                'step-link' => false,
529                'annotations' => []
530            ];
531            $ply = $plys[$i];
532            $comment = $ply[2][2];
533            if ( $comment !== null ) {
534                $span['annotations'][] = [ 'comment' => $comment ];
535            }
536            if ( ( $index + $i ) % 2 === 0 ) {
537                $moveNumber = ( ( $index + $i ) / 2 ) + 1;
538                $span['step-link'] = true;
539                $span['move-number'] = $moveNumber;
540            }
541            $span['variation-ply'] = $i;
542            $span['variation-token'] = $token;
543            $spanList[] = $span;
544        }
545        return $spanList;
546    }
547
548    /**
549     * Create array of mustache arguments for ChessBoard.mustache from a given
550     * associative array of tag pairs
551     * @since 0.2.0
552     * @param array $tagPairs
553     * @return array
554     */
555    public static function getMetadata( $tagPairs ): array {
556        // TODO localize the defaults
557        $metadata = [
558            'event' => 'Unknown event',
559            'site' => 'Unknown site',
560            'date' => 'Unknown date',
561            'round' => 'Unkown round',
562            'white' => 'Unknown white',
563            'black' => 'Unknown black',
564            'result' => 'Unknown result',
565            'other-metadata' => []
566        ];
567
568        foreach ( $tagPairs as $key => $value ) {
569            if ( array_key_exists( $key, $metadata ) ) {
570                $metadata[$key] = $value;
571                continue;
572            }
573            $metadata['other-metadata'][] = [
574                'label' => $key,
575                'value' => $value
576            ];
577        }
578        return $metadata;
579    }
580
581    /**
582     * Create an array of arguments for chess-piece.mustache for a single piece
583     * at a given location on the board
584     * @since 0.2.0
585     * @param string $symbol The FEN symbol for the piece
586     * @param string|int $rank Preserves input type on output
587     * @param string|int $file Preserves input type on output
588     * @return array
589     */
590    public static function createPiece( $symbol, $rank, $file ): array {
591        if ( $rank > 7 || $file > 7 || $rank < 0 || $file < 0 ) {
592            throw new ChessBrowserException( "Impossible rank ($rank) or file ($file)" );
593        }
594
595        $validTypes = [ 'b', 'k', 'n', 'p', 'q', 'r' ];
596        $type = strtolower( $symbol );
597
598        if ( !in_array( $type, $validTypes ) ) {
599            throw new ChessBrowserException( "Invalid piece type $type" );
600        }
601
602        $color = ( $type === $symbol ? 'd' : 'l' );
603
604        return [
605            'piece-type' => $type,
606            'piece-color' => $color,
607            'piece-rank' => $rank,
608            'piece-file' => $file
609        ];
610    }
611}