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