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