Merge "resourceloader: Add $conf parameter to the 'ResourceLoaderGetConfigVars' hook"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Sun, 8 Sep 2019 02:37:44 +0000 (02:37 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Sun, 8 Sep 2019 02:37:44 +0000 (02:37 +0000)
48 files changed:
RELEASE-NOTES-1.34
api.php
autoload.php
includes/WebRequest.php
includes/api/ApiBase.php
includes/api/ApiMain.php
includes/config/ConfigFactory.php
includes/config/ConfigRepository.php
includes/deferred/CdnCacheUpdate.php
includes/deferred/JobQueueEnqueueUpdate.php
includes/deferred/MessageCacheUpdate.php
includes/deferred/SiteStatsUpdate.php
includes/deferred/UserEditCountUpdate.php
includes/installer/SqliteInstaller.php
includes/libs/StatusValue.php
includes/libs/filebackend/FSFileBackend.php
includes/logging/LogPage.php
includes/specials/SpecialRecentChangesLinked.php
includes/specials/SpecialStatistics.php
includes/upload/UploadFromStash.php
maintenance/Doxyfile
maintenance/dumpUploads.php
maintenance/includes/MWDoxygenFilter.php [new file with mode: 0644]
maintenance/jsduck/categories.json
maintenance/mergeMessageFileList.php
maintenance/mwdoc-filter.php
maintenance/mwdocgen.php
maintenance/resetUserTokens.php
resources/Resources.php
resources/src/jquery.tablesorter/jquery.tablesorter.js
resources/src/jquery/jquery.accessKeyLabel.js [deleted file]
resources/src/jquery/jquery.highlightText.js
resources/src/mediawiki.RegExp.js
resources/src/mediawiki.htmlform/cloner.js
resources/src/mediawiki.inspect.js
resources/src/mediawiki.page.watch.ajax.js
resources/src/mediawiki.util/.eslintrc.json [new file with mode: 0644]
resources/src/mediawiki.util/jquery.accessKeyLabel.js [new file with mode: 0644]
resources/src/mediawiki.util/util.js
resources/src/mediawiki.widgets.datetime/DateTimeFormatter.js
resources/src/moment/moment-locale-overrides.js
tests/phpunit/includes/filebackend/FileBackendTest.php
tests/phpunit/maintenance/MWDoxygenFilterTest.php [new file with mode: 0644]
tests/phpunit/unit/includes/installer/SqliteInstallerTest.php [new file with mode: 0644]
tests/qunit/QUnitTestResources.php
tests/qunit/suites/resources/mediawiki/mediawiki.RegExp.test.js [deleted file]
tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js
thumb.php

index 828d0fb..aaf4d78 100644 (file)
@@ -369,6 +369,8 @@ because of Phabricator reports.
   initialized after calling SearchResult::initFromTitle().
 * The UserIsBlockedFrom hook is only called if a block is found first, and
   should only be used to unblock a blocked user.
+* Parameters for index.php from PATH_INFO, such as the title, are no longer
+  written to $_GET.
 * …
 
 === Deprecations in 1.34 ===
@@ -425,6 +427,8 @@ because of Phabricator reports.
 * ResourceLoaderContext::getConfig and ResourceLoaderContext::getLogger have
   been deprecated. Inside ResourceLoaderModule subclasses, use the local methods
   instead. Elsewhere, use the methods from the ResourceLoader class.
+* The 'jquery.accessKeyLabel' module has been deprecated. This jQuery
+  plugin is now ships as part of the 'mediawiki.util' module bundle.
 * The Profiler::setTemplated and Profiler::getTemplated methods have been
   deprecated. Use Profiler::setAllowOutput and Profiler::getAllowOutput
   instead.
@@ -487,6 +491,7 @@ because of Phabricator reports.
   class. If you extend this class please be sure to override all its methods
   or extend RevisionSearchResult.
 * Skin::getSkinNameMessages() is deprecated and no longer used.
+* The mediawiki.RegExp module is deprecated; use mw.util.escapeRegExp() instead.
 
 === Other changes in 1.34 ===
 * …
diff --git a/api.php b/api.php
index 0fb674b..fe13263 100644 (file)
--- a/api.php
+++ b/api.php
@@ -44,7 +44,7 @@ if ( !$wgRequest->checkUrlExtension() ) {
 // PATH_INFO can be used for stupid things. We don't support it for api.php at
 // all, so error out if it's present.
 if ( isset( $_SERVER['PATH_INFO'] ) && $_SERVER['PATH_INFO'] != '' ) {
-       $correctUrl = wfAppendQuery( wfScript( 'api' ), $wgRequest->getQueryValues() );
+       $correctUrl = wfAppendQuery( wfScript( 'api' ), $wgRequest->getQueryValuesOnly() );
        $correctUrl = wfExpandUrl( $correctUrl, PROTO_CANONICAL );
        header( "Location: $correctUrl", true, 301 );
        echo 'This endpoint does not support "path info", i.e. extra text between "api.php"'
index eb54f7c..0255a23 100644 (file)
@@ -829,6 +829,7 @@ $wgAutoloadLocalClasses = [
        'MWCryptRand' => __DIR__ . '/includes/utils/MWCryptRand.php',
        'MWDebug' => __DIR__ . '/includes/debug/MWDebug.php',
        'MWDocGen' => __DIR__ . '/maintenance/mwdocgen.php',
+       'MWDoxygenFilter' => __DIR__ . '/maintenance/includes/MWDoxygenFilter.php',
        'MWException' => __DIR__ . '/includes/exception/MWException.php',
        'MWExceptionHandler' => __DIR__ . '/includes/exception/MWExceptionHandler.php',
        'MWExceptionRenderer' => __DIR__ . '/includes/exception/MWExceptionRenderer.php',
index 7b14667..a48d032 100644 (file)
@@ -40,9 +40,28 @@ use Wikimedia\AtEase\AtEase;
  * @ingroup HTTP
  */
 class WebRequest {
-       /** @var array */
+       /**
+        * The parameters from $_GET, $_POST and the path router
+        * @var array
+        */
        protected $data;
-       /** @var array */
+
+       /**
+        * The parameters from $_GET. The parameters from the path router are
+        * added by interpolateTitle() during Setup.php.
+        * @var array
+        */
+       protected $queryAndPathParams;
+
+       /**
+        * The parameters from $_GET only.
+        */
+       protected $queryParams;
+
+       /**
+        * Lazy-initialized request headers indexed by upper-case header name
+        * @var array
+        */
        protected $headers = [];
 
        /**
@@ -100,6 +119,8 @@ class WebRequest {
                // POST overrides GET data
                // We don't use $_REQUEST here to avoid interference from cookies...
                $this->data = $_POST + $_GET;
+
+               $this->queryAndPathParams = $this->queryParams = $_GET;
        }
 
        /**
@@ -336,7 +357,7 @@ class WebRequest {
 
                $matches = self::getPathInfo( 'title' );
                foreach ( $matches as $key => $val ) {
-                       $this->data[$key] = $_GET[$key] = $_REQUEST[$key] = $val;
+                       $this->data[$key] = $this->queryAndPathParams[$key] = $val;
                }
        }
 
@@ -668,14 +689,27 @@ class WebRequest {
        }
 
        /**
-        * Get the values passed in the query string.
+        * Get the values passed in the query string and the path router parameters.
         * No transformation is performed on the values.
         *
         * @codeCoverageIgnore
         * @return array
         */
        public function getQueryValues() {
-               return $_GET;
+               return $this->queryAndPathParams;
+       }
+
+       /**
+        * Get the values passed in the query string only, not including the path
+        * router parameters. This is less suitable for self-links to index.php but
+        * useful for other entry points. No transformation is performed on the
+        * values.
+        *
+        * @since 1.34
+        * @return array
+        */
+       public function getQueryValuesOnly() {
+               return $this->queryParams;
        }
 
        /**
index 0cd9806..056d10c 100644 (file)
@@ -992,7 +992,7 @@ abstract class ApiBase extends ContextSource {
                        return;
                }
 
-               $queryValues = $this->getRequest()->getQueryValues();
+               $queryValues = $this->getRequest()->getQueryValuesOnly();
                $badParams = [];
                foreach ( $params as $param ) {
                        if ( $prefix !== 'noprefix' ) {
index a9fe258..f0e0077 100644 (file)
@@ -293,7 +293,6 @@ class ApiMain extends ApiBase {
                $this->mEnableWrite = $enableWrite;
 
                $this->mCdnMaxAge = -1; // flag for executeActionWithErrorHandling()
-               $this->mCommit = false;
        }
 
        /**
index 696bbf4..bd174b2 100644 (file)
@@ -66,7 +66,8 @@ class ConfigFactory implements SalvageableService {
        public function salvage( SalvageableService $other ) {
                Assert::parameterType( self::class, $other, '$other' );
 
-               /** @var ConfigFactory $other */
+               /** @var self $other */
+               '@phan-var self $other';
                foreach ( $other->factoryFunctions as $name => $otherFunc ) {
                        if ( !isset( $this->factoryFunctions[$name] ) ) {
                                continue;
index d48eb0e..ceb3944 100644 (file)
@@ -188,6 +188,8 @@ class ConfigRepository implements SalvageableService {
         */
        public function salvage( SalvageableService $other ) {
                Assert::parameterType( self::class, $other, '$other' );
+               /** @var self $other */
+               '@phan-var self $other';
 
                foreach ( $other->configItems['public'] as $name => $otherConfig ) {
                        if ( isset( $this->configItems['public'][$name] ) ) {
index 66ce9a3..ddffaa3 100644 (file)
@@ -39,8 +39,9 @@ class CdnCacheUpdate implements DeferrableUpdate, MergeableUpdate {
        }
 
        public function merge( MergeableUpdate $update ) {
-               /** @var CdnCacheUpdate $update */
+               /** @var self $update */
                Assert::parameterType( __CLASS__, $update, '$update' );
+               '@phan-var self $update';
 
                $this->urls = array_merge( $this->urls, $update->urls );
        }
index 1691da2..d1b592d 100644 (file)
@@ -41,8 +41,9 @@ class JobQueueEnqueueUpdate implements DeferrableUpdate, MergeableUpdate {
        }
 
        public function merge( MergeableUpdate $update ) {
-               /** @var JobQueueEnqueueUpdate $update */
+               /** @var self $update */
                Assert::parameterType( __CLASS__, $update, '$update' );
+               '@phan-var self $update';
 
                foreach ( $update->jobsByDomain as $domain => $jobs ) {
                        $this->jobsByDomain[$domain] = $this->jobsByDomain[$domain] ?? [];
index c499d08..7f56a36 100644 (file)
@@ -42,8 +42,9 @@ class MessageCacheUpdate implements DeferrableUpdate, MergeableUpdate {
        }
 
        public function merge( MergeableUpdate $update ) {
-               /** @var MessageCacheUpdate $update */
+               /** @var self $update */
                Assert::parameterType( __CLASS__, $update, '$update' );
+               '@phan-var self $update';
 
                foreach ( $update->replacements as $code => $messages ) {
                        $this->replacements[$code] = array_merge( $this->replacements[$code] ?? [], $messages );
index 11e9337..dbd7c50 100644 (file)
@@ -56,6 +56,7 @@ class SiteStatsUpdate implements DeferrableUpdate, MergeableUpdate {
        public function merge( MergeableUpdate $update ) {
                /** @var SiteStatsUpdate $update */
                Assert::parameterType( __CLASS__, $update, '$update' );
+               '@phan-var SiteStatsUpdate $update';
 
                foreach ( self::$counters as $field ) {
                        $this->$field += $update->$field;
index 687dfbe..4333c94 100644 (file)
@@ -46,6 +46,7 @@ class UserEditCountUpdate implements DeferrableUpdate, MergeableUpdate {
        public function merge( MergeableUpdate $update ) {
                /** @var UserEditCountUpdate $update */
                Assert::parameterType( __CLASS__, $update, '$update' );
+               '@phan-var UserEditCountUpdate $update';
 
                foreach ( $update->infoByUser as $userId => $info ) {
                        if ( !isset( $this->infoByUser[$userId] ) ) {
index 01bb30e..b21a177 100644 (file)
@@ -71,16 +71,19 @@ class SqliteInstaller extends DatabaseInstaller {
        }
 
        public function getGlobalDefaults() {
+               global $IP;
                $defaults = parent::getGlobalDefaults();
-               if ( isset( $_SERVER['DOCUMENT_ROOT'] ) ) {
-                       $path = str_replace(
-                               [ '/', '\\' ],
-                               DIRECTORY_SEPARATOR,
-                               dirname( $_SERVER['DOCUMENT_ROOT'] ) . '/data'
-                       );
-
-                       $defaults['wgSQLiteDataDir'] = $path;
+               if ( !empty( $_SERVER['DOCUMENT_ROOT'] ) ) {
+                       $path = dirname( $_SERVER['DOCUMENT_ROOT'] );
+               } else {
+                       // We use $IP when unable to get $_SERVER['DOCUMENT_ROOT']
+                       $path = $IP;
                }
+               $defaults['wgSQLiteDataDir'] = str_replace(
+                       [ '/', '\\' ],
+                       DIRECTORY_SEPARATOR,
+                       $path . '/data'
+               );
                return $defaults;
        }
 
@@ -122,7 +125,7 @@ class SqliteInstaller extends DatabaseInstaller {
 
                # Try realpath() if the directory already exists
                $dir = self::realpath( $this->getVar( 'wgSQLiteDataDir' ) );
-               $result = self::dataDirOKmaybeCreate( $dir, true /* create? */ );
+               $result = self::checkDataDir( $dir );
                if ( $result->isOK() ) {
                        # Try expanding again in case we've just created it
                        $dir = self::realpath( $dir );
@@ -135,12 +138,17 @@ class SqliteInstaller extends DatabaseInstaller {
        }
 
        /**
-        * @param string $dir
-        * @param bool $create
-        * @return Status
+        * Check if the data directory is writable or can be created
+        * @param string $dir Path to the data directory
+        * @return Status Return fatal Status if $dir un-writable or no permission to create a directory
         */
-       private static function dataDirOKmaybeCreate( $dir, $create = false ) {
-               if ( !is_dir( $dir ) ) {
+       private static function checkDataDir( $dir ) : Status {
+               if ( is_dir( $dir ) ) {
+                       if ( !is_readable( $dir ) ) {
+                               return Status::newFatal( 'config-sqlite-dir-unwritable', $dir );
+                       }
+               } else {
+                       // Check the parent directory if $dir not exists
                        if ( !is_writable( dirname( $dir ) ) ) {
                                $webserverGroup = Installer::maybeGetWebserverPrimaryGroup();
                                if ( $webserverGroup !== null ) {
@@ -156,25 +164,25 @@ class SqliteInstaller extends DatabaseInstaller {
                                        );
                                }
                        }
+               }
+               return Status::newGood();
+       }
 
-                       # Called early on in the installer, later we just want to sanity check
-                       # if it's still writable
-                       if ( $create ) {
-                               Wikimedia\suppressWarnings();
-                               $ok = wfMkdirParents( $dir, 0700, __METHOD__ );
-                               Wikimedia\restoreWarnings();
-                               if ( !$ok ) {
-                                       return Status::newFatal( 'config-sqlite-mkdir-error', $dir );
-                               }
-                               # Put a .htaccess file in in case the user didn't take our advice
-                               file_put_contents( "$dir/.htaccess", "Deny from all\n" );
+       /**
+        * @param string $dir Path to the data directory
+        * @return Status Return good Status if without error
+        */
+       private static function createDataDir( $dir ) : Status {
+               if ( !is_dir( $dir ) ) {
+                       Wikimedia\suppressWarnings();
+                       $ok = wfMkdirParents( $dir, 0700, __METHOD__ );
+                       Wikimedia\restoreWarnings();
+                       if ( !$ok ) {
+                               return Status::newFatal( 'config-sqlite-mkdir-error', $dir );
                        }
                }
-               if ( !is_writable( $dir ) ) {
-                       return Status::newFatal( 'config-sqlite-dir-unwritable', $dir );
-               }
-
-               # We haven't blown up yet, fall through
+               # Put a .htaccess file in in case the user didn't take our advice
+               file_put_contents( "$dir/.htaccess", "Deny from all\n" );
                return Status::newGood();
        }
 
@@ -217,10 +225,15 @@ class SqliteInstaller extends DatabaseInstaller {
        public function setupDatabase() {
                $dir = $this->getVar( 'wgSQLiteDataDir' );
 
-               # Sanity check. We checked this before but maybe someone deleted the
-               # data dir between then and now
-               $dir_status = self::dataDirOKmaybeCreate( $dir, false /* create? */ );
-               if ( !$dir_status->isOK() ) {
+               # Sanity check (Only available in web installation). We checked this before but maybe someone
+               # deleted the data dir between then and now
+               $dir_status = self::checkDataDir( $dir );
+               if ( $dir_status->isGood() ) {
+                       $res = self::createDataDir( $dir );
+                       if ( !$res->isGood() ) {
+                               return $res;
+                       }
+               } else {
                        return $dir_status;
                }
 
index 71a0e34..4b381f8 100644 (file)
@@ -93,7 +93,7 @@ class StatusValue {
         *     1 => object(StatusValue) # The StatusValue with warning messages, only
         * ]
         *
-        * @return StatusValue[]
+        * @return static[]
         */
        public function splitByErrorType() {
                $errorsOnlyStatusValue = clone $this;
index c333a5e..0549d91 100644 (file)
@@ -40,6 +40,7 @@
  * @file
  * @ingroup FileBackend
  */
+use Wikimedia\AtEase\AtEase;
 use Wikimedia\Timestamp\ConvertibleTimestamp;
 
 /**
@@ -63,23 +64,22 @@ class FSFileBackend extends FileBackendStore {
        protected $basePath;
 
        /** @var array Map of container names to root paths for custom container paths */
-       protected $containerPaths = [];
+       protected $containerPaths;
 
+       /** @var int Directory permission mode */
+       protected $dirMode;
        /** @var int File permission mode */
        protected $fileMode;
-       /** @var int File permission mode */
-       protected $dirMode;
-
        /** @var string Required OS username to own files */
        protected $fileOwner;
 
-       /** @var bool */
+       /** @var bool Whether the OS is Windows (otherwise assumed Unix-like)*/
        protected $isWindows;
        /** @var string OS username running this script */
        protected $currentUser;
 
-       /** @var array */
-       protected $hadWarningErrors = [];
+       /** @var bool[] Map of (stack index => whether a warning happened) */
+       private $warningTrapStack = [];
 
        /**
         * @see FileBackendStore::__construct()
@@ -102,11 +102,9 @@ class FSFileBackend extends FileBackendStore {
                        $this->basePath = null; // none; containers must have explicit paths
                }
 
-               if ( isset( $config['containerPaths'] ) ) {
-                       $this->containerPaths = (array)$config['containerPaths'];
-                       foreach ( $this->containerPaths as &$path ) {
-                               $path = rtrim( $path, '/' ); // remove trailing slash
-                       }
+               $this->containerPaths = [];
+               foreach ( ( $config['containerPaths'] ?? [] ) as $container => $path ) {
+                       $this->containerPaths[$container] = rtrim( $path, '/' ); // remove trailing slash
                }
 
                $this->fileMode = $config['fileMode'] ?? 0644;
@@ -228,20 +226,12 @@ class FSFileBackend extends FileBackendStore {
                }
 
                if ( !empty( $params['async'] ) ) { // deferred
-                       $tempFile = $this->tmpFileFactory->newTempFSFile( 'create_', 'tmp' );
+                       $tempFile = $this->stageContentAsTempFile( $params );
                        if ( !$tempFile ) {
                                $status->fatal( 'backend-fail-create', $params['dst'] );
 
                                return $status;
                        }
-                       $this->trapWarnings();
-                       $bytes = file_put_contents( $tempFile->getPath(), $params['content'] );
-                       $this->untrapWarnings();
-                       if ( $bytes === false ) {
-                               $status->fatal( 'backend-fail-create', $params['dst'] );
-
-                               return $status;
-                       }
                        $cmd = implode( ' ', [
                                $this->isWindows ? 'COPY /B /Y' : 'cp', // (binary, overwrite)
                                escapeshellarg( $this->cleanPathSlashes( $tempFile->getPath() ) ),
@@ -376,34 +366,38 @@ class FSFileBackend extends FileBackendStore {
        protected function doMoveInternal( array $params ) {
                $status = $this->newStatus();
 
-               $source = $this->resolveToFSPath( $params['src'] );
-               if ( $source === null ) {
+               $fsSrcPath = $this->resolveToFSPath( $params['src'] );
+               if ( $fsSrcPath === null ) {
                        $status->fatal( 'backend-fail-invalidpath', $params['src'] );
 
                        return $status;
                }
 
-               $dest = $this->resolveToFSPath( $params['dst'] );
-               if ( $dest === null ) {
+               $fsDstPath = $this->resolveToFSPath( $params['dst'] );
+               if ( $fsDstPath === null ) {
                        $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
 
                        return $status;
                }
 
-               if ( !is_file( $source ) ) {
-                       if ( empty( $params['ignoreMissingSource'] ) ) {
-                               $status->fatal( 'backend-fail-move', $params['src'] );
-                       }
-
-                       return $status; // do nothing; either OK or bad status
+               if ( $fsSrcPath === $fsDstPath ) {
+                       return $status; // no-op
                }
 
+               $ignoreMissing = !empty( $params['ignoreMissingSource'] );
+
                if ( !empty( $params['async'] ) ) { // deferred
-                       $cmd = implode( ' ', [
-                               $this->isWindows ? 'MOVE /Y' : 'mv', // (overwrite)
-                               escapeshellarg( $this->cleanPathSlashes( $source ) ),
-                               escapeshellarg( $this->cleanPathSlashes( $dest ) )
-                       ] );
+                       // https://manpages.debian.org/buster/coreutils/mv.1.en.html
+                       // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/move
+                       $encSrc = escapeshellarg( $this->cleanPathSlashes( $fsSrcPath ) );
+                       $encDst = escapeshellarg( $this->cleanPathSlashes( $fsDstPath ) );
+                       if ( $this->isWindows ) {
+                               $writeCmd = "MOVE /Y $encSrc $encDst";
+                               $cmd = $ignoreMissing ? "IF EXIST $encSrc $writeCmd" : $writeCmd;
+                       } else {
+                               $writeCmd = "mv -f $encSrc $encDst";
+                               $cmd = $ignoreMissing ? "test -f $encSrc && $writeCmd" : $writeCmd;
+                       }
                        $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
                                if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
                                        $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
@@ -412,11 +406,13 @@ class FSFileBackend extends FileBackendStore {
                        };
                        $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
                } else { // immediate write
-                       $this->trapWarnings();
-                       $ok = ( $source === $dest ) ? true : rename( $source, $dest );
-                       $this->untrapWarnings();
-                       clearstatcache(); // file no longer at source
-                       if ( !$ok ) {
+                       // Use rename() here since (a) this clears xattrs, (b) any threads still reading the
+                       // old inode are unaffected since it writes to a new inode, and (c) this is fast and
+                       // atomic within a file system volume (as is normally the case)
+                       $this->trapWarnings( '/: No such file or directory$/' );
+                       $moved = rename( $fsSrcPath, $fsDstPath );
+                       $hadError = $this->untrapWarnings();
+                       if ( $hadError || ( !$moved && !$ignoreMissing ) ) {
                                $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
 
                                return $status;
@@ -429,26 +425,25 @@ class FSFileBackend extends FileBackendStore {
        protected function doDeleteInternal( array $params ) {
                $status = $this->newStatus();
 
-               $source = $this->resolveToFSPath( $params['src'] );
-               if ( $source === null ) {
+               $fsSrcPath = $this->resolveToFSPath( $params['src'] );
+               if ( $fsSrcPath === null ) {
                        $status->fatal( 'backend-fail-invalidpath', $params['src'] );
 
                        return $status;
                }
 
-               if ( !is_file( $source ) ) {
-                       if ( empty( $params['ignoreMissingSource'] ) ) {
-                               $status->fatal( 'backend-fail-delete', $params['src'] );
-                       }
-
-                       return $status; // do nothing; either OK or bad status
-               }
+               $ignoreMissing = !empty( $params['ignoreMissingSource'] );
 
                if ( !empty( $params['async'] ) ) { // deferred
-                       $cmd = implode( ' ', [
-                               $this->isWindows ? 'DEL' : 'unlink',
-                               escapeshellarg( $this->cleanPathSlashes( $source ) )
-                       ] );
+                       // https://manpages.debian.org/buster/coreutils/rm.1.en.html
+                       // https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/del
+                       $encSrc = escapeshellarg( $this->cleanPathSlashes( $fsSrcPath ) );
+                       if ( $this->isWindows ) {
+                               $writeCmd = "DEL /Q $encSrc";
+                               $cmd = $ignoreMissing ? "IF EXIST $encSrc $writeCmd" : $writeCmd;
+                       } else {
+                               $cmd = $ignoreMissing ? "rm -f $encSrc" : "rm $encSrc";
+                       }
                        $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
                                if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
                                        $status->fatal( 'backend-fail-delete', $params['src'] );
@@ -457,10 +452,10 @@ class FSFileBackend extends FileBackendStore {
                        };
                        $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
                } else { // immediate write
-                       $this->trapWarnings();
-                       $ok = unlink( $source );
-                       $this->untrapWarnings();
-                       if ( !$ok ) {
+                       $this->trapWarnings( '/: No such file or directory$/' );
+                       $deleted = unlink( $fsSrcPath );
+                       $hadError = $this->untrapWarnings();
+                       if ( $hadError || ( !$deleted && !$ignoreMissing ) ) {
                                $status->fatal( 'backend-fail-delete', $params['src'] );
 
                                return $status;
@@ -483,7 +478,7 @@ class FSFileBackend extends FileBackendStore {
                $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
                $existed = is_dir( $dir ); // already there?
                // Create the directory and its parents as needed...
-               $this->trapWarnings();
+               AtEase::suppressWarnings();
                if ( !$existed && !mkdir( $dir, $this->dirMode, true ) && !is_dir( $dir ) ) {
                        $this->logger->error( __METHOD__ . ": cannot create directory $dir" );
                        $status->fatal( 'directorycreateerror', $params['dir'] ); // fails on races
@@ -494,7 +489,7 @@ class FSFileBackend extends FileBackendStore {
                        $this->logger->error( __METHOD__ . ": directory $dir is not readable" );
                        $status->fatal( 'directorynotreadableerror', $params['dir'] );
                }
-               $this->untrapWarnings();
+               AtEase::restoreWarnings();
                // Respect any 'noAccess' or 'noListing' flags...
                if ( is_dir( $dir ) && !$existed ) {
                        $status->merge( $this->doSecureInternal( $fullCont, $dirRel, $params ) );
@@ -519,9 +514,9 @@ class FSFileBackend extends FileBackendStore {
                }
                // Add a .htaccess file to the root of the container...
                if ( !empty( $params['noAccess'] ) && !file_exists( "{$contRoot}/.htaccess" ) ) {
-                       $this->trapWarnings();
+                       AtEase::suppressWarnings();
                        $bytes = file_put_contents( "{$contRoot}/.htaccess", $this->htaccessPrivate() );
-                       $this->untrapWarnings();
+                       AtEase::restoreWarnings();
                        if ( $bytes === false ) {
                                $storeDir = "mwstore://{$this->name}/{$shortCont}";
                                $status->fatal( 'backend-fail-create', "{$storeDir}/.htaccess" );
@@ -539,21 +534,17 @@ class FSFileBackend extends FileBackendStore {
                // Unseed new directories with a blank index.html, to allow crawling...
                if ( !empty( $params['listing'] ) && is_file( "{$dir}/index.html" ) ) {
                        $exists = ( file_get_contents( "{$dir}/index.html" ) === $this->indexHtmlPrivate() );
-                       $this->trapWarnings();
-                       if ( $exists && !unlink( "{$dir}/index.html" ) ) { // reverse secure()
+                       if ( $exists && !$this->unlink( "{$dir}/index.html" ) ) { // reverse secure()
                                $status->fatal( 'backend-fail-delete', $params['dir'] . '/index.html' );
                        }
-                       $this->untrapWarnings();
                }
                // Remove the .htaccess file from the root of the container...
                if ( !empty( $params['access'] ) && is_file( "{$contRoot}/.htaccess" ) ) {
                        $exists = ( file_get_contents( "{$contRoot}/.htaccess" ) === $this->htaccessPrivate() );
-                       $this->trapWarnings();
-                       if ( $exists && !unlink( "{$contRoot}/.htaccess" ) ) { // reverse secure()
+                       if ( $exists && !$this->unlink( "{$contRoot}/.htaccess" ) ) { // reverse secure()
                                $storeDir = "mwstore://{$this->name}/{$shortCont}";
                                $status->fatal( 'backend-fail-delete', "{$storeDir}/.htaccess" );
                        }
-                       $this->untrapWarnings();
                }
 
                return $status;
@@ -564,11 +555,11 @@ class FSFileBackend extends FileBackendStore {
                list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
                $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
                $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
-               $this->trapWarnings();
+               AtEase::suppressWarnings();
                if ( is_dir( $dir ) ) {
                        rmdir( $dir ); // remove directory if empty
                }
-               $this->untrapWarnings();
+               AtEase::restoreWarnings();
 
                return $status;
        }
@@ -724,7 +715,7 @@ class FSFileBackend extends FileBackendStore {
 
                        $tmpPath = $tmpFile->getPath();
                        // Copy the source file over the temp file
-                       $this->trapWarnings();
+                       $this->trapWarnings(); // don't trust 'false' if there were errors
                        $isFile = is_file( $source ); // regular files only
                        $copySuccess = $isFile ? copy( $source, $tmpPath ) : false;
                        $hadError = $this->untrapWarnings();
@@ -755,8 +746,19 @@ class FSFileBackend extends FileBackendStore {
                $statuses = [];
 
                $pipes = [];
+               $octalPermissions = '0' . decoct( $this->fileMode );
                foreach ( $fileOpHandles as $index => $fileOpHandle ) {
-                       $pipes[$index] = popen( "{$fileOpHandle->cmd} 2>&1", 'r' );
+                       $cmd = "{$fileOpHandle->cmd} 2>&1";
+                       // Add a post-operation chmod command for permissions cleanup if applicable
+                       if (
+                               !$this->isWindows &&
+                               $fileOpHandle->chmodPath !== null &&
+                               strlen( $octalPermissions ) == 4
+                       ) {
+                               $encPath = escapeshellarg( $fileOpHandle->chmodPath );
+                               $cmd .= " && chmod $octalPermissions $encPath 2>/dev/null";
+                       }
+                       $pipes[$index] = popen( $cmd, 'r' );
                }
 
                $errs = [];
@@ -772,12 +774,10 @@ class FSFileBackend extends FileBackendStore {
                        $function = $fileOpHandle->call;
                        $function( $errs[$index], $status, $fileOpHandle->params, $fileOpHandle->cmd );
                        $statuses[$index] = $status;
-                       if ( $status->isOK() && $fileOpHandle->chmodPath ) {
-                               $this->chmod( $fileOpHandle->chmodPath );
-                       }
                }
 
                clearstatcache(); // files changed
+
                return $statuses;
        }
 
@@ -788,13 +788,52 @@ class FSFileBackend extends FileBackendStore {
         * @return bool Success
         */
        protected function chmod( $path ) {
-               $this->trapWarnings();
+               if ( $this->isWindows ) {
+                       return true;
+               }
+
+               AtEase::suppressWarnings();
                $ok = chmod( $path, $this->fileMode );
-               $this->untrapWarnings();
+               AtEase::restoreWarnings();
 
                return $ok;
        }
 
+       /**
+        * Unlink a file, suppressing the warnings
+        *
+        * @param string $path Absolute file system path
+        * @return bool Success
+        */
+       protected function unlink( $path ) {
+               AtEase::suppressWarnings();
+               $ok = unlink( $path );
+               AtEase::restoreWarnings();
+
+               return $ok;
+       }
+
+       /**
+        * @param array $params Operation parameters with 'content' and 'headers' fields
+        * @return TempFSFile|null
+        */
+       protected function stageContentAsTempFile( array $params ) {
+               $content = $params['content'];
+               $tempFile = $this->tmpFileFactory->newTempFSFile( 'create_', 'tmp' );
+               if ( !$tempFile ) {
+                       return null;
+               }
+
+               AtEase::suppressWarnings();
+               $tmpPath = $tempFile->getPath();
+               if ( file_put_contents( $tmpPath, $content ) === false ) {
+                       $tempFile = null;
+               }
+               AtEase::restoreWarnings();
+
+               return $tempFile;
+       }
+
        /**
         * Return the text of an index.html file to hide directory listings
         *
@@ -824,30 +863,29 @@ class FSFileBackend extends FileBackendStore {
        }
 
        /**
-        * Listen for E_WARNING errors and track whether any happen
+        * Listen for E_WARNING errors and track whether any that happen
+        *
+        * @param string|null $regexIgnore Optional regex of errors to ignore
         */
-       protected function trapWarnings() {
-               // push to stack
-               $this->hadWarningErrors[] = false;
-               set_error_handler( function ( $errno, $errstr ) {
-                       // more detailed error logging
-                       $this->logger->error( $errstr );
-                       $this->hadWarningErrors[count( $this->hadWarningErrors ) - 1] = true;
-
-                       // suppress from PHP handler
-                       return true;
+       protected function trapWarnings( $regexIgnore = null ) {
+               $this->warningTrapStack[] = false;
+               set_error_handler( function ( $errno, $errstr ) use ( $regexIgnore ) {
+                       if ( $regexIgnore === null || !preg_match( $regexIgnore, $errstr ) ) {
+                               $this->logger->error( $errstr );
+                               $this->warningTrapStack[count( $this->warningTrapStack ) - 1] = true;
+                       }
+                       return true; // suppress from PHP handler
                }, E_WARNING );
        }
 
        /**
-        * Stop listening for E_WARNING errors and return true if any happened
+        * Stop listening for E_WARNING errors and get whether any happened
         *
-        * @return bool
+        * @return bool Whether any warnings happened
         */
        protected function untrapWarnings() {
-               // restore previous handler
                restore_error_handler();
-               // pop from stack
-               return array_pop( $this->hadWarningErrors );
+
+               return array_pop( $this->warningTrapStack );
        }
 }
index 981aeb0..d5f5de3 100644 (file)
@@ -94,8 +94,7 @@ class LogPage {
 
                $dbw = wfGetDB( DB_MASTER );
 
-               // @todo FIXME private/protected/public property?
-               $this->timestamp = $now = wfTimestampNow();
+               $now = wfTimestampNow();
                $data = [
                        'log_type' => $this->type,
                        'log_action' => $this->action,
index 8865654..26f3665 100644 (file)
@@ -229,13 +229,7 @@ class SpecialRecentChangesLinked extends SpecialRecentChanges {
                        $sql = $dbr->limitResult( $sql, $limit, false );
                }
 
-               $res = $dbr->query( $sql, __METHOD__ );
-
-               if ( $res->numRows() == 0 ) {
-                       $this->mResultEmpty = true;
-               }
-
-               return $res;
+               return $dbr->query( $sql, __METHOD__ );
        }
 
        function setTopText( FormOptions $opts ) {
index eff8889..83153ae 100644 (file)
@@ -47,7 +47,6 @@ class SpecialStatistics extends SpecialPage {
                $this->total = SiteStats::pages();
                $this->users = SiteStats::users();
                $this->activeUsers = SiteStats::activeUsers();
-               $this->hook = '';
 
                $text = Xml::openElement( 'table', [ 'class' => 'wikitable mw-statistics-table' ] );
 
index a9f399b..7f4d7c5 100644 (file)
@@ -40,14 +40,11 @@ class UploadFromStash extends UploadBase {
        private $repo;
 
        /**
-        * @param User|bool $user Default: false
+        * @param User|bool $user Default: false Sometimes this won't exist, as when running from cron.
         * @param UploadStash|bool $stash Default: false
         * @param FileRepo|bool $repo Default: false
         */
        public function __construct( $user = false, $stash = false, $repo = false ) {
-               // user object. sometimes this won't exist, as when running from cron.
-               $this->user = $user;
-
                if ( $repo ) {
                        $this->repo = $repo;
                } else {
@@ -63,7 +60,7 @@ class UploadFromStash extends UploadBase {
                                wfDebug( __METHOD__ . " creating new UploadStash instance with no user\n" );
                        }
 
-                       $this->stash = new UploadStash( $this->repo, $this->user );
+                       $this->stash = new UploadStash( $this->repo, $user );
                }
        }
 
index dcd147f..1a5daca 100644 (file)
@@ -236,63 +236,6 @@ SEARCHDATA_FILE        = searchdata.xml
 EXTERNAL_SEARCH_ID     =
 EXTRA_SEARCH_MAPPINGS  =
 #---------------------------------------------------------------------------
-# Configuration options related to the LaTeX output
-#---------------------------------------------------------------------------
-GENERATE_LATEX         = NO
-LATEX_OUTPUT           = latex
-LATEX_CMD_NAME         = latex
-MAKEINDEX_CMD_NAME     = makeindex
-COMPACT_LATEX          = NO
-PAPER_TYPE             = a4wide
-EXTRA_PACKAGES         =
-LATEX_HEADER           =
-LATEX_FOOTER           =
-LATEX_EXTRA_FILES      =
-PDF_HYPERLINKS         = YES
-USE_PDFLATEX           = YES
-LATEX_BATCHMODE        = NO
-LATEX_HIDE_INDICES     = NO
-LATEX_SOURCE_CODE      = NO
-LATEX_BIB_STYLE        = plain
-#---------------------------------------------------------------------------
-# Configuration options related to the RTF output
-#---------------------------------------------------------------------------
-GENERATE_RTF           = NO
-RTF_OUTPUT             = rtf
-COMPACT_RTF            = NO
-RTF_HYPERLINKS         = NO
-RTF_STYLESHEET_FILE    =
-RTF_EXTENSIONS_FILE    =
-#---------------------------------------------------------------------------
-# Configuration options related to the man page output
-#---------------------------------------------------------------------------
-GENERATE_MAN           = {{GENERATE_MAN}}
-MAN_OUTPUT             = man
-MAN_EXTENSION          = .3
-MAN_LINKS              = NO
-#---------------------------------------------------------------------------
-# Configuration options related to the XML output
-#---------------------------------------------------------------------------
-GENERATE_XML           = NO
-XML_OUTPUT             = xml
-XML_PROGRAMLISTING     = YES
-#---------------------------------------------------------------------------
-# Configuration options related to the DOCBOOK output
-#---------------------------------------------------------------------------
-GENERATE_DOCBOOK       = NO
-DOCBOOK_OUTPUT         = docbook
-#---------------------------------------------------------------------------
-# Configuration options for the AutoGen Definitions output
-#---------------------------------------------------------------------------
-GENERATE_AUTOGEN_DEF   = NO
-#---------------------------------------------------------------------------
-# Configuration options related to the Perl module output
-#---------------------------------------------------------------------------
-GENERATE_PERLMOD       = NO
-PERLMOD_LATEX          = NO
-PERLMOD_PRETTY         = YES
-PERLMOD_MAKEVAR_PREFIX =
-#---------------------------------------------------------------------------
 # Configuration options related to the preprocessor
 #---------------------------------------------------------------------------
 ENABLE_PREPROCESSING   = YES
index c4ca056..40cac1b 100644 (file)
@@ -32,6 +32,9 @@ require_once __DIR__ . '/Maintenance.php';
  * @ingroup Maintenance
  */
 class DumpUploads extends Maintenance {
+       /** @var string */
+       private $mBasePath;
+
        public function __construct() {
                parent::__construct();
                $this->addDescription( 'Generates list of uploaded files which can be fed to tar or similar.
@@ -44,30 +47,29 @@ By default, outputs relative paths against the parent directory of $wgUploadDire
 
        public function execute() {
                global $IP;
-               $this->mAction = 'fetchLocal';
                $this->mBasePath = $this->getOption( 'base', $IP );
-               $this->mShared = false;
-               $this->mSharedSupplement = false;
-
-               if ( $this->hasOption( 'local' ) ) {
-                       $this->mAction = 'fetchLocal';
-               }
-
-               if ( $this->hasOption( 'used' ) ) {
-                       $this->mAction = 'fetchUsed';
-               }
+               $shared = false;
+               $sharedSupplement = false;
 
                if ( $this->hasOption( 'shared' ) ) {
                        if ( $this->hasOption( 'used' ) ) {
                                // Include shared-repo files in the used check
-                               $this->mShared = true;
+                               $shared = true;
                        } else {
                                // Grab all local *plus* used shared
-                               $this->mSharedSupplement = true;
+                               $sharedSupplement = true;
                        }
                }
-               $this->{$this->mAction} ( $this->mShared );
-               if ( $this->mSharedSupplement ) {
+
+               if ( $this->hasOption( 'local' ) ) {
+                       $this->fetchLocal( $shared );
+               } elseif ( $this->hasOption( 'used' ) ) {
+                       $this->fetchUsed( $shared );
+               } else {
+                       $this->fetchLocal( $shared );
+               }
+
+               if ( $sharedSupplement ) {
                        $this->fetchUsed( true );
                }
        }
diff --git a/maintenance/includes/MWDoxygenFilter.php b/maintenance/includes/MWDoxygenFilter.php
new file mode 100644 (file)
index 0000000..287a927
--- /dev/null
@@ -0,0 +1,143 @@
+<?php
+/**
+ * Copyright (C) 2012 Tamas Imrei <tamas.imrei@gmail.com> https://virtualtee.blogspot.com/
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Maintenance
+ * @phan-file-suppress PhanInvalidCommentForDeclarationType False negative about about `@var`
+ * @phan-file-suppress PhanUnextractableAnnotation False negative about about `@var`
+ */
+
+/**
+ * Doxygen filter to show correct member variable types in documentation.
+ *
+ * Based on
+ * <https://virtualtee.blogspot.co.uk/2012/03/tip-for-using-doxygen-for-php-code.html>
+ *
+ * It has been adapted for MediaWiki to resolve various bugs we experienced
+ * from using Doxygen with our coding conventions:
+ *
+ * - We want to allow documenting class members on a single line by documenting
+ *   them as `/** @var SomeType Description here.`, and in long-form as
+ *   `/**\n * Description here.\n * @var SomeType`.
+ *
+ * - PHP does not support native type-hinting of class members. Instead, we document
+ *   that using `@var` in the doc blocks above it. However, Doxygen only supports
+ *   parsing this from executable code. We achieve this by having the below filter
+ *   take the typehint from the doc block and insert it into the source code in
+ *   front of `$myvar`, like `protected SomeType $myvar`. This result is technically
+ *   invalid PHP code, but Doxygen understands it this way.
+ *
+ * @internal For use by maintenance/mwdoc-filter.php
+ * @ingroup Maintenance
+ */
+class MWDoxygenFilter {
+       /**
+        * @param string $source Original source code
+        * @return string Filtered source code
+        */
+       public static function filter( $source ) {
+               $tokens = token_get_all( $source );
+               $buffer = null;
+               $output = '';
+               foreach ( $tokens as $token ) {
+                       if ( is_string( $token ) ) {
+                               if ( $buffer !== null && $token === ';' ) {
+                                       // If we still have a buffer and the statement has ended,
+                                       // flush it and move on.
+                                       $output .= $buffer['raw'];
+                                       $buffer = null;
+                               }
+                               $output .= $token;
+                               continue;
+                       }
+                       list( $id, $content ) = $token;
+                       switch ( $id ) {
+                               case T_DOC_COMMENT:
+                                       // Escape slashes so that references to namespaces are not
+                                       // wrongly interpreted as a Doxygen "\command".
+                                       $content = addcslashes( $content, '\\' );
+                                       // Look for instances of "@var SomeType".
+                                       if ( preg_match( '#@var\s+\S+#s', $content ) ) {
+                                               $buffer = [ 'raw' => $content, 'desc' => null, 'type' => null, 'name' => null ];
+                                               $buffer['desc'] = preg_replace_callback(
+                                                       // Strip "@var SomeType" part, but remember the type and optional name
+                                                       '#@var\s+(\S+)(\s+)?(\S+)?#s',
+                                                       function ( $matches ) use ( &$buffer ) {
+                                                               $buffer['type'] = $matches[1];
+                                                               $buffer['name'] = $matches[3] ?? null;
+                                                               return ( $matches[2] ?? '' ) . ( $matches[3] ?? '' );
+                                                       },
+                                                       $content
+                                               );
+                                       } else {
+                                               $output .= $content;
+                                       }
+                                       break;
+
+                               case T_VARIABLE:
+                                       // Doxygen requires class members to be documented in one of two ways:
+                                       //
+                                       // 1. Fully qualified:
+                                       //    /** @var SomeType $name Description here. */
+                                       //
+                                       //    These result in the creation of a new virtual node called $name
+                                       //    with the specified type and description. The real code doesn't
+                                       //    even need to exist in this case.
+                                       //
+                                       // 2. Contextual:
+                                       //    /** Description here. */
+                                       //    private SomeType? $name;
+                                       //
+                                       // In MediaWiki, we are mostly like #1 but without the name repeated:
+                                       //    /** @var SomeType Description here. */
+                                       //    private $name;
+                                       //
+                                       // These emit a warning in Doxygen because they are missing a variable name.
+                                       // Convert these to the "Contextual" kind by stripping ""@var", injecting
+                                       // type into the code, and leaving the description in-place.
+                                       if ( $buffer !== null ) {
+                                               if ( $buffer['name'] === $content ) {
+                                                       // Fully qualitied "@var" comment, leave as-is.
+                                                       $output .= $buffer['raw'];
+                                                       $output .= $content;
+                                               } else {
+                                                       // MW-style "@var" comment. Keep only the description and transplant
+                                                       // the type into the code.
+                                                       $output .= $buffer['desc'];
+                                                       $output .= "{$buffer['type']} $content";
+                                               }
+                                               $buffer = null;
+                                       } else {
+                                               $output .= $content;
+                                       }
+                                       break;
+
+                               default:
+                                       if ( $buffer !== null ) {
+                                               $buffer['raw'] .= $content;
+                                               $buffer['desc'] .= $content;
+                                       } else {
+                                               $output .= $content;
+                                       }
+                                       break;
+                       }
+               }
+               return $output;
+       }
+}
index 54c1d38..b19420f 100644 (file)
@@ -21,7 +21,6 @@
                                "classes": [
                                        "mw.Title",
                                        "mw.Uri",
-                                       "mw.RegExp",
                                        "mw.String",
                                        "mw.messagePoster.*",
                                        "mw.notification",
index 48a6666..d78c5a0 100644 (file)
@@ -129,7 +129,6 @@ class MergeMessageFileList extends Maintenance {
                $files = [];
                $fileLines = file( $fileName );
                if ( $fileLines === false ) {
-                       $this->hasError = true;
                        $this->error( "Unable to open list file $fileName." );
 
                        return $files;
@@ -144,7 +143,6 @@ class MergeMessageFileList extends Maintenance {
                                if ( file_exists( $extension ) ) {
                                        $files[] = $extension;
                                } else {
-                                       $this->hasError = true;
                                        $this->error( "Extension {$extension} doesn't exist" );
                                }
                        }
index 1da805e..cabf489 100644 (file)
@@ -1,22 +1,9 @@
 <?php
 /**
- * Doxygen filter to show correct member variable types in documentation.
+ * Filter for PHP source code that allows Doxygen to understand it better.
  *
- * Should be set in Doxygen INPUT_FILTER as "php mwdoc-filter.php"
- *
- * Based on
- * <https://virtualtee.blogspot.co.uk/2012/03/tip-for-using-doxygen-for-php-code.html>
- *
- * Improved to resolve various bugs and better MediaWiki PHPDoc conventions:
- *
- * - Insert variable name after typehint instead of at end of line so that
- *   documentation text may follow after "@var Type".
- * - Insert typehint into source code before $variable instead of inside the comment
- *   so that Doxygen interprets it.
- * - Strip the text after @var from the output to avoid Doxygen warnings aboug bogus
- *   symbols being documented but not declared or defined.
- *
- * Copyright (C) 2012 Tamas Imrei <tamas.imrei@gmail.com> https://virtualtee.blogspot.com/
+ * This CLI script is intended to be configured as the INPUT_FILTER
+ * script in a Doxyfile, e.g. like "php mwdoc-filter.php".
  *
  * Permission is hereby granted, free of charge, to any person obtaining
  * a copy of this software and associated documentation files (the "Software"),
@@ -42,59 +29,7 @@ if ( PHP_SAPI != 'cli' && PHP_SAPI != 'phpdbg' ) {
        die( "This filter can only be run from the command line.\n" );
 }
 
-$source = file_get_contents( $argv[1] );
-$tokens = token_get_all( $source );
+require_once __DIR__ . '/includes/MWDoxygenFilter.php';
 
-$buffer = $bufferType = null;
-foreach ( $tokens as $token ) {
-       if ( is_string( $token ) ) {
-               if ( $buffer !== null && $token === ';' ) {
-                       // If we still have a buffer and the statement has ended,
-                       // flush it and move on.
-                       echo $buffer;
-                       $buffer = $bufferType = null;
-               }
-               echo $token;
-               continue;
-       }
-       list( $id, $content ) = $token;
-       switch ( $id ) {
-               case T_DOC_COMMENT:
-                       // Escape slashes so that references to namespaces are not
-                       // wrongly interpreted as a Doxygen "\command".
-                       $content = addcslashes( $content, '\\' );
-                       // Look for instances of "@var Type" not followed by $name.
-                       if ( preg_match( '#@var\s+([^\s]+)\s+([^\$]+)#s', $content ) ) {
-                               $buffer = preg_replace_callback(
-                                       // Strip the "@var Type" part and remember the type
-                                       '#(@var\s+)([^\s]+)#s',
-                                       function ( $matches ) use ( &$bufferType ) {
-                                               $bufferType = $matches[2];
-                                               return '';
-                                       },
-                                       $content
-                               );
-                       } else {
-                               echo $content;
-                       }
-                       break;
-
-               case T_VARIABLE:
-                       if ( $buffer !== null ) {
-                               echo $buffer;
-                               echo "$bufferType $content";
-                               $buffer = $bufferType = null;
-                       } else {
-                               echo $content;
-                       }
-                       break;
-
-               default:
-                       if ( $buffer !== null ) {
-                               $buffer .= $content;
-                       } else {
-                               echo $content;
-                       }
-                       break;
-       }
-}
+$source = file_get_contents( $argv[1] );
+echo MWDoxygenFilter::filter( $source );
index e2c2629..4a50cc5 100644 (file)
@@ -56,8 +56,6 @@ class MWDocGen extends Maintenance {
                $this->addOption( 'version',
                        'Pass a MediaWiki version',
                        false, true );
-               $this->addOption( 'generate-man',
-                       'Whether to generate man files' );
                $this->addOption( 'file',
                        "Only process given file or directory. Multiple values " .
                        "accepted with comma separation. Path relative to \$IP.",
@@ -109,7 +107,6 @@ class MWDocGen extends Maintenance {
                }
 
                $this->doDot = shell_exec( 'which dot' );
-               $this->doMan = $this->hasOption( 'generate-man' );
        }
 
        public function execute() {
@@ -134,7 +131,6 @@ class MWDocGen extends Maintenance {
                                '{{EXCLUDE}}' => $exclude,
                                '{{EXCLUDE_PATTERNS}}' => $excludePatterns,
                                '{{HAVE_DOT}}' => $this->doDot ? 'YES' : 'NO',
-                               '{{GENERATE_MAN}}' => $this->doMan ? 'YES' : 'NO',
                                '{{INPUT_FILTER}}' => $this->inputFilter,
                        ]
                );
index 284db2c..154482c 100644 (file)
@@ -50,10 +50,10 @@ class ResetUserTokens extends Maintenance {
        }
 
        public function execute() {
-               $this->nullsOnly = $this->getOption( 'nulls' );
+               $nullsOnly = $this->getOption( 'nulls' );
 
                if ( !$this->getOption( 'nowarn' ) ) {
-                       if ( $this->nullsOnly ) {
+                       if ( $nullsOnly ) {
                                $this->output( "The script is about to reset the user_token "
                                        . "for USERS WITH NULL TOKENS in the database.\n" );
                        } else {
@@ -71,7 +71,7 @@ class ResetUserTokens extends Maintenance {
                $dbr = $this->getDB( DB_REPLICA );
 
                $where = [];
-               if ( $this->nullsOnly ) {
+               if ( $nullsOnly ) {
                        // Have to build this by hand, because \ is escaped in helper functions
                        $where = [ 'user_token = \'' . str_repeat( '\0', 32 ) . '\'' ];
                }
index 55bd780..b3aee84 100644 (file)
@@ -156,12 +156,10 @@ return [
        /* jQuery Plugins */
 
        'jquery.accessKeyLabel' => [
-               'scripts' => 'resources/src/jquery/jquery.accessKeyLabel.js',
+               'deprecated' => 'Please use "mediawiki.util" instead.',
                'dependencies' => [
-                       'jquery.client',
-                       'mediawiki.RegExp',
+                       'mediawiki.util',
                ],
-               'messages' => [ 'brackets', 'word-separator' ],
                'targets' => [ 'mobile', 'desktop' ],
        ],
        'jquery.checkboxShiftClick' => [
@@ -214,7 +212,7 @@ return [
        'jquery.highlightText' => [
                'scripts' => 'resources/src/jquery/jquery.highlightText.js',
                'dependencies' => [
-                       'mediawiki.RegExp',
+                       'mediawiki.util',
                ],
                'targets' => [ 'desktop', 'mobile' ],
        ],
@@ -290,7 +288,7 @@ return [
                'messages' => [ 'sort-descending', 'sort-ascending' ],
                'dependencies' => [
                        'jquery.tablesorter.styles',
-                       'mediawiki.RegExp',
+                       'mediawiki.util',
                        'mediawiki.language.months',
                ],
        ],
@@ -757,7 +755,7 @@ return [
                ],
                'dependencies' => [
                        'mediawiki.language',
-                       'mediawiki.RegExp',
+                       'mediawiki.util',
                ],
                'targets' => [ 'desktop', 'mobile' ],
        ],
@@ -928,7 +926,7 @@ return [
                        'resources/src/mediawiki.htmlform/selectorother.js',
                ],
                'dependencies' => [
-                       'mediawiki.RegExp',
+                       'mediawiki.util',
                        'jquery.lengthLimit',
                ],
                'messages' => [
@@ -969,7 +967,7 @@ return [
                'scripts' => 'resources/src/mediawiki.inspect.js',
                'dependencies' => [
                        'mediawiki.String',
-                       'mediawiki.RegExp',
+                       'mediawiki.util',
                ],
                'targets' => [ 'desktop', 'mobile' ],
        ],
@@ -1029,8 +1027,12 @@ return [
                'targets' => [ 'desktop', 'mobile' ],
        ],
        'mediawiki.RegExp' => [
+               'deprecated' => 'Please use mw.util.escapeRegExp() instead.',
                'scripts' => 'resources/src/mediawiki.RegExp.js',
                'targets' => [ 'desktop', 'mobile' ],
+               'dependencies' => [
+                       'mediawiki.util',
+               ],
        ],
        'mediawiki.String' => [
                'scripts' => 'resources/src/mediawiki.String.js',
@@ -1252,18 +1254,19 @@ return [
        ],
        'mediawiki.util' => [
                'localBasePath' => "$IP/resources/src/mediawiki.util/",
-               'remoteBasePath' => "$wgResourceBasePath/resources/srcmediawiki.util/",
+               'remoteBasePath' => "$wgResourceBasePath/resources/src/mediawiki.util/",
                'packageFiles' => [
                        'util.js',
+                       'jquery.accessKeyLabel.js',
                        [ 'name' => 'config.json', 'config' => [
                                'FragmentMode',
                                'LoadScript',
                        ] ],
                ],
                'dependencies' => [
-                       'jquery.accessKeyLabel',
-                       'mediawiki.RegExp',
+                       'jquery.client',
                ],
+               'messages' => [ 'brackets', 'word-separator' ],
                'targets' => [ 'desktop', 'mobile' ],
        ],
        'mediawiki.viewport' => [
@@ -1660,8 +1663,8 @@ return [
        'mediawiki.page.ready' => [
                'scripts' => 'resources/src/mediawiki.page.ready.js',
                'dependencies' => [
-                       'jquery.accessKeyLabel',
                        'jquery.checkboxShiftClick',
+                       'mediawiki.util',
                        'mediawiki.notify',
                        'mediawiki.api'
                ],
@@ -1699,8 +1702,7 @@ return [
                        'mediawiki.util',
                        'mediawiki.Title',
                        'mediawiki.jqueryMsg',
-                       'jquery.accessKeyLabel',
-                       'mediawiki.RegExp',
+                       'mediawiki.util',
                ],
                'messages' => [
                        'watch',
@@ -2639,7 +2641,7 @@ return [
                        'period-pm',
                ],
                'dependencies' => [
-                       'mediawiki.RegExp',
+                       'mediawiki.util',
                        'oojs-ui-core',
                        'oojs-ui.styles.icons-moderation',
                        'oojs-ui.styles.icons-movement',
index 7a131da..a6c8cd7 100644 (file)
                        // Construct regexes for number identification
                        for ( i = 0; i < ascii.length; i++ ) {
                                ts.transformTable[ localised[ i ] ] = ascii[ i ];
-                               digits.push( mw.RegExp.escape( localised[ i ] ) );
+                               digits.push( mw.util.escapeRegExp( localised[ i ] ) );
                        }
                }
                digitClass = '[' + digits.join( '', digits ) + ']';
                for ( i = 0; i < 12; i++ ) {
                        name = mw.language.months.names[ i ].toLowerCase();
                        ts.monthNames[ name ] = i + 1;
-                       regex.push( mw.RegExp.escape( name ) );
+                       regex.push( mw.util.escapeRegExp( name ) );
                        name = mw.language.months.genitive[ i ].toLowerCase();
                        ts.monthNames[ name ] = i + 1;
-                       regex.push( mw.RegExp.escape( name ) );
+                       regex.push( mw.util.escapeRegExp( name ) );
                        name = mw.language.months.abbrev[ i ].toLowerCase().replace( '.', '' );
                        ts.monthNames[ name ] = i + 1;
-                       regex.push( mw.RegExp.escape( name ) );
+                       regex.push( mw.util.escapeRegExp( name ) );
                }
 
                // Build piped string
                if ( ts.collationTable ) {
                        // Build array of key names
                        for ( key in ts.collationTable ) {
-                               keys.push( mw.RegExp.escape( key ) );
+                               keys.push( mw.util.escapeRegExp( key ) );
                        }
                        if ( keys.length ) {
                                ts.collationRegex = new RegExp( keys.join( '|' ), 'ig' );
diff --git a/resources/src/jquery/jquery.accessKeyLabel.js b/resources/src/jquery/jquery.accessKeyLabel.js
deleted file mode 100644 (file)
index cdc5808..0000000
+++ /dev/null
@@ -1,239 +0,0 @@
-/**
- * jQuery plugin to update the tooltip to show the correct access key
- *
- * @class jQuery.plugin.accessKeyLabel
- */
-( function () {
-
-       // Cached access key modifiers for used browser
-       var cachedAccessKeyModifiers,
-
-               // Whether to use 'test-' instead of correct prefix (used for testing)
-               useTestPrefix = false,
-
-               // tag names which can have a label tag
-               // https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Form-associated_content
-               labelable = 'button, input, textarea, keygen, meter, output, progress, select';
-
-       /**
-        * Find the modifier keys that need to be pressed together with the accesskey to trigger the input.
-        *
-        * The result is dependant on the ua paramater or the current platform.
-        * For browsers that support accessKeyLabel, #getAccessKeyLabel never calls here.
-        * Valid key values that are returned can be: ctrl, alt, option, shift, esc
-        *
-        * @private
-        * @param {Object} [ua] An object with a 'userAgent' and 'platform' property.
-        * @return {Array} Array with 1 or more of the string values, in this order: ctrl, option, alt, shift, esc
-        */
-       function getAccessKeyModifiers( ua ) {
-               var profile, accessKeyModifiers;
-
-               // use cached prefix if possible
-               if ( !ua && cachedAccessKeyModifiers ) {
-                       return cachedAccessKeyModifiers;
-               }
-
-               profile = $.client.profile( ua );
-
-               switch ( profile.name ) {
-                       case 'chrome':
-                       case 'opera':
-                               if ( profile.name === 'opera' && profile.versionNumber < 15 ) {
-                                       accessKeyModifiers = [ 'shift', 'esc' ];
-                               } else if ( profile.platform === 'mac' ) {
-                                       accessKeyModifiers = [ 'ctrl', 'option' ];
-                               } else {
-                                       // Chrome/Opera on Windows or Linux
-                                       // (both alt- and alt-shift work, but alt with E, D, F etc does not
-                                       // work since they are browser shortcuts)
-                                       accessKeyModifiers = [ 'alt', 'shift' ];
-                               }
-                               break;
-                       case 'firefox':
-                       case 'iceweasel':
-                               if ( profile.versionBase < 2 ) {
-                                       // Before v2, Firefox used alt, though it was rebindable in about:config
-                                       accessKeyModifiers = [ 'alt' ];
-                               } else {
-                                       if ( profile.platform === 'mac' ) {
-                                               if ( profile.versionNumber < 14 ) {
-                                                       accessKeyModifiers = [ 'ctrl' ];
-                                               } else {
-                                                       accessKeyModifiers = [ 'ctrl', 'option' ];
-                                               }
-                                       } else {
-                                               accessKeyModifiers = [ 'alt', 'shift' ];
-                                       }
-                               }
-                               break;
-                       case 'safari':
-                       case 'konqueror':
-                               if ( profile.platform === 'win' ) {
-                                       accessKeyModifiers = [ 'alt' ];
-                               } else {
-                                       if ( profile.layoutVersion > 526 ) {
-                                               // Non-Windows Safari with webkit_version > 526
-                                               accessKeyModifiers = [ 'ctrl', profile.platform === 'mac' ? 'option' : 'alt' ];
-                                       } else {
-                                               accessKeyModifiers = [ 'ctrl' ];
-                                       }
-                               }
-                               break;
-                       case 'msie':
-                       case 'edge':
-                               accessKeyModifiers = [ 'alt' ];
-                               break;
-                       default:
-                               accessKeyModifiers = profile.platform === 'mac' ? [ 'ctrl' ] : [ 'alt' ];
-                               break;
-               }
-
-               // cache modifiers
-               if ( !ua ) {
-                       cachedAccessKeyModifiers = accessKeyModifiers;
-               }
-               return accessKeyModifiers;
-       }
-
-       /**
-        * Get the access key label for an element.
-        *
-        * Will use native accessKeyLabel if available (currently only in Firefox 8+),
-        * falls back to #getAccessKeyModifiers.
-        *
-        * @private
-        * @param {HTMLElement} element Element to get the label for
-        * @return {string} Access key label
-        */
-       function getAccessKeyLabel( element ) {
-               // abort early if no access key
-               if ( !element.accessKey ) {
-                       return '';
-               }
-               // use accessKeyLabel if possible
-               // https://html.spec.whatwg.org/multipage/interaction.html#dom-accesskeylabel
-               if ( !useTestPrefix && element.accessKeyLabel ) {
-                       return element.accessKeyLabel;
-               }
-               return ( useTestPrefix ? 'test' : getAccessKeyModifiers().join( '-' ) ) + '-' + element.accessKey;
-       }
-
-       /**
-        * Update the title for an element (on the element with the access key or it's label) to show
-        * the correct access key label.
-        *
-        * @private
-        * @param {HTMLElement} element Element with the accesskey
-        * @param {HTMLElement} titleElement Element with the title to update (may be the same as `element`)
-        */
-       function updateTooltipOnElement( element, titleElement ) {
-               var oldTitle, parts, regexp, newTitle, accessKeyLabel,
-                       separatorMsg = mw.message( 'word-separator' ).plain();
-
-               oldTitle = titleElement.title;
-               if ( !oldTitle ) {
-                       // don't add a title if the element didn't have one before
-                       return;
-               }
-
-               parts = ( separatorMsg + mw.message( 'brackets' ).plain() ).split( '$1' );
-               regexp = new RegExp( parts.map( mw.RegExp.escape ).join( '.*?' ) + '$' );
-               newTitle = oldTitle.replace( regexp, '' );
-               accessKeyLabel = getAccessKeyLabel( element );
-
-               if ( accessKeyLabel ) {
-                       // Should be build the same as in Linker::titleAttrib
-                       newTitle += separatorMsg + mw.message( 'brackets', accessKeyLabel ).plain();
-               }
-               if ( oldTitle !== newTitle ) {
-                       titleElement.title = newTitle;
-               }
-       }
-
-       /**
-        * Update the title for an element to show the correct access key label.
-        *
-        * @private
-        * @param {HTMLElement} element Element with the accesskey
-        */
-       function updateTooltip( element ) {
-               var id, $element, $label, $labelParent;
-               updateTooltipOnElement( element, element );
-
-               // update associated label if there is one
-               $element = $( element );
-               if ( $element.is( labelable ) ) {
-                       // Search it using 'for' attribute
-                       id = element.id.replace( /"/g, '\\"' );
-                       if ( id ) {
-                               $label = $( 'label[for="' + id + '"]' );
-                               if ( $label.length === 1 ) {
-                                       updateTooltipOnElement( element, $label[ 0 ] );
-                               }
-                       }
-
-                       // Search it as parent, because the form control can also be inside the label element itself
-                       $labelParent = $element.parents( 'label' );
-                       if ( $labelParent.length === 1 ) {
-                               updateTooltipOnElement( element, $labelParent[ 0 ] );
-                       }
-               }
-       }
-
-       /**
-        * Update the titles for all elements in a jQuery selection.
-        *
-        * @return {jQuery}
-        * @chainable
-        */
-       $.fn.updateTooltipAccessKeys = function () {
-               return this.each( function () {
-                       updateTooltip( this );
-               } );
-       };
-
-       /**
-        * getAccessKeyModifiers
-        *
-        * @method updateTooltipAccessKeys_getAccessKeyModifiers
-        * @inheritdoc #getAccessKeyModifiers
-        */
-       $.fn.updateTooltipAccessKeys.getAccessKeyModifiers = getAccessKeyModifiers;
-
-       /**
-        * getAccessKeyLabel
-        *
-        * @method updateTooltipAccessKeys_getAccessKeyLabel
-        * @inheritdoc #getAccessKeyLabel
-        */
-       $.fn.updateTooltipAccessKeys.getAccessKeyLabel = getAccessKeyLabel;
-
-       /**
-        * getAccessKeyPrefix
-        *
-        * @method updateTooltipAccessKeys_getAccessKeyPrefix
-        * @deprecated since 1.27 Use #getAccessKeyModifiers
-        * @param {Object} [ua] An object with a 'userAgent' and 'platform' property.
-        * @return {string}
-        */
-       $.fn.updateTooltipAccessKeys.getAccessKeyPrefix = function ( ua ) {
-               return getAccessKeyModifiers( ua ).join( '-' ) + '-';
-       };
-
-       /**
-        * Switch test mode on and off.
-        *
-        * @method updateTooltipAccessKeys_setTestMode
-        * @param {boolean} mode New mode
-        */
-       $.fn.updateTooltipAccessKeys.setTestMode = function ( mode ) {
-               useTestPrefix = mode;
-       };
-
-       /**
-        * @class jQuery
-        * @mixins jQuery.plugin.accessKeyLabel
-        */
-
-}() );
index de08607..1fabb43 100644 (file)
@@ -17,7 +17,7 @@
                                }
                                $.highlightText.innerHighlight(
                                        node,
-                                       new RegExp( '(^|\\s)' + mw.RegExp.escape( words[ i ] ), 'i' )
+                                       new RegExp( '(^|\\s)' + mw.util.escapeRegExp( words[ i ] ), 'i' )
                                );
                        }
                        return node;
@@ -26,7 +26,7 @@
                prefixHighlight: function ( node, prefix ) {
                        $.highlightText.innerHighlight(
                                node,
-                               new RegExp( '(^)' + mw.RegExp.escape( prefix ), 'i' )
+                               new RegExp( '(^)' + mw.util.escapeRegExp( prefix ), 'i' )
                        );
                },
 
@@ -38,7 +38,7 @@
 
                        $.highlightText.innerHighlight(
                                node,
-                               new RegExp( '(^)' + mw.RegExp.escape( prefix ) + comboMarks + '*', 'i' )
+                               new RegExp( '(^)' + mw.util.escapeRegExp( prefix ) + comboMarks + '*', 'i' )
                        );
                },
 
index 5323d4f..258bc2c 100644 (file)
@@ -1,22 +1,5 @@
 ( function () {
-       /**
-        * @class mw.RegExp
-        */
-       mw.RegExp = {
-               /**
-                * Escape string for safe inclusion in regular expression
-                *
-                * The following characters are escaped:
-                *
-                *     \ { } ( ) | . ? * + - ^ $ [ ]
-                *
-                * @since 1.26
-                * @static
-                * @param {string} str String to escape
-                * @return {string} Escaped string
-                */
-               escape: function ( str ) {
-                       return str.replace( /([\\{}()|.?*+\-^$\[\]])/g, '\\$1' ); // eslint-disable-line no-useless-escape
-               }
-       };
+       mw.RegExp = {};
+       // Backwards-compatible alias; @deprecated since 1.34
+       mw.log.deprecate( mw.RegExp, 'escape', mw.util.escapeRegExp, 'Use mw.util.escapeRegExp() instead.', 'mw.RegExp.escape' );
 }() );
index 99eebae..9cafcd4 100644 (file)
@@ -16,7 +16,7 @@
                var $li,
                        $ul = $createButton.prev( 'ul.mw-htmlform-cloner-ul' ),
                        html = $ul.data( 'template' ).replace(
-                               new RegExp( mw.RegExp.escape( $ul.data( 'uniqueId' ) ), 'g' ),
+                               new RegExp( mw.util.escapeRegExp( $ul.data( 'uniqueId' ) ), 'g' ),
                                'clone' + ( ++cloneCounter )
                        );
 
index 7f4af5b..c7b99b2 100644 (file)
         */
        inspect.grep = function ( pattern ) {
                if ( typeof pattern.test !== 'function' ) {
-                       pattern = new RegExp( mw.RegExp.escape( pattern ), 'g' );
+                       pattern = new RegExp( mw.util.escapeRegExp( pattern ), 'g' );
                }
 
                return inspect.getLoadedModules().filter( function ( moduleName ) {
index f550a91..6b04b3c 100644 (file)
@@ -91,7 +91,7 @@
                actionPaths = mw.config.get( 'wgActionPaths' );
                for ( key in actionPaths ) {
                        parts = actionPaths[ key ].split( '$1' );
-                       parts = parts.map( mw.RegExp.escape );
+                       parts = parts.map( mw.util.escapeRegExp );
                        m = new RegExp( parts.join( '(.+)' ) ).exec( url );
                        if ( m && m[ 1 ] ) {
                                return key;
diff --git a/resources/src/mediawiki.util/.eslintrc.json b/resources/src/mediawiki.util/.eslintrc.json
new file mode 100644 (file)
index 0000000..ad8dbb3
--- /dev/null
@@ -0,0 +1,5 @@
+{
+       "parserOptions": {
+               "sourceType": "module"
+       }
+}
diff --git a/resources/src/mediawiki.util/jquery.accessKeyLabel.js b/resources/src/mediawiki.util/jquery.accessKeyLabel.js
new file mode 100644 (file)
index 0000000..07a06bf
--- /dev/null
@@ -0,0 +1,236 @@
+/**
+ * jQuery plugin to update the tooltip to show the correct access key
+ *
+ * @class jQuery.plugin.accessKeyLabel
+ */
+
+// Cached access key modifiers for used browser
+var cachedAccessKeyModifiers,
+
+       // Whether to use 'test-' instead of correct prefix (used for testing)
+       useTestPrefix = false,
+
+       // tag names which can have a label tag
+       // https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Form-associated_content
+       labelable = 'button, input, textarea, keygen, meter, output, progress, select';
+
+/**
+ * Find the modifier keys that need to be pressed together with the accesskey to trigger the input.
+ *
+ * The result is dependant on the ua paramater or the current platform.
+ * For browsers that support accessKeyLabel, #getAccessKeyLabel never calls here.
+ * Valid key values that are returned can be: ctrl, alt, option, shift, esc
+ *
+ * @private
+ * @param {Object} [ua] An object with a 'userAgent' and 'platform' property.
+ * @return {Array} Array with 1 or more of the string values, in this order: ctrl, option, alt, shift, esc
+ */
+function getAccessKeyModifiers( ua ) {
+       var profile, accessKeyModifiers;
+
+       // use cached prefix if possible
+       if ( !ua && cachedAccessKeyModifiers ) {
+               return cachedAccessKeyModifiers;
+       }
+
+       profile = $.client.profile( ua );
+
+       switch ( profile.name ) {
+               case 'chrome':
+               case 'opera':
+                       if ( profile.name === 'opera' && profile.versionNumber < 15 ) {
+                               accessKeyModifiers = [ 'shift', 'esc' ];
+                       } else if ( profile.platform === 'mac' ) {
+                               accessKeyModifiers = [ 'ctrl', 'option' ];
+                       } else {
+                               // Chrome/Opera on Windows or Linux
+                               // (both alt- and alt-shift work, but alt with E, D, F etc does not
+                               // work since they are browser shortcuts)
+                               accessKeyModifiers = [ 'alt', 'shift' ];
+                       }
+                       break;
+               case 'firefox':
+               case 'iceweasel':
+                       if ( profile.versionBase < 2 ) {
+                               // Before v2, Firefox used alt, though it was rebindable in about:config
+                               accessKeyModifiers = [ 'alt' ];
+                       } else {
+                               if ( profile.platform === 'mac' ) {
+                                       if ( profile.versionNumber < 14 ) {
+                                               accessKeyModifiers = [ 'ctrl' ];
+                                       } else {
+                                               accessKeyModifiers = [ 'ctrl', 'option' ];
+                                       }
+                               } else {
+                                       accessKeyModifiers = [ 'alt', 'shift' ];
+                               }
+                       }
+                       break;
+               case 'safari':
+               case 'konqueror':
+                       if ( profile.platform === 'win' ) {
+                               accessKeyModifiers = [ 'alt' ];
+                       } else {
+                               if ( profile.layoutVersion > 526 ) {
+                                       // Non-Windows Safari with webkit_version > 526
+                                       accessKeyModifiers = [ 'ctrl', profile.platform === 'mac' ? 'option' : 'alt' ];
+                               } else {
+                                       accessKeyModifiers = [ 'ctrl' ];
+                               }
+                       }
+                       break;
+               case 'msie':
+               case 'edge':
+                       accessKeyModifiers = [ 'alt' ];
+                       break;
+               default:
+                       accessKeyModifiers = profile.platform === 'mac' ? [ 'ctrl' ] : [ 'alt' ];
+                       break;
+       }
+
+       // cache modifiers
+       if ( !ua ) {
+               cachedAccessKeyModifiers = accessKeyModifiers;
+       }
+       return accessKeyModifiers;
+}
+
+/**
+ * Get the access key label for an element.
+ *
+ * Will use native accessKeyLabel if available (currently only in Firefox 8+),
+ * falls back to #getAccessKeyModifiers.
+ *
+ * @private
+ * @param {HTMLElement} element Element to get the label for
+ * @return {string} Access key label
+ */
+function getAccessKeyLabel( element ) {
+       // abort early if no access key
+       if ( !element.accessKey ) {
+               return '';
+       }
+       // use accessKeyLabel if possible
+       // https://html.spec.whatwg.org/multipage/interaction.html#dom-accesskeylabel
+       if ( !useTestPrefix && element.accessKeyLabel ) {
+               return element.accessKeyLabel;
+       }
+       return ( useTestPrefix ? 'test' : getAccessKeyModifiers().join( '-' ) ) + '-' + element.accessKey;
+}
+
+/**
+ * Update the title for an element (on the element with the access key or it's label) to show
+ * the correct access key label.
+ *
+ * @private
+ * @param {HTMLElement} element Element with the accesskey
+ * @param {HTMLElement} titleElement Element with the title to update (may be the same as `element`)
+ */
+function updateTooltipOnElement( element, titleElement ) {
+       var oldTitle, parts, regexp, newTitle, accessKeyLabel,
+               separatorMsg = mw.message( 'word-separator' ).plain();
+
+       oldTitle = titleElement.title;
+       if ( !oldTitle ) {
+               // don't add a title if the element didn't have one before
+               return;
+       }
+
+       parts = ( separatorMsg + mw.message( 'brackets' ).plain() ).split( '$1' );
+       regexp = new RegExp( parts.map( mw.util.escapeRegExp ).join( '.*?' ) + '$' );
+       newTitle = oldTitle.replace( regexp, '' );
+       accessKeyLabel = getAccessKeyLabel( element );
+
+       if ( accessKeyLabel ) {
+               // Should be build the same as in Linker::titleAttrib
+               newTitle += separatorMsg + mw.message( 'brackets', accessKeyLabel ).plain();
+       }
+       if ( oldTitle !== newTitle ) {
+               titleElement.title = newTitle;
+       }
+}
+
+/**
+ * Update the title for an element to show the correct access key label.
+ *
+ * @private
+ * @param {HTMLElement} element Element with the accesskey
+ */
+function updateTooltip( element ) {
+       var id, $element, $label, $labelParent;
+       updateTooltipOnElement( element, element );
+
+       // update associated label if there is one
+       $element = $( element );
+       if ( $element.is( labelable ) ) {
+               // Search it using 'for' attribute
+               id = element.id.replace( /"/g, '\\"' );
+               if ( id ) {
+                       $label = $( 'label[for="' + id + '"]' );
+                       if ( $label.length === 1 ) {
+                               updateTooltipOnElement( element, $label[ 0 ] );
+                       }
+               }
+
+               // Search it as parent, because the form control can also be inside the label element itself
+               $labelParent = $element.parents( 'label' );
+               if ( $labelParent.length === 1 ) {
+                       updateTooltipOnElement( element, $labelParent[ 0 ] );
+               }
+       }
+}
+
+/**
+ * Update the titles for all elements in a jQuery selection.
+ *
+ * @return {jQuery}
+ * @chainable
+ */
+$.fn.updateTooltipAccessKeys = function () {
+       return this.each( function () {
+               updateTooltip( this );
+       } );
+};
+
+/**
+ * getAccessKeyModifiers
+ *
+ * @method updateTooltipAccessKeys_getAccessKeyModifiers
+ * @inheritdoc #getAccessKeyModifiers
+ */
+$.fn.updateTooltipAccessKeys.getAccessKeyModifiers = getAccessKeyModifiers;
+
+/**
+ * getAccessKeyLabel
+ *
+ * @method updateTooltipAccessKeys_getAccessKeyLabel
+ * @inheritdoc #getAccessKeyLabel
+ */
+$.fn.updateTooltipAccessKeys.getAccessKeyLabel = getAccessKeyLabel;
+
+/**
+ * getAccessKeyPrefix
+ *
+ * @method updateTooltipAccessKeys_getAccessKeyPrefix
+ * @deprecated since 1.27 Use #getAccessKeyModifiers
+ * @param {Object} [ua] An object with a 'userAgent' and 'platform' property.
+ * @return {string}
+ */
+$.fn.updateTooltipAccessKeys.getAccessKeyPrefix = function ( ua ) {
+       return getAccessKeyModifiers( ua ).join( '-' ) + '-';
+};
+
+/**
+ * Switch test mode on and off.
+ *
+ * @method updateTooltipAccessKeys_setTestMode
+ * @param {boolean} mode New mode
+ */
+$.fn.updateTooltipAccessKeys.setTestMode = function ( mode ) {
+       useTestPrefix = mode;
+};
+
+/**
+ * @class jQuery
+ * @mixins jQuery.plugin.accessKeyLabel
+ */
index 36a0195..6342011 100644 (file)
-( function () {
-       'use strict';
+'use strict';
+
+var util,
+       config = require( './config.json' );
+
+require( './jquery.accessKeyLabel.js' );
+
+/**
+ * Encode the string like PHP's rawurlencode
+ * @ignore
+ *
+ * @param {string} str String to be encoded.
+ * @return {string} Encoded string
+ */
+function rawurlencode( str ) {
+       str = String( str );
+       return encodeURIComponent( str )
+               .replace( /!/g, '%21' ).replace( /'/g, '%27' ).replace( /\(/g, '%28' )
+               .replace( /\)/g, '%29' ).replace( /\*/g, '%2A' ).replace( /~/g, '%7E' );
+}
+
+/**
+ * Private helper function used by util.escapeId*()
+ * @ignore
+ *
+ * @param {string} str String to be encoded
+ * @param {string} mode Encoding mode, see documentation for $wgFragmentMode
+ *     in DefaultSettings.php
+ * @return {string} Encoded string
+ */
+function escapeIdInternal( str, mode ) {
+       str = String( str );
+
+       switch ( mode ) {
+               case 'html5':
+                       return str.replace( / /g, '_' );
+               case 'legacy':
+                       return rawurlencode( str.replace( / /g, '_' ) )
+                               .replace( /%3A/g, ':' )
+                               .replace( /%/g, '.' );
+               default:
+                       throw new Error( 'Unrecognized ID escaping mode ' + mode );
+       }
+}
 
-       var util,
-               config = require( './config.json' );
+/**
+ * Utility library
+ * @class mw.util
+ * @singleton
+ */
+util = {
 
        /**
         * Encode the string like PHP's rawurlencode
-        * @ignore
         *
         * @param {string} str String to be encoded.
         * @return {string} Encoded string
         */
-       function rawurlencode( str ) {
-               str = String( str );
-               return encodeURIComponent( str )
-                       .replace( /!/g, '%21' ).replace( /'/g, '%27' ).replace( /\(/g, '%28' )
-                       .replace( /\)/g, '%29' ).replace( /\*/g, '%2A' ).replace( /~/g, '%7E' );
-       }
+       rawurlencode: rawurlencode,
+
+       /**
+        * Encode string into HTML id compatible form suitable for use in HTML
+        * Analog to PHP Sanitizer::escapeIdForAttribute()
+        *
+        * @since 1.30
+        *
+        * @param {string} str String to encode
+        * @return {string} Encoded string
+        */
+       escapeIdForAttribute: function ( str ) {
+               var mode = config.FragmentMode[ 0 ];
+
+               return escapeIdInternal( str, mode );
+       },
+
+       /**
+        * Encode string into HTML id compatible form suitable for use in links
+        * Analog to PHP Sanitizer::escapeIdForLink()
+        *
+        * @since 1.30
+        *
+        * @param {string} str String to encode
+        * @return {string} Encoded string
+        */
+       escapeIdForLink: function ( str ) {
+               var mode = config.FragmentMode[ 0 ];
+
+               return escapeIdInternal( str, mode );
+       },
 
        /**
-        * Private helper function used by util.escapeId*()
-        * @ignore
+        * Encode page titles for use in a URL
         *
-        * @param {string} str String to be encoded
-        * @param {string} mode Encoding mode, see documentation for $wgFragmentMode
-        *     in DefaultSettings.php
+        * We want / and : to be included as literal characters in our title URLs
+        * as they otherwise fatally break the title.
+        *
+        * The others are decoded because we can, it's prettier and matches behaviour
+        * of `wfUrlencode` in PHP.
+        *
+        * @param {string} str String to be encoded.
         * @return {string} Encoded string
         */
-       function escapeIdInternal( str, mode ) {
-               str = String( str );
-
-               switch ( mode ) {
-                       case 'html5':
-                               return str.replace( / /g, '_' );
-                       case 'legacy':
-                               return rawurlencode( str.replace( / /g, '_' ) )
-                                       .replace( /%3A/g, ':' )
-                                       .replace( /%/g, '.' );
-                       default:
-                               throw new Error( 'Unrecognized ID escaping mode ' + mode );
+       wikiUrlencode: function ( str ) {
+               return util.rawurlencode( str )
+                       .replace( /%20/g, '_' )
+                       // wfUrlencode replacements
+                       .replace( /%3B/g, ';' )
+                       .replace( /%40/g, '@' )
+                       .replace( /%24/g, '$' )
+                       .replace( /%21/g, '!' )
+                       .replace( /%2A/g, '*' )
+                       .replace( /%28/g, '(' )
+                       .replace( /%29/g, ')' )
+                       .replace( /%2C/g, ',' )
+                       .replace( /%2F/g, '/' )
+                       .replace( /%7E/g, '~' )
+                       .replace( /%3A/g, ':' );
+       },
+
+       /**
+        * Get the link to a page name (relative to `wgServer`),
+        *
+        * @param {string|null} [pageName=wgPageName] Page name
+        * @param {Object} [params] A mapping of query parameter names to values,
+        *  e.g. `{ action: 'edit' }`
+        * @return {string} Url of the page with name of `pageName`
+        */
+       getUrl: function ( pageName, params ) {
+               var titleFragmentStart, url, query,
+                       fragment = '',
+                       title = typeof pageName === 'string' ? pageName : mw.config.get( 'wgPageName' );
+
+               // Find any fragment
+               titleFragmentStart = title.indexOf( '#' );
+               if ( titleFragmentStart !== -1 ) {
+                       fragment = title.slice( titleFragmentStart + 1 );
+                       // Exclude the fragment from the page name
+                       title = title.slice( 0, titleFragmentStart );
                }
-       }
+
+               // Produce query string
+               if ( params ) {
+                       query = $.param( params );
+               }
+               if ( query ) {
+                       url = title ?
+                               util.wikiScript() + '?title=' + util.wikiUrlencode( title ) + '&' + query :
+                               util.wikiScript() + '?' + query;
+               } else {
+                       url = mw.config.get( 'wgArticlePath' )
+                               .replace( '$1', util.wikiUrlencode( title ).replace( /\$/g, '$$$$' ) );
+               }
+
+               // Append the encoded fragment
+               if ( fragment.length ) {
+                       url += '#' + util.escapeIdForLink( fragment );
+               }
+
+               return url;
+       },
 
        /**
-        * Utility library
-        * @class mw.util
-        * @singleton
+        * Get address to a script in the wiki root.
+        * For index.php use `mw.config.get( 'wgScript' )`.
+        *
+        * @since 1.18
+        * @param {string} str Name of script (e.g. 'api'), defaults to 'index'
+        * @return {string} Address to script (e.g. '/w/api.php' )
         */
-       util = {
-
-               /**
-                * Encode the string like PHP's rawurlencode
-                *
-                * @param {string} str String to be encoded.
-                * @return {string} Encoded string
-                */
-               rawurlencode: rawurlencode,
-
-               /**
-                * Encode string into HTML id compatible form suitable for use in HTML
-                * Analog to PHP Sanitizer::escapeIdForAttribute()
-                *
-                * @since 1.30
-                *
-                * @param {string} str String to encode
-                * @return {string} Encoded string
-                */
-               escapeIdForAttribute: function ( str ) {
-                       var mode = config.FragmentMode[ 0 ];
-
-                       return escapeIdInternal( str, mode );
-               },
-
-               /**
-                * Encode string into HTML id compatible form suitable for use in links
-                * Analog to PHP Sanitizer::escapeIdForLink()
-                *
-                * @since 1.30
-                *
-                * @param {string} str String to encode
-                * @return {string} Encoded string
-                */
-               escapeIdForLink: function ( str ) {
-                       var mode = config.FragmentMode[ 0 ];
-
-                       return escapeIdInternal( str, mode );
-               },
-
-               /**
-                * Encode page titles for use in a URL
-                *
-                * We want / and : to be included as literal characters in our title URLs
-                * as they otherwise fatally break the title.
-                *
-                * The others are decoded because we can, it's prettier and matches behaviour
-                * of `wfUrlencode` in PHP.
-                *
-                * @param {string} str String to be encoded.
-                * @return {string} Encoded string
-                */
-               wikiUrlencode: function ( str ) {
-                       return util.rawurlencode( str )
-                               .replace( /%20/g, '_' )
-                               // wfUrlencode replacements
-                               .replace( /%3B/g, ';' )
-                               .replace( /%40/g, '@' )
-                               .replace( /%24/g, '$' )
-                               .replace( /%21/g, '!' )
-                               .replace( /%2A/g, '*' )
-                               .replace( /%28/g, '(' )
-                               .replace( /%29/g, ')' )
-                               .replace( /%2C/g, ',' )
-                               .replace( /%2F/g, '/' )
-                               .replace( /%7E/g, '~' )
-                               .replace( /%3A/g, ':' );
-               },
-
-               /**
-                * Get the link to a page name (relative to `wgServer`),
-                *
-                * @param {string|null} [pageName=wgPageName] Page name
-                * @param {Object} [params] A mapping of query parameter names to values,
-                *  e.g. `{ action: 'edit' }`
-                * @return {string} Url of the page with name of `pageName`
-                */
-               getUrl: function ( pageName, params ) {
-                       var titleFragmentStart, url, query,
-                               fragment = '',
-                               title = typeof pageName === 'string' ? pageName : mw.config.get( 'wgPageName' );
-
-                       // Find any fragment
-                       titleFragmentStart = title.indexOf( '#' );
-                       if ( titleFragmentStart !== -1 ) {
-                               fragment = title.slice( titleFragmentStart + 1 );
-                               // Exclude the fragment from the page name
-                               title = title.slice( 0, titleFragmentStart );
-                       }
+       wikiScript: function ( str ) {
+               str = str || 'index';
+               if ( str === 'index' ) {
+                       return mw.config.get( 'wgScript' );
+               } else if ( str === 'load' ) {
+                       return config.LoadScript;
+               } else {
+                       return mw.config.get( 'wgScriptPath' ) + '/' + str + '.php';
+               }
+       },
 
-                       // Produce query string
-                       if ( params ) {
-                               query = $.param( params );
-                       }
-                       if ( query ) {
-                               url = title ?
-                                       util.wikiScript() + '?title=' + util.wikiUrlencode( title ) + '&' + query :
-                                       util.wikiScript() + '?' + query;
-                       } else {
-                               url = mw.config.get( 'wgArticlePath' )
-                                       .replace( '$1', util.wikiUrlencode( title ).replace( /\$/g, '$$$$' ) );
-                       }
+       /**
+        * Append a new style block to the head and return the CSSStyleSheet object.
+        * Use .ownerNode to access the `<style>` element, or use mw.loader#addStyleTag.
+        * This function returns the styleSheet object for convience (due to cross-browsers
+        * difference as to where it is located).
+        *
+        *     var sheet = util.addCSS( '.foobar { display: none; }' );
+        *     $( foo ).click( function () {
+        *         // Toggle the sheet on and off
+        *         sheet.disabled = !sheet.disabled;
+        *     } );
+        *
+        * @param {string} text CSS to be appended
+        * @return {CSSStyleSheet} Use .ownerNode to get to the `<style>` element.
+        */
+       addCSS: function ( text ) {
+               var s = mw.loader.addStyleTag( text );
+               return s.sheet || s.styleSheet || s;
+       },
 
-                       // Append the encoded fragment
-                       if ( fragment.length ) {
-                               url += '#' + util.escapeIdForLink( fragment );
-                       }
+       /**
+        * Grab the URL parameter value for the given parameter.
+        * Returns null if not found.
+        *
+        * @param {string} param The parameter name.
+        * @param {string} [url=location.href] URL to search through, defaulting to the current browsing location.
+        * @return {Mixed} Parameter value or null.
+        */
+       getParamValue: function ( param, url ) {
+               // Get last match, stop at hash
+               var re = new RegExp( '^[^#]*[&?]' + util.escapeRegExp( param ) + '=([^&#]*)' ),
+                       m = re.exec( url !== undefined ? url : location.href );
+
+               if ( m ) {
+                       // Beware that decodeURIComponent is not required to understand '+'
+                       // by spec, as encodeURIComponent does not produce it.
+                       return decodeURIComponent( m[ 1 ].replace( /\+/g, '%20' ) );
+               }
+               return null;
+       },
 
-                       return url;
-               },
-
-               /**
-                * Get address to a script in the wiki root.
-                * For index.php use `mw.config.get( 'wgScript' )`.
-                *
-                * @since 1.18
-                * @param {string} str Name of script (e.g. 'api'), defaults to 'index'
-                * @return {string} Address to script (e.g. '/w/api.php' )
-                */
-               wikiScript: function ( str ) {
-                       str = str || 'index';
-                       if ( str === 'index' ) {
-                               return mw.config.get( 'wgScript' );
-                       } else if ( str === 'load' ) {
-                               return config.LoadScript;
-                       } else {
-                               return mw.config.get( 'wgScriptPath' ) + '/' + str + '.php';
-                       }
-               },
-
-               /**
-                * Append a new style block to the head and return the CSSStyleSheet object.
-                * Use .ownerNode to access the `<style>` element, or use mw.loader#addStyleTag.
-                * This function returns the styleSheet object for convience (due to cross-browsers
-                * difference as to where it is located).
-                *
-                *     var sheet = util.addCSS( '.foobar { display: none; }' );
-                *     $( foo ).click( function () {
-                *         // Toggle the sheet on and off
-                *         sheet.disabled = !sheet.disabled;
-                *     } );
-                *
-                * @param {string} text CSS to be appended
-                * @return {CSSStyleSheet} Use .ownerNode to get to the `<style>` element.
-                */
-               addCSS: function ( text ) {
-                       var s = mw.loader.addStyleTag( text );
-                       return s.sheet || s.styleSheet || s;
-               },
-
-               /**
-                * Grab the URL parameter value for the given parameter.
-                * Returns null if not found.
-                *
-                * @param {string} param The parameter name.
-                * @param {string} [url=location.href] URL to search through, defaulting to the current browsing location.
-                * @return {Mixed} Parameter value or null.
-                */
-               getParamValue: function ( param, url ) {
-                       // Get last match, stop at hash
-                       var re = new RegExp( '^[^#]*[&?]' + mw.RegExp.escape( param ) + '=([^&#]*)' ),
-                               m = re.exec( url !== undefined ? url : location.href );
-
-                       if ( m ) {
-                               // Beware that decodeURIComponent is not required to understand '+'
-                               // by spec, as encodeURIComponent does not produce it.
-                               return decodeURIComponent( m[ 1 ].replace( /\+/g, '%20' ) );
-                       }
+       /**
+        * The content wrapper of the skin (e.g. `.mw-body`).
+        *
+        * Populated on document ready. To use this property,
+        * wait for `$.ready` and be sure to have a module dependency on
+        * `mediawiki.util` which will ensure
+        * your document ready handler fires after initialization.
+        *
+        * Because of the lazy-initialised nature of this property,
+        * you're discouraged from using it.
+        *
+        * If you need just the wikipage content (not any of the
+        * extra elements output by the skin), use `$( '#mw-content-text' )`
+        * instead. Or listen to mw.hook#wikipage_content which will
+        * allow your code to re-run when the page changes (e.g. live preview
+        * or re-render after ajax save).
+        *
+        * @property {jQuery}
+        */
+       $content: null,
+
+       /**
+        * Add a link to a portlet menu on the page, such as:
+        *
+        * p-cactions (Content actions), p-personal (Personal tools),
+        * p-navigation (Navigation), p-tb (Toolbox)
+        *
+        * The first three parameters are required, the others are optional and
+        * may be null. Though providing an id and tooltip is recommended.
+        *
+        * By default the new link will be added to the end of the list. To
+        * add the link before a given existing item, pass the DOM node
+        * (e.g. `document.getElementById( 'foobar' )`) or a jQuery-selector
+        * (e.g. `'#foobar'`) for that item.
+        *
+        *     util.addPortletLink(
+        *         'p-tb', 'https://www.mediawiki.org/',
+        *         'mediawiki.org', 't-mworg', 'Go to mediawiki.org', 'm', '#t-print'
+        *     );
+        *
+        *     var node = util.addPortletLink(
+        *         'p-tb',
+        *         new mw.Title( 'Special:Example' ).getUrl(),
+        *         'Example'
+        *     );
+        *     $( node ).on( 'click', function ( e ) {
+        *         console.log( 'Example' );
+        *         e.preventDefault();
+        *     } );
+        *
+        * @param {string} portletId ID of the target portlet (e.g. 'p-cactions' or 'p-personal')
+        * @param {string} href Link URL
+        * @param {string} text Link text
+        * @param {string} [id] ID of the list item, should be unique and preferably have
+        *  the appropriate prefix ('ca-', 'pt-', 'n-' or 't-')
+        * @param {string} [tooltip] Text to show when hovering over the link, without accesskey suffix
+        * @param {string} [accesskey] Access key to activate this link. One character only,
+        *  avoid conflicts with other links. Use `$( '[accesskey=x]' )` in the console to
+        *  see if 'x' is already used.
+        * @param {HTMLElement|jQuery|string} [nextnode] Element that the new item should be added before.
+        *  Must be another item in the same list, it will be ignored otherwise.
+        *  Can be specified as DOM reference, as jQuery object, or as CSS selector string.
+        * @return {HTMLElement|null} The added list item, or null if no element was added.
+        */
+       addPortletLink: function ( portletId, href, text, id, tooltip, accesskey, nextnode ) {
+               var item, link, $portlet, portlet, portletDiv, ul, next;
+
+               if ( !portletId ) {
+                       // Avoid confusing id="undefined" lookup
                        return null;
-               },
-
-               /**
-                * The content wrapper of the skin (e.g. `.mw-body`).
-                *
-                * Populated on document ready. To use this property,
-                * wait for `$.ready` and be sure to have a module dependency on
-                * `mediawiki.util` which will ensure
-                * your document ready handler fires after initialization.
-                *
-                * Because of the lazy-initialised nature of this property,
-                * you're discouraged from using it.
-                *
-                * If you need just the wikipage content (not any of the
-                * extra elements output by the skin), use `$( '#mw-content-text' )`
-                * instead. Or listen to mw.hook#wikipage_content which will
-                * allow your code to re-run when the page changes (e.g. live preview
-                * or re-render after ajax save).
-                *
-                * @property {jQuery}
-                */
-               $content: null,
-
-               /**
-                * Add a link to a portlet menu on the page, such as:
-                *
-                * p-cactions (Content actions), p-personal (Personal tools),
-                * p-navigation (Navigation), p-tb (Toolbox)
-                *
-                * The first three parameters are required, the others are optional and
-                * may be null. Though providing an id and tooltip is recommended.
-                *
-                * By default the new link will be added to the end of the list. To
-                * add the link before a given existing item, pass the DOM node
-                * (e.g. `document.getElementById( 'foobar' )`) or a jQuery-selector
-                * (e.g. `'#foobar'`) for that item.
-                *
-                *     util.addPortletLink(
-                *         'p-tb', 'https://www.mediawiki.org/',
-                *         'mediawiki.org', 't-mworg', 'Go to mediawiki.org', 'm', '#t-print'
-                *     );
-                *
-                *     var node = util.addPortletLink(
-                *         'p-tb',
-                *         new mw.Title( 'Special:Example' ).getUrl(),
-                *         'Example'
-                *     );
-                *     $( node ).on( 'click', function ( e ) {
-                *         console.log( 'Example' );
-                *         e.preventDefault();
-                *     } );
-                *
-                * @param {string} portletId ID of the target portlet (e.g. 'p-cactions' or 'p-personal')
-                * @param {string} href Link URL
-                * @param {string} text Link text
-                * @param {string} [id] ID of the list item, should be unique and preferably have
-                *  the appropriate prefix ('ca-', 'pt-', 'n-' or 't-')
-                * @param {string} [tooltip] Text to show when hovering over the link, without accesskey suffix
-                * @param {string} [accesskey] Access key to activate this link. One character only,
-                *  avoid conflicts with other links. Use `$( '[accesskey=x]' )` in the console to
-                *  see if 'x' is already used.
-                * @param {HTMLElement|jQuery|string} [nextnode] Element that the new item should be added before.
-                *  Must be another item in the same list, it will be ignored otherwise.
-                *  Can be specified as DOM reference, as jQuery object, or as CSS selector string.
-                * @return {HTMLElement|null} The added list item, or null if no element was added.
-                */
-               addPortletLink: function ( portletId, href, text, id, tooltip, accesskey, nextnode ) {
-                       var item, link, $portlet, portlet, portletDiv, ul, next;
-
-                       if ( !portletId ) {
-                               // Avoid confusing id="undefined" lookup
-                               return null;
-                       }
+               }
 
-                       portlet = document.getElementById( portletId );
-                       if ( !portlet ) {
-                               // Invalid portlet ID
-                               return null;
-                       }
+               portlet = document.getElementById( portletId );
+               if ( !portlet ) {
+                       // Invalid portlet ID
+                       return null;
+               }
 
-                       // Setup the anchor tag and set any the properties
-                       link = document.createElement( 'a' );
-                       link.href = href;
-                       link.textContent = text;
-                       if ( tooltip ) {
-                               link.title = tooltip;
-                       }
-                       if ( accesskey ) {
-                               link.accessKey = accesskey;
-                       }
+               // Setup the anchor tag and set any the properties
+               link = document.createElement( 'a' );
+               link.href = href;
+               link.textContent = text;
+               if ( tooltip ) {
+                       link.title = tooltip;
+               }
+               if ( accesskey ) {
+                       link.accessKey = accesskey;
+               }
 
-                       // Unhide portlet if it was hidden before
-                       $portlet = $( portlet );
-                       $portlet.removeClass( 'emptyPortlet' );
+               // Unhide portlet if it was hidden before
+               $portlet = $( portlet );
+               $portlet.removeClass( 'emptyPortlet' );
+
+               // Setup the list item (and a span if $portlet is a Vector tab)
+               // eslint-disable-next-line no-jquery/no-class-state
+               if ( $portlet.hasClass( 'vectorTabs' ) ) {
+                       item = $( '<li>' ).append( $( '<span>' ).append( link )[ 0 ] )[ 0 ];
+               } else {
+                       item = $( '<li>' ).append( link )[ 0 ];
+               }
+               if ( id ) {
+                       item.id = id;
+               }
 
-                       // Setup the list item (and a span if $portlet is a Vector tab)
-                       // eslint-disable-next-line no-jquery/no-class-state
-                       if ( $portlet.hasClass( 'vectorTabs' ) ) {
-                               item = $( '<li>' ).append( $( '<span>' ).append( link )[ 0 ] )[ 0 ];
+               // Select the first (most likely only) unordered list inside the portlet
+               ul = portlet.querySelector( 'ul' );
+               if ( !ul ) {
+                       // If it didn't have an unordered list yet, create one
+                       ul = document.createElement( 'ul' );
+                       portletDiv = portlet.querySelector( 'div' );
+                       if ( portletDiv ) {
+                               // Support: Legacy skins have a div (such as div.body or div.pBody).
+                               // Append the <ul> to that.
+                               portletDiv.appendChild( ul );
                        } else {
-                               item = $( '<li>' ).append( link )[ 0 ];
-                       }
-                       if ( id ) {
-                               item.id = id;
+                               // Append it to the portlet directly
+                               portlet.appendChild( ul );
                        }
+               }
 
-                       // Select the first (most likely only) unordered list inside the portlet
-                       ul = portlet.querySelector( 'ul' );
-                       if ( !ul ) {
-                               // If it didn't have an unordered list yet, create one
-                               ul = document.createElement( 'ul' );
-                               portletDiv = portlet.querySelector( 'div' );
-                               if ( portletDiv ) {
-                                       // Support: Legacy skins have a div (such as div.body or div.pBody).
-                                       // Append the <ul> to that.
-                                       portletDiv.appendChild( ul );
-                               } else {
-                                       // Append it to the portlet directly
-                                       portlet.appendChild( ul );
-                               }
+               if ( nextnode && ( typeof nextnode === 'string' || nextnode.nodeType || nextnode.jquery ) ) {
+                       nextnode = $( ul ).find( nextnode );
+                       if ( nextnode.length === 1 && nextnode[ 0 ].parentNode === ul ) {
+                               // Insertion point: Before nextnode
+                               nextnode.before( item );
+                               next = true;
                        }
+                       // Else: Invalid nextnode value (no match, more than one match, or not a direct child)
+                       // Else: Invalid nextnode type
+               }
 
-                       if ( nextnode && ( typeof nextnode === 'string' || nextnode.nodeType || nextnode.jquery ) ) {
-                               nextnode = $( ul ).find( nextnode );
-                               if ( nextnode.length === 1 && nextnode[ 0 ].parentNode === ul ) {
-                                       // Insertion point: Before nextnode
-                                       nextnode.before( item );
-                                       next = true;
-                               }
-                               // Else: Invalid nextnode value (no match, more than one match, or not a direct child)
-                               // Else: Invalid nextnode type
-                       }
+               if ( !next ) {
+                       // Insertion point: End of list (default)
+                       ul.appendChild( item );
+               }
 
-                       if ( !next ) {
-                               // Insertion point: End of list (default)
-                               ul.appendChild( item );
-                       }
+               // Update tooltip for the access key after inserting into DOM
+               // to get a localized access key label (T69946).
+               if ( accesskey ) {
+                       $( link ).updateTooltipAccessKeys();
+               }
 
-                       // Update tooltip for the access key after inserting into DOM
-                       // to get a localized access key label (T69946).
-                       if ( accesskey ) {
-                               $( link ).updateTooltipAccessKeys();
-                       }
+               return item;
+       },
 
-                       return item;
-               },
-
-               /**
-                * Validate a string as representing a valid e-mail address
-                * according to HTML5 specification. Please note the specification
-                * does not validate a domain with one character.
-                *
-                * FIXME: should be moved to or replaced by a validation module.
-                *
-                * @param {string} mailtxt E-mail address to be validated.
-                * @return {boolean|null} Null if `mailtxt` was an empty string, otherwise true/false
-                * as determined by validation.
-                */
-               validateEmail: function ( mailtxt ) {
-                       var rfc5322Atext, rfc1034LdhStr, html5EmailRegexp;
-
-                       if ( mailtxt === '' ) {
-                               return null;
-                       }
+       /**
+        * Validate a string as representing a valid e-mail address
+        * according to HTML5 specification. Please note the specification
+        * does not validate a domain with one character.
+        *
+        * FIXME: should be moved to or replaced by a validation module.
+        *
+        * @param {string} mailtxt E-mail address to be validated.
+        * @return {boolean|null} Null if `mailtxt` was an empty string, otherwise true/false
+        * as determined by validation.
+        */
+       validateEmail: function ( mailtxt ) {
+               var rfc5322Atext, rfc1034LdhStr, html5EmailRegexp;
 
-                       // HTML5 defines a string as valid e-mail address if it matches
-                       // the ABNF:
-                       //     1 * ( atext / "." ) "@" ldh-str 1*( "." ldh-str )
-                       // With:
-                       // - atext   : defined in RFC 5322 section 3.2.3
-                       // - ldh-str : defined in RFC 1034 section 3.5
-                       //
-                       // (see STD 68 / RFC 5234 https://tools.ietf.org/html/std68)
-                       // First, define the RFC 5322 'atext' which is pretty easy:
-                       // atext = ALPHA / DIGIT / ; Printable US-ASCII
-                       //     "!" / "#" /    ; characters not including
-                       //     "$" / "%" /    ; specials. Used for atoms.
-                       //     "&" / "'" /
-                       //     "*" / "+" /
-                       //     "-" / "/" /
-                       //     "=" / "?" /
-                       //     "^" / "_" /
-                       //     "`" / "{" /
-                       //     "|" / "}" /
-                       //     "~"
-                       rfc5322Atext = 'a-z0-9!#$%&\'*+\\-/=?^_`{|}~';
-
-                       // Next define the RFC 1034 'ldh-str'
-                       //     <domain> ::= <subdomain> | " "
-                       //     <subdomain> ::= <label> | <subdomain> "." <label>
-                       //     <label> ::= <letter> [ [ <ldh-str> ] <let-dig> ]
-                       //     <ldh-str> ::= <let-dig-hyp> | <let-dig-hyp> <ldh-str>
-                       //     <let-dig-hyp> ::= <let-dig> | "-"
-                       //     <let-dig> ::= <letter> | <digit>
-                       rfc1034LdhStr = 'a-z0-9\\-';
-
-                       html5EmailRegexp = new RegExp(
-                               // start of string
-                               '^' +
-                               // User part which is liberal :p
-                               '[' + rfc5322Atext + '\\.]+' +
-                               // 'at'
-                               '@' +
-                               // Domain first part
-                               '[' + rfc1034LdhStr + ']+' +
-                               // Optional second part and following are separated by a dot
-                               '(?:\\.[' + rfc1034LdhStr + ']+)*' +
-                               // End of string
-                               '$',
-                               // RegExp is case insensitive
-                               'i'
-                       );
-                       return ( mailtxt.match( html5EmailRegexp ) !== null );
-               },
-
-               /**
-                * Note: borrows from IP::isIPv4
-                *
-                * @param {string} address
-                * @param {boolean} [allowBlock=false]
-                * @return {boolean}
-                */
-               isIPv4Address: function ( address, allowBlock ) {
-                       var block, RE_IP_BYTE, RE_IP_ADD;
-
-                       if ( typeof address !== 'string' ) {
-                               return false;
-                       }
+               if ( mailtxt === '' ) {
+                       return null;
+               }
 
-                       block = allowBlock ? '(?:\\/(?:3[0-2]|[12]?\\d))?' : '';
-                       RE_IP_BYTE = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|0?[0-9]?[0-9])';
-                       RE_IP_ADD = '(?:' + RE_IP_BYTE + '\\.){3}' + RE_IP_BYTE;
-
-                       return ( new RegExp( '^' + RE_IP_ADD + block + '$' ).test( address ) );
-               },
-
-               /**
-                * Note: borrows from IP::isIPv6
-                *
-                * @param {string} address
-                * @param {boolean} [allowBlock=false]
-                * @return {boolean}
-                */
-               isIPv6Address: function ( address, allowBlock ) {
-                       var block, RE_IPV6_ADD;
-
-                       if ( typeof address !== 'string' ) {
-                               return false;
-                       }
+               // HTML5 defines a string as valid e-mail address if it matches
+               // the ABNF:
+               //     1 * ( atext / "." ) "@" ldh-str 1*( "." ldh-str )
+               // With:
+               // - atext   : defined in RFC 5322 section 3.2.3
+               // - ldh-str : defined in RFC 1034 section 3.5
+               //
+               // (see STD 68 / RFC 5234 https://tools.ietf.org/html/std68)
+               // First, define the RFC 5322 'atext' which is pretty easy:
+               // atext = ALPHA / DIGIT / ; Printable US-ASCII
+               //     "!" / "#" /    ; characters not including
+               //     "$" / "%" /    ; specials. Used for atoms.
+               //     "&" / "'" /
+               //     "*" / "+" /
+               //     "-" / "/" /
+               //     "=" / "?" /
+               //     "^" / "_" /
+               //     "`" / "{" /
+               //     "|" / "}" /
+               //     "~"
+               rfc5322Atext = 'a-z0-9!#$%&\'*+\\-/=?^_`{|}~';
+
+               // Next define the RFC 1034 'ldh-str'
+               //     <domain> ::= <subdomain> | " "
+               //     <subdomain> ::= <label> | <subdomain> "." <label>
+               //     <label> ::= <letter> [ [ <ldh-str> ] <let-dig> ]
+               //     <ldh-str> ::= <let-dig-hyp> | <let-dig-hyp> <ldh-str>
+               //     <let-dig-hyp> ::= <let-dig> | "-"
+               //     <let-dig> ::= <letter> | <digit>
+               rfc1034LdhStr = 'a-z0-9\\-';
+
+               html5EmailRegexp = new RegExp(
+                       // start of string
+                       '^' +
+                       // User part which is liberal :p
+                       '[' + rfc5322Atext + '\\.]+' +
+                       // 'at'
+                       '@' +
+                       // Domain first part
+                       '[' + rfc1034LdhStr + ']+' +
+                       // Optional second part and following are separated by a dot
+                       '(?:\\.[' + rfc1034LdhStr + ']+)*' +
+                       // End of string
+                       '$',
+                       // RegExp is case insensitive
+                       'i'
+               );
+               return ( mailtxt.match( html5EmailRegexp ) !== null );
+       },
 
-                       block = allowBlock ? '(?:\\/(?:12[0-8]|1[01][0-9]|[1-9]?\\d))?' : '';
-                       RE_IPV6_ADD =
-                               '(?:' + // starts with "::" (including "::")
-                                       ':(?::|(?::' +
-                                               '[0-9A-Fa-f]{1,4}' +
-                                       '){1,7})' +
-                                       '|' + // ends with "::" (except "::")
-                                       '[0-9A-Fa-f]{1,4}' +
-                                       '(?::' +
-                                               '[0-9A-Fa-f]{1,4}' +
-                                       '){0,6}::' +
-                                       '|' + // contains no "::"
-                                       '[0-9A-Fa-f]{1,4}' +
-                                       '(?::' +
-                                               '[0-9A-Fa-f]{1,4}' +
-                                       '){7}' +
-                               ')';
+       /**
+        * Note: borrows from IP::isIPv4
+        *
+        * @param {string} address
+        * @param {boolean} [allowBlock=false]
+        * @return {boolean}
+        */
+       isIPv4Address: function ( address, allowBlock ) {
+               var block, RE_IP_BYTE, RE_IP_ADD;
 
-                       if ( new RegExp( '^' + RE_IPV6_ADD + block + '$' ).test( address ) ) {
-                               return true;
-                       }
+               if ( typeof address !== 'string' ) {
+                       return false;
+               }
+
+               block = allowBlock ? '(?:\\/(?:3[0-2]|[12]?\\d))?' : '';
+               RE_IP_BYTE = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|0?[0-9]?[0-9])';
+               RE_IP_ADD = '(?:' + RE_IP_BYTE + '\\.){3}' + RE_IP_BYTE;
+
+               return ( new RegExp( '^' + RE_IP_ADD + block + '$' ).test( address ) );
+       },
+
+       /**
+        * Note: borrows from IP::isIPv6
+        *
+        * @param {string} address
+        * @param {boolean} [allowBlock=false]
+        * @return {boolean}
+        */
+       isIPv6Address: function ( address, allowBlock ) {
+               var block, RE_IPV6_ADD;
+
+               if ( typeof address !== 'string' ) {
+                       return false;
+               }
 
-                       // contains one "::" in the middle (single '::' check below)
-                       RE_IPV6_ADD =
+               block = allowBlock ? '(?:\\/(?:12[0-8]|1[01][0-9]|[1-9]?\\d))?' : '';
+               RE_IPV6_ADD =
+                       '(?:' + // starts with "::" (including "::")
+                               ':(?::|(?::' +
+                                       '[0-9A-Fa-f]{1,4}' +
+                               '){1,7})' +
+                               '|' + // ends with "::" (except "::")
+                               '[0-9A-Fa-f]{1,4}' +
+                               '(?::' +
+                                       '[0-9A-Fa-f]{1,4}' +
+                               '){0,6}::' +
+                               '|' + // contains no "::"
                                '[0-9A-Fa-f]{1,4}' +
-                               '(?:::?' +
+                               '(?::' +
                                        '[0-9A-Fa-f]{1,4}' +
-                               '){1,6}';
-
-                       return (
-                               new RegExp( '^' + RE_IPV6_ADD + block + '$' ).test( address ) &&
-                               /::/.test( address ) &&
-                               !/::.*::/.test( address )
-                       );
-               },
-
-               /**
-                * Check whether a string is an IP address
-                *
-                * @since 1.25
-                * @param {string} address String to check
-                * @param {boolean} [allowBlock=false] If a block of IPs should be allowed
-                * @return {boolean}
-                */
-               isIPAddress: function ( address, allowBlock ) {
-                       return util.isIPv4Address( address, allowBlock ) ||
-                               util.isIPv6Address( address, allowBlock );
+                               '){7}' +
+                       ')';
+
+               if ( new RegExp( '^' + RE_IPV6_ADD + block + '$' ).test( address ) ) {
+                       return true;
                }
-       };
 
-       // Not allowed outside unit tests
-       if ( window.QUnit ) {
-               util.setOptionsForTest = function ( opts ) {
-                       var oldConfig = config;
-                       config = $.extend( {}, config, opts );
-                       return oldConfig;
-               };
-       }
+               // contains one "::" in the middle (single '::' check below)
+               RE_IPV6_ADD =
+                       '[0-9A-Fa-f]{1,4}' +
+                       '(?:::?' +
+                               '[0-9A-Fa-f]{1,4}' +
+                       '){1,6}';
+
+               return (
+                       new RegExp( '^' + RE_IPV6_ADD + block + '$' ).test( address ) &&
+                       /::/.test( address ) &&
+                       !/::.*::/.test( address )
+               );
+       },
 
        /**
-        * Initialisation of mw.util.$content
+        * Check whether a string is an IP address
+        *
+        * @since 1.25
+        * @param {string} address String to check
+        * @param {boolean} [allowBlock=false] If a block of IPs should be allowed
+        * @return {boolean}
         */
-       function init() {
-               util.$content = ( function () {
-                       var i, l, $node, selectors;
-
-                       selectors = [
-                               // The preferred standard is class "mw-body".
-                               // You may also use class "mw-body mw-body-primary" if you use
-                               // mw-body in multiple locations. Or class "mw-body-primary" if
-                               // you use mw-body deeper in the DOM.
-                               '.mw-body-primary',
-                               '.mw-body',
-
-                               // If the skin has no such class, fall back to the parser output
-                               '#mw-content-text'
-                       ];
-
-                       for ( i = 0, l = selectors.length; i < l; i++ ) {
-                               $node = $( selectors[ i ] );
-                               if ( $node.length ) {
-                                       return $node.first();
-                               }
-                       }
+       isIPAddress: function ( address, allowBlock ) {
+               return util.isIPv4Address( address, allowBlock ) ||
+                       util.isIPv6Address( address, allowBlock );
+       },
 
-                       // Should never happen... well, it could if someone is not finished writing a
-                       // skin and has not yet inserted bodytext yet.
-                       return $( 'body' );
-               }() );
+       /**
+        * Escape string for safe inclusion in regular expression
+        *
+        * The following characters are escaped:
+        *
+        *     \ { } ( ) | . ? * + - ^ $ [ ]
+        *
+        * @since 1.26; moved to mw.util in 1.34
+        * @param {string} str String to escape
+        * @return {string} Escaped string
+        */
+       escapeRegExp: function ( str ) {
+               // eslint-disable-next-line no-useless-escape
+               return str.replace( /([\\{}()|.?*+\-^$\[\]])/g, '\\$1' );
        }
+};
+
+// Not allowed outside unit tests
+if ( window.QUnit ) {
+       util.setOptionsForTest = function ( opts ) {
+               var oldConfig = config;
+               config = $.extend( {}, config, opts );
+               return oldConfig;
+       };
+}
+
+/**
+ * Initialisation of mw.util.$content
+ */
+function init() {
+       util.$content = ( function () {
+               var i, l, $node, selectors;
+
+               selectors = [
+                       // The preferred standard is class "mw-body".
+                       // You may also use class "mw-body mw-body-primary" if you use
+                       // mw-body in multiple locations. Or class "mw-body-primary" if
+                       // you use mw-body deeper in the DOM.
+                       '.mw-body-primary',
+                       '.mw-body',
+
+                       // If the skin has no such class, fall back to the parser output
+                       '#mw-content-text'
+               ];
+
+               for ( i = 0, l = selectors.length; i < l; i++ ) {
+                       $node = $( selectors[ i ] );
+                       if ( $node.length ) {
+                               return $node.first();
+                       }
+               }
 
-       $( init );
+               // Should never happen... well, it could if someone is not finished writing a
+               // skin and has not yet inserted bodytext yet.
+               return $( 'body' );
+       }() );
+}
 
-       mw.util = util;
-       module.exports = util;
+$( init );
 
-}() );
+mw.util = util;
+module.exports = util;
index 81cf433..7102b67 100644 (file)
                        // eslint-disable-next-line no-restricted-properties
                        v = v.normalize();
                }
-               re = new RegExp( '^\\s*' + mw.RegExp.escape( v ), 'i' );
+               re = new RegExp( '^\\s*' + mw.util.escapeRegExp( v ), 'i' );
                for ( k in this.values ) {
                        k = +k;
                        if ( !isNaN( k ) && re.test( this.values[ k ] ) ) {
index 04658b9..d00ed82 100644 (file)
@@ -10,7 +10,7 @@
                        if ( mw.config.get( 'wgTranslateNumerals' ) ) {
                                for ( i = 0; i < 10; i++ ) {
                                        if ( table[ i ] !== undefined ) {
-                                               s = s.replace( new RegExp( mw.RegExp.escape( table[ i ] ), 'g' ), i );
+                                               s = s.replace( new RegExp( mw.util.escapeRegExp( table[ i ] ), 'g' ), i );
                                        }
                                }
                        }
index 062087d..3b30d7e 100644 (file)
@@ -942,8 +942,7 @@ class FileBackendTest extends MediaWikiTestCase {
                        "$base/unittest-cont1/e/fileB.a",
                        "$base/unittest-cont1/e/fileC.a"
                ];
-               $createOps = [];
-               $purgeOps = [];
+               $createOps = $copyOps = $moveOps = $deleteOps = [];
                foreach ( $files as $path ) {
                        $status = $this->prepare( [ 'dir' => dirname( $path ) ] );
                        $this->assertGoodStatus( $status,
@@ -951,10 +950,21 @@ class FileBackendTest extends MediaWikiTestCase {
                        $createOps[] = [ 'op' => 'create', 'dst' => $path, 'content' => mt_rand( 0, 50000 ) ];
                        $copyOps[] = [ 'op' => 'copy', 'src' => $path, 'dst' => "$path-2" ];
                        $moveOps[] = [ 'op' => 'move', 'src' => "$path-2", 'dst' => "$path-3" ];
-                       $purgeOps[] = [ 'op' => 'delete', 'src' => $path ];
-                       $purgeOps[] = [ 'op' => 'delete', 'src' => "$path-3" ];
+                       $moveOps[] = [
+                               'op' => 'move',
+                               'src' => "$path-nothing",
+                               'dst' => "$path-nowhere",
+                               'ignoreMissingSource' => true
+                       ];
+                       $deleteOps[] = [ 'op' => 'delete', 'src' => $path ];
+                       $deleteOps[] = [ 'op' => 'delete', 'src' => "$path-3" ];
+                       $deleteOps[] = [
+                               'op' => 'delete',
+                               'src' => "$path-gone",
+                               'ignoreMissingSource' => true
+                       ];
                }
-               $purgeOps[] = [ 'op' => 'null' ];
+               $deleteOps[] = [ 'op' => 'null' ];
 
                $this->assertGoodStatus(
                        $this->backend->doQuickOperations( $createOps ),
@@ -995,7 +1005,7 @@ class FileBackendTest extends MediaWikiTestCase {
                        "File {$files[0]} still exists." );
 
                $this->assertGoodStatus(
-                       $this->backend->doQuickOperations( $purgeOps ),
+                       $this->backend->doQuickOperations( $deleteOps ),
                        "Quick deletion of source files succeeded ($backendName)." );
                foreach ( $files as $file ) {
                        $this->assertFalse( $this->backend->fileExists( [ 'src' => $file ] ),
diff --git a/tests/phpunit/maintenance/MWDoxygenFilterTest.php b/tests/phpunit/maintenance/MWDoxygenFilterTest.php
new file mode 100644 (file)
index 0000000..22b5938
--- /dev/null
@@ -0,0 +1,145 @@
+<?php
+
+/**
+* @covers MWDoxygenFilter
+ */
+class MWDoxygenFilterTest extends \PHPUnit\Framework\TestCase {
+
+       public static function provideFilter() {
+               yield 'No @var' => [
+                       <<<'CODE'
+<?php class MyClass {
+       /** Some Words here */
+       protected $name;
+}
+CODE
+               ];
+
+               yield 'One-line var with type' => [
+                       <<<'CODE'
+<?php class MyClass {
+       /** @var SomeType */
+       protected $name;
+}
+CODE
+                       , <<<'CODE'
+<?php class MyClass {
+       /**  */
+       protected SomeType $name;
+}
+CODE
+               ];
+
+               yield 'One-line var with type and description' => [
+                       <<<'CODE'
+<?php class MyClass {
+       /** @var SomeType Some description */
+       protected $name;
+}
+CODE
+                       , <<<'CODE'
+<?php class MyClass {
+       /**  Some description */
+       protected SomeType $name;
+}
+CODE
+               ];
+
+               yield 'One-line var with type and description that starts like a variable name' => [
+                       <<<'CODE'
+<?php class MyClass {
+       /** @var array $_GET data from some thing */
+       protected $name;
+}
+CODE
+                       , <<<'CODE'
+<?php class MyClass {
+       /**  $_GET data from some thing */
+       protected array $name;
+}
+CODE
+               ];
+
+               yield 'One-line var with type, name, and description' => [
+                       // In this full form, Doxygen understands it just fine.
+                       // No changes made.
+                       <<<'CODE'
+<?php class MyClass {
+       /** @var SomeType $name Some description */
+       protected $name;
+}
+CODE
+               ];
+
+               yield 'Multi-line var with type' => [
+                       <<<'CODE'
+<?php class MyClass {
+       /**
+        * @var SomeType
+        */
+       protected $name;
+}
+CODE
+                       , <<<'CODE'
+<?php class MyClass {
+       /**
+        * 
+        */
+       protected SomeType $name;
+}
+CODE
+               ];
+
+               yield 'Multi-line var with type and description' => [
+                       <<<'CODE'
+<?php class MyClass {
+       /**
+        * Some description
+        * @var SomeType
+        */
+       protected $name;
+}
+CODE
+                       , <<<'CODE'
+<?php class MyClass {
+       /**
+        * Some description
+        * 
+        */
+       protected SomeType $name;
+}
+CODE
+               ];
+
+               yield 'Multi-line var with type, name, and description' => [
+                       <<<'CODE'
+<?php class MyClass {
+       /**
+        * Some description
+        * @var SomeType $name
+        */
+       protected $name;
+}
+CODE
+                       , <<<'CODE'
+<?php class MyClass {
+       /**
+        * Some description
+        * @var SomeType $name
+        */
+       protected $name;
+}
+CODE
+               ];
+       }
+
+       /**
+        * @dataProvider provideFilter
+        */
+       public function testFilter( $source, $expected = null ) {
+               if ( $expected === null ) {
+                       $expected = $source;
+               }
+               $this->assertSame( $expected, MWDoxygenFilter::filter( $source ), 'Source code' );
+       }
+}
diff --git a/tests/phpunit/unit/includes/installer/SqliteInstallerTest.php b/tests/phpunit/unit/includes/installer/SqliteInstallerTest.php
new file mode 100644 (file)
index 0000000..19a2973
--- /dev/null
@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * @group sqlite
+ * @group Database
+ * @group medium
+ */
+class SqliteInstallerTest extends \MediaWikiUnitTestCase {
+       /**
+        * @covers SqliteInstaller::checkDataDir
+        */
+       public function testCheckDataDir() {
+               $method = new ReflectionMethod( SqliteInstaller::class, 'checkDataDir' );
+               $method->setAccessible( true );
+
+               # Test 1: Should return fatal Status if $dir exist and it un-writable
+               if ( ( isset( $_SERVER['USER'] ) && $_SERVER['USER'] !== 'root' ) && !wfIsWindows() ) {
+                       // We can't simulate this environment under Windows or login as root
+                       $dir = sys_get_temp_dir() . '/' . uniqid( 'MediaWikiTest' );
+                       mkdir( $dir, 0000 );
+                       /** @var Status $status */
+                       $status = $method->invoke( null, $dir );
+                       $this->assertFalse( $status->isGood() );
+                       $this->assertSame( 'config-sqlite-dir-unwritable', $status->getErrors()[0]['message'] );
+                       rmdir( $dir );
+               }
+
+               # Test 2: Should return fatal Status if $dir not exist and it parent also not exist
+               $dir = sys_get_temp_dir() . '/' . uniqid( 'MediaWikiTest' ) . '/' . uniqid( 'MediaWikiTest' );
+               $status = $method->invoke( null, $dir );
+               $this->assertFalse( $status->isGood() );
+
+               # Test 3: Should return good Status if $dir not exist and it parent writable
+               $dir = sys_get_temp_dir() . '/' . uniqid( 'MediaWikiTest' );
+               /** @var Status $status */
+               $status = $method->invoke( null, $dir );
+               $this->assertTrue( $status->isGood() );
+       }
+
+       /**
+        * @covers SqliteInstaller::createDataDir
+        */
+       public function testCreateDataDir() {
+               $method = new ReflectionMethod( SqliteInstaller::class, 'createDataDir' );
+               $method->setAccessible( true );
+
+               # Test 1: Should return fatal Status if $dir not exist and it parent un-writable
+               if ( ( isset( $_SERVER['USER'] ) && $_SERVER['USER'] !== 'root' ) && !wfIsWindows() ) {
+                       // We can't simulate this environment under Windows or login as root
+                       $random = uniqid( 'MediaWikiTest' );
+                       $dir = sys_get_temp_dir() . '/' . $random . '/' . uniqid( 'MediaWikiTest' );
+                       mkdir( sys_get_temp_dir() . "/$random", 0000 );
+                       /** @var Status $status */
+                       $status = $method->invoke( null, $dir );
+                       $this->assertFalse( $status->isGood() );
+                       $this->assertSame( 'config-sqlite-mkdir-error', $status->getErrors()[0]['message'] );
+                       rmdir( sys_get_temp_dir() . "/$random" );
+               }
+
+               # Test 2: Test .htaccess content after created successfully
+               $dir = sys_get_temp_dir() . '/' . uniqid( 'MediaWikiTest' );
+               $status = $method->invoke( null, $dir );
+               $this->assertTrue( $status->isGood() );
+               $this->assertSame( "Deny from all\n", file_get_contents( "$dir/.htaccess" ) );
+               unlink( "$dir/.htaccess" );
+               rmdir( $dir );
+       }
+}
index d55b603..cab6c3b 100644 (file)
@@ -60,7 +60,6 @@ return [
                        'tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.jscompat.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js',
-                       'tests/qunit/suites/resources/mediawiki/mediawiki.RegExp.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.String.byteLength.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.String.trimByteLength.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.storage.test.js',
@@ -102,7 +101,6 @@ return [
                        'tests/qunit/suites/resources/mediawiki/mediawiki.visibleTimeout.test.js',
                ],
                'dependencies' => [
-                       'jquery.accessKeyLabel',
                        'jquery.color',
                        'jquery.colorUtil',
                        'jquery.getAttrs',
@@ -116,7 +114,6 @@ return [
                        'mediawiki.ForeignApi.core',
                        'mediawiki.jqueryMsg',
                        'mediawiki.messagePoster',
-                       'mediawiki.RegExp',
                        'mediawiki.String',
                        'mediawiki.storage',
                        'mediawiki.Title',
diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.RegExp.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.RegExp.test.js
deleted file mode 100644 (file)
index cde77e7..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-( function () {
-       QUnit.module( 'mediawiki.RegExp' );
-
-       QUnit.test( 'escape', function ( assert ) {
-               var specials, normal;
-
-               specials = [
-                       '\\',
-                       '{',
-                       '}',
-                       '(',
-                       ')',
-                       '[',
-                       ']',
-                       '|',
-                       '.',
-                       '?',
-                       '*',
-                       '+',
-                       '-',
-                       '^',
-                       '$'
-               ];
-
-               normal = [
-                       'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
-                       'abcdefghijklmnopqrstuvwxyz',
-                       '0123456789'
-               ].join( '' );
-
-               specials.forEach( function ( str ) {
-                       assert.propEqual( str.match( new RegExp( mw.RegExp.escape( str ) ) ), [ str ], 'Match ' + str );
-               } );
-
-               assert.strictEqual( mw.RegExp.escape( normal ), normal, 'Alphanumerals are left alone' );
-       } );
-
-}() );
index 17672db..4f61abd 100644 (file)
                        assert.strictEqual( util.isIPv6Address( ipCase[ 1 ] ), ipCase[ 0 ], ipCase[ 2 ] );
                } );
        } );
+
+       QUnit.test( 'escapeRegExp', function ( assert ) {
+               var specials, normal;
+
+               specials = [
+                       '\\',
+                       '{',
+                       '}',
+                       '(',
+                       ')',
+                       '[',
+                       ']',
+                       '|',
+                       '.',
+                       '?',
+                       '*',
+                       '+',
+                       '-',
+                       '^',
+                       '$'
+               ];
+
+               normal = [
+                       'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
+                       'abcdefghijklmnopqrstuvwxyz',
+                       '0123456789'
+               ].join( '' );
+
+               specials.forEach( function ( str ) {
+                       assert.propEqual( str.match( new RegExp( mw.util.escapeRegExp( str ) ) ), [ str ], 'Match ' + str );
+               } );
+
+               assert.strictEqual( mw.util.escapeRegExp( normal ), normal, 'Alphanumerals are left alone' );
+       } );
 }() );
index 13dbc0e..f425d87 100644 (file)
--- a/thumb.php
+++ b/thumb.php
@@ -35,7 +35,7 @@ if ( defined( 'THUMB_HANDLER' ) ) {
        wfThumbHandle404();
 } else {
        // Called directly, use $_GET params
-       wfStreamThumb( $wgRequest->getQueryValues() );
+       wfStreamThumb( $wgRequest->getQueryValuesOnly() );
 }
 
 $mediawiki = new MediaWiki();