Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.47% covered (danger)
5.47%
38 / 695
7.69% covered (danger)
7.69%
4 / 52
CRAP
0.00% covered (danger)
0.00%
0 / 1
CargoUtils
5.47% covered (danger)
5.47%
38 / 695
7.69% covered (danger)
7.69%
4 / 52
61854.19
0.00% covered (danger)
0.00%
0 / 1
 getDB
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
272
 getMainDBForRead
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getMainDBForWrite
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getPageProp
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 getAllPageProps
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 getTemplateIDForDBTable
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 formatError
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 displayErrorMessage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTables
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getParentTables
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getChildTables
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 getDrilldownTabsParams
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getTableSchemas
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
90
 getTableNameForTemplate
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 makeDifferentAlias
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 smartSplit
69.44% covered (warning)
69.44%
25 / 36
0.00% covered (danger)
0.00%
0 / 1
21.42
 findQuotedStringEnd
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 removeQuotedStrings
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 removeNamespaceFromFileName
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getSQLFieldPattern
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getSQLTableAndFieldPattern
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getSQLTablePattern
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 isSQLStringLiteral
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getDateFunctions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 smartParse
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
182
 parsePageForStorage
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 recreateDBTablesForTemplate
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
272
 tableFullyExists
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 fieldTypeToSQLType
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
1482
 createCargoTableOrTables
0.00% covered (danger)
0.00%
0 / 75
0.00% covered (danger)
0.00%
0 / 1
420
 createTable
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
306
 specialTableNames
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 fullTextMatchSQL
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 coordinatePartToNumber
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
110
 parseCoordinatesString
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
110
 escapedFieldName
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 joinOfMainAndFieldTable
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 joinOfMainAndParentTable
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 joinOfFieldAndMainTable
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 joinOfSingleFieldAndHierarchyTable
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 escapedInsert
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 makeLink
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
12.41
 getSpecialPage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getContentLang
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logTableAction
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 validateHierarchyStructure
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
 validateFieldDescriptionString
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
90
 globalFields
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 addGlobalFieldsToSchema
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 stringContainsParentheses
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 makeWikiPage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getContentText
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Utility functions for the Cargo extension.
4 *
5 * @author Yaron Koren
6 * @ingroup Cargo
7 */
8
9use MediaWiki\Html\Html;
10use MediaWiki\Linker\Linker;
11use MediaWiki\Linker\LinkRenderer;
12use MediaWiki\Linker\LinkTarget;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Title\Title;
15
16class CargoUtils {
17
18    /** @var \Wikimedia\Rdbms\IMaintainableDatabase|null */
19    private static $CargoDB = null;
20
21    /**
22     * @return \Wikimedia\Rdbms\IMaintainableDatabase
23     */
24    public static function getDB() {
25        if ( self::$CargoDB != null && self::$CargoDB->isOpen() ) {
26            return self::$CargoDB;
27        }
28
29        global $wgDBuser, $wgDBpassword, $wgDBprefix, $wgDBservers;
30        global $wgCargoDBserver, $wgCargoDBname, $wgCargoDBuser, $wgCargoDBpassword, $wgCargoDBprefix, $wgCargoDBtype, $wgCargoDBfilePath;
31
32        $services = MediaWikiServices::getInstance();
33        $dbr = self::getMainDBForRead();
34        $server = $dbr->getServer();
35        $name = $dbr->getDBname();
36        $type = $dbr->getType();
37
38        // We need $wgCargoDBtype for other functions.
39        if ( $wgCargoDBtype === null ) {
40            $wgCargoDBtype = $type;
41        }
42        $dbServer = $wgCargoDBserver === null ? $server : $wgCargoDBserver;
43        $dbName = $wgCargoDBname === null ? $name : $wgCargoDBname;
44
45        // Server (host), db name, and db type can be retrieved from $dbr via
46        // public methods, but username and password cannot. If these values are
47        // not set for Cargo, get them from either $wgDBservers or wgDBuser and
48        // $wgDBpassword, depending on whether or not there are multiple DB servers.
49        if ( $wgCargoDBuser !== null ) {
50            $dbUsername = $wgCargoDBuser;
51        } elseif ( is_array( $wgDBservers ) && isset( $wgDBservers[0] ) ) {
52            $dbUsername = $wgDBservers[0]['user'];
53        } else {
54            $dbUsername = $wgDBuser;
55        }
56        if ( $wgCargoDBpassword !== null ) {
57            $dbPassword = $wgCargoDBpassword;
58        } elseif ( is_array( $wgDBservers ) && isset( $wgDBservers[0] ) ) {
59            $dbPassword = $wgDBservers[0]['password'];
60        } else {
61            $dbPassword = $wgDBpassword;
62        }
63
64        if ( $wgCargoDBprefix !== null ) {
65            $dbTablePrefix = $wgCargoDBprefix;
66        } else {
67            $dbTablePrefix = $wgDBprefix . 'cargo__';
68        }
69
70        $params = [
71            'host' => $dbServer,
72            'user' => $dbUsername,
73            'password' => $dbPassword,
74            'dbname' => $dbName,
75            'tablePrefix' => $dbTablePrefix,
76        ];
77
78        if ( $type === 'sqlite' ) {
79            if ( $wgCargoDBfilePath !== null ) {
80                $params['dbFilePath'] = $wgCargoDBfilePath;
81            } else {
82                $params['dbFilePath'] = $dbr->getDbFilePath();
83            }
84        } elseif ( $type === 'postgres' ) {
85            global $wgDBport;
86            // @TODO - a $wgCargoDBport variable is still needed.
87            $params['port'] = $wgDBport;
88        }
89
90        self::$CargoDB = $services->getDatabaseFactory()->create( $wgCargoDBtype, $params );
91        return self::$CargoDB;
92    }
93
94    /**
95     * Provides a reference to the main (not the Cargo) database for read
96     * access.
97     *
98     * @return \Wikimedia\Rdbms\IReadableDatabase
99     */
100    public static function getMainDBForRead() {
101        return MediaWikiServices::getInstance()
102            ->getDBLoadBalancerFactory()
103            ->getReplicaDatabase();
104    }
105
106    /**
107     * Provides a reference to the main (not the Cargo) database for write
108     * access.
109     *
110     * @return \Wikimedia\Rdbms\IDatabase
111     */
112    public static function getMainDBForWrite() {
113        return MediaWikiServices::getInstance()
114            ->getDBLoadBalancerFactory()
115            ->getPrimaryDatabase();
116    }
117
118    /**
119     * Gets a page property for the specified page ID and property name.
120     */
121    public static function getPageProp( $pageID, $pageProp ) {
122        $dbr = self::getMainDBForRead();
123        $value = $dbr->selectField( 'page_props', [
124                'pp_value'
125            ], [
126                'pp_page' => $pageID,
127                    'pp_propname' => $pageProp,
128            ],
129            __METHOD__
130        );
131
132        if ( !$value ) {
133            return null;
134        }
135
136        return $value;
137    }
138
139    /**
140     * Similar to getPageProp().
141     */
142    public static function getAllPageProps( $pageProp ) {
143        $dbr = self::getMainDBForRead();
144        $res = $dbr->select( 'page_props', [
145            'pp_page',
146            'pp_value'
147            ], [
148            'pp_propname' => $pageProp
149            ],
150            __METHOD__
151        );
152
153        $pagesPerValue = [];
154        foreach ( $res as $row ) {
155            $pageID = $row->pp_page;
156            $pageValue = $row->pp_value;
157            if ( array_key_exists( $pageValue, $pagesPerValue ) ) {
158                $pagesPerValue[$pageValue][] = $pageID;
159            } else {
160                $pagesPerValue[$pageValue] = [ $pageID ];
161            }
162        }
163
164        return $pagesPerValue;
165    }
166
167    /**
168     * Gets the template page where this table is defined -
169     * hopefully there's exactly one of them.
170     */
171    public static function getTemplateIDForDBTable( $tableName ) {
172        $dbr = self::getMainDBForRead();
173        $page = $dbr->selectField( 'page_props', [
174            'pp_page'
175            ], [
176            'pp_value' => $tableName,
177            'pp_propname' => 'CargoTableName'
178            ],
179            __METHOD__
180        );
181        if ( !$page ) {
182            return null;
183        }
184        return $page;
185    }
186
187    public static function formatError( $errorString ) {
188        return Html::element( 'div', [ 'class' => 'error' ], $errorString );
189    }
190
191    public static function displayErrorMessage( OutputPage $out, Message $message ) {
192        $out->wrapWikiTextAsInterface( 'error', $message->plain() );
193    }
194
195    public static function getTables() {
196        $tableNames = [];
197        $dbr = self::getMainDBForRead();
198        $res = $dbr->select( 'cargo_tables', 'main_table', '', __METHOD__ );
199        foreach ( $res as $row ) {
200            $tableName = $row->main_table;
201            // Skip "replacement" tables.
202            if ( substr( $tableName, -6 ) == '__NEXT' ) {
203                continue;
204            }
205            $tableNames[] = $tableName;
206        }
207        return $tableNames;
208    }
209
210    public static function getParentTables( $tableName ) {
211        $parentTables = [];
212        $dbr = self::getMainDBForRead();
213        $res = $dbr->select( 'cargo_tables', [ 'template_id', 'main_table' ], '', __METHOD__ );
214        foreach ( $res as $row ) {
215            if ( $tableName == $row->main_table ) {
216                $parentTables = self::getPageProp( $row->template_id, 'CargoParentTables' );
217            }
218        }
219        if ( $parentTables ) {
220            return unserialize( $parentTables );
221        }
222    }
223
224    public static function getChildTables( $tableName ) {
225        $childTables = [];
226        $allParentTablesInfo = self::getAllPageProps( 'CargoParentTables' );
227        foreach ( $allParentTablesInfo as $parentTablesInfoStr => $templateIDs ) {
228            $parentTablesInfo = unserialize( $parentTablesInfoStr );
229            foreach ( $parentTablesInfo as $parentTableInfo ) {
230                $remoteTable = $parentTableInfo['Name'];
231                if ( $remoteTable !== $tableName ) {
232                    continue;
233                }
234                $localField = $parentTableInfo['_localField'];
235                $remoteField = $parentTableInfo['_remoteField'];
236                // There should only ever be one ID here... right?
237                foreach ( $templateIDs as $templateID ) {
238                    $childTable = self::getPageProp( $templateID, 'CargoTableName' );
239                    $childTables[] = [
240                        'childTable' => $childTable,
241                        'childField' => $localField,
242                        'parentTable' => $remoteTable,
243                        'parentField' => $remoteField
244                    ];
245                }
246            }
247        }
248        return $childTables;
249    }
250
251    public static function getDrilldownTabsParams( $tableName ) {
252        $drilldownTabs = [];
253        $dbr = self::getMainDBForRead();
254        $res = $dbr->select( 'cargo_tables', [ 'template_id', 'main_table' ], '', __METHOD__ );
255        foreach ( $res as $row ) {
256            if ( $tableName == $row->main_table ) {
257                $drilldownTabs = self::getPageProp( $row->template_id, 'CargoDrilldownTabsParams' );
258            }
259        }
260        if ( $drilldownTabs ) {
261            return unserialize( $drilldownTabs );
262        }
263    }
264
265    public static function getTableSchemas( $tableNames ) {
266        $mainTableNames = [];
267        foreach ( $tableNames as $tableName ) {
268            if ( strpos( $tableName, '__' ) !== false &&
269                strpos( $tableName, '__NEXT' ) === false ) {
270                // We just want the first part of it.
271                $tableNameParts = explode( '__', $tableName );
272                $tableName = $tableNameParts[0];
273            }
274            if ( !in_array( $tableName, $mainTableNames ) ) {
275                $mainTableNames[] = $tableName;
276            }
277        }
278        $tableSchemas = [];
279        $dbr = self::getMainDBForRead();
280        $res = $dbr->select( 'cargo_tables', [ 'main_table', 'table_schema' ],
281            [ 'main_table' => $mainTableNames ], __METHOD__ );
282        foreach ( $res as $row ) {
283            $tableName = $row->main_table;
284            $tableSchemaString = $row->table_schema;
285            $tableSchemas[$tableName] = CargoTableSchema::newFromDBString( $tableSchemaString );
286        }
287
288        // Validate the table names.
289        if ( count( $tableSchemas ) < count( $mainTableNames ) ) {
290            foreach ( $mainTableNames as $tableName ) {
291                if ( !array_key_exists( $tableName, $tableSchemas ) ) {
292                    throw new MWException( wfMessage( "cargo-unknowntable", $tableName )->parse() );
293                }
294            }
295        }
296
297        return $tableSchemas;
298    }
299
300    /**
301     * Get the Cargo table for the passed-in template specified via
302     * either #cargo_declare or #cargo_attach, if the template has a
303     * call to either one.
304     */
305    public static function getTableNameForTemplate( $templateTitle ) {
306        $templatePageID = $templateTitle->getArticleID();
307        $declaredTableName = self::getPageProp( $templatePageID, 'CargoTableName' );
308        if ( $declaredTableName !== null ) {
309            return [ $declaredTableName, true ];
310        }
311        $attachedTableName = self::getPageProp( $templatePageID, 'CargoAttachedTable' );
312        return [ $attachedTableName, false ];
313    }
314
315    /**
316     * Make the alias different from table name to avoid removal of aliases (when passed
317     * in to Mediawiki's select() call) if the alias and table name are the same.
318     * Aliases are needed because sometimes the same table can be joined more than once, if
319     * it serves as two different parent tables
320     * @param string $tableName
321     * @return string
322     */
323    public static function makeDifferentAlias( $tableName ) {
324        $tableAlias = $tableName . "_alias";
325        return $tableAlias;
326    }
327
328    /**
329     * Splits a string by the delimiter, but ensures that parentheses,
330     * separators and "the other quote" (single quote in a double quoted
331     * string, double quote in a single quoted string, etc.) inside a quoted
332     * string are not considered lexically.
333     * @param string $delimiter The delimiter to split by.
334     * @param string $string The string to split.
335     * @param bool $includeBlankValues Whether to include blank values in the returned array.
336     * @return string[] Array of substrings (with or without blank values).
337     * @throws MWException On unmatched quotes or incomplete escape sequences.
338     */
339    public static function smartSplit( $delimiter, $string, $includeBlankValues = false ) {
340        if ( $string == '' ) {
341            return [];
342        }
343
344        $ignoreNextChar = false;
345        $returnValues = [];
346        $numOpenParentheses = 0;
347        $curReturnValue = '';
348        $strLength = strlen( $string );
349        for ( $i = 0; $i < $strLength; $i++ ) {
350            $curChar = $string[$i];
351
352            if ( $ignoreNextChar ) {
353                // If previous character was a backslash,
354                // ignore the current one, since it's escaped.
355                // What if this one is a backslash too?
356                // Doesn't matter - it's escaped.
357                $ignoreNextChar = false;
358            } elseif ( $curChar == '(' ) {
359                $numOpenParentheses++;
360            } elseif ( $curChar == ')' ) {
361                $numOpenParentheses--;
362            } elseif ( $curChar == '\'' || $curChar == '"' || $curChar == '`' ) {
363                $pos = self::findQuotedStringEnd( $string, $curChar, $i + 1 );
364                if ( $pos === false ) {
365                    throw new MWException( "Error: unmatched quote in SQL string constant." );
366                }
367                $curReturnValue .= substr( $string, $i, $pos - $i );
368                $i = $pos;
369            } elseif ( $curChar == '\\' ) {
370                $ignoreNextChar = true;
371            }
372
373            if ( $curChar == $delimiter && $numOpenParentheses == 0 ) {
374                $returnValues[] = trim( $curReturnValue );
375                $curReturnValue = '';
376            } else {
377                $curReturnValue .= $curChar;
378            }
379        }
380        $returnValues[] = trim( $curReturnValue );
381
382        if ( $ignoreNextChar ) {
383            throw new MWException( "Error: incomplete escape sequence." );
384        }
385
386        if ( $includeBlankValues ) {
387            return $returnValues;
388        }
389
390        // Remove empty strings (but not other quasi-empty values, like '0') and re-key the array.
391        $noEmptyStrings = static function ( $s ) {
392            return $s !== '';
393        };
394        return array_values( array_filter( $returnValues, $noEmptyStrings ) );
395    }
396
397    /**
398     * Finds the end of a quoted string.
399     */
400    public static function findQuotedStringEnd( $string, $quoteChar, $pos ) {
401        $ignoreNextChar = false;
402        $strLength = strlen( $string );
403        for ( $i = $pos; $i < $strLength; $i++ ) {
404            $curChar = $string[$i];
405            if ( $ignoreNextChar ) {
406                $ignoreNextChar = false;
407            } elseif ( $curChar == $quoteChar ) {
408                if ( $i + 1 < $strLength && $string[$i + 1] == $quoteChar ) {
409                    $i++;
410                } else {
411                    return $i;
412                }
413            } elseif ( $curChar == '\\' ) {
414                $ignoreNextChar = true;
415            }
416        }
417        if ( $ignoreNextChar ) {
418            throw new MWException( "Error: incomplete escape sequence." );
419        }
420        return false;
421    }
422
423    /**
424     * Deletes text within quotes and raises and exception if a quoted string
425     * is not closed.
426     */
427    public static function removeQuotedStrings( ?string $string ): string {
428        if ( $string === null ) {
429            return '';
430        }
431
432        $noQuotesPattern = '/("|\')([^\\1\\\\]|\\\\.)*?\\1/s';
433        $string = preg_replace( $noQuotesPattern, '', $string );
434        if ( strpos( $string, '"' ) !== false || strpos( $string, "'" ) !== false ) {
435            throw new MWException( "Error: unclosed string literal." );
436        }
437        return $string;
438    }
439
440    /**
441     * Get rid of the "File:" or "Image:" (in the wiki's language) at the
442     * beginning of a file name, if it's there.
443     */
444    public static function removeNamespaceFromFileName( $fileName ) {
445        $fileTitle = Title::newFromText( $fileName, NS_FILE );
446        if ( $fileTitle == null ) {
447            return null;
448        }
449        return $fileTitle->getText();
450    }
451
452    /**
453     * Generates a Regular Expression to match $fieldName in a SQL string.
454     * Allows for $ as valid identifier character.
455     */
456    public static function getSQLFieldPattern( $fieldName, $closePattern = true ) {
457        $fieldName = str_replace( '$', '\$', $fieldName );
458        $pattern = '/([^\w$.,]|^)' . $fieldName;
459        return $pattern . ( $closePattern ? '([^\w$]|$)/' : '' );
460    }
461
462    /**
463     * Generates a Regular Expression to match $tableName.$fieldName in a
464     * SQL string. Allows for $ as valid identifier character.
465     */
466    public static function getSQLTableAndFieldPattern( $tableName, $fieldName, $closePattern = true ) {
467        $fieldName = str_replace( '$', '\$', $fieldName );
468        $tableName = str_replace( '$', '\$', $tableName );
469        $pattern = '/([^\w$,]|^)' . $tableName . '\.' . $fieldName;
470        return $pattern . ( $closePattern ? '([^\w$]|$)/ui' : '' );
471    }
472
473    /**
474     * Generates a Regular Expression to match $tableName in a SQL string.
475     * Allows for $ as valid identifier character.
476     */
477    public static function getSQLTablePattern( $tableName, $closePattern = true ) {
478        $tableName = str_replace( '$', '\$', $tableName );
479        $pattern = '/([^\w$]|^)(' . $tableName . ')\.(\w*)';
480        return $pattern . ( $closePattern ? '/ui' : '' );
481    }
482
483    /**
484     * Determines whether a string is a literal.
485     * This may need different handling for different (non-MySQL) DB types.
486     */
487    public static function isSQLStringLiteral( $string ) {
488        return $string[0] == "'" && substr( $string, -1, 1 ) == "'";
489    }
490
491    public static function getDateFunctions( $dateDBField ) {
492        global $wgCargoDBtype;
493
494        // Unfortunately, date handling in general - and date extraction
495        // specifically - is done differently in almost every DB
496        // system. If support was ever added for SQLite,
497        // that would require special handling as well.
498        if ( $wgCargoDBtype == 'postgres' ) {
499            $yearValue = "DATE_PART('year', $dateDBField)";
500            $monthValue = "DATE_PART('month', $dateDBField)";
501            $dayValue = "DATE_PART('day', $dateDBField)";
502        } else { // MySQL
503            $yearValue = "YEAR($dateDBField)";
504            $monthValue = "MONTH($dateDBField)";
505            $dayValue = "DAY($dateDBField)";
506        }
507        return [ $yearValue, $monthValue, $dayValue ];
508    }
509
510    /**
511     * Parses a piece of wikitext differently depending on whether
512     * we're in a special or regular page.
513     *
514     * @param string $value
515     * @param Parser|null $parser
516     * @return string
517     */
518    public static function smartParse( $value, $parser ) {
519        global $wgRequest;
520
521        // Escape immediately if it's blank or null.
522        if ( $value == '' ) {
523            return '';
524        }
525
526        // This decode() call is here in case the value was
527        // set using {{PAGENAME}}, which for some reason
528        // HTML-encodes some of its characters - see
529        // https://www.mediawiki.org/wiki/Help:Magic_words#Page_names
530        // Of course, String and Page fields could be set using
531        // {{PAGENAME}} as well, but those seem less likely.
532        $value = htmlspecialchars_decode( $value );
533
534        // Add a newline at the beginning if it looks like the value
535        // starts with a bulleted or numbered list, to make sure that
536        // the first line gets formatted correctly.
537        if ( strpos( $value, '*' ) === 0 || strpos( $value, '#' ) === 0 ) {
538            $value = "\n" . $value;
539        }
540
541        // Parse it as if it's wikitext. The exact call
542        // depends on whether we're in a special page or not.
543        if ( $parser === null ) {
544            $parser = MediaWikiServices::getInstance()->getParser();
545        }
546
547        // Parser::getTitle() throws a TypeError if it would have
548        // returned null, so just catch the error.
549        // Why would the title be null? It's not clear, but it seems to
550        // happen in at least once case: in "action=pagevalues" for a
551        // page with non-ASCII characters in its name.
552        try {
553            $title = $parser->getTitle();
554        } catch ( TypeError $e ) {
555            $title = null;
556        }
557
558        if ( $title === null ) {
559            global $wgTitle;
560            $title = $wgTitle;
561        }
562
563        if ( $title != null && $title->isSpecial( 'RunJobs' ) ) {
564            // Conveniently, if this is called from within a job
565            // being run, the name of the page will be
566            // Special:RunJobs.
567            // If that's the case, do nothing - we don't need to
568            // parse the value.
569        // This next clause should only be called for Cargo's special
570        // pages, not for SF's Special:RunQuery. Don't know about other
571        // special pages.
572        } elseif ( ( $title != null && $title->isSpecialPage() && !$wgRequest->getCheck( 'wpRunQuery' ) ) ||
573            // The 'pagevalues' action is also a Cargo special page.
574            $wgRequest->getVal( 'action' ) == 'pagevalues' ) {
575            $parserOptions = ParserOptions::newFromAnon();
576            $parserForInnerParse = MediaWikiServices::getInstance()->getParserFactory()->create();
577            $parserOutput = $parserForInnerParse->parse( $value, $title, $parserOptions );
578            $value = $parserOutput->getText( [ 'unwrap' => true ] );
579        } else {
580            $value = $parser->internalParse( $value );
581        }
582        return $value;
583    }
584
585    public static function parsePageForStorage( $title, $pageContents ) {
586        // Special handling for the Approved Revs extension.
587        $approvedContent = null;
588        if ( class_exists( 'ApprovedRevs' ) ) {
589            $approvedContent = ApprovedRevs::getApprovedContent( $title );
590        }
591        if ( $approvedContent != null ) {
592            if ( method_exists( $approvedContent, 'getText' ) ) {
593                // Approved Revs 1.0+
594                $pageText = $approvedContent->getText();
595            } else {
596                $pageText = $approvedContent;
597            }
598        } else {
599            $pageText = $pageContents;
600        }
601        $parser = MediaWikiServices::getInstance()->getParser();
602        $parserOptions = ParserOptions::newFromAnon();
603        $parser->parse( $pageText, $title, $parserOptions );
604    }
605
606    /**
607     * Drop, and then create again, the database table(s) holding the
608     * data for this template.
609     * Why "tables"? Because every field that holds a list of values gets
610     * its own helper table.
611     *
612     * @param int $templatePageID
613     * @return bool
614     * @throws MWException
615     */
616    public static function recreateDBTablesForTemplate(
617        $templatePageID,
618        $createReplacement,
619        User $user,
620        $tableName = null,
621        $tableSchema = null,
622        $parentTables = null
623    ) {
624        if ( $tableName == null ) {
625            $tableName = self::getPageProp( $templatePageID, 'CargoTableName' );
626        }
627
628        if ( $tableSchema == null ) {
629            $tableSchemaString = self::getPageProp( $templatePageID, 'CargoFields' );
630            // First, see if there even is DB storage for this template -
631            // if not, exit.
632            if ( $tableSchemaString === null ) {
633                return false;
634            }
635            $tableSchema = CargoTableSchema::newFromDBString( $tableSchemaString );
636        } else {
637            $tableSchemaString = $tableSchema->toDBString();
638        }
639
640        $dbw = self::getMainDBForWrite();
641        $cdb = self::getDB();
642
643        // Cannot run any recreate if a replacement table exists.
644        $possibleReplacementTable = $tableName . '__NEXT';
645        if ( self::tableFullyExists( $tableName ) && self::tableFullyExists( $possibleReplacementTable ) ) {
646            throw new MWException( wfMessage( 'cargo-recreatedata-replacementexists', $tableName, $possibleReplacementTable )->parse() );
647        }
648
649        if ( $createReplacement ) {
650            $tableName .= '__NEXT';
651            if ( $cdb->tableExists( $possibleReplacementTable, __METHOD__ ) ) {
652                // The replacement table exists, but it does
653                // not have a row in cargo_tables - this is
654                // hopefully a rare occurrence.
655                try {
656                    $cdb->begin( __METHOD__ );
657                    $cdb->dropTable( $tableName, __METHOD__ );
658                    $cdb->commit( __METHOD__ );
659                } catch ( Exception $e ) {
660                    throw new MWException( "Caught exception ($e) while trying to drop Cargo table. "
661                    . "Please make sure that your database user account has the DROP permission." );
662                }
663            }
664        } else {
665            // @TODO - is an array really necessary? Shouldn't it
666            // always be just one table name? Tied in with that,
667            // if a table name was already specified, do we need
668            // to do a lookup here?
669            $tableNames = [];
670            $res = $dbw->select( 'cargo_tables', 'main_table', [ 'template_id' => $templatePageID ], __METHOD__ );
671            foreach ( $res as $row ) {
672                $tableNames[] = $row->main_table;
673            }
674
675            // For whatever reason, that DB query might have failed -
676            // if so, just add the table name here.
677            if ( $tableName != null && !in_array( $tableName, $tableNames ) ) {
678                $tableNames[] = $tableName;
679            }
680
681            $mainTableAlreadyExists = self::tableFullyExists( $tableNames[0] );
682            foreach ( $tableNames as $curTable ) {
683                try {
684                    $cdb->begin( __METHOD__ );
685                    $cdb->dropTable( $curTable, __METHOD__ );
686                    $cdb->commit( __METHOD__ );
687                } catch ( Exception $e ) {
688                    throw new MWException( "Caught exception ($e) while trying to drop Cargo table. "
689                    . "Please make sure that your database user account has the DROP permission." );
690                }
691                $dbw->delete( 'cargo_pages', [ 'table_name' => $curTable ], __METHOD__ );
692            }
693
694            $dbw->delete( 'cargo_tables', [ 'template_id' => $templatePageID ], __METHOD__ );
695        }
696
697        self::createCargoTableOrTables( $cdb, $dbw, $tableName, $tableSchema, $tableSchemaString, $templatePageID );
698
699        if ( !$createReplacement ) {
700            // Log this.
701            if ( $mainTableAlreadyExists ) {
702                self::logTableAction( 'recreatetable', $tableName, $user );
703            } else {
704                self::logTableAction( 'createtable', $tableName, $user );
705            }
706        }
707
708        return true;
709    }
710
711    public static function tableFullyExists( $tableName ) {
712        $dbr = self::getMainDBForRead();
713        $numRows = $dbr->selectRowCount( 'cargo_tables', '*', [ 'main_table' => $tableName ], __METHOD__ );
714        if ( $numRows == 0 ) {
715            return false;
716        }
717
718        $cdb = self::getDB();
719        return $cdb->tableExists( $tableName, __METHOD__ );
720    }
721
722    public static function fieldTypeToSQLType( $fieldType, $dbType, $size = null ) {
723        global $wgCargoDefaultStringBytes;
724
725        // Possible values for $dbType: "mysql", "postgres", "sqlite"
726        // @TODO - make sure it's one of these.
727        if ( $fieldType == 'Integer' ) {
728            switch ( $dbType ) {
729                case "mysql":
730                case "postgres":
731                    return 'Int';
732                case "sqlite":
733                    return 'INTEGER';
734            }
735        } elseif ( $fieldType == 'Float' || $fieldType == 'Rating' ) {
736            switch ( $dbType ) {
737                case "mysql":
738                    return 'Double';
739                case "postgres":
740                    return 'Numeric';
741                case "sqlite":
742                    return 'REAL';
743            }
744        } elseif ( $fieldType == 'Boolean' ) {
745            switch ( $dbType ) {
746                case "mysql":
747                case "postgres":
748                    return 'Boolean';
749                case "sqlite":
750                    return 'INTEGER';
751            }
752        } elseif ( $fieldType == 'Date' || $fieldType == 'Start date' || $fieldType == 'End date' ) {
753            switch ( $dbType ) {
754                case "mysql":
755                case "postgres":
756                    return 'Date';
757                case "sqlite":
758                    // Should really be 'REAL', with
759                    // accompanying handling.
760                    return 'TEXT';
761            }
762        } elseif ( $fieldType == 'Datetime' || $fieldType == 'Start datetime' || $fieldType == 'End datetime' ) {
763            // Some DB types have a datetime type that includes
764            // the time zone, but MySQL unfortunately doesn't,
765            // so the best solution for time zones is probably
766            // to have a separate field for them.
767            switch ( $dbType ) {
768                case "mysql":
769                    return 'Datetime';
770                case "postgres":
771                    return 'Timestamp';
772                case "sqlite":
773                    // Should really be 'REAL', with
774                    // accompanying handling.
775                    return 'TEXT';
776            }
777        } elseif ( $fieldType == 'Text' || $fieldType == 'Wikitext' ) {
778            switch ( $dbType ) {
779                case "mysql":
780                case "postgres":
781                case "sqlite":
782                    return 'Text';
783            }
784        } elseif ( $fieldType == 'Searchtext' ) {
785            if ( $dbType != 'mysql' ) {
786                throw new MWException( "Error: a \"Searchtext\" field can currently only be defined for MySQL databases." );
787            }
788            return 'Mediumtext';
789        } else { // 'String', 'Page', 'Wikitext string', etc.
790            if ( $size == null ) {
791                $size = $wgCargoDefaultStringBytes;
792            }
793            switch ( $dbType ) {
794                case "mysql":
795                case "postgres":
796                    // For at least MySQL, there's a limit
797                    // on how many total bytes a table's
798                    // fields can have, and "Text" and
799                    // "Blob" fields don't get added to the
800                    // total, so if it's a big piece of
801                    // text, just make it a "Text" field.
802                    if ( $size > 1000 ) {
803                        return 'Text';
804                    } else {
805                        return "Varchar($size)";
806                    }
807                case "sqlite":
808                    return 'TEXT';
809            }
810        }
811    }
812
813    public static function createCargoTableOrTables( $cdb, $dbw, $tableName, $tableSchema, $tableSchemaString, $templatePageID ) {
814        $cdb->begin( __METHOD__ );
815        $fieldsInMainTable = [
816            '_ID' => 'Integer',
817            '_pageName' => 'String',
818            '_pageTitle' => 'String',
819            '_pageNamespace' => 'Integer',
820            '_pageID' => 'Integer',
821        ];
822
823        $containsFileType = false;
824        foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) {
825            $isList = $fieldDescription->mIsList;
826            $fieldType = $fieldDescription->mType;
827
828            if ( $isList || $fieldType == 'Coordinates' ) {
829                // No field will be created with this name -
830                // instead, we'll have one called
831                // fieldName + '__full', and a separate table
832                // for holding each value.
833                // The field holding the full list will always
834                // just be text - and it could be long.
835                $fieldsInMainTable[$fieldName . '__full'] = 'Text';
836            } else {
837                $fieldsInMainTable[$fieldName] = $fieldDescription;
838            }
839
840            if ( !$isList && $fieldType == 'Coordinates' ) {
841                $fieldsInMainTable[$fieldName . '__lat'] = 'Float';
842                $fieldsInMainTable[$fieldName . '__lon'] = 'Float';
843            } elseif ( $fieldType == 'Date' || $fieldType == 'Datetime' ||
844                    $fieldType == 'Start date' || $fieldType == 'Start datetime' ||
845                    $fieldType == 'End date' || $fieldType == 'End datetime' ) {
846                $fieldsInMainTable[$fieldName . '__precision'] = 'Integer';
847            } elseif ( $fieldType == 'File' ) {
848                $containsFileType = true;
849            }
850        }
851
852        self::createTable( $cdb, $tableName, $fieldsInMainTable );
853
854        // Now also create tables for each of the 'list' fields,
855        // if there are any.
856        $fieldTableNames = []; // Names of tables that store data regarding pages
857        $fieldHelperTableNames = []; // Names of tables that store metadata regarding template or fields
858        foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) {
859            if ( $fieldDescription->mIsList ) {
860                // The double underscore in this table name
861                // should prevent anyone from giving this name
862                // to a "real" table.
863                $fieldTableName = $tableName . '__' . $fieldName;
864                $cdb->dropTable( $fieldTableName, __METHOD__ );
865
866                $fieldsInTable = [ '_rowID' => 'Integer' ];
867                $fieldType = $fieldDescription->mType;
868                if ( $fieldType == 'Coordinates' ) {
869                    $fieldsInTable['_lat'] = 'Float';
870                    $fieldsInTable['_lon'] = 'Float';
871                } else {
872                    $fieldsInTable['_value'] = $fieldType;
873                }
874                if ( $fieldDescription->isDateOrDateTime() ) {
875                    $fieldsInTable['_value__precision'] = 'Integer';
876                }
877                $fieldsInTable['_position'] = 'Integer';
878
879                self::createTable( $cdb, $fieldTableName, $fieldsInTable );
880                $fieldTableNames[] = $fieldTableName;
881            }
882            if ( $fieldDescription->mIsHierarchy ) {
883                $fieldHelperTableName = $tableName . '__' . $fieldName . '__hierarchy';
884                $cdb->dropTable( $fieldHelperTableName, __METHOD__ );
885                $fieldType = $fieldDescription->mType;
886                $fieldsInTable = [
887                    '_value' => $fieldType,
888                    '_left' => 'Integer',
889                    '_right' => 'Integer',
890                ];
891                self::createTable( $cdb, $fieldHelperTableName, $fieldsInTable, true );
892
893                $fieldHelperTableNames[] = $fieldHelperTableName;
894                // Insert hierarchy information in the __hierarchy table
895                $hierarchyTree = CargoHierarchyTree::newFromWikiText( $fieldDescription->mHierarchyStructure );
896                $hierarchyStructureTableData = $hierarchyTree->generateHierarchyStructureTableData();
897                foreach ( $hierarchyStructureTableData as $entry ) {
898                    $cdb->insert( $fieldHelperTableName, $entry, __METHOD__ );
899                }
900            }
901        }
902
903        // And create a helper table holding all the files stored in
904        // this table, if there are any.
905        if ( $containsFileType ) {
906            $fileTableName = $tableName . '___files';
907            $cdb->dropTable( $fileTableName, __METHOD__ );
908            $fieldsInTable = [
909                '_pageName' => 'String',
910                '_pageID' => 'Integer',
911                '_fieldName' => 'String',
912                '_fileName' => 'String'
913            ];
914            self::createTable( $cdb, $fileTableName, $fieldsInTable );
915        }
916
917        // End transaction and apply DB changes.
918        $cdb->commit( __METHOD__ );
919
920        // Finally, store all the info in the cargo_tables table.
921        $dbw->insert( 'cargo_tables', [
922            'template_id' => $templatePageID,
923            'main_table' => $tableName,
924            'field_tables' => serialize( $fieldTableNames ),
925            'field_helper_tables' => serialize( $fieldHelperTableNames ),
926            'table_schema' => $tableSchemaString
927        ], __METHOD__ );
928    }
929
930    public static function createTable( $cdb, $tableName, $fieldsInTable, $multipleColumnIndex = false ) {
931        global $wgCargoDBRowFormat;
932
933        // Unfortunately, there is not yet a 'CREATE TABLE' wrapper
934        // in the MediaWiki DB API, so we have to call SQL directly.
935        $dbType = $cdb->getType();
936        $sqlTableName = $cdb->tableName( $tableName );
937        $createSQL = "CREATE TABLE $sqlTableName ( ";
938        $firstField = true;
939        foreach ( $fieldsInTable as $fieldName => $fieldDescOrType ) {
940            $fieldOptionsText = '';
941            if ( is_object( $fieldDescOrType ) ) {
942                $fieldType = $fieldDescOrType->mType;
943                $fieldSize = $fieldDescOrType->mSize;
944                $sqlType = self::fieldTypeToSQLType( $fieldType, $dbType, $fieldSize );
945
946                if ( $fieldDescOrType->mIsMandatory ) {
947                    $fieldOptionsText .= ' NOT NULL';
948                }
949                if ( $fieldDescOrType->mIsUnique ) {
950                    $fieldOptionsText .= ' UNIQUE';
951                }
952            } else {
953                $fieldType = $fieldDescOrType;
954                $sqlType = self::fieldTypeToSQLType( $fieldType, $dbType );
955                if ( $fieldName == '_ID' ) {
956                    $fieldOptionsText .= ' PRIMARY KEY';
957                } elseif ( $fieldName == '_rowID' ) {
958                    $fieldOptionsText .= ' NOT NULL';
959                }
960            }
961            if ( $firstField ) {
962                $firstField = false;
963            } else {
964                $createSQL .= ', ';
965            }
966            $sqlFieldName = $cdb->addIdentifierQuotes( $fieldName );
967            $createSQL .= "$sqlFieldName $sqlType $fieldOptionsText";
968            if ( $fieldType == 'Searchtext' ) {
969                $createSQL .= ", FULLTEXT KEY $fieldName ( $sqlFieldName )";
970            }
971        }
972
973        $createSQL .= ' )';
974        // Allow for setting a format like COMPRESSED, DYNAMIC etc.
975        if ( $wgCargoDBRowFormat != null ) {
976            $createSQL .= " ROW_FORMAT=$wgCargoDBRowFormat";
977        }
978        $cdb->query( $createSQL, __METHOD__ );
979
980        // Add an index for any field that's not of type Text,
981        // Searchtext or Wikitext.
982        $indexedFields = [];
983        foreach ( $fieldsInTable as $fieldName => $fieldDescOrType ) {
984            // We don't need to index _ID, because it's already
985            // the primary key.
986            if ( $fieldName == '_ID' ) {
987                continue;
988            }
989
990            // @HACK - MySQL does not allow more than 64 keys/
991            // indexes per table. We are indexing most fields -
992            // so if a table has more than 64 fields, there's a
993            // good chance that it will overrun this limit.
994            // So we just stop indexing after the first 60.
995            if ( count( $indexedFields ) >= 60 ) {
996                break;
997            }
998
999            if ( is_object( $fieldDescOrType ) ) {
1000                $fieldType = $fieldDescOrType->mType;
1001            } else {
1002                $fieldType = $fieldDescOrType;
1003            }
1004            if ( in_array( $fieldType, [ 'Text', 'Searchtext', 'Wikitext' ] ) ) {
1005                continue;
1006            }
1007            $indexedFields[] = $fieldName;
1008        }
1009
1010        if ( $multipleColumnIndex ) {
1011            $indexName = "nested_set_$tableName";
1012            $sqlFieldNames = array_map(
1013                [ $cdb, 'addIdentifierQuotes' ],
1014                $indexedFields
1015            );
1016            $sqlFieldNamesStr = implode( ', ', $sqlFieldNames );
1017            $createIndexSQL = "CREATE INDEX $indexName ON " .
1018                "$sqlTableName ($sqlFieldNamesStr)";
1019            $cdb->query( $createIndexSQL, __METHOD__ );
1020        } else {
1021            foreach ( $indexedFields as $fieldName ) {
1022                $indexName = $fieldName . '_' . $tableName;
1023                // MySQL doesn't allow index names with more than 64 characters.
1024                $indexName = substr( $indexName, 0, 64 );
1025                $sqlFieldName = $cdb->addIdentifierQuotes( $fieldName );
1026                $sqlIndexName = $cdb->addIdentifierQuotes( $indexName );
1027                $createIndexSQL = "CREATE INDEX $sqlIndexName ON " .
1028                    "$sqlTableName ($sqlFieldName)";
1029                $cdb->query( $createIndexSQL, __METHOD__ );
1030            }
1031        }
1032    }
1033
1034    public static function specialTableNames() {
1035        $specialTableNames = [ '_pageData', '_fileData' ];
1036        if ( class_exists( 'FDGanttContent' ) ) {
1037            // The Flex Diagrams extension is installed.
1038            $specialTableNames[] = '_bpmnData';
1039            $specialTableNames[] = '_ganttData';
1040        }
1041        return $specialTableNames;
1042    }
1043
1044    public static function fullTextMatchSQL( $cdb, $tableName, $fieldName, $searchTerm ) {
1045        $fullFieldName = self::escapedFieldName( $cdb, $tableName, $fieldName );
1046        $searchTerm = $cdb->addQuotes( $searchTerm );
1047        return " MATCH($fullFieldName) AGAINST ($searchTerm IN BOOLEAN MODE) ";
1048    }
1049
1050    /**
1051     * Parses one half of a set of coordinates into a number.
1052     *
1053     * Copied from Miga, also written by Yaron Koren
1054     * (https://github.com/yaronkoren/miga/blob/master/MDVCoordinates.js)
1055     * - though that one is in Javascript.
1056     */
1057    public static function coordinatePartToNumber( $coordinateStr ) {
1058        $degreesSymbols = [ "\x{00B0}", "d" ];
1059        $minutesSymbols = [ "'", "\x{2032}", "\x{00B4}" ];
1060        $secondsSymbols = [ '"', "\x{2033}", "\x{00B4}\x{00B4}" ];
1061
1062        $numDegrees = null;
1063        $numMinutes = null;
1064        $numSeconds = null;
1065
1066        foreach ( $degreesSymbols as $degreesSymbol ) {
1067            $pattern = '/([\d\.]+)' . $degreesSymbol . '/u';
1068            if ( preg_match( $pattern, $coordinateStr, $matches ) ) {
1069                $numDegrees = floatval( $matches[1] );
1070                break;
1071            }
1072        }
1073        if ( $numDegrees === null ) {
1074            throw new MWException( "Error: could not parse degrees in \"$coordinateStr\"." );
1075        }
1076
1077        foreach ( $minutesSymbols as $minutesSymbol ) {
1078            $pattern = '/([\d\.]+)' . $minutesSymbol . '/u';
1079            if ( preg_match( $pattern, $coordinateStr, $matches ) ) {
1080                $numMinutes = floatval( $matches[1] );
1081                break;
1082            }
1083        }
1084        if ( $numMinutes === null ) {
1085            // This might not be an error - the number of minutes
1086            // might just not have been set.
1087            $numMinutes = 0;
1088        }
1089
1090        foreach ( $secondsSymbols as $secondsSymbol ) {
1091            $pattern = '/(\d+)' . $secondsSymbol . '/u';
1092            if ( preg_match( $pattern, $coordinateStr, $matches ) ) {
1093                $numSeconds = floatval( $matches[1] );
1094                break;
1095            }
1096        }
1097        if ( $numSeconds === null ) {
1098            // This might not be an error - the number of seconds
1099            // might just not have been set.
1100            $numSeconds = 0;
1101        }
1102
1103        return ( $numDegrees + ( $numMinutes / 60 ) + ( $numSeconds / 3600 ) );
1104    }
1105
1106    /**
1107     * Parses a coordinate string in (hopefully) any standard format.
1108     *
1109     * Copied from Miga, also written by Yaron Koren
1110     * (https://github.com/yaronkoren/miga/blob/master/MDVCoordinates.js)
1111     * - though that one is in Javascript.
1112     */
1113    public static function parseCoordinatesString( $coordinatesString ) {
1114        $coordinatesString = trim( $coordinatesString );
1115        if ( $coordinatesString === '' ) {
1116            // FIXME: No caller expects this!
1117            return;
1118        }
1119
1120        // This is safe to do, right?
1121        $coordinatesString = str_replace( [ '[', ']' ], '', $coordinatesString );
1122        // See if they're separated by commas.
1123        if ( strpos( $coordinatesString, ',' ) > 0 ) {
1124            $latAndLonStrings = explode( ',', $coordinatesString );
1125        } else {
1126            // If there are no commas, the first half, for the
1127            // latitude, should end with either 'N' or 'S', so do a
1128            // little hack to split up the two halves.
1129            $coordinatesString = str_replace( [ 'N', 'S' ], [ 'N,', 'S,' ], $coordinatesString );
1130            $latAndLonStrings = explode( ',', $coordinatesString );
1131        }
1132
1133        if ( count( $latAndLonStrings ) != 2 ) {
1134            throw new MWException( "Error parsing coordinates string: \"$coordinatesString\"." );
1135        }
1136        [ $latString, $lonString ] = $latAndLonStrings;
1137
1138        // Handle strings one at a time.
1139        $latIsNegative = false;
1140        if ( strpos( $latString, 'S' ) > 0 ) {
1141            $latIsNegative = true;
1142        }
1143        $latString = str_replace( [ 'N', 'S' ], '', $latString );
1144        if ( is_numeric( $latString ) ) {
1145            $latNum = floatval( $latString );
1146        } else {
1147            $latNum = self::coordinatePartToNumber( $latString );
1148        }
1149        if ( $latIsNegative ) {
1150            $latNum *= -1;
1151        }
1152
1153        $lonIsNegative = false;
1154        if ( strpos( $lonString, 'W' ) > 0 ) {
1155            $lonIsNegative = true;
1156        }
1157        $lonString = str_replace( [ 'E', 'W' ], '', $lonString );
1158        if ( is_numeric( $lonString ) ) {
1159            $lonNum = floatval( $lonString );
1160        } else {
1161            $lonNum = self::coordinatePartToNumber( $lonString );
1162        }
1163        if ( $lonIsNegative ) {
1164            $lonNum *= -1;
1165        }
1166
1167        return [ $latNum, $lonNum ];
1168    }
1169
1170    public static function escapedFieldName( $cdb, $tableName, $fieldName ) {
1171        if ( is_array( $tableName ) ) {
1172            $tableAlias = key( $tableName );
1173            return $cdb->addIdentifierQuotes( $tableAlias ) . '.' .
1174                $cdb->addIdentifierQuotes( $fieldName );
1175        }
1176        return $cdb->tableName( $tableName ) . '.' .
1177            $cdb->addIdentifierQuotes( $fieldName );
1178    }
1179
1180    public static function joinOfMainAndFieldTable( $cdb, $mainTableName, $fieldTableName ) {
1181        return [
1182            'LEFT OUTER JOIN',
1183            self::escapedFieldName( $cdb, $mainTableName, '_ID' ) .
1184                ' = ' .
1185                self::escapedFieldName( $cdb, $fieldTableName, '_rowID' )
1186        ];
1187    }
1188
1189    public static function joinOfMainAndParentTable( $cdb, $mainTable, $mainTableField,
1190            $parentTable, $parentTableField ) {
1191        return [
1192            'LEFT OUTER JOIN',
1193            self::escapedFieldName( $cdb, $mainTable, $mainTableField ) .
1194            ' = ' .
1195            self::escapedFieldName( $cdb, $parentTable, $parentTableField )
1196        ];
1197    }
1198
1199    public static function joinOfFieldAndMainTable( $cdb, $fieldTable, $mainTable,
1200            $isHierarchy = false, $hierarchyFieldName = null ) {
1201        if ( $isHierarchy ) {
1202            return [
1203                'LEFT OUTER JOIN',
1204                self::escapedFieldName( $cdb, $fieldTable, '_value' ) . ' = ' .
1205                self::escapedFieldName( $cdb, $mainTable, $hierarchyFieldName ),
1206            ];
1207        } else {
1208            return [
1209                'LEFT OUTER JOIN',
1210                self::escapedFieldName( $cdb, $fieldTable, '_rowID' ) . ' = ' .
1211                self::escapedFieldName( $cdb, $mainTable, '_ID' ),
1212            ];
1213        }
1214    }
1215
1216    public static function joinOfSingleFieldAndHierarchyTable( $cdb, $singleFieldTableName, $fieldColumnName, $hierarchyTableName ) {
1217        return [
1218            'LEFT OUTER JOIN',
1219            self::escapedFieldName( $cdb, $singleFieldTableName, $fieldColumnName ) .
1220                ' = ' .
1221                self::escapedFieldName( $cdb, $hierarchyTableName, '_value' )
1222        ];
1223    }
1224
1225    public static function escapedInsert( $db, $tableName, $fieldValues ) {
1226        // Put quotes around the field names - needed for Postgres,
1227        // which otherwise lowercases all field names.
1228        $quotedFieldValues = [];
1229        foreach ( $fieldValues as $fieldName => $fieldValue ) {
1230            $quotedFieldName = $db->addIdentifierQuotes( $fieldName );
1231            $quotedFieldValues[$quotedFieldName] = $fieldValue;
1232        }
1233        // Calling tableName() here is necessary, for some reason,
1234        // to pass validation (and maybe even to work at all?) for
1235        // MW 1.41+.
1236        $sqlTableName = $db->tableName( $tableName );
1237        $db->insert( $sqlTableName, $quotedFieldValues, __METHOD__ );
1238    }
1239
1240    /**
1241     * @param LinkRenderer $linkRenderer
1242     * @param LinkTarget|Title $title
1243     * @param string|null $msg Must already be HTML escaped
1244     * @param array $attrs link attributes
1245     * @param array $params query parameters
1246     *
1247     * @return string|null HTML link
1248     */
1249    public static function makeLink( $linkRenderer, $title, $msg = null, $attrs = [], $params = [] ) {
1250        global $wgTitle;
1251
1252        if ( $title === null ) {
1253            return null;
1254        } elseif ( $wgTitle !== null && $title->equals( $wgTitle ) ) {
1255            // Display bolded text instead of a link.
1256            return Linker::makeSelfLinkObj( $title, $msg );
1257        } else {
1258            $html = ( $msg == null ) ? null : new HtmlArmor( $msg );
1259            return $linkRenderer->makeLink( $title, $html, $attrs, $params );
1260        }
1261    }
1262
1263    public static function getSpecialPage( $pageName ) {
1264        return MediaWikiServices::getInstance()->getSpecialPageFactory()
1265            ->getPage( $pageName );
1266    }
1267
1268    /**
1269     * Get the wiki's content language.
1270     * @since 2.6
1271     * @return Language
1272     */
1273    public static function getContentLang() {
1274        return MediaWikiServices::getInstance()->getContentLanguage();
1275    }
1276
1277    public static function logTableAction( $actionName, $tableName, User $user ) {
1278        $log = new LogPage( 'cargo' );
1279        $ctPage = self::getSpecialPage( 'CargoTables' );
1280        $ctTitle = $ctPage->getPageTitle();
1281        if ( $actionName == 'deletetable' ) {
1282            $logParams = [ $tableName ];
1283        } else {
1284            $ctURL = $ctTitle->getFullURL();
1285            $tableURL = "$ctURL/$tableName";
1286            $tableLink = Html::element(
1287                'a',
1288                [ 'href' => $tableURL ],
1289                $tableName
1290            );
1291            $logParams = [ $tableLink ];
1292        }
1293        // Every log entry requires an associated title; Cargo table
1294        // actions don't involve an actual page, so we just use
1295        // Special:CargoTables as the title.
1296        $log->addEntry( $actionName, $ctTitle, '', $logParams, $user );
1297    }
1298
1299    public static function validateHierarchyStructure( $hierarchyStructure ) {
1300        $hierarchyNodesArray = explode( "\n", $hierarchyStructure );
1301        $matches = [];
1302        preg_match( '/^([*]*)[^*]*/i', $hierarchyNodesArray[0], $matches );
1303        if ( strlen( $matches[1] ) != 1 ) {
1304            throw new MWException( "Error: First entry of hierarchy values should start with exact one '*', the entry \"" .
1305                $hierarchyNodesArray[0] . "\" has " . strlen( $matches[1] ) . " '*'" );
1306        }
1307        $level = 0;
1308        foreach ( $hierarchyNodesArray as $node ) {
1309            if ( !preg_match( '/^([*]*)( *)(.*)/i', $node, $matches ) ) {
1310                throw new MWException( "Error: The \"" . $node . "\" entry of hierarchy values does not follow syntax. " .
1311                    "The entry should be of the form : * entry" );
1312            }
1313            if ( strlen( $matches[1] ) < 1 ) {
1314                throw new MWException( "Error: Each entry of hierarchy values should start with atleast one '*', the entry \"" .
1315                    $node . "\" has " . strlen( $matches[1] ) . " '*'" );
1316            }
1317            if ( strlen( $matches[1] ) - $level > 1 ) {
1318                throw new MWException( "Error: Level or count of '*' in hierarchy values should be increased only by count of 1, the entry \"" .
1319                    $node . "\" should have " . ( $level + 1 ) . " or less '*'" );
1320            }
1321            $level = strlen( $matches[1] );
1322            if ( strlen( $matches[3] ) == 0 ) {
1323                throw new MWException( "Error: The entry of hierarchy values cannot be empty." );
1324            }
1325        }
1326    }
1327
1328    public static function validateFieldDescriptionString( $fieldDescriptionStr ) {
1329        $hasParameterFormat = preg_match( '/^([^(]*)\s*\((.*)\)$/s', $fieldDescriptionStr, $matches );
1330
1331        if ( !$hasParameterFormat ) {
1332            if ( self::stringContainsParentheses( $fieldDescriptionStr ) ) {
1333                throw new MWException( 'Invalid field description - Parentheses do not match: "' . $fieldDescriptionStr . '"' );
1334            }
1335        } else {
1336            if ( count( $matches ) == 3 ) {
1337                $extraParamsString = $matches[2];
1338                $extraParams = explode( ';', $extraParamsString );
1339
1340                foreach ( $extraParams as $extraParam ) {
1341                    $extraParamParts = explode( '=', $extraParam, 2 );
1342                    $paramKey = strtolower( trim( $extraParamParts[0] ) );
1343                    $paramValue = isset( $extraParamParts[1] ) ? trim( $extraParamParts[1] ) : '';
1344
1345                    if ( self::stringContainsParentheses( $paramKey ) ) {
1346                        throw new MWException( 'Invalid field description - Parameter name "' . $paramKey . '" must not include parentheses: "' . $fieldDescriptionStr . '"' );
1347                    }
1348
1349                    if ( $paramKey == 'allowed values' ) {
1350                        continue;
1351                    }
1352
1353                    if ( self::stringContainsParentheses( $paramValue ) ) {
1354                        throw new MWException( 'Invalid field description - Parameter value "' . $paramValue . '" must not include parentheses: "' . $fieldDescriptionStr . '"' );
1355                    }
1356                }
1357            }
1358        }
1359    }
1360
1361    public static function globalFields() {
1362        return [
1363            '_pageID' => [ 'type' => 'Integer', 'isList' => false ],
1364            '_pageName' => [ 'type' => 'Page', 'isList' => false ],
1365            '_pageTitle' => [ 'type' => 'String', 'isList' => false ],
1366            '_pageNamespace' => [ 'type' => 'Integer', 'isList' => false ],
1367        ];
1368    }
1369
1370    public static function addGlobalFieldsToSchema( $schema ) {
1371        foreach ( self::globalFields() as $field => $fieldInfo ) {
1372            $fieldDesc = new CargoFieldDescription();
1373            $fieldDesc->mType = $fieldInfo["type"];
1374            $fieldDesc->mIsList = $fieldInfo["isList"];
1375            if ( $fieldInfo["isList"] ) {
1376                $fieldDesc->setDelimiter( '|' );
1377            }
1378            $schema->addField( $field, $fieldDesc );
1379        }
1380    }
1381
1382    public static function stringContainsParentheses( $str ) {
1383        return substr_count( $str, ')' ) > 0 || substr_count( $str, '(' ) > 0;
1384    }
1385
1386    public static function makeWikiPage( $title ) {
1387        return MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title );
1388    }
1389
1390    /**
1391     * Replacement for the removed ContentHandler::getContextText().
1392     */
1393    public static function getContentText( $content ) {
1394        if ( !$content instanceof TextContent ) {
1395            return null;
1396        }
1397        return $content->getText();
1398    }
1399}