Merge "Rehabilitate DateFormatter"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Thu, 11 Apr 2019 06:45:16 +0000 (06:45 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Thu, 11 Apr 2019 06:45:16 +0000 (06:45 +0000)
66 files changed:
HISTORY
RELEASE-NOTES-1.33
RELEASE-NOTES-1.34 [new file with mode: 0644]
autoload.php
includes/ConfiguredReadOnlyMode.php
includes/DefaultSettings.php
includes/PHPVersionCheck.php
includes/ReadOnlyMode.php
includes/ServiceWiring.php
includes/api/i18n/pt.json
includes/db/DatabaseOracle.php
includes/db/MWLBFactory.php
includes/deferred/CdnCacheUpdate.php
includes/htmlform/fields/HTMLDateTimeField.php
includes/jobqueue/GenericParameterJob.php [new file with mode: 0644]
includes/jobqueue/IJobSpecification.php
includes/jobqueue/Job.php
includes/jobqueue/JobQueue.php
includes/jobqueue/JobQueueDB.php
includes/jobqueue/JobQueueFederated.php
includes/jobqueue/JobQueueGroup.php
includes/jobqueue/JobQueueMemory.php
includes/jobqueue/JobQueueRedis.php
includes/jobqueue/JobSpecification.php
includes/jobqueue/RunnableJob.php [new file with mode: 0644]
includes/jobqueue/jobs/CdnPurgeJob.php
includes/jobqueue/jobs/ClearUserWatchlistJob.php
includes/jobqueue/jobs/ClearWatchlistNotificationsJob.php
includes/jobqueue/jobs/DeletePageJob.php
includes/jobqueue/jobs/DuplicateJob.php
includes/jobqueue/jobs/EnqueueJob.php
includes/jobqueue/jobs/NullJob.php
includes/jobqueue/jobs/UserGroupExpiryJob.php
includes/page/WikiPage.php
includes/specialpage/SpecialPageFactory.php
includes/specials/SpecialVersion.php
includes/watcheditem/WatchedItemStore.php
languages/i18n/ast.json
languages/i18n/be-tarask.json
languages/i18n/cs.json
languages/i18n/de.json
languages/i18n/diq.json
languages/i18n/en.json
languages/i18n/es.json
languages/i18n/fi.json
languages/i18n/fr.json
languages/i18n/fy.json
languages/i18n/hy.json
languages/i18n/hyw.json
languages/i18n/it.json
languages/i18n/lb.json
languages/i18n/lrc.json
languages/i18n/lt.json
languages/i18n/mk.json
languages/i18n/nl.json
languages/i18n/qqq.json
resources/lib/foreign-resources.yaml
resources/lib/mustache/LICENSE
resources/lib/mustache/mustache.js
tests/phpunit/includes/GlobalFunctions/GlobalTest.php
tests/phpunit/includes/ReadOnlyModeTest.php
tests/phpunit/includes/SiteStatsTest.php
tests/phpunit/includes/Storage/NameTableStoreTest.php
tests/phpunit/includes/jobqueue/JobQueueTest.php
tests/phpunit/includes/jobqueue/JobTest.php
tests/phpunit/includes/jobqueue/jobs/ClearUserWatchlistJobTest.php

diff --git a/HISTORY b/HISTORY
index a38e215..426eb17 100644 (file)
--- a/HISTORY
+++ b/HISTORY
@@ -1,4 +1,4 @@
-Change notes from older releases. For current info see RELEASE-NOTES-1.33.
+Change notes from older releases. For current info see RELEASE-NOTES-1.34.
 
 = MediaWiki 1.32 =
 
index fd316c4..09fd35e 100644 (file)
@@ -4,8 +4,8 @@
 
 THIS IS NOT A RELEASE YET
 
-MediaWiki 1.33 is an alpha-quality branch and is not recommended for use in
-production.
+MediaWiki 1.33 is a pre-release testing branch, and is not recommended for use
+in production.
 
 == Upgrading notes for 1.33 ==
 1.33 has several database changes since 1.32, and will not work without schema
@@ -366,6 +366,8 @@ because of Phabricator reports.
   For classes that inherit from MediaWikiTestCase and used setMwGlobals() to
   modify a variable that affects namespaces, caches will automatically be
   reset and any calls to MWNamespace::clearCaches() can be removed entirely.
+* ReadOnlyMode::clearCache() and ConfiguredReadOnlyMode::clearCache() have been
+  removed. Use MediaWikiTestCase::overrideMwServices() instead.
 
 === Deprecations in 1.33 ===
 * The configuration option $wgUseESI has been deprecated, and is expected
diff --git a/RELEASE-NOTES-1.34 b/RELEASE-NOTES-1.34
new file mode 100644 (file)
index 0000000..a1c50d0
--- /dev/null
@@ -0,0 +1,115 @@
+= MediaWiki 1.34 =
+
+== MediaWiki 1.34.0-PRERELEASE ==
+
+THIS IS NOT A RELEASE YET
+
+MediaWiki 1.34 is an alpha-quality development branch, and is not recommended
+for use in production.
+
+== Upgrading notes for 1.34 ==
+1.34 has several database changes since 1.33, and will not work without schema
+updates. Note that due to changes to some very large tables like the revision
+table, the schema update may take quite long (minutes on a medium sized site,
+many hours on a large site).
+
+Don't forget to always back up your database before upgrading!
+
+See the file UPGRADE for more detailed upgrade instructions, including
+important information when upgrading from versions prior to 1.11.
+
+Some specific notes for MediaWiki 1.34 upgrades are below:
+
+* …
+
+For notes on 1.33.x and older releases, see HISTORY.
+
+=== Configuration changes for system administrators in 1.34 ===
+==== New configuration ====
+* …
+
+==== Changed configuration ====
+* …
+
+==== Removed configuration ====
+* …
+
+=== New user-facing features in 1.34 ===
+* …
+
+=== New developer features in 1.34 ===
+* …
+
+=== External library changes in 1.34 ===
+==== New external libraries ====
+* …
+
+==== Changed external libraries ====
+* …
+
+==== Removed external libraries ====
+* …
+
+=== Bug fixes in 1.34 ===
+* …
+
+=== Action API changes in 1.34 ===
+* …
+
+=== Action API internal changes in 1.34 ===
+* …
+
+=== Languages updated in 1.34 ===
+MediaWiki supports over 350 languages. Many localisations are updated regularly.
+Below only new and removed languages are listed, as well as changes to languages
+because of Phabricator reports.
+
+* …
+
+=== Breaking changes in 1.34 ===
+* …
+
+=== Deprecations in 1.34 ===
+* …
+
+=== Other changes in 1.34 ===
+* …
+
+== Compatibility ==
+MediaWiki 1.34 requires PHP 7.0.13 or later. Although HHVM 3.18.5 or later is
+supported, it is generally advised to use PHP 7.0.13 or later for long term
+support.
+
+MySQL/MariaDB is the recommended DBMS. PostgreSQL or SQLite can also be used,
+but support for them is somewhat less mature. There is experimental support for
+Oracle and Microsoft SQL Server.
+
+The supported versions are:
+
+* MySQL 5.5.8 or later
+* PostgreSQL 9.2 or later
+* SQLite 3.8.0 or later
+* Oracle 9.0.1 or later
+* Microsoft SQL Server 2005 (9.00.1399)
+
+== Online documentation ==
+Documentation for both end-users and site administrators is available on
+MediaWiki.org, and is covered under the GNU Free Documentation License (except
+for pages that explicitly state that their contents are in the public domain):
+
+       https://www.mediawiki.org/wiki/Special:MyLanguage/Documentation
+
+== Mailing list ==
+A mailing list is available for MediaWiki user support and discussion:
+
+       https://lists.wikimedia.org/mailman/listinfo/mediawiki-l
+
+A low-traffic announcements-only list is also available:
+
+       https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce
+
+It's highly recommended that you sign up for one of these lists if you're
+going to run a public MediaWiki, so you can be notified of security fixes.
+
+== IRC help ==
+There's usually someone online in #mediawiki on irc.freenode.net.
index e713017..033c4be 100644 (file)
@@ -566,6 +566,7 @@ $wgAutoloadLocalClasses = [
        'GenerateNormalizerDataMl' => __DIR__ . '/maintenance/language/generateNormalizerDataMl.php',
        'GenerateSitemap' => __DIR__ . '/maintenance/generateSitemap.php',
        'GenericArrayObject' => __DIR__ . '/includes/libs/GenericArrayObject.php',
+       'GenericParameterJob' => __DIR__ . '/includes/jobqueue/GenericParameterJob.php',
        'GetConfiguration' => __DIR__ . '/maintenance/getConfiguration.php',
        'GetLagTimes' => __DIR__ . '/maintenance/getLagTimes.php',
        'GetReplicaServer' => __DIR__ . '/maintenance/getReplicaServer.php',
@@ -1290,6 +1291,7 @@ $wgAutoloadLocalClasses = [
        'RowUpdateGenerator' => __DIR__ . '/includes/utils/RowUpdateGenerator.php',
        'RunBatchedQuery' => __DIR__ . '/maintenance/runBatchedQuery.php',
        'RunJobs' => __DIR__ . '/maintenance/runJobs.php',
+       'RunnableJob' => __DIR__ . '/includes/jobqueue/RunnableJob.php',
        'SVGMetadataExtractor' => __DIR__ . '/includes/media/SVGMetadataExtractor.php',
        'SVGReader' => __DIR__ . '/includes/media/SVGMetadataExtractor.php',
        'SamplingStatsdClient' => __DIR__ . '/includes/libs/stats/SamplingStatsdClient.php',
index 17c28ec..7df2aed 100644 (file)
@@ -63,11 +63,4 @@ class ConfiguredReadOnlyMode {
        public function setReason( $msg ) {
                $this->overrideReason = $msg;
        }
-
-       /**
-        * Clear the cache of the read only file
-        */
-       public function clearCache() {
-               $this->fileReason = null;
-       }
 }
index 44e0310..4dece5b 100644 (file)
@@ -72,7 +72,7 @@ $wgConfigRegistry = [
  * MediaWiki version number
  * @since 1.2
  */
-$wgVersion = '1.33.0-alpha';
+$wgVersion = '1.34.0-alpha';
 
 /**
  * Name of the site. It must be changed in LocalSettings.php
index 01d5f9d..3f08a37 100644 (file)
@@ -36,7 +36,7 @@
  */
 class PHPVersionCheck {
        /* @var string The number of the MediaWiki version used. */
-       var $mwVersion = '1.33';
+       var $mwVersion = '1.34';
 
        /* @var array A mapping of PHP functions to PHP extensions. */
        var $functionsExtensionsMapping = array(
index e767359..1a09290 100644 (file)
@@ -58,11 +58,4 @@ class ReadOnlyMode {
        public function setReason( $msg ) {
                $this->configuredReadOnly->setReason( $msg );
        }
-
-       /**
-        * Clear the cache of the read only file
-        */
-       public function clearCache() {
-               $this->configuredReadOnly->clearCache();
-       }
 }
index 8bb8e25..fed1b6d 100644 (file)
@@ -575,8 +575,13 @@ return [
        },
 
        'SpecialPageFactory' => function ( MediaWikiServices $services ) : SpecialPageFactory {
+               $config = $services->getMainConfig();
+               $options = [];
+               foreach ( SpecialPageFactory::$constructorOptions as $key ) {
+                       $options[$key] = $config->get( $key );
+               }
                return new SpecialPageFactory(
-                       $services->getMainConfig(),
+                       $options,
                        $services->getContentLanguage()
                );
        },
index df5fd69..67465e2 100644 (file)
        "apihelp-query+userinfo-paramvalue-prop-registrationdate": "Adiciona a data de registo do utilizador.",
        "apihelp-query+userinfo-paramvalue-prop-unreadcount": "Adiciona a contagem de páginas não lidas da lista de páginas vigiadas do utilizador (máximo $1; devolve <samp>$2</samp> se forem mais).",
        "apihelp-query+userinfo-paramvalue-prop-centralids": "Adiciona os identificadores centrais e o estado de ligação central (''attachment'') do utilizador.",
+       "apihelp-query+userinfo-paramvalue-prop-latestcontrib": "Adiciona a data da última contribuição do utilizador.",
        "apihelp-query+userinfo-param-attachedwiki": "Com <kbd>$1prop=centralids</kbd>, indicar se o utilizador tem ligação com a wiki designada por este identificador.",
        "apihelp-query+userinfo-example-simple": "Obter informações sobre o utilizador atual.",
        "apihelp-query+userinfo-example-data": "Obter informações adicionais sobre o utilizador atual.",
index 3d80bbd..7f79ca1 100644 (file)
@@ -21,7 +21,7 @@
  * @ingroup Database
  */
 
-use MediaWiki\MediaWikiServices;
+use Wikimedia\Timestamp\ConvertibleTimestamp;
 use Wikimedia\Rdbms\Database;
 use Wikimedia\Rdbms\DatabaseDomain;
 use Wikimedia\Rdbms\Blob;
@@ -55,6 +55,8 @@ class DatabaseOracle extends Database {
        function __construct( array $p ) {
                $p['tablePrefix'] = strtoupper( $p['tablePrefix'] );
                parent::__construct( $p );
+
+               // @TODO: dependency inject
                Hooks::run( 'DatabaseOraclePostInit', [ $this ] );
        }
 
@@ -79,8 +81,6 @@ class DatabaseOracle extends Database {
        }
 
        protected function open( $server, $user, $password, $dbName, $schema, $tablePrefix ) {
-               global $wgDBOracleDRCP;
-
                if ( !function_exists( 'oci_connect' ) ) {
                        throw new DBConnectionError(
                                $this,
@@ -107,10 +107,6 @@ class DatabaseOracle extends Database {
                        return null;
                }
 
-               if ( $wgDBOracleDRCP ) {
-                       $this->setFlag( DBO_PERSISTENT );
-               }
-
                $session_mode = ( $this->flags & DBO_SYSDBA ) ? OCI_SYSDBA : OCI_DEFAULT;
 
                Wikimedia\suppressWarnings();
@@ -185,8 +181,8 @@ class DatabaseOracle extends Database {
         */
        protected function doQuery( $sql ) {
                wfDebug( "SQL: [$sql]\n" );
-               if ( !StringUtils::isUtf8( $sql ) ) {
-                       throw new InvalidArgumentException( "SQL encoding is invalid\n$sql" );
+               if ( !mb_check_encoding( (string)$sql, 'UTF-8' ) ) {
+                       throw new DBUnexpectedError( $this, "SQL encoding is invalid\n$sql" );
                }
 
                // handle some oracle specifics
@@ -420,7 +416,11 @@ class DatabaseOracle extends Database {
                }
 
                if ( $val === null ) {
-                       if ( $col_info != false && $col_info->isNullable() == 0 && $col_info->defaultValue() != null ) {
+                       if (
+                               $col_info != false &&
+                               $col_info->isNullable() == 0 &&
+                               $col_info->defaultValue() != null
+                       ) {
                                $bind .= 'DEFAULT';
                        } else {
                                $bind .= 'NULL';
@@ -481,12 +481,14 @@ class DatabaseOracle extends Database {
                                }
 
                                // backward compatibility
-                               if ( preg_match( '/^timestamp.*/i', $col_type ) == 1 && strtolower( $val ) == 'infinity' ) {
+                               if (
+                                       preg_match( '/^timestamp.*/i', $col_type ) == 1 &&
+                                       strtolower( $val ) == 'infinity'
+                               ) {
                                        $val = $this->getInfinity();
                                }
 
-                               $val = MediaWikiServices::getInstance()->getContentLanguage()->
-                                       checkTitleEncoding( $val );
+                               $val = $this->getVerifiedUTF8( $val );
                                if ( oci_bind_by_name( $stmt, ":$col", $val, -1, SQLT_CHR ) === false ) {
                                        $e = oci_error( $stmt );
                                        $this->reportQueryError( $e['message'], $e['code'], $sql, __METHOD__ );
@@ -498,7 +500,10 @@ class DatabaseOracle extends Database {
                                $lob[$col] = oci_new_descriptor( $this->conn, OCI_D_LOB );
                                if ( $lob[$col] === false ) {
                                        $e = oci_error( $stmt );
-                                       throw new DBUnexpectedError( $this, "Cannot create LOB descriptor: " . $e['message'] );
+                                       throw new DBUnexpectedError(
+                                               $this,
+                                               "Cannot create LOB descriptor: " . $e['message']
+                                       );
                                }
 
                                if ( is_object( $val ) ) {
@@ -554,7 +559,8 @@ class DatabaseOracle extends Database {
                if ( $sequenceData !== false &&
                        !isset( $varMap[$sequenceData['column']] )
                ) {
-                       $varMap[$sequenceData['column']] = 'GET_SEQUENCE_VALUE(\'' . $sequenceData['sequence'] . '\')';
+                       $varMap[$sequenceData['column']] =
+                               'GET_SEQUENCE_VALUE(\'' . $sequenceData['sequence'] . '\')';
                }
 
                // count-alias subselect fields to avoid abigious definition errors
@@ -573,7 +579,8 @@ class DatabaseOracle extends Database {
                        $selectJoinConds
                );
 
-               $sql = "INSERT INTO $destTable (" . implode( ',', array_keys( $varMap ) ) . ') ' . $selectSql;
+               $sql = "INSERT INTO $destTable (" .
+                       implode( ',', array_keys( $varMap ) ) . ') ' . $selectSql;
 
                if ( in_array( 'IGNORE', $insertOptions ) ) {
                        $this->ignoreDupValOnIndex = true;
@@ -756,8 +763,10 @@ class DatabaseOracle extends Database {
                return $this->doQuery( "DROP TABLE $tableName CASCADE CONSTRAINTS PURGE" );
        }
 
-       function timestamp( $ts = 0 ) {
-               return wfTimestamp( TS_ORACLE, $ts );
+       public function timestamp( $ts = 0 ) {
+               $t = new ConvertibleTimestamp( $ts );
+               // Let errors bubble up to avoid putting garbage in the DB
+               return $t->getTimestamp( TS_ORACLE );
        }
 
        /**
@@ -912,7 +921,10 @@ class DatabaseOracle extends Database {
         */
        function fieldInfo( $table, $field ) {
                if ( is_array( $table ) ) {
-                       throw new DBUnexpectedError( $this, 'DatabaseOracle::fieldInfo called with table array!' );
+                       throw new DBUnexpectedError(
+                               $this,
+                               'DatabaseOracle::fieldInfo called with table array!'
+                       );
                }
 
                return $this->fieldInfoMulti( $table, $field );
@@ -1061,12 +1073,7 @@ class DatabaseOracle extends Database {
        }
 
        function addQuotes( $s ) {
-               $contLang = MediaWikiServices::getInstance()->getContentLanguage();
-               if ( isset( $contLang->mLoaded ) && $contLang->mLoaded ) {
-                       $s = $contLang->checkTitleEncoding( $s );
-               }
-
-               return "'" . $this->strencode( $s ) . "'";
+               return "'" . $this->strencode( $this->getVerifiedUTF8( $s ) ) . "'";
        }
 
        public function addIdentifierQuotes( $s ) {
@@ -1090,11 +1097,9 @@ class DatabaseOracle extends Database {
                $col_type = $col_info != false ? $col_info->type() : 'CONSTANT';
                if ( $col_type == 'CLOB' ) {
                        $col = 'TO_CHAR(' . $col . ')';
-                       $val =
-                               MediaWikiServices::getInstance()->getContentLanguage()->checkTitleEncoding( $val );
+                       $val = $this->getVerifiedUTF8( $val );
                } elseif ( $col_type == 'VARCHAR2' ) {
-                       $val =
-                               MediaWikiServices::getInstance()->getContentLanguage()->checkTitleEncoding( $val );
+                       $val = $this->getVerifiedUTF8( $val );
                }
        }
 
@@ -1260,12 +1265,14 @@ class DatabaseOracle extends Database {
                                        $val = $val->getData();
                                }
 
-                               if ( preg_match( '/^timestamp.*/i', $col_type ) == 1 && strtolower( $val ) == 'infinity' ) {
+                               if (
+                                       preg_match( '/^timestamp.*/i', $col_type ) == 1 &&
+                                       strtolower( $val ) == 'infinity'
+                               ) {
                                        $val = '31-12-2030 12:00:00.000000';
                                }
 
-                               $val = MediaWikiServices::getInstance()->getContentLanguage()->
-                                       checkTitleEncoding( $val );
+                               $val = $this->getVerifiedUTF8( $val );
                                if ( oci_bind_by_name( $stmt, ":$col", $val ) === false ) {
                                        $e = oci_error( $stmt );
                                        $this->reportQueryError( $e['message'], $e['code'], $sql, __METHOD__ );
@@ -1277,7 +1284,10 @@ class DatabaseOracle extends Database {
                                $lob[$col] = oci_new_descriptor( $this->conn, OCI_D_LOB );
                                if ( $lob[$col] === false ) {
                                        $e = oci_error( $stmt );
-                                       throw new DBUnexpectedError( $this, "Cannot create LOB descriptor: " . $e['message'] );
+                                       throw new DBUnexpectedError(
+                                               $this,
+                                               "Cannot create LOB descriptor: " . $e['message']
+                                       );
                                }
 
                                if ( is_object( $val ) ) {
@@ -1366,4 +1376,16 @@ class DatabaseOracle extends Database {
        public function getInfinity() {
                return '31-12-2030 12:00:00.000000';
        }
+
+       /**
+        * @param string $s
+        * @return string
+        */
+       private function getVerifiedUTF8( $s ) {
+               if ( mb_check_encoding( (string)$s, 'UTF-8' ) ) {
+                       return $s; // valid
+               }
+
+               throw new DBUnexpectedError( $this, "Non BLOB/CLOB field must be UTF-8." );
+       }
 }
index bbb3d2f..f0aa8b2 100644 (file)
@@ -53,7 +53,7 @@ abstract class MWLBFactory {
        ) {
                global $wgCommandLineMode;
 
-               static $typesWithSchema = [ 'postgres', 'msssql' ];
+               $typesWithSchema = self::getDbTypesWithSchemas();
 
                $lbConf += [
                        'localDomain' => new DatabaseDomain(
@@ -82,77 +82,29 @@ abstract class MWLBFactory {
                // for Database classes in the relevant Installer subclass.
                // Such as MysqlInstaller::openConnection and PostgresInstaller::openConnectionWithParams.
                if ( $lbConf['class'] === Wikimedia\Rdbms\LBFactorySimple::class ) {
-                       $httpMethod = $_SERVER['REQUEST_METHOD'] ?? null;
-                       // T93097: hint for how file-based databases (e.g. sqlite) should go about locking.
-                       // See https://www.sqlite.org/lang_transaction.html
-                       // See https://www.sqlite.org/lockingv3.html#shared_lock
-                       $isReadRequest = in_array( $httpMethod, [ 'GET', 'HEAD', 'OPTIONS', 'TRACE' ] );
-
                        if ( isset( $lbConf['servers'] ) ) {
-                               // Server array is already explicitly configured; leave alone
+                               // Server array is already explicitly configured
                        } elseif ( is_array( $mainConfig->get( 'DBservers' ) ) ) {
                                $lbConf['servers'] = [];
                                foreach ( $mainConfig->get( 'DBservers' ) as $i => $server ) {
-                                       if ( $server['type'] === 'sqlite' ) {
-                                               $server += [
-                                                       'dbDirectory' => $mainConfig->get( 'SQLiteDataDir' ),
-                                                       'trxMode' => $isReadRequest ? 'DEFERRED' : 'IMMEDIATE'
-                                               ];
-                                       } elseif ( $server['type'] === 'postgres' ) {
-                                               $server += [
-                                                       'port' => $mainConfig->get( 'DBport' ),
-                                                       // Work around the reserved word usage in MediaWiki schema
-                                                       'keywordTableMap' => [ 'user' => 'mwuser', 'text' => 'pagecontent' ]
-                                               ];
-                                       } elseif ( $server['type'] === 'mssql' ) {
-                                               $server += [
-                                                       'port' => $mainConfig->get( 'DBport' ),
-                                                       'useWindowsAuth' => $mainConfig->get( 'DBWindowsAuthentication' )
-                                               ];
-                                       }
-
-                                       if ( in_array( $server['type'], $typesWithSchema, true ) ) {
-                                               $server += [ 'schema' => $mainConfig->get( 'DBmwschema' ) ];
-                                       }
-
-                                       $server += [
-                                               'tablePrefix' => $mainConfig->get( 'DBprefix' ),
-                                               'flags' => DBO_DEFAULT,
-                                               'sqlMode' => $mainConfig->get( 'SQLMode' ),
-                                       ];
-
-                                       $lbConf['servers'][$i] = $server;
+                                       $lbConf['servers'][$i] = self::initServerInfo( $server, $mainConfig );
                                }
                        } else {
-                               $flags = DBO_DEFAULT;
-                               $flags |= $mainConfig->get( 'DebugDumpSql' ) ? DBO_DEBUG : 0;
-                               $flags |= $mainConfig->get( 'DBssl' ) ? DBO_SSL : 0;
-                               $flags |= $mainConfig->get( 'DBcompress' ) ? DBO_COMPRESS : 0;
-                               $server = [
-                                       'host' => $mainConfig->get( 'DBserver' ),
-                                       'user' => $mainConfig->get( 'DBuser' ),
-                                       'password' => $mainConfig->get( 'DBpassword' ),
-                                       'dbname' => $mainConfig->get( 'DBname' ),
-                                       'tablePrefix' => $mainConfig->get( 'DBprefix' ),
-                                       'type' => $mainConfig->get( 'DBtype' ),
-                                       'load' => 1,
-                                       'flags' => $flags,
-                                       'sqlMode' => $mainConfig->get( 'SQLMode' ),
-                                       'trxMode' => $isReadRequest ? 'DEFERRED' : 'IMMEDIATE'
-                               ];
-                               if ( in_array( $server['type'], $typesWithSchema, true ) ) {
-                                       $server += [ 'schema' => $mainConfig->get( 'DBmwschema' ) ];
-                               }
-                               if ( $server['type'] === 'sqlite' ) {
-                                       $server[ 'dbDirectory'] = $mainConfig->get( 'SQLiteDataDir' );
-                               } elseif ( $server['type'] === 'postgres' ) {
-                                       $server['port'] = $mainConfig->get( 'DBport' );
-                                       // Work around the reserved word usage in MediaWiki schema
-                                       $server['keywordTableMap'] = [ 'user' => 'mwuser', 'text' => 'pagecontent' ];
-                               } elseif ( $server['type'] === 'mssql' ) {
-                                       $server['port'] = $mainConfig->get( 'DBport' );
-                                       $server['useWindowsAuth'] = $mainConfig->get( 'DBWindowsAuthentication' );
-                               }
+                               $server = self::initServerInfo(
+                                       [
+                                               'host' => $mainConfig->get( 'DBserver' ),
+                                               'user' => $mainConfig->get( 'DBuser' ),
+                                               'password' => $mainConfig->get( 'DBpassword' ),
+                                               'dbname' => $mainConfig->get( 'DBname' ),
+                                               'type' => $mainConfig->get( 'DBtype' ),
+                                               'load' => 1
+                                       ],
+                                       $mainConfig
+                               );
+
+                               $server['flags'] |= $mainConfig->get( 'DBssl' ) ? DBO_SSL : 0;
+                               $server['flags'] |= $mainConfig->get( 'DBcompress' ) ? DBO_COMPRESS : 0;
+
                                $lbConf['servers'] = [ $server ];
                        }
                        if ( !isset( $lbConf['externalClusters'] ) ) {
@@ -167,15 +119,71 @@ abstract class MWLBFactory {
                                }
                                $lbConf['serverTemplate']['sqlMode'] = $mainConfig->get( 'SQLMode' );
                        }
-                       $serversCheck = $lbConf['serverTemplate'] ?? [];
+                       $serversCheck = [ $lbConf['serverTemplate'] ] ?? [];
                }
 
-               self::sanityCheckServerConfig( $serversCheck, $mainConfig );
-               $lbConf = self::applyDefaultCaching( $lbConf, $srvCace, $mainStash, $wanCache );
+               self::assertValidServerConfigs( $serversCheck, $mainConfig );
+
+               $lbConf = self::injectObjectCaches( $lbConf, $srvCace, $mainStash, $wanCache );
 
                return $lbConf;
        }
 
+       /**
+        * @return array
+        */
+       private static function getDbTypesWithSchemas() {
+               return [ 'postgres', 'msssql' ];
+       }
+
+       /**
+        * @param array $server
+        * @param Config $mainConfig
+        * @return array
+        */
+       private static function initServerInfo( array $server, Config $mainConfig ) {
+               if ( $server['type'] === 'sqlite' ) {
+                       $httpMethod = $_SERVER['REQUEST_METHOD'] ?? null;
+                       // T93097: hint for how file-based databases (e.g. sqlite) should go about locking.
+                       // See https://www.sqlite.org/lang_transaction.html
+                       // See https://www.sqlite.org/lockingv3.html#shared_lock
+                       $isHttpRead = in_array( $httpMethod, [ 'GET', 'HEAD', 'OPTIONS', 'TRACE' ] );
+                       $server += [
+                               'dbDirectory' => $mainConfig->get( 'SQLiteDataDir' ),
+                               'trxMode' => $isHttpRead ? 'DEFERRED' : 'IMMEDIATE'
+                       ];
+               } elseif ( $server['type'] === 'postgres' ) {
+                       $server += [
+                               'port' => $mainConfig->get( 'DBport' ),
+                               // Work around the reserved word usage in MediaWiki schema
+                               'keywordTableMap' => [ 'user' => 'mwuser', 'text' => 'pagecontent' ]
+                       ];
+               } elseif ( $server['type'] === 'mssql' ) {
+                       $server += [
+                               'port' => $mainConfig->get( 'DBport' ),
+                               'useWindowsAuth' => $mainConfig->get( 'DBWindowsAuthentication' )
+                       ];
+               }
+
+               if ( in_array( $server['type'], self::getDbTypesWithSchemas(), true ) ) {
+                       $server += [ 'schema' => $mainConfig->get( 'DBmwschema' ) ];
+               }
+
+               $flags = DBO_DEFAULT;
+               $flags |= $mainConfig->get( 'DebugDumpSql' ) ? DBO_DEBUG : 0;
+               if ( $server['type'] === 'oracle' ) {
+                       $flags |= $mainConfig->get( 'DBOracleDRCP' ) ? DBO_PERSISTENT : 0;
+               }
+
+               $server += [
+                       'tablePrefix' => $mainConfig->get( 'DBprefix' ),
+                       'flags' => $flags,
+                       'sqlMode' => $mainConfig->get( 'SQLMode' ),
+               ];
+
+               return $server;
+       }
+
        /**
         * @param array $lbConf
         * @param BagOStuff $sCache
@@ -183,7 +191,7 @@ abstract class MWLBFactory {
         * @param WANObjectCache $wCache
         * @return array
         */
-       private static function applyDefaultCaching(
+       private static function injectObjectCaches(
                array $lbConf, BagOStuff $sCache, BagOStuff $mStash, WANObjectCache $wCache
        ) {
                // Use APC/memcached style caching, but avoids loops with CACHE_DB (T141804)
@@ -204,7 +212,7 @@ abstract class MWLBFactory {
         * @param array $servers
         * @param Config $mainConfig
         */
-       private static function sanityCheckServerConfig( array $servers, Config $mainConfig ) {
+       private static function assertValidServerConfigs( array $servers, Config $mainConfig ) {
                $ldDB = $mainConfig->get( 'DBname' ); // local domain DB
                $ldTP = $mainConfig->get( 'DBprefix' ); // local domain prefix
 
index 6f961e8..2d07f75 100644 (file)
@@ -71,13 +71,10 @@ class CdnCacheUpdate implements DeferrableUpdate, MergeableUpdate {
                self::purge( $this->urls );
 
                if ( $wgCdnReboundPurgeDelay > 0 ) {
-                       JobQueueGroup::singleton()->lazyPush( new CdnPurgeJob(
-                               Title::makeTitle( NS_SPECIAL, 'Badtitle/' . __CLASS__ ),
-                               [
-                                       'urls' => $this->urls,
-                                       'jobReleaseTimestamp' => time() + $wgCdnReboundPurgeDelay
-                               ]
-                       ) );
+                       JobQueueGroup::singleton()->lazyPush( new CdnPurgeJob( [
+                               'urls' => $this->urls,
+                               'jobReleaseTimestamp' => time() + $wgCdnReboundPurgeDelay
+                       ] ) );
                }
        }
 
index ffdf5f8..d1f3c44 100644 (file)
@@ -172,6 +172,7 @@ class HTMLDateTimeField extends HTMLTextField {
                }
 
                if ( $this->mType === 'date' ) {
+                       $this->mParent->getOutput()->addModuleStyles( 'mediawiki.widgets.DateInputWidget.styles' );
                        return new MediaWiki\Widget\DateInputWidget( $params );
                } else {
                        return new MediaWiki\Widget\DateTimeInputWidget( $params );
diff --git a/includes/jobqueue/GenericParameterJob.php b/includes/jobqueue/GenericParameterJob.php
new file mode 100644 (file)
index 0000000..f7da42b
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Interface for generic jobs only uses the parameters field.
+ *
+ * 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
+ */
+
+/**
+ * Interface for generic jobs only uses the parameters field and are JSON serializable
+ *
+ * @ingroup JobQueue
+ * @since 1.33
+ */
+interface GenericParameterJob extends IJobSpecification {
+       /**
+        * @param array $params JSON-serializable map of parameters
+        */
+       public function __construct( array $params );
+}
index 8bc1bc3..2b3caa2 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 /**
- * Job queue task description base code.
+ * Job queue task description interface
  *
  * 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
  */
 
 /**
- * Job queue task description interface
+ * Interface for serializable objects that describe a job queue task
+ *
+ * A job specification can be inserted into a queue via JobQueue::push().
+ * The specification parameters should be JSON serializable (e.g. no PHP classes).
+ * Whatever queue the job specification is pushed into is assumed to have job runners
+ * that will eventually pop the job specification from the queue, construct a RunnableJob
+ * instance from the specification, and then execute that instance via RunnableJob::run().
  *
  * @ingroup JobQueue
  * @since 1.23
  */
 interface IJobSpecification {
        /**
-        * @return string Job type
+        * @return string Job type that defines what sort of changes this job makes
         */
        public function getType();
 
        /**
-        * @return array
+        * @return array Parameters that specify sources, targets, and options for execution
         */
        public function getParams();
 
@@ -76,9 +82,4 @@ interface IJobSpecification {
         * @return bool Whether this is job is a root job
         */
        public function isRootJob();
-
-       /**
-        * @return Title Descriptive title (this can simply be informative)
-        */
-       public function getTitle();
 }
index 060003b..6054e35 100644 (file)
@@ -27,7 +27,7 @@
  *
  * @ingroup JobQueue
  */
-abstract class Job implements IJobSpecification {
+abstract class Job implements RunnableJob {
        /** @var string */
        public $command;
 
@@ -55,12 +55,6 @@ abstract class Job implements IJobSpecification {
        /** @var int Job must not be wrapped in the usual explicit LBFactory transaction round */
        const JOB_NO_EXPLICIT_TRX_ROUND = 1;
 
-       /**
-        * Run the job
-        * @return bool Success
-        */
-       abstract public function run();
-
        /**
         * Create the appropriate object to handle a specific job
         *
@@ -77,17 +71,24 @@ abstract class Job implements IJobSpecification {
                        $title = $params;
                        $params = func_num_args() >= 3 ? func_get_arg( 2 ) : [];
                } else {
-                       // Subclasses can override getTitle() to return something more meaningful
-                       $title = Title::makeTitle( NS_SPECIAL, 'Blankpage' );
+                       $title = ( isset( $params['namespace'] ) && isset( $params['title'] ) )
+                               ? Title::makeTitle( $params['namespace'], $params['title'] )
+                               : Title::makeTitle( NS_SPECIAL, '' );
                }
 
+               $params = is_array( $params ) ? $params : []; // sanity
+
                if ( isset( $wgJobClasses[$command] ) ) {
                        $handler = $wgJobClasses[$command];
 
                        if ( is_callable( $handler ) ) {
                                $job = call_user_func( $handler, $title, $params );
                        } elseif ( class_exists( $handler ) ) {
-                               $job = new $handler( $title, $params );
+                               if ( is_subclass_of( $handler, GenericParameterJob::class ) ) {
+                                       $job = new $handler( $params );
+                               } else {
+                                       $job = new $handler( $title, $params );
+                               }
                        } else {
                                $job = null;
                        }
@@ -112,17 +113,27 @@ abstract class Job implements IJobSpecification {
                if ( $params instanceof Title ) {
                        // Backwards compatibility for old signature ($command, $title, $params)
                        $title = $params;
-                       $params = func_get_arg( 2 );
-               } else {
-                       // Subclasses can override getTitle() to return something more meaningful
-                       $title = Title::makeTitle( NS_SPECIAL, 'Blankpage' );
+                       $params = func_num_args() >= 3 ? func_get_arg( 2 ) : [];
+                       $params = is_array( $params ) ? $params : []; // sanity
+                       // Set namespace/title params if both are missing and this is not a dummy title
+                       if (
+                               $title->getDBkey() !== '' &&
+                               !isset( $params['namespace'] ) &&
+                               !isset( $params['title'] )
+                       ) {
+                               $params['namespace'] = $title->getNamespace();
+                               $params['title'] = $title->getDBKey();
+                               // Note that JobQueue classes will prefer the parameters over getTitle()
+                               $this->title = $title;
+                       }
                }
 
                $this->command = $command;
-               $this->title = $title;
-               $this->params = is_array( $params ) ? $params : [];
-               if ( !isset( $this->params['requestId'] ) ) {
-                       $this->params['requestId'] = WebRequest::getRequestId();
+               $this->params = $params + [ 'requestId' => WebRequest::getRequestId() ];
+               if ( $this->title === null ) {
+                       $this->title = ( isset( $params['namespace'] ) && isset( $params['title'] ) )
+                               ? Title::makeTitle( $params['namespace'], $params['title'] )
+                               : Title::makeTitle( NS_SPECIAL, '' );
                }
        }
 
@@ -145,7 +156,7 @@ abstract class Job implements IJobSpecification {
        /**
         * @return Title
         */
-       public function getTitle() {
+       final public function getTitle() {
                return $this->title;
        }
 
@@ -268,8 +279,6 @@ abstract class Job implements IJobSpecification {
        public function getDeduplicationInfo() {
                $info = [
                        'type' => $this->getType(),
-                       'namespace' => $this->getTitle()->getNamespace(),
-                       'title' => $this->getTitle()->getDBkey(),
                        'params' => $this->getParams()
                ];
                if ( is_array( $info['params'] ) ) {
index 0644002..f5ed7b9 100644 (file)
@@ -361,7 +361,7 @@ abstract class JobQueue {
         * Outside callers should use JobQueueGroup::pop() instead of this function.
         *
         * @throws JobQueueError
-        * @return Job|bool Returns false if there are no jobs
+        * @return RunnableJob|bool Returns false if there are no jobs
         */
        final public function pop() {
                $this->assertNotReadOnly();
@@ -383,7 +383,7 @@ abstract class JobQueue {
 
        /**
         * @see JobQueue::pop()
-        * @return Job|bool
+        * @return RunnableJob|bool
         */
        abstract protected function doPop();
 
@@ -393,11 +393,11 @@ abstract class JobQueue {
         * This does nothing for certain queue classes or if "claimTTL" is not set.
         * Outside callers should use JobQueueGroup::ack() instead of this function.
         *
-        * @param Job $job
+        * @param RunnableJob $job
         * @return void
         * @throws JobQueueError
         */
-       final public function ack( Job $job ) {
+       final public function ack( RunnableJob $job ) {
                $this->assertNotReadOnly();
                if ( $job->getType() !== $this->type ) {
                        throw new JobQueueError( "Got '{$job->getType()}' job; expected '{$this->type}'." );
@@ -408,9 +408,9 @@ abstract class JobQueue {
 
        /**
         * @see JobQueue::ack()
-        * @param Job $job
+        * @param RunnableJob $job
         */
-       abstract protected function doAck( Job $job );
+       abstract protected function doAck( RunnableJob $job );
 
        /**
         * Register the "root job" of a given job into the queue for de-duplication.
@@ -482,11 +482,11 @@ abstract class JobQueue {
        /**
         * Check if the "root" job of a given job has been superseded by a newer one
         *
-        * @param Job $job
+        * @param IJobSpecification $job
         * @throws JobQueueError
         * @return bool
         */
-       final protected function isRootJobOldDuplicate( Job $job ) {
+       final protected function isRootJobOldDuplicate( IJobSpecification $job ) {
                if ( $job->getType() !== $this->type ) {
                        throw new JobQueueError( "Got '{$job->getType()}' job; expected '{$this->type}'." );
                }
@@ -497,10 +497,10 @@ abstract class JobQueue {
 
        /**
         * @see JobQueue::isRootJobOldDuplicate()
-        * @param Job $job
+        * @param IJobSpecification $job
         * @return bool
         */
-       protected function doIsRootJobOldDuplicate( Job $job ) {
+       protected function doIsRootJobOldDuplicate( IJobSpecification $job ) {
                if ( !$job->hasRootJobParams() ) {
                        return false; // job has no de-deplication info
                }
@@ -686,6 +686,16 @@ abstract class JobQueue {
                return null; // not supported
        }
 
+       /**
+        * @param string $command
+        * @param array $params
+        * @return Job
+        */
+       protected function factoryJob( $command, $params ) {
+               // @TODO: dependency inject this as a callback
+               return Job::factory( $command, $params );
+       }
+
        /**
         * @throws JobQueueReadOnlyError
         */
index c2772a6..47ee588 100644 (file)
@@ -290,7 +290,7 @@ class JobQueueDB extends JobQueue {
 
        /**
         * @see JobQueue::doPop()
-        * @return Job|bool
+        * @return RunnableJob|bool
         */
        protected function doPop() {
                $dbw = $this->getMasterDB();
@@ -314,10 +314,12 @@ class JobQueueDB extends JobQueue {
                                        break; // nothing to do
                                }
                                $this->incrStats( 'pops', $this->type );
+
                                // Get the job object from the row...
-                               $title = Title::makeTitle( $row->job_namespace, $row->job_title );
-                               $job = Job::factory( $row->job_cmd, $title,
-                                       self::extractBlob( $row->job_params ) );
+                               $params = self::extractBlob( $row->job_params );
+                               $params = is_array( $params ) ? $params : []; // sanity
+                               $params += [ 'namespace' => $row->job_namespace, 'title' => $row->job_title ];
+                               $job = $this->factoryJob( $row->job_cmd, $params );
                                $job->setMetadata( 'id', $row->job_id );
                                $job->setMetadata( 'timestamp', $row->job_timestamp );
                                break; // done
@@ -481,10 +483,10 @@ class JobQueueDB extends JobQueue {
 
        /**
         * @see JobQueue::doAck()
-        * @param Job $job
+        * @param RunnableJob $job
         * @throws MWException
         */
-       protected function doAck( Job $job ) {
+       protected function doAck( RunnableJob $job ) {
                $id = $job->getMetadata( 'id' );
                if ( $id === null ) {
                        throw new MWException( "Job of type '{$job->getType()}' has no ID." );
@@ -617,11 +619,14 @@ class JobQueueDB extends JobQueue {
                        return new MappedIterator(
                                $dbr->select( 'job', self::selectFields(), $conds ),
                                function ( $row ) {
-                                       $job = Job::factory(
-                                               $row->job_cmd,
-                                               Title::makeTitle( $row->job_namespace, $row->job_title ),
-                                               strlen( $row->job_params ) ? unserialize( $row->job_params ) : []
-                                       );
+                                       $params = strlen( $row->job_params ) ? unserialize( $row->job_params ) : [];
+                                       $params = is_array( $params ) ? $params : []; // sanity
+                                       $params += [
+                                               'namespace' => $row->job_namespace,
+                                               'title' => $row->job_title
+                                       ];
+
+                                       $job = $this->factoryJob( $row->job_cmd, $params );
                                        $job->setMetadata( 'id', $row->job_id );
                                        $job->setMetadata( 'timestamp', $row->job_timestamp );
 
@@ -774,8 +779,8 @@ class JobQueueDB extends JobQueue {
                return [
                        // Fields that describe the nature of the job
                        'job_cmd' => $job->getType(),
-                       'job_namespace' => $job->getTitle()->getNamespace(),
-                       'job_title' => $job->getTitle()->getDBkey(),
+                       'job_namespace' => $job->getParams()['namespace'] ?? NS_SPECIAL,
+                       'job_title' => $job->getParams()['title'] ?? '',
                        'job_params' => self::makeBlob( $job->getParams() ),
                        // Additional job metadata
                        'job_timestamp' => $db->timestamp(),
index 30ab7e7..8b5a62e 100644 (file)
@@ -199,7 +199,7 @@ class JobQueueFederated extends JobQueue {
         * @param HashRing &$partitionRing
         * @param int $flags
         * @throws JobQueueError
-        * @return array List of Job object that could not be inserted
+        * @return IJobSpecification[] List of Job object that could not be inserted
         */
        protected function tryJobInsertions( array $jobs, HashRing &$partitionRing, $flags ) {
                $jobsLeft = [];
@@ -299,7 +299,7 @@ class JobQueueFederated extends JobQueue {
                return false;
        }
 
-       protected function doAck( Job $job ) {
+       protected function doAck( RunnableJob $job ) {
                $partition = $job->getMetadata( 'QueuePartition' );
                if ( $partition === null ) {
                        throw new MWException( "The given job has no defined partition name." );
@@ -308,7 +308,7 @@ class JobQueueFederated extends JobQueue {
                $this->partitionQueues[$partition]->ack( $job );
        }
 
-       protected function doIsRootJobOldDuplicate( Job $job ) {
+       protected function doIsRootJobOldDuplicate( IJobSpecification $job ) {
                $signature = $job->getRootJobParams()['rootJobSignature'];
                $partition = $this->partitionRing->getLiveLocation( $signature );
                try {
index 4bac304..83e5fb2 100644 (file)
@@ -234,7 +234,7 @@ class JobQueueGroup {
         * @param int|string $qtype JobQueueGroup::TYPE_* constant or job type string
         * @param int $flags Bitfield of JobQueueGroup::USE_* constants
         * @param array $blacklist List of job types to ignore
-        * @return Job|bool Returns false on failure
+        * @return RunnableJob|bool Returns false on failure
         */
        public function pop( $qtype = self::TYPE_DEFAULT, $flags = 0, array $blacklist = [] ) {
                global $wgJobClasses;
index cbcd4fb..cb20a76 100644 (file)
@@ -111,7 +111,7 @@ class JobQueueMemory extends JobQueue {
        /**
         * @see JobQueue::doPop
         *
-        * @return Job|bool
+        * @return RunnableJob|bool
         */
        protected function doPop() {
                if ( $this->doGetSize() == 0 ) {
@@ -143,9 +143,9 @@ class JobQueueMemory extends JobQueue {
        /**
         * @see JobQueue::doAck
         *
-        * @param Job $job
+        * @param RunnableJob $job
         */
-       protected function doAck( Job $job ) {
+       protected function doAck( RunnableJob $job ) {
                if ( $this->getAcquiredCount() == 0 ) {
                        return;
                }
@@ -206,11 +206,10 @@ class JobQueueMemory extends JobQueue {
 
        /**
         * @param IJobSpecification $spec
-        *
-        * @return Job
+        * @return RunnableJob
         */
        public function jobFromSpecInternal( IJobSpecification $spec ) {
-               return Job::factory( $spec->getType(), $spec->getTitle(), $spec->getParams() );
+               return $this->factoryJob( $spec->getType(), $spec->getParams() );
        }
 
        /**
index 98a5491..8864688 100644 (file)
@@ -307,7 +307,7 @@ LUA;
 
        /**
         * @see JobQueue::doPop()
-        * @return Job|bool
+        * @return RunnableJob|bool
         * @throws JobQueueError
         */
        protected function doPop() {
@@ -379,12 +379,12 @@ LUA;
 
        /**
         * @see JobQueue::doAck()
-        * @param Job $job
-        * @return Job|bool
+        * @param RunnableJob $job
+        * @return RunnableJob|bool
         * @throws UnexpectedValueException
         * @throws JobQueueError
         */
-       protected function doAck( Job $job ) {
+       protected function doAck( RunnableJob $job ) {
                $uuid = $job->getMetadata( 'uuid' );
                if ( $uuid === null ) {
                        throw new UnexpectedValueException( "Job of type '{$job->getType()}' has no UUID." );
@@ -463,11 +463,11 @@ LUA;
 
        /**
         * @see JobQueue::doIsRootJobOldDuplicate()
-        * @param Job $job
+        * @param IJobSpecification $job
         * @return bool
         * @throws JobQueueError
         */
-       protected function doIsRootJobOldDuplicate( Job $job ) {
+       protected function doIsRootJobOldDuplicate( IJobSpecification $job ) {
                if ( !$job->hasRootJobParams() ) {
                        return false; // job has no de-deplication info
                }
@@ -627,7 +627,7 @@ LUA;
         *
         * @param string $uid
         * @param RedisConnRef $conn
-        * @return Job|bool Returns false if the job does not exist
+        * @return RunnableJob|bool Returns false if the job does not exist
         * @throws JobQueueError
         * @throws UnexpectedValueException
         */
@@ -641,8 +641,10 @@ LUA;
                        if ( !is_array( $item ) ) { // this shouldn't happen
                                throw new UnexpectedValueException( "Could not find job with ID '$uid'." );
                        }
-                       $title = Title::makeTitle( $item['namespace'], $item['title'] );
-                       $job = Job::factory( $item['type'], $title, $item['params'] );
+
+                       $params = $item['params'];
+                       $params += [ 'namespace' => $item['namespace'], 'title' => $item['title'] ];
+                       $job = $this->factoryJob( $item['type'], $params );
                        $job->setMetadata( 'uuid', $item['uuid'] );
                        $job->setMetadata( 'timestamp', $item['timestamp'] );
                        // Add in attempt count for debugging at showJobs.php
@@ -684,8 +686,8 @@ LUA;
                return [
                        // Fields that describe the nature of the job
                        'type' => $job->getType(),
-                       'namespace' => $job->getTitle()->getNamespace(),
-                       'title' => $job->getTitle()->getDBkey(),
+                       'namespace' => $job->getParams()['namespace'] ?? NS_SPECIAL,
+                       'title' => $job->getParams()['title'] ?? '',
                        'params' => $job->getParams(),
                        // Some jobs cannot run until a "release timestamp"
                        'rtimestamp' => $job->getReleaseTimestamp() ?: 0,
@@ -700,11 +702,13 @@ LUA;
 
        /**
         * @param array $fields
-        * @return Job|bool
+        * @return RunnableJob|bool
         */
        protected function getJobFromFields( array $fields ) {
-               $title = Title::makeTitle( $fields['namespace'], $fields['title'] );
-               $job = Job::factory( $fields['type'], $title, $fields['params'] );
+               $params = $fields['params'];
+               $params += [ 'namespace' => $fields['namespace'], 'title' => $fields['title'] ];
+
+               $job = $this->factoryJob( $fields['type'], $params );
                $job->setMetadata( 'uuid', $fields['uuid'] );
                $job->setMetadata( 'timestamp', $fields['timestamp'] );
 
index b04aa83..80a46d0 100644 (file)
@@ -28,8 +28,7 @@
  * $job = new JobSpecification(
  *             'null',
  *             array( 'lives' => 1, 'usleep' => 100, 'pi' => 3.141569 ),
- *             array( 'removeDuplicates' => 1 ),
- *             Title::makeTitle( NS_SPECIAL, 'nullity' )
+ *             array( 'removeDuplicates' => 1 )
  * );
  * JobQueueGroup::singleton()->push( $job )
  * @endcode
@@ -63,8 +62,19 @@ class JobSpecification implements IJobSpecification {
                $this->validateParams( $opts );
 
                $this->type = $type;
+               if ( $title instanceof Title ) {
+                       // Make sure JobQueue classes can pull the title from parameters alone
+                       if ( $title->getDBkey() !== '' ) {
+                               $params += [
+                                       'namespace' => $title->getNamespace(),
+                                       'title' => $title->getDBkey()
+                               ];
+                       }
+               } else {
+                       $title = Title::makeTitle( NS_SPECIAL, '' );
+               }
                $this->params = $params;
-               $this->title = $title ?: Title::makeTitle( NS_SPECIAL, 'Blankpage' );
+               $this->title = $title;
                $this->opts = $opts;
        }
 
diff --git a/includes/jobqueue/RunnableJob.php b/includes/jobqueue/RunnableJob.php
new file mode 100644 (file)
index 0000000..e477b12
--- /dev/null
@@ -0,0 +1,54 @@
+<?php
+/**
+ * Job queue task instance that can be executed via a run() method
+ *
+ * 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
+ */
+
+/**
+ * Job that has a run() method and metadata accessors for JobQueue::pop() and JobQueue::ack()
+ *
+ * Instances are not only enqueueable via JobQueue::push(), but they can also be executed by
+ * by calling their run() method. When constructing a job to be enqueued via JobQueue::push(),
+ * it will not be possible to construct a RunnableJob instance if the class for that job is not
+ * loaded by the application for the local DB domain. In that case, the general-purpose
+ * JobSpecification class can be used instead.
+ *
+ * @ingroup JobQueue
+ * @since 1.33
+ */
+interface RunnableJob extends IJobSpecification {
+       /**
+        * Run the job
+        * @return bool Success
+        */
+       public function run();
+
+       /**
+        * @param string|null $field Metadata field or null to get all the metadata
+        * @return mixed|null Value; null if missing
+        */
+       public function getMetadata( $field = null );
+
+       /**
+        * @param string $field Key name to set the value for
+        * @param mixed $value The value to set the field for
+        * @return mixed|null The prior field value; null if missing
+        */
+       public function setMetadata( $field, $value );
+}
index 356eeba..d8cbf75 100644 (file)
  * @ingroup JobQueue
  * @since 1.27
  */
-class CdnPurgeJob extends Job {
-       /**
-        * @param Title $title
-        * @param array $params Job parameters (urls)
-        */
-       function __construct( Title $title, array $params ) {
-               parent::__construct( 'cdnPurge', $title, $params );
+class CdnPurgeJob extends Job implements GenericParameterJob {
+       function __construct( array $params ) {
+               parent::__construct( 'cdnPurge', $params );
                $this->removeDuplicates = false; // delay semantics are critical
        }
 
index 77adfa1..01fa46c 100644 (file)
@@ -10,7 +10,17 @@ use MediaWiki\MediaWikiServices;
  * @ingroup JobQueue
  * @since 1.31
  */
-class ClearUserWatchlistJob extends Job {
+class ClearUserWatchlistJob extends Job implements GenericParameterJob {
+       /**
+        * @param array $params
+        *  - userId,         The ID for the user whose watchlist is being cleared.
+        *  - maxWatchlistId, The maximum wl_id at the time the job was first created,
+        */
+       public function __construct( array $params ) {
+               parent::__construct( 'clearUserWatchlist', $params );
+
+               $this->removeDuplicates = true;
+       }
 
        /**
         * @param User $user User to clear the watchlist for.
@@ -19,26 +29,7 @@ class ClearUserWatchlistJob extends Job {
         * @return ClearUserWatchlistJob
         */
        public static function newForUser( User $user, $maxWatchlistId ) {
-               return new self(
-                       null,
-                       [ 'userId' => $user->getId(), 'maxWatchlistId' => $maxWatchlistId ]
-               );
-       }
-
-       /**
-        * @param Title|null $title Not used by this job.
-        * @param array $params
-        *  - userId,         The ID for the user whose watchlist is being cleared.
-        *  - maxWatchlistId, The maximum wl_id at the time the job was first created,
-        */
-       public function __construct( Title $title = null, array $params ) {
-               parent::__construct(
-                       'clearUserWatchlist',
-                       SpecialPage::getTitleFor( 'EditWatchlist', 'clear' ),
-                       $params
-               );
-
-               $this->removeDuplicates = true;
+               return new self( [ 'userId' => $user->getId(), 'maxWatchlistId' => $maxWatchlistId ] );
        }
 
        public function run() {
@@ -101,7 +92,7 @@ class ClearUserWatchlistJob extends Job {
                if ( count( $watchlistIds ) === (int)$batchSize ) {
                        // Until we get less results than the limit, recursively push
                        // the same job again.
-                       JobQueueGroup::singleton()->push( new self( $this->getTitle(), $this->getParams() ) );
+                       JobQueueGroup::singleton()->push( new self( $this->getParams() ) );
                }
 
                return true;
index 3b2c899..f53174a 100644 (file)
@@ -33,9 +33,9 @@ use MediaWiki\MediaWikiServices;
  * @ingroup JobQueue
  * @since 1.31
  */
-class ClearWatchlistNotificationsJob extends Job {
-       function __construct( Title $title, array $params ) {
-               parent::__construct( 'clearWatchlistNotifications', $title, $params );
+class ClearWatchlistNotificationsJob extends Job implements GenericParameterJob {
+       function __construct( array $params ) {
+               parent::__construct( 'clearWatchlistNotifications', $params );
 
                static $required = [ 'userId', 'casTime' ];
                $missing = implode( ', ', array_diff( $required, array_keys( $this->params ) ) );
index e6dfae4..b0ce6a5 100644 (file)
@@ -3,16 +3,13 @@
 /**
  * Class DeletePageJob
  */
-class DeletePageJob extends Job {
-       public function __construct( $title, $params = [] ) {
-               parent::__construct( 'deletePage', $title, $params );
+class DeletePageJob extends Job implements GenericParameterJob {
+       public function __construct( array $params ) {
+               parent::__construct( 'deletePage', $params );
+
+               $this->title = Title::makeTitle( $params['namespace'], $params['title'] );
        }
 
-       /**
-        * Execute the job
-        *
-        * @return bool
-        */
        public function run() {
                // Failure to load the page is not job failure.
                // A parallel deletion operation may have already completed the page deletion.
index c005a29..4231e15 100644 (file)
  *
  * @ingroup JobQueue
  */
-final class DuplicateJob extends Job {
+final class DuplicateJob extends Job implements GenericParameterJob {
        /**
         * Callers should use DuplicateJob::newFromJob() instead
         *
-        * @param Title $title
         * @param array $params Job parameters
         */
-       function __construct( Title $title, array $params ) {
-               parent::__construct( 'duplicate', $title, $params );
+       function __construct( array $params ) {
+               parent::__construct( 'duplicate', $params );
        }
 
        /**
         * Get a duplicate no-op version of a job
         *
-        * @param Job $job
+        * @param RunnableJob $job
         * @return Job
         */
-       public static function newFromJob( Job $job ) {
-               $djob = new self( $job->getTitle(), $job->getParams() );
+       public static function newFromJob( RunnableJob $job ) {
+               $djob = new self( $job->getParams() );
                $djob->command = $job->getType();
                $djob->params = is_array( $djob->params ) ? $djob->params : [];
                $djob->params = [ 'isDuplicate' => true ] + $djob->params;
-               $djob->metadata = $job->metadata;
+               $djob->metadata = $job->getMetadata();
 
                return $djob;
        }
index 72923ce..f9735d5 100644 (file)
  * @ingroup JobQueue
  * @since 1.25
  */
-final class EnqueueJob extends Job {
+final class EnqueueJob extends Job implements GenericParameterJob {
        /**
         * Callers should use the factory methods instead
         *
-        * @param Title $title
         * @param array $params Job parameters
         */
-       function __construct( Title $title, array $params ) {
-               parent::__construct( 'enqueue', $title, $params );
+       public function __construct( array $params ) {
+               parent::__construct( 'enqueue', $params );
        }
 
        /**
@@ -75,10 +74,7 @@ final class EnqueueJob extends Job {
                        }
                }
 
-               $eJob = new self(
-                       Title::makeTitle( NS_SPECIAL, 'Badtitle/' . __CLASS__ ),
-                       [ 'jobsByDomain' => $jobMapsByDomain ]
-               );
+               $eJob = new self( [ 'jobsByDomain' => $jobMapsByDomain ] );
                // If *all* jobs to be pushed are to be de-duplicated (a common case), then
                // de-duplicate this whole job itself to avoid build up in high traffic cases
                $eJob->removeDuplicates = $deduplicate;
index 80826fe..01afe6f 100644 (file)
@@ -31,7 +31,7 @@
  * @code
  * $ php maintenance/eval.php
  * > $queue = JobQueueGroup::singleton();
- * > $job = new NullJob( Title::newMainPage(), [ 'lives' => 10 ] );
+ * > $job = new NullJob( [ 'lives' => 10 ] );
  * > $queue->push( $job );
  * @endcode
  * You can then confirm the job has been enqueued by using the showJobs.php
  *
  * @ingroup JobQueue
  */
-class NullJob extends Job {
+class NullJob extends Job implements GenericParameterJob {
        /**
-        * @param Title $title
         * @param array $params Job parameters (lives, usleep)
         */
-       function __construct( Title $title, array $params ) {
-               parent::__construct( 'null', $title, $params );
+       function __construct( array $params ) {
+               parent::__construct( 'null', $params );
                if ( !isset( $this->params['lives'] ) ) {
                        $this->params['lives'] = 1;
                }
@@ -67,7 +66,7 @@ class NullJob extends Job {
                if ( $this->params['lives'] > 1 ) {
                        $params = $this->params;
                        $params['lives']--;
-                       $job = new self( $this->title, $params );
+                       $job = new self( $params );
                        JobQueueGroup::singleton()->push( $job );
                }
 
index bd0df5b..ac8f94a 100644 (file)
@@ -21,9 +21,9 @@
  * @ingroup JobQueue
  */
 
-class UserGroupExpiryJob extends Job {
-       public function __construct( $params = [] ) {
-               parent::__construct( 'userGroupExpiry', Title::newMainPage(), $params );
+class UserGroupExpiryJob extends Job implements GenericParameterJob {
+       public function __construct( array $params = [] ) {
+               parent::__construct( 'userGroupExpiry', $params );
                $this->removeDuplicates = true;
        }
 
index 96ed8ee..931740c 100644 (file)
@@ -2736,6 +2736,8 @@ class WikiPage implements Page, IDBAccessObject {
                        $dbw->endAtomic( __METHOD__ );
 
                        $jobParams = [
+                               'namespace' => $this->getTitle()->getNamespace(),
+                               'title' => $this->getTitle()->getDBkey(),
                                'wikiPageId' => $id,
                                'requestId' => $webRequestId ?? WebRequest::getRequestId(),
                                'reason' => $reason,
@@ -2745,7 +2747,7 @@ class WikiPage implements Page, IDBAccessObject {
                                'logsubtype' => $logsubtype,
                        ];
 
-                       $job = new DeletePageJob( $this->getTitle(), $jobParams );
+                       $job = new DeletePageJob( $jobParams );
                        JobQueueGroup::singleton()->push( $job );
 
                        $status->warning( 'delete-scheduled',
index 58212dd..a3b7296 100644 (file)
@@ -34,6 +34,7 @@ use RequestContext;
 use SpecialPage;
 use Title;
 use User;
+use Wikimedia\Assert\Assert;
 
 /**
  * Factory for handling the special page list and generating SpecialPage objects.
@@ -215,17 +216,36 @@ class SpecialPageFactory {
        private $aliases;
 
        /** @var Config */
-       private $config;
+       private $options;
 
        /** @var Language */
        private $contLang;
 
        /**
-        * @param Config $config
+        * TODO Make this a const when HHVM support is dropped (T192166)
+        *
+        * @var array
+        * @since 1.33
+        * */
+       public static $constructorOptions = [
+               'ContentHandlerUseDB',
+               'DisableInternalSearch',
+               'EmailAuthentication',
+               'EnableEmail',
+               'EnableJavaScriptTest',
+               'PageLanguageUseDB',
+               'SpecialPages',
+       ];
+
+       /**
+        * @param array $options
         * @param Language $contLang
         */
-       public function __construct( Config $config, Language $contLang ) {
-               $this->config = $config;
+       public function __construct( array $options, Language $contLang ) {
+               Assert::parameter( count( $options ) === count( self::$constructorOptions ) &&
+                       !array_diff( self::$constructorOptions, array_keys( $options ) ),
+                       '$options', 'Wrong set of options present' );
+               $this->options = $options;
                $this->contLang = $contLang;
        }
 
@@ -248,32 +268,32 @@ class SpecialPageFactory {
                if ( !is_array( $this->list ) ) {
                        $this->list = self::$coreList;
 
-                       if ( !$this->config->get( 'DisableInternalSearch' ) ) {
+                       if ( !$this->options['DisableInternalSearch'] ) {
                                $this->list['Search'] = \SpecialSearch::class;
                        }
 
-                       if ( $this->config->get( 'EmailAuthentication' ) ) {
+                       if ( $this->options['EmailAuthentication'] ) {
                                $this->list['Confirmemail'] = \EmailConfirmation::class;
                                $this->list['Invalidateemail'] = \EmailInvalidation::class;
                        }
 
-                       if ( $this->config->get( 'EnableEmail' ) ) {
+                       if ( $this->options['EnableEmail'] ) {
                                $this->list['ChangeEmail'] = \SpecialChangeEmail::class;
                        }
 
-                       if ( $this->config->get( 'EnableJavaScriptTest' ) ) {
+                       if ( $this->options['EnableJavaScriptTest'] ) {
                                $this->list['JavaScriptTest'] = \SpecialJavaScriptTest::class;
                        }
 
-                       if ( $this->config->get( 'PageLanguageUseDB' ) ) {
+                       if ( $this->options['PageLanguageUseDB'] ) {
                                $this->list['PageLanguage'] = \SpecialPageLanguage::class;
                        }
-                       if ( $this->config->get( 'ContentHandlerUseDB' ) ) {
+                       if ( $this->options['ContentHandlerUseDB'] ) {
                                $this->list['ChangeContentModel'] = \SpecialChangeContentModel::class;
                        }
 
                        // Add extension special pages
-                       $this->list = array_merge( $this->list, $this->config->get( 'SpecialPages' ) );
+                       $this->list = array_merge( $this->list, $this->options['SpecialPages'] );
 
                        // This hook can be used to disable unwanted core special pages
                        // or conditionally register special pages.
index 391d9ab..2632092 100644 (file)
@@ -206,6 +206,7 @@ class SpecialVersion extends SpecialPage {
                        'Victor Vasiliev', 'Rotem Liss', 'Platonides', 'Antoine Musso',
                        'Timo Tijhof', 'Daniel Kinzler', 'Jeroen De Dauw', 'Brad Jorsch',
                        'Bartosz Dziewoński', 'Ed Sanders', 'Moriel Schottlender',
+                       'Kunal Mehta', 'James D. Forrester', 'Brian Wolff', 'Adam Shorland',
                        $othersLink, $translatorsLink
                ];
 
index 8aca689..e287a35 100644 (file)
@@ -904,10 +904,9 @@ class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterfac
                }
 
                // If the page is watched by the user (or may be watched), update the timestamp
-               $job = new ClearWatchlistNotificationsJob(
-                       $user->getUserPage(),
-                       [ 'userId'  => $user->getId(), 'timestamp' => $timestamp, 'casTime' => time() ]
-               );
+               $job = new ClearWatchlistNotificationsJob( [
+                       'userId'  => $user->getId(), 'timestamp' => $timestamp, 'casTime' => time()
+               ] );
 
                // Try to run this post-send
                // Calls DeferredUpdates::addCallableUpdate in normal operation
index b66914b..77a312a 100644 (file)
        "accmailtext": "Unvióse a $2 una contraseña xenerada al debalu pal usuariu [[User talk:$1|$1]]. Pue camudase na páxina ''[[Special:ChangePassword|camudar contraseña]]'' depués d'aniciar sesión.",
        "newarticle": "(Nuevu)",
        "newarticletext": "Siguisti un enllaz a un artículu qu'inda nun esiste.\nPa crear la páxina, empecipia a escribir nel cuadru d'embaxo (mira la [$1 páxina d'ayuda] pa más información).\nSi llegasti equí por enquivocu, calca nel botón <strong>atrás</strong> del to restolador.",
-       "anontalkpagetext": "----\n''Esta ye la páxina d'alderique pa un usuariu anónimu qu'inda nun creó una cuenta o que nun la usa.''\nPola mor d'ello ha usase la direición numbérica IP pa identificalu/la.\nTala IP pue compartise por varios usuarios.\nSi yes un usuariu anónimu y notes qu'hai comentarios irrelevantes empobinaos pa ti, por favor [[Special:CreateAccount|crea una cuenta]] o [[Special:UserLogin|anicia sesiín]] pa torgar futures confusiones con otros usuarios anónimos.",
+       "anontalkpagetext": "----\n<em>Esta ye la páxina d'alderique pa un usuariu anónimu qu'inda nun creó una cuenta o que nun la usa.</em>\nPola mor d'ello ha usase la direición numbérica IP como identificación.\nTala IP pué compartise por dellos usuarios.\nSi yes un usuariu anónimu y notes qu'hai comentarios ensin relevancia dirixíos pa ti, por favor [[Special:CreateAccount|crea una cuenta]] o [[Special:UserLogin|anicia sesión]] pa torgar futures confusiones con otros usuarios anónimos.",
        "noarticletext": "Nestos momentos nun hai testu nesta páxina.\nPuedes [[Special:Search/{{PAGENAME}}|buscar esti títulu de páxina]] n'otres páxines,\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} buscar los rexistros rellacionaos],\no [{{fullurl:{{FULLPAGENAME}}|action=edit}} crear esta páxina]</span>.",
        "noarticletext-nopermission": "Nestos momentos nun hai testu nesta páxina.\nPuedes [[Special:Search/{{PAGENAME}}|buscar esti títulu de páxina]] n'otres páxines o <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} buscar los rexistros rellacionaos]</span>, pero nun tienes permisu pa crear esta páxina.",
        "missing-revision": "La revisión #$1 de la páxina llamada \"{{FULLPAGENAME}}\" nun esiste.\n\nDe vezu la causa d'esto ye siguir un enllaz antiguu del historial a una páxina que se desanició.\nSe puen alcontrar más detalles nel [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} rexistru de desanicios].",
index cfe5371..7a5b6e0 100644 (file)
        "delete-confirm": "Выдаліць «$1»",
        "delete-legend": "Выдаліць",
        "historywarning": "<strong>Папярэджаньне</strong>: старонка, якую Вы зьбіраецеся выдаліць, мае гісторыю з $1 {{PLURAL:$1|вэрсіі|вэрсіяў|вэрсіяў}}:",
-       "historyaction-submit": "Паказаць",
+       "historyaction-submit": "Паказаць вэрсіі",
        "confirmdeletetext": "Зараз Вы выдаліце старонку разам з усёй гісторыяй зьменаў.\nКалі ласка, пацьвердзіце, што Вы зьбіраецеся гэта зрабіць і што Вы разумееце ўсе наступствы, а таксама робіце гэта ў адпаведнасьці з [[{{MediaWiki:Policy-url}}|правіламі]].",
        "actioncomplete": "Дзеяньне выкананае",
        "actionfailed": "Дзеяньне ня выкананае",
index a39d2be..6dfc160 100644 (file)
        "delete-confirm": "Smazání stránky „$1“",
        "delete-legend": "Smazat",
        "historywarning": "<strong>Varování:</strong> Stránka, kterou se chystáte smazat, má historii s $1 {{PLURAL:$1|revizí|revizemi}}:",
-       "historyaction-submit": "Zobrazit",
+       "historyaction-submit": "Zobrazit revize",
        "confirmdeletetext": "Chystáte se smazat stránku s celou její historií. Prosím potvrďte, že to opravdu chcete učinit, že si uvědomujete důsledky a že je to v souladu s [[{{MediaWiki:Policy-url}}|pravidly]].",
        "actioncomplete": "Provedeno",
        "actionfailed": "Operace se nezdařila",
index 9612ca7..4bcd9a1 100644 (file)
        "delete-confirm": "Löschen von „$1“",
        "delete-legend": "Löschen",
        "historywarning": "<strong>Achtung:</strong> Die Seite, die du löschen möchtest, hat eine Versionsgeschichte mit {{PLURAL:$1|einer Version|$1 Versionen}}:",
-       "historyaction-submit": "Anzeigen",
+       "historyaction-submit": "Versionen anzeigen",
        "confirmdeletetext": "Du bist dabei, eine Seite mit allen zugehörigen älteren Versionen zu löschen. Bitte bestätige dazu, dass du dir der Konsequenzen bewusst bist, und dass du in Übereinstimmung mit den [[{{MediaWiki:Policy-url}}|Richtlinien]] handelst.",
        "actioncomplete": "Aktion beendet",
        "actionfailed": "Aktion fehlgeschlagen",
index 9510e96..dd50dea 100644 (file)
        "watchthis": "Na pele de seyr ke",
        "savearticle": "Pele qeyd ke",
        "savechanges": "Vurnayışan qeyd ke",
-       "publishpage": "Pele neşr kerê",
-       "publishchanges": "Vırnayışan qeyd ke",
+       "publishpage": "Pele qeyd ke",
+       "publishchanges": "Vurnayışan qeyd ke",
        "savearticle-start": "Pele qeyd ke...",
        "savechanges-start": "Vurnayışan qeyd ke...",
        "publishpage-start": "Pele weşane...",
        "page_first": "verên",
        "page_last": "peyên",
        "histlegend": "Ferqê weçinayışi: Qutiya versiyonan qandé  têversanayış işaret ke u dest be ''enter''i ya zi gocega cêrêne rone.<br />\nCetwel: <strong>({{int:ferq}})</strong> = ferqê versiyonê peyêni, <strong>({{int:peyên}})</strong> = ferqê versiyonê verêni, <strong>{{int:q}}</strong> = vırnayışo werdiyo.",
-       "history-fieldset-title": "Çım ra viyarnayışan cı geyre",
+       "history-fieldset-title": "Çımraviyarnayışan cı geyre",
        "history-show-deleted": "Tenya çımraviyarnayışanê esterıteyan bımocne",
        "histfirst": "Verênêr",
        "histlast": "Peyênêr",
        "delete-confirm": "\"$1\" bestere",
        "delete-legend": "Bestere",
        "historywarning": "'''Teme:''' Pela ke şıma esterenê tede yew viyarte be teqriben $1 {{PLURAL:$1|versiyon esto|versiyoni estê}}:",
-       "historyaction-submit": "Bımocne",
+       "historyaction-submit": "Versiyonan bımocne",
        "confirmdeletetext": "Tı ho yew pele u tarixê pele wederneno.\nTı ra rica keno, tı zani tı ho sekeno, tı zani neticeyanê eno wedarnayışi u tı zani tı ser [[{{MediaWiki:Policy-url}}|poliçe]] kar keno.",
        "actioncomplete": "Kar bi temam",
        "actionfailed": "kar nêbı",
        "tooltip-ca-nstab-category": "Pela kategoriye bıvêne",
        "tooltip-minoredit": "Ney vırnayışo werdi nışan bıkerê",
        "tooltip-save": "Vurnayışanê xo qeyd ke",
-       "tooltip-publish": "Vurnayışê xo neşr kerê",
+       "tooltip-publish": "Vurnayışê xo qeyd ke",
        "tooltip-preview": "Vurnayışanê xo çım ra bıviyarnê. Qeydkerdış ra ver bıgurê cı!",
        "tooltip-diff": "Kamci vırnayışê ke şıma nuştey sero kerdê, inan bıvênê.",
        "tooltip-compareselectedversions": "Ena per de ferqê rewziyonan de dı weçinaya bıvinê",
index 197499c..e5b74fe 100644 (file)
        "page_first": "first",
        "page_last": "last",
        "histlegend": "Diff selection: Mark the radio boxes of the revisions to compare and hit enter or the button at the bottom.<br />\nLegend: <strong>({{int:cur}})</strong> = difference with latest revision, <strong>({{int:last}})</strong> = difference with preceding revision, <strong>{{int:minoreditletter}}</strong> = minor edit.",
-       "history-fieldset-title": "Search for revisions",
+       "history-fieldset-title": "Filter revisions",
        "history-show-deleted": "Revision deleted only",
        "history_copyright": "-",
        "histfirst": "oldest",
index 72a383e..5078817 100644 (file)
        "mycontris": "Contribuciones",
        "anoncontribs": "Contribuciones",
        "contribsub2": "Para {{GENDER:$3|$1}} ($2)",
+       "contributions-subtitle": "Para {{GENDER:$3|$1}} ($2)",
        "contributions-userdoesnotexist": "La cuenta de usuario «$1» no está registrada.",
        "negative-namespace-not-supported": "Los espacios de nombres con valores negativos no están permitidos",
        "nocontribs": "No se encontraron cambios que cumplieran estos criterios.",
index 4bf08f2..d64121d 100644 (file)
        "nstab-category": "Luokka",
        "mainpage-nstab": "Etusivu",
        "nosuchaction": "Toimintoa ei ole olemassa",
-       "nosuchactiontext": "URL:ssä määritelty toiminto ei ole kelvollinen.\nOlet saattanut kirjoittaa URL:in väärin tai olet seurannut virheellistä linkkiä.\nKyseessä voi myös mahdollisesti olla virhe sivuston {{SITENAME}} käyttämässä ohjelmistossa.",
+       "nosuchactiontext": "URL:ssä määritelty toiminto ei ole kelvollinen.\nOlet saattanut kirjoittaa URL:in väärin tai olet seurannut virheellistä linkkiä.\nKyseessä voi myös mahdollisesti olla virhe {{GRAMMAR:genitive|{{SITENAME}}}} käyttämässä ohjelmistossa.",
        "nosuchspecialpage": "Kyseistä toimintosivua ei ole",
        "nospecialpagetext": "<strong>Ohjelmisto ei tunnista pyytämääsi toimintosivua.</strong>\n\nLuettelo toimintosivuista löytyy sivulta [[Special:SpecialPages|{{int:specialpages}}]].",
        "error": "Virhe",
        "passwordreset-email": "Sähköpostiosoite:",
        "passwordreset-emailtitle": "Tunnuksen tiedot {{GRAMMAR:inessive|{{SITENAME}}}}",
        "passwordreset-emailtext-ip": "Joku (todennäköisesti sinä, IP-osoitteesta $1) pyysi salasanasi\nvaihtamista sivustolla {{SITENAME}} ($4). {{PLURAL:$3|Seuraava käyttäjätunnus on|Seuraavat käyttäjätunnukset ovat}}\nyhdistettynä tähän sähköpostiosoitteeseen:\n\n$2\n\n{{PLURAL:$3|Tämä väliaikainen salasana vanhentuu|Nämä väliaikaiset salasanat vanhentuvat}} {{PLURAL:$5|yhden päivän|$5 päivän}} kuluttua.\nKirjaudu sisään nyt ja valitse uusi salasana heti. Jos joku toinen teki tämän pyynnön \ntai jos muistitkin vanhan salasanasi etkä halua enää muuttaa sitä,\nvoit jättää tämän viestin huomiotta ja jatkaa vanhan salasanasi käyttämistä.",
-       "passwordreset-emailtext-user": "Käyttäjä $1 pyysi muistutusta tunnuksesi tiedoista sivustolla {{SITENAME}} ($4).\n{{PLURAL:$3|Seuraava käyttäjätunnus on|Seuraavat käyttäjätunnukset ovat}} liitetty tähän sähköpostiosoitteeseen:\n\n$2\n\n{{PLURAL:$3|Tämä väliaikainen salasana vanhentuu|Nämä väliaikaiset salasanat vanhentuvat}} {{PLURAL:$5|yhden päivän|$5 päivän}} kuluttua.\nSinun kannattaa kirjautua sisään ja valita uusi salasana. Jos joku toinen teki tämän\npyynnön, tai muistat sittenkin vanhan salasanasi, etkä halua muuttaa sitä,\nvoit jättää tämän viestin huomiotta ja jatkaa vanhan salasanan käyttöä.",
+       "passwordreset-emailtext-user": "Käyttäjä $1 pyysi muistutusta tunnuksesi tiedoista {{GRAMMAR:inessive|{{SITENAME}}}} ($4).\n{{PLURAL:$3|Seuraava käyttäjätunnus on|Seuraavat käyttäjätunnukset ovat}} liitetty tähän sähköpostiosoitteeseen:\n\n$2\n\n{{PLURAL:$3|Tämä väliaikainen salasana vanhentuu|Nämä väliaikaiset salasanat vanhentuvat}} {{PLURAL:$5|yhden päivän|$5 päivän}} kuluttua.\nSinun kannattaa kirjautua sisään ja valita uusi salasana. Jos joku toinen teki tämän\npyynnön, tai muistat sittenkin vanhan salasanasi, etkä halua muuttaa sitä,\nvoit jättää tämän viestin huomiotta ja jatkaa vanhan salasanan käyttöä.",
        "passwordreset-emailelement": "Käyttäjätunnus: \n$1\n\nVäliaikainen salasana: \n$2",
        "passwordreset-emailsentemail": "Jos tämä on sinun tunnuksellesi rekisteröity sähköpostiosoite, salasanan uudistamisesta kertova viesti lähetetään.",
        "passwordreset-emailsentusername": "Jos on olemassa vastaava rekisteröity sähköpostiosoite, salasanan uudistamisesta kertova viesti lähetetään.",
        "continue-editing": "Siirry muokkauskenttään",
        "previewconflict": "Tämä esikatselu näyttää miltä muokkausalueella oleva teksti näyttää tallennettuna.",
        "session_fail_preview": "Muokkaustasi ei voitu tallentaa, koska istuntosi tiedot ovat kadonneet.\n\nSaatat olla kirjautunut ulos. '''Varmista, että olet edelleen kirjautunut sisään ja yritä uudelleen'''. Jos ongelma ei katoa, yritä [[Special:UserLogout|kirjautua ulos]] ja takaisin sisään, ja varmista, että selaimesi sallii evästeet tältä sivustolta.",
-       "session_fail_preview_html": "Valitettavasti muokkaustasi ei voitu käsitellä istunnon tietojen katoamisen vuoksi.\n\n<em>Koska sivustolla {{SITENAME}} on käytössä suodattamaton HTML-koodi, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi</em>\n\n<strong>Jos tämä on oikea muokkausyritys, yritä uudelleen.</strong> Jos ongelma ei katoa, yritä [[Special:UserLogout|kirjautua ulos]] ja takaisin sisään. Tarkista myös, että selaimesi sallii evästeet tältä sivustolta.",
+       "session_fail_preview_html": "Valitettavasti muokkaustasi ei voitu käsitellä istunnon tietojen katoamisen vuoksi.\n\n<em>Koska {{GRAMMAR:inessive|{{SITENAME}}}} on käytössä suodattamaton HTML-koodi, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi</em>\n\n<strong>Jos tämä on oikea muokkausyritys, yritä uudelleen.</strong> Jos ongelma ei katoa, yritä [[Special:UserLogout|kirjautua ulos]] ja takaisin sisään. Tarkista myös, että selaimesi sallii evästeet tältä sivustolta.",
        "token_suffix_mismatch": "'''Muokkauksesi on hylätty, koska asiakasohjelmasi ei osaa käsitellä välimerkkejä muokkaustarkisteessa. Syynä voi olla viallinen välityspalvelin.'''",
        "edit_form_incomplete": "'''Osa muokkauslomakkeesta ei saavuttanut palvelinta. Tarkista, että muokkauksesi ovat vahingoittumattomia ja yritä uudelleen.'''",
        "editing": "Muokataan sivua $1",
        "upload-form-label-infoform-categories": "Luokat",
        "upload-form-label-infoform-date": "Päivämäärä",
        "upload-form-label-own-work-message-generic-local": "Vakuutan, että tallennan tämän tiedoston noudattaen {{GRAMMAR:inessive|{{SITENAME}}}} voimassa olevia käyttöehtoja sekä lisenssejä koskevia käytäntöjä.",
-       "upload-form-label-not-own-work-message-generic-local": "Jos et kykene tallentamaan tätä tiedostoa noudattaen niitä käytäntöjä, jotka ovat voimassa sivustolla {{SITENAME}}, sulje tämä dialogi ja kokeile jotain toista menetelmää.",
+       "upload-form-label-not-own-work-message-generic-local": "Jos et kykene tallentamaan tätä tiedostoa noudattaen niitä käytäntöjä, jotka ovat voimassa {{GRAMMAR:inessive|{{SITENAME}}}}, sulje tämä dialogi ja kokeile jotain toista menetelmää.",
        "upload-form-label-not-own-work-local-generic-local": "Voit myös kokeilla [[Special:Upload|yleistä tallentamista]].",
        "upload-form-label-own-work-message-generic-foreign": "Ymmärrän, että olen tallentamassa tätä tiedostoa yhteiseen mediasäilöön. Vakuutan, että teen tämän noudattaen asiaankuuluvia käyttöehtoja ja lisenssejä koskevia käytäntöjä.",
        "upload-form-label-not-own-work-message-generic-foreign": "Jos et kykene tallentamaan tätä tiedostoa noudattaen niitä käytäntöjä, jotka ovat voimassa yhteisessä mediasäilössä, sulje tämä dialogi ja kokeile jotain toista menetelmää.",
-       "upload-form-label-not-own-work-local-generic-foreign": "Voit myös kokeilla [[Special:Upload|tallennussivua sivustolla {{SITENAME}}]]. Saattaa olla, että tämän tiedoston tallentaminen sinne on mahdollista siellä voimassa olevien käytäntöjen mukaisesti.",
+       "upload-form-label-not-own-work-local-generic-foreign": "Voit myös kokeilla [[Special:Upload|tallennussivua {{GRAMMAR:inessive|{{SITENAME}}}}]]. Saattaa olla, että tämän tiedoston tallentaminen sinne on mahdollista siellä voimassa olevien käytäntöjen mukaisesti.",
        "backend-fail-stream": "Tiedoston $1 virtauttaminen epäonnistui.",
        "backend-fail-backup": "Tiedostoa $1 ei voitu varmuuskopioida.",
        "backend-fail-notexists": "Tiedostoa $1 ei ole olemassa.",
        "emailccsubject": "Kopio lähettämästäsi viestistä osoitteeseen $1: $2",
        "emailsent": "Sähköposti lähetetty",
        "emailsenttext": "Sähköpostiviestisi on lähetetty.",
-       "emailuserfooter": "Tämän sähköpostin {{GENDER:$1|lähetti}} $1 vastaanottajalle {{GENDER:$2|$2}} käyttämällä ”{{int:emailuser}}” -toimintoa {{GRAMMAR:inessive|{{SITENAME}}}}. Jos vastaat tähän sähköpostiin, sinun sähköpostiviestisi lähetetään suoraan {{GENDER:$1|alkuperäiselle lähettäjälle}} ja samalla paljastetaan {{GENDER:$2|sinun}} sähköpostiosoitteesi {{GENDER:$1|hänelle}}.",
+       "emailuserfooter": "Tämän sähköpostin {{GENDER:$1|lähetti}} $1 vastaanottajalle {{GENDER:$2|$2}} käyttämällä ”{{int:emailuser}}” -toimintoa {{GRAMMAR:inessive|{{SITENAME}}}}. Jos vastaat tähän sähköpostiin, sähköpostiviestisi lähetetään suoraan {{GENDER:$1|alkuperäiselle lähettäjälle}} ja samalla paljastetaan {{GENDER:$2|sinun}} sähköpostiosoitteesi {{GENDER:$1|hänelle}}.",
        "usermessage-summary": "Jätetään järjestelmäviesti.",
        "usermessage-editor": "Järjestelmäviestittäjä",
        "watchlist": "Tarkkailulista",
        "deletereason-dropdown": "* Yleiset poistosyyt\n** Spam tai mainossivu\n** Vandalismi\n** Tekijänoikeusrikkomus\n** Sivun tekijän pyyntö\n** Virheellinen ohjaus",
        "delete-edit-reasonlist": "Muokkaa poistosyitä",
        "delete-toobig": "Tällä sivulla on pitkä muokkaushistoria, yli $1 {{PLURAL:$1|versio|versiota}}. \nTämänkaltaisten sivujen poistamista on rajoitettu. Tällä ehkäistään {{GRAMMAR:genitive|{{SITENAME}}}} vaurioitumista tahattomasti.",
-       "delete-warning-toobig": "Tällä sivulla on pitkä muutoshistoria – yli $1 {{PLURAL:$1|versio|versiota}}. Näin suurien muutoshistorioiden poistaminen voi haitata sivuston suorituskykyä.",
+       "delete-warning-toobig": "Tällä sivulla on pitkä muutoshistoria – yli $1 {{PLURAL:$1|versio|versiota}}. Näin suurien muutoshistorioiden poistaminen voi haitata {{GRAMMAR:genitive|{{SITENAME}}}} suorituskykyä.",
        "deleteprotected": "Et voi poistaa tätä sivua, koska se on suojattu.",
        "deleting-backlinks-warning": "<strong>Varoitus:</strong> Sivulle, jota olet poistamassa, johtaa [[Special:WhatLinksHere/{{FULLPAGENAME}}|linkkejä muilta sivuilta]], taikka sivu on sisällytetty muuhun sivuun.",
        "deleting-subpages-warning": "<strong>Varoitus:</strong> Sivu jota olet poistamassa on [[Special:PrefixIndex/{{FULLPAGENAME}}/|{{PLURAL:$1|alasivu|$1 alasivua|51=yli 50 alasivua}}]].",
        "invalidateemail": "Sähköpostiosoitteen varmennuksen peruuttaminen",
        "notificationemail_subject_changed": "{{GRAMMAR:genitive|{{SITENAME}}}} rekisteröity sähköpostiosoite on vaihdettu",
        "notificationemail_subject_removed": "{{GRAMMAR:genitive|{{SITENAME}}}} rekisteröity sähköpostiosoite on poistettu",
-       "notificationemail_body_changed": "Joku, todennäköisesti sinä, IP-osoitteesta $1 on vaihtanut tunnuksen ”$2” sähköpostiosoitteeksi ”$3” sivustolla {{SITENAME}}.\n\nJos se et ollut sinä, ota yhteyttä sivuston ylläpitäjään välittömästi.",
-       "notificationemail_body_removed": "Joku, todennäköisesti sinä, IP-osoitteesta $1 on poistanut tunnuksen ”$2” sähköpostiosoitteen sivustolla {{SITENAME}}.\n\nJos se et ollut sinä, ota yhteyttä sivuston ylläpitäjään välittömästi.",
+       "notificationemail_body_changed": "Joku, todennäköisesti sinä, IP-osoitteesta $1 on vaihtanut tunnuksen ”$2” sähköpostiosoitteeksi ”$3” {{GRAMMAR:inessive|{{SITENAME}}}}.\n\nJos se et ollut sinä, ota yhteyttä sivuston ylläpitäjään välittömästi.",
+       "notificationemail_body_removed": "Joku, todennäköisesti sinä, IP-osoitteesta $1 on poistanut tunnuksen ”$2” sähköpostiosoitteen {{GRAMMAR:inessive|{{SITENAME}}}}.\n\nJos se et ollut sinä, ota yhteyttä sivuston ylläpitäjään välittömästi.",
        "scarytranscludedisabled": "[Wikienvälinen sisällytys ei ole käytössä]",
        "scarytranscludefailed": "[Mallineen hakeminen epäonnistui: $1]",
        "scarytranscludefailed-httpstatus": "[Mallineen hakeminen epäonnistui: $1 HTTP $2]",
        "expand_templates_generate_xml": "Näytä XML-jäsennyspuu",
        "expand_templates_generate_rawhtml": "Näytä raaka HTML",
        "expand_templates_preview": "Esikatselu",
-       "expand_templates_preview_fail_html": "<em>Koska sivustolla {{SITENAME}} on käytössä suodattamaton HTML-koodi ja koska istunnon tiedot ovat kadonneet, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi.</em>\n\n<strong>Jos yritit esikatsella sivua, yritä uudestaan.</strong>\nJos esikatselu ei vieläkään toimi, yritä [[Special:UserLogout|kirjautua ulos]] ja sitten kirjautua uudestaan sisään. Tarkista myös, että selaimesi sallii evästeet tältä sivustolta.",
-       "expand_templates_preview_fail_html_anon": "<em>Koska sivustolla {{SITENAME}} on käytössä puhdas HTML-koodi ja koska et ole kirjautunut sisään, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi.</em>\n\n<strong>Jos olet oikealla asialla, [[Special:UserLogin|kirjaudu sisään]] ja yritä uudestaan.</strong>",
+       "expand_templates_preview_fail_html": "<em>Koska {{GRAMMAR:inessive|{{SITENAME}}}} on käytössä suodattamaton HTML-koodi ja koska istunnon tiedot ovat kadonneet, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi.</em>\n\n<strong>Jos yritit esikatsella sivua, yritä uudestaan.</strong>\nJos esikatselu ei vieläkään toimi, yritä [[Special:UserLogout|kirjautua ulos]] ja sitten kirjautua uudestaan sisään. Tarkista myös, että selaimesi sallii evästeet tältä sivustolta.",
+       "expand_templates_preview_fail_html_anon": "<em>Koska {{GRAMMAR:inessive|{{SITENAME}}}} on käytössä puhdas HTML-koodi ja et ole kirjautunut sisään, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi.</em>\n\n<strong>Jos olet oikealla asialla, [[Special:UserLogin|kirjaudu sisään]] ja yritä uudestaan.</strong>",
        "expand_templates_input_missing": "Sinun on annettava ainakin jotakin wikitekstiä syötteeksi.",
        "pagelanguage": "Sivun kielen vaihto",
        "pagelang-name": "Sivu",
index 6826cf6..84ba5a1 100644 (file)
        "delete-confirm": "Supprimer « $1 »",
        "delete-legend": "Supprimer",
        "historywarning": "<strong>Attention :</strong> la page que vous êtes sur le point de supprimer a un historique avec $1 {{PLURAL:$1|version|versions}} :",
-       "historyaction-submit": "Lister",
+       "historyaction-submit": "Afficher les révisions",
        "confirmdeletetext": "Vous êtes sur le point de supprimer une page ou un fichier, ainsi que toutes ses versions antérieures historisées. Veuillez confirmer que c’est bien là ce que vous voulez faire, que vous en comprenez les conséquences et que vous faites ceci en accord avec les [[{{MediaWiki:Policy-url}}|règles internes]].",
        "actioncomplete": "Action effectuée",
        "actionfailed": "L'action a échoué",
index c4bf792..d1d9de9 100644 (file)
        "nstab-special": "Bysûndere side",
        "nstab-project": "Projektside",
        "nstab-image": "Bestân",
-       "nstab-mediawiki": "Berjocht",
+       "nstab-mediawiki": "Systeemberjocht",
        "nstab-template": "Berjocht",
        "nstab-help": "Helpside",
        "nstab-category": "Kategory",
        "mainpage-nstab": "Haadside",
        "nosuchaction": "Unbekende aksje.",
-       "nosuchactiontext": "De opdracht yn de URL is ûnjildich.\nMooglik hasto in typefout makke yn de URL of in ferkearde keppeling folge.\nIt soe likegoed in programmatuerflater fan {{SITENAME}} wêze kinne.",
+       "nosuchactiontext": "De opdracht yn de URL is ûnjildich.\nMooglik hawwe jo in typflater yn 'e URL makke of in ferkearde keppeling folge.\nIt soe likegoed in programmatuerflater fan {{SITENAME}} wêze kinne.",
        "nosuchspecialpage": "Gjin soksoarte bysûndere side",
        "nospecialpagetext": "<strong>Jo hawwe in ûnjildige bysûndere side opfrege.</strong>\n\nIn list fan jildige bysûndere siden stiet op [[Special:SpecialPages|{{int:specialpages}}]].",
        "error": "Flater",
        "noname": "Jo hawwe gjin jildige meidochnamme opjûn.",
        "loginsuccesstitle": "Oanmelden slagge.",
        "loginsuccess": "<strong>Jo binne no oanmeld op {{SITENAME}} as \"$1\".</strong>",
-       "nosuchuser": "Der is gjin meidogger \"$1\".\nKontrolearje de stavering, of [[Special:CreateAccount|meitsje in nije meidogger oan]].",
+       "nosuchuser": "Der is gjin meidogger mei de namme \"$1\".\nMeidochnammen binne haadlettergefoelich.\nSjoch de stavering nei, of [[Special:CreateAccount|meitsje in nij akkount oan]].",
        "nosuchusershort": "Der is gjin meidogger mei de namme \"$1\". It is goed skreaun?",
        "nouserspecified": "Jo moatte in meidochnamme opjaan.",
        "wrongpassword": "Ferkearde meidochnamme of wachtwurd ynfolle.\nBesykje it nochris.",
        "headline_sample": "Koptekst",
        "headline_tip": "Underkopke",
        "nowiki_sample": "Foechje hjir platte tekst yn",
-       "nowiki_tip": "Negearje it wiki formaat",
+       "nowiki_tip": "Wiki-opmaak negearje",
        "image_sample": "Foarbyld.jpg",
        "image_tip": "Mediabestân",
        "media_tip": "Link nei bestân",
        "tooltip-ca-nstab-special": "Dit is in bysûndere side, en kin net bewurke wurde",
        "tooltip-ca-nstab-project": "Projektside sjen litte",
        "tooltip-ca-nstab-image": "De bestânsside sjen litte",
-       "tooltip-ca-nstab-mediawiki": "Systeemberjocht sjen litte",
-       "tooltip-ca-nstab-template": "Sjabloan sjen litte",
+       "tooltip-ca-nstab-mediawiki": "It systeemberjocht sjen litte",
+       "tooltip-ca-nstab-template": "It berjocht sjen litte",
        "tooltip-ca-nstab-help": "Helpside sjen litte",
        "tooltip-ca-nstab-category": "Kategory-side sjen litte",
        "tooltip-minoredit": "Markearje dizze feroaring as fan lytse betsjutting",
index fb8899f..4c10d9c 100644 (file)
        "mainpage": "Գլխավոր էջ",
        "mainpage-description": "Գլխավոր էջ",
        "policy-url": "Project:Կանոնակարգ",
-       "portal": "Խորհրդարան",
+       "portal": "Համայնքային պորտալ",
        "portal-url": "Project:Համայնքային պորտալ",
        "privacy": "Գաղտնիության քաղաքականություն",
        "privacypage": "Project:Գաղտնիության քաղաքականություն",
index 7988367..b0fb634 100644 (file)
        "logentry-newusers-autocreate": "$1 մասնակցային հաշիւը {{GENDER:$2|ստեղծուած է}} ինքնաբերաբար",
        "logentry-upload-upload": "$1 {{GENDER:$2|ներբեռնուած է}} $3",
        "logentry-upload-overwrite": "$1 {{GENDER:$2|վերբեռնեց}} $3ի նոր տարբերակ",
+       "feedback-cancel": "Չեղարկել",
        "searchsuggest-search": "Որոնել {{SITENAME}} կայքին մէջ",
        "duration-days": "$1 {{PLURAL:$1|օր}}",
        "randomrootpage": "Պատահական արմատ էջ"
index 54c5788..50b9441 100644 (file)
        "delete-confirm": "Cancella \"$1\"",
        "delete-legend": "Cancella",
        "historywarning": "'''Attenzione:''' La pagina che stai per cancellare ha una cronologia con $1 {{PLURAL:$1|versione|versioni}}:",
-       "historyaction-submit": "Mostra",
+       "historyaction-submit": "Mostra versioni",
        "confirmdeletetext": "Stai per cancellare una pagina con tutta la sua cronologia. Per cortesia, conferma che è tua intenzione procedere a tale cancellazione, che hai piena consapevolezza delle conseguenze della tua azione e che essa è conforme alle linee guida stabilite in [[{{MediaWiki:Policy-url}}]].",
        "actioncomplete": "Azione completata",
        "actionfailed": "Azione fallita",
index f3425ca..d4b3e7f 100644 (file)
        "delete-confirm": "Läsche vu(n) \"$1\"",
        "delete-legend": "Läschen",
        "historywarning": "<strong>Opgepasst:</strong> D'Säit déi Dir läsche wëllt huet en Historique mat $1 {{PLURAL:$1|Versioun|Versiounen}}:",
-       "historyaction-submit": "Weisen",
+       "historyaction-submit": "Versioune weisen",
        "confirmdeletetext": "Dir sidd am Gaang, eng Säit mat hirem kompletten Historique vollstänneg aus der Datebank ze läschen.\nW.e.g. confirméiert, datt Dir dëst wierklech wëllt, datt Dir d'Konsequenze verstitt, an datt dat Ganzt am Aklang mat de [[{{MediaWiki:Policy-url}}|Richtlinne]] geschitt.",
        "actioncomplete": "Aktioun ofgeschloss",
        "actionfailed": "Aktioun huet net funktionéiert",
        "deleting-backlinks-warning": "<strong>Opgepasst:</strong> [[Special:WhatLinksHere/{{FULLPAGENAME}}|Aner Säite]] linken op déi Säit déi Dir am Gaang sidd ze läschen oder déi Säit Déi Dir am Gaang sidd ze läschen ass an aner Säiten agebonn.",
        "deleting-subpages-warning": "<strong>Opgepasst:</strong> D'Säit, déi Dir läsche wëllt, huet [[Special:PrefixIndex/{{FULLPAGENAME}}/|{{PLURAL:$1|eng Ënnersäit|$1 Ënnersäiten|51=méi wéi 50 Ënnersäiten}}]].",
        "rollback": "Ännerungen zrécksetzen",
+       "rollback-confirmation-confirm": "W.e.g. Konfirméieren",
        "rollback-confirmation-yes": "Zrécksetzen",
        "rollback-confirmation-no": "Ofbriechen",
        "rollbacklink": "Zrécksetzen",
index 7458dea..7679db6 100644 (file)
        "thu": "پٱن شٱمٱ",
        "fri": "جۏمٱ",
        "sat": "شٱمٱ",
-       "january": "جانڤیٱ",
+       "january": "ژانڤیٱ",
        "february": "فڤریٱ",
        "march": "مارس",
        "april": "آڤریل",
        "may_long": "ماٛی",
-       "june": "جوئٱن",
+       "june": "ژوئٱن",
        "july": "جۊلای",
        "august": "آگوست",
        "september": "سپتامر",
        "october-gen": "اوکتوبر",
        "november-gen": "نوڤامر",
        "december-gen": "دسامر",
-       "jan": "جانڤیٱ",
+       "jan": "ژانڤیٱ",
        "feb": "فڤریٱ",
        "mar": "مارس",
        "apr": "آڤریل",
        "may": "ماٛی",
-       "jun": "جوئٱن",
+       "jun": "ژوئٱن",
        "jul": "جۊلای",
        "aug": "آگوست",
        "sep": "سپتامر",
        "history": "ڤیرگار بٱلگٱ",
        "history_short": "ڤیرگار",
        "updatedmarker": "د آخئری دییئن مئنە ڤئ هنگوم کو",
-       "printableversion": "نۏسخٱ پلا بینی",
+       "printableversion": "نۏسخٱ پلا بیئنی",
        "permalink": "هوم پاٛڤٱن هٱمیشاٛیی",
        "print": "چاپ گئرئتئن",
        "view": "دیئن",
        "pool-errorunknown": "خأطا نادیار",
        "pool-servererror": "پوٙل ئشمار خئذمأتگە د دأسرئس نی($1).",
        "poolcounter-usage-error": "خأطا ڤئ کار گئرئتئن:$1",
-       "aboutsite": "دبارٱ {{SITENAME}}",
-       "aboutpage": "Project:دبارٱ",
+       "aboutsite": "دٱربارٱ {{SITENAME}}",
+       "aboutpage": "Project:دٱربارٱ",
        "copyright": "مینۊنٱیا هان د دٱسرس $1 مٱر یٱ کاٛ ڤ یاٛ گاٛل شیڤاٛ هٱنی نیسٱنٱ بۊٱ.",
        "copyrightpage": "{{ns:project}}:کوپی رایت",
        "currentevents": "روخ ڤنؽا ایسنی",
        "tooltip-ca-watch": "اْزاف کردن اؽ بٱلگٱ ڤ نوم نڤشت پاٛگیریاتو",
        "tooltip-ca-unwatch": "ڤرداشتن اؽ بٱلگٱ ڤ نوم نڤشت پاٛگیریاتو",
        "tooltip-search": "پاٛ جۊری {{SITENAME}}",
-       "tooltip-search-go": "رۉ د بٱلگاٛیؽ کاْ یٱ نوم روسی ها مؽنش ٱلڤٱت ٱر دش بۊئٱ",
+       "tooltip-search-go": "رۉ د بٱلگاٛیؽ کاْ یٱ نوم راسی ها مؽنش ٱلڤٱت ٱر دش بۊئٱ",
        "tooltip-search-fulltext": "بٱلگٱیاناْ سی چنی نیسساٛیؽ پاٛ جۊری بٱکو.",
        "tooltip-p-logo": "ساٛلٛ سرآسونٱ بٱکؽت",
        "tooltip-n-mainpage": "سرآسونٱ ناْ ساٛلٛ بٱکؽت",
        "tooltip-n-mainpage-description": "سرآسونٱ ناْ ساٛلٛ بٱکؽت",
        "tooltip-n-portal": "دبارٱ پرۉژٱ؛ شما مؽ تونؽت چؽ بٱکؽت؛ د کوجا اؽ چیاناْ بٱجۊرؽت.",
-       "tooltip-n-currentevents": "ساڤند دونسمنیایؽ کا هان د روخ ڤنؽا تازٱ باڤ دؽاری بٱک",
+       "tooltip-n-currentevents": "ساڤن دونسمنیایؽ کا هان د روخ ڤٱنؽا تازٱ بۊ دؽاری بٱک",
        "tooltip-n-recentchanges": "یاٛ نومگٱ سی آلشتکاریا د ڤیکی",
        "tooltip-n-randompage": "سڤار کرد بٱلگٱ بٱختٱکی",
        "tooltip-n-help": "یاٛ جاگٱ سی فٱمسن",
index 4daa9ba..72d86c1 100644 (file)
        "mycontris": "Indėlis",
        "anoncontribs": "Indėlis",
        "contribsub2": "Naudotojas: {{GENDER:$3|$1}} ($2)",
+       "contributions-subtitle": "{{GENDER:$3|$1}}",
        "contributions-userdoesnotexist": "Naudotojo paskyra „$1“ neužregistruota.",
        "nocontribs": "Jokie keitimai neatitiko šių kriterijų.",
        "uctop": "dabartinis",
index bbf8090..c7ce646 100644 (file)
        "accountcreatedtext": "Корисничката сметка за [[{{ns:User}}:$1|$1]] ([[{{ns:User talk}}:$1|разговор]]) е направена.",
        "createaccount-title": "Создавање на сметка за {{SITENAME}}",
        "createaccount-text": "Некој направил сметка со вашата е-поштенска адреса на {{SITENAME}} ($4) со име „$2“ и  лозинка „$3“.\nБи требало сега да се пријавите и да ја промените вашата лозинка.\n\nМожете да ја занемарите оваа порака ако сметката била направена по грешка.",
-       "login-throttled": "Ð\98маÑ\82е Ð¿Ñ\80емногÑ\83 Ð¾Ð±Ð¸Ð´Ð¸ Ð·Ð° Ð½Ð°Ñ\98ава Ð·Ð° ÐºÑ\80аÑ\82ко Ð²Ñ\80еме.\nПочекајте $1 пред да се обидете повторно.",
+       "login-throttled": "Ð\9dапÑ\80авивÑ\82е Ð¿Ñ\80емногÑ\83 Ð¾Ð±Ð¸Ð´Ð¸ Ð·Ð° Ð½Ð°Ñ\98ава.\nПочекајте $1 пред да се обидете повторно.",
        "login-abort-generic": "Најавата е неуспешна — Откажано",
        "login-migrated-generic": "Вашата сметка е пренесена и корисничкото име веќе не постои на ова вики.",
        "loginlanguagelabel": "Јазик: $1",
        "rcfilters-restore-default-filters": "Поврати основни филтри",
        "rcfilters-clear-all-filters": "Тргни ги сите филтри",
        "rcfilters-show-new-changes": "Погл. најнови промени",
-       "rcfilters-search-placeholder": "Филтрирање на промени (со менито или пребарајте назив на филтер)",
+       "rcfilters-search-placeholder": "Филтрирање на промени (користете го менито или пребарајте назив на филтер)",
        "rcfilters-invalid-filter": "Неважечки филтер",
        "rcfilters-empty-filter": "Нема активни филтри. Прикажани се сите придонеси.",
        "rcfilters-filterlist-title": "Филтри",
        "delete-confirm": "Бришење на „$1“",
        "delete-legend": "Бришење",
        "historywarning": "<strong>Предупредување:</strong> Страницата што сакате да ја избришете има историја со {{PLURAL:$1|една преработка|$1 преработки}}:",
-       "historyaction-submit": "Прикажи",
+       "historyaction-submit": "Прикажи преработки",
        "confirmdeletetext": "На пат сте трајно да избришете страница заедно со нејзината историја.\nПотврдете дека имате намера да го направите ова, дека ги разбирате последиците од тоа и дека го правите во согласност со [[{{MediaWiki:Policy-url}}|правилата]].",
        "actioncomplete": "Дејството е извршено",
        "actionfailed": "Неуспешно дејство",
        "duration-hours": "{{PLURAL:$1|еден час|$1 часа}}",
        "duration-days": "{{PLURAL:$1|еден ден|$1 дена}}",
        "duration-weeks": "$1 {{PLURAL:$1|недела|недели}}",
-       "duration-years": "{{PLURAL:$1|$1 година|$1 години}}",
+       "duration-years": "$1 {{PLURAL:$1|година|години}}",
        "duration-decades": "$1 {{PLURAL:$1|деценија|децении}}",
        "duration-centuries": "$1 {{PLURAL:$1|век|века}}",
        "duration-millennia": "$1 {{PLURAL:$1|милениум|милениуми}}",
index dfaf649..489fba6 100644 (file)
        "userpage-userdoesnotexist": "Gebruikersaccount \"$1\" bestaat niet.\nControleer of u deze pagina wel wilt aanmaken/bewerken.",
        "userpage-userdoesnotexist-view": "Gebruikersaccount \"$1\" bestaat niet.",
        "blocked-notice-logextract": "Deze gebruiker is momenteel geblokkeerd.\nDe laatste regel uit het blokkeerlogboek wordt hieronder ter referentie weergegeven:",
-       "clearyourcache": "<strong>Opmerking:</strong> nadat u de wijzigingen hebt opgeslagen is het wellicht nodig uw browsercache te legen.\n* <strong>Firefox / Safari:</strong> houd <em>Shift</em> ingedrukt terwijl u op <em>Vernieuwen</em> klikt of druk op <em>Ctrl-F5</em> of <em>Ctrl-R</em> (<em>⌘-Shift-R</em> op een Mac)\n* <strong>Google Chrome:</strong> druk op <em>Ctrl-Shift-R</em> (<em>⌘-Shift-R</em> op een Mac)\n* <strong>Internet Explorer:</strong> houd <em>Ctrl</em> ingedrukt terwijl u op <em>Vernieuwen</em> klikt of druk op <em>Ctrl-F5</em>\n* '''Opera:''' ga naar <em>Menu → Instellingen</em> (<em>Opera → Voorkeuren</em> op een Mac) en daarna naar <em>Privacy & beveiliging → Browsegegevens wissen... →  Tijdelijk opgeslgen afbeeldingen en bestanden</em>.",
+       "clearyourcache": "<strong>Opmerking:</strong> nadat u de wijzigingen hebt opgeslagen is het wellicht nodig uw browsercache te legen.\n* <strong>Firefox / Safari:</strong> houd <em>Shift</em> ingedrukt terwijl u op <em>Vernieuwen</em> klikt of druk op <em>Ctrl-F5</em> of <em>Ctrl-R</em> (<em>⌘-Shift-R</em> op een Mac)\n* <strong>Google Chrome:</strong> druk op <em>Ctrl-Shift-R</em> (<em>⌘-Shift-R</em> op een Mac)\n* <strong>Internet Explorer:</strong> houd <em>Ctrl</em> ingedrukt terwijl u op <em>Vernieuwen</em> klikt of druk op <em>Ctrl-F5</em>\n* '''Opera:''' ga naar <em>Menu → Instellingen</em> (<em>Opera → Voorkeuren</em> op een Mac) en daarna naar <em>Privacy & beveiliging → Browsegegevens wissen... →  Tijdelijk opgeslagen afbeeldingen en bestanden</em>.",
        "usercssyoucanpreview": "'''Tip:''' gebruik de knop \"{{int:showpreview}}\" om uw nieuwe CSS te testen alvorens op te slaan.",
        "userjsonyoucanpreview": "'''Tip:''' gebruik de knop \"{{int:showpreview}}\" om uw nieuwe JSON te testen alvorens op te slaan.",
        "userjsyoucanpreview": "'''Tip:''' gebruik de knop \"{{int:showpreview}}\" om uw nieuwe JavaScript te testen alvorens op te slaan.",
        "delete-confirm": "\"$1\" verwijderen",
        "delete-legend": "Verwijderen",
        "historywarning": "<strong>Waarschuwing:</strong> de pagina die u wilt verwijderen heeft ongeveer $1 {{PLURAL:$1|versie|versies}}:",
-       "historyaction-submit": "Weergeven",
+       "historyaction-submit": "Versies weergeven",
        "confirmdeletetext": "U staat op het punt een pagina te verwijderen, inclusief de geschiedenis.\nBevestig hieronder dat dit inderdaad uw bedoeling is, dat u de gevolgen begrijpt en dat de verwijdering overeenstemt met het [[{{MediaWiki:Policy-url}}|beleid]].",
        "actioncomplete": "Handeling voltooid",
        "actionfailed": "Handeling mislukt",
index a658900..72a4413 100644 (file)
        "page_first": "This is part of the navigation message on the top and bottom of Special pages which are lists of things in alphabetical order, e.g. the '[[Special:Categories|Categories]]' special page. It is followed by the message {{msg-mw|Viewprevnext}}.\n{{Identical|First}}",
        "page_last": "This is part of the navigation message on the top and bottom of Special pages which are lists of things in alphabetical order, e.g. the '[[Special:Categories|Categories]]' special page. It is followed by the message {{msg-mw|Viewprevnext}}.\n\n{{Identical|Last}}",
        "histlegend": "Text in history page.\n\nSee also:\n* {{msg-mw|Cur}}\n* {{msg-mw|Last}}\n* {{msg-mw|Minoreditletter}}",
-       "history-fieldset-title": "Fieldset label in the edit history pages.",
+       "history-fieldset-title": "Form legend label in the edit history page.",
        "history-show-deleted": "CheckBox to show only per [[mw:Manual:RevisionDelete|RevisionDelete]] deleted versions.\n\nUsed in History and [[Special:Contributions]].",
        "history_copyright": "{{notranslate}}",
        "histfirst": "This is part of the navigation message on the top and bottom of Page History pages which are lists of things in date order, e.g. [{{canonicalurl:Support|action=history}} Page History of Support].\n\nIt is followed by the message {{msg-mw|Viewprevnext}}.\n{{Identical|Oldest}}",
index 651acc8..dc7379a 100644 (file)
@@ -192,11 +192,11 @@ mustache:
   type: multi-file
   files:
     mustache.js:
-      src: https://raw.githubusercontent.com/janl/mustache.js/v1.0.0/mustache.js
-      integrity: sha384-k2UYqmzoiq/qgIzZvcYBxbXQW4YdPAsXDOTkHTGb9TCZ9sjCkyT4TlaUN0wQRkql
+      src: https://raw.githubusercontent.com/janl/mustache.js/v3.0.1/mustache.js
+      integrity: sha384-YjAj6Nll7fkEWzxTlE9o3NWC9qdZt1Upat6Afjib9eLs8lTODpSKEBHeXq8o/VUH
     LICENSE:
-      src: https://raw.githubusercontent.com/janl/mustache.js/v1.0.0/LICENSE
-      integrity: sha384-MYVwXwula9+YkyXexOJVZ0v0DaVvG22uX57mNq5Di+7u8OH9EG9q3yuXkp1Iehiq
+      src: https://raw.githubusercontent.com/janl/mustache.js/v3.0.1/LICENSE
+      integrity: sha384-j2EDj6YtCRgFvYDtzo6pXzbskIj/K1Yg65BL0j3/L6UIHxbMtRMJwC/W+XoYx0FZ
 
 oojs:
   type: tar
index aa1b831..4df7d1a 100644 (file)
@@ -2,6 +2,7 @@ The MIT License
 
 Copyright (c) 2009 Chris Wanstrath (Ruby)
 Copyright (c) 2010-2014 Jan Lehnardt (JavaScript)
+Copyright (c) 2010-2015 The mustache.js community
 
 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
 
index c7ffbef..8ec1b44 100644 (file)
@@ -3,54 +3,86 @@
  * http://github.com/janl/mustache.js
  */
 
-/*global define: false*/
+/*global define: false Mustache: true*/
 
-(function (global, factory) {
-  if (typeof exports === "object" && exports) {
+(function defineMustache (global, factory) {
+  if (typeof exports === 'object' && exports && typeof exports.nodeName !== 'string') {
     factory(exports); // CommonJS
-  } else if (typeof define === "function" && define.amd) {
+  } else if (typeof define === 'function' && define.amd) {
     define(['exports'], factory); // AMD
   } else {
-    factory(global.Mustache = {}); // <script>
+    global.Mustache = {};
+    factory(global.Mustache); // script, wsh, asp
   }
-}(this, function (mustache) {
+}(this, function mustacheFactory (mustache) {
 
-  var Object_toString = Object.prototype.toString;
-  var isArray = Array.isArray || function (object) {
-    return Object_toString.call(object) === '[object Array]';
+  var objectToString = Object.prototype.toString;
+  var isArray = Array.isArray || function isArrayPolyfill (object) {
+    return objectToString.call(object) === '[object Array]';
   };
 
-  function isFunction(object) {
+  function isFunction (object) {
     return typeof object === 'function';
   }
 
-  function escapeRegExp(string) {
-    return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, "\\$&");
+  /**
+   * More correct typeof string handling array
+   * which normally returns typeof 'object'
+   */
+  function typeStr (obj) {
+    return isArray(obj) ? 'array' : typeof obj;
+  }
+
+  function escapeRegExp (string) {
+    return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&');
+  }
+
+  /**
+   * Null safe way of checking whether or not an object,
+   * including its prototype, has a given property
+   */
+  function hasProperty (obj, propName) {
+    return obj != null && typeof obj === 'object' && (propName in obj);
+  }
+
+  /**
+   * Safe way of detecting whether or not the given thing is a primitive and
+   * whether it has the given property
+   */
+  function primitiveHasOwnProperty (primitive, propName) {  
+    return (
+      primitive != null
+      && typeof primitive !== 'object'
+      && primitive.hasOwnProperty
+      && primitive.hasOwnProperty(propName)
+    );
   }
 
   // Workaround for https://issues.apache.org/jira/browse/COUCHDB-577
   // See https://github.com/janl/mustache.js/issues/189
-  var RegExp_test = RegExp.prototype.test;
-  function testRegExp(re, string) {
-    return RegExp_test.call(re, string);
+  var regExpTest = RegExp.prototype.test;
+  function testRegExp (re, string) {
+    return regExpTest.call(re, string);
   }
 
   var nonSpaceRe = /\S/;
-  function isWhitespace(string) {
+  function isWhitespace (string) {
     return !testRegExp(nonSpaceRe, string);
   }
 
   var entityMap = {
-    "&": "&amp;",
-    "<": "&lt;",
-    ">": "&gt;",
+    '&': '&amp;',
+    '<': '&lt;',
+    '>': '&gt;',
     '"': '&quot;',
     "'": '&#39;',
-    "/": '&#x2F;'
+    '/': '&#x2F;',
+    '`': '&#x60;',
+    '=': '&#x3D;'
   };
 
-  function escapeHtml(string) {
-    return String(string).replace(/[&<>"'\/]/g, function (s) {
+  function escapeHtml (string) {
+    return String(string).replace(/[&<>"'`=\/]/g, function fromEntityMap (s) {
       return entityMap[s];
     });
   }
    * array of tokens in the subtree and 2) the index in the original template at
    * which the closing tag for that section begins.
    */
-  function parseTemplate(template, tags) {
+  function parseTemplate (template, tags) {
     if (!template)
       return [];
 
 
     // Strips all whitespace tokens array for the current line
     // if there was a {{#tag}} on it and otherwise only space.
-    function stripSpace() {
+    function stripSpace () {
       if (hasTag && !nonSpace) {
         while (spaces.length)
           delete tokens[spaces.pop()];
     }
 
     var openingTagRe, closingTagRe, closingCurlyRe;
-    function compileTags(tags) {
-      if (typeof tags === 'string')
-        tags = tags.split(spaceRe, 2);
+    function compileTags (tagsToCompile) {
+      if (typeof tagsToCompile === 'string')
+        tagsToCompile = tagsToCompile.split(spaceRe, 2);
 
-      if (!isArray(tags) || tags.length !== 2)
-        throw new Error('Invalid tags: ' + tags);
+      if (!isArray(tagsToCompile) || tagsToCompile.length !== 2)
+        throw new Error('Invalid tags: ' + tagsToCompile);
 
-      openingTagRe = new RegExp(escapeRegExp(tags[0]) + '\\s*');
-      closingTagRe = new RegExp('\\s*' + escapeRegExp(tags[1]));
-      closingCurlyRe = new RegExp('\\s*' + escapeRegExp('}' + tags[1]));
+      openingTagRe = new RegExp(escapeRegExp(tagsToCompile[0]) + '\\s*');
+      closingTagRe = new RegExp('\\s*' + escapeRegExp(tagsToCompile[1]));
+      closingCurlyRe = new RegExp('\\s*' + escapeRegExp('}' + tagsToCompile[1]));
     }
 
     compileTags(tags || mustache.tags);
    * Combines the values of consecutive text tokens in the given `tokens` array
    * to a single token.
    */
-  function squashTokens(tokens) {
+  function squashTokens (tokens) {
     var squashedTokens = [];
 
     var token, lastToken;
    * all tokens that appear in that section and 2) the index in the original
    * template that represents the end of that section.
    */
-  function nestTokens(tokens) {
+  function nestTokens (tokens) {
     var nestedTokens = [];
     var collector = nestedTokens;
     var sections = [];
       token = tokens[i];
 
       switch (token[0]) {
-      case '#':
-      case '^':
-        collector.push(token);
-        sections.push(token);
-        collector = token[4] = [];
-        break;
-      case '/':
-        section = sections.pop();
-        section[5] = token[2];
-        collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens;
-        break;
-      default:
-        collector.push(token);
+        case '#':
+        case '^':
+          collector.push(token);
+          sections.push(token);
+          collector = token[4] = [];
+          break;
+        case '/':
+          section = sections.pop();
+          section[5] = token[2];
+          collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens;
+          break;
+        default:
+          collector.push(token);
       }
     }
 
    * A simple string scanner that is used by the template parser to find
    * tokens in template strings.
    */
-  function Scanner(string) {
+  function Scanner (string) {
     this.string = string;
     this.tail = string;
     this.pos = 0;
   /**
    * Returns `true` if the tail is empty (end of string).
    */
-  Scanner.prototype.eos = function () {
-    return this.tail === "";
+  Scanner.prototype.eos = function eos () {
+    return this.tail === '';
   };
 
   /**
    * Tries to match the given regular expression at the current position.
    * Returns the matched text if it can match, the empty string otherwise.
    */
-  Scanner.prototype.scan = function (re) {
+  Scanner.prototype.scan = function scan (re) {
     var match = this.tail.match(re);
 
     if (!match || match.index !== 0)
    * Skips all text until the given regular expression can be matched. Returns
    * the skipped string, which is the entire tail if no match can be made.
    */
-  Scanner.prototype.scanUntil = function (re) {
+  Scanner.prototype.scanUntil = function scanUntil (re) {
     var index = this.tail.search(re), match;
 
     switch (index) {
-    case -1:
-      match = this.tail;
-      this.tail = "";
-      break;
-    case 0:
-      match = "";
-      break;
-    default:
-      match = this.tail.substring(0, index);
-      this.tail = this.tail.substring(index);
+      case -1:
+        match = this.tail;
+        this.tail = '';
+        break;
+      case 0:
+        match = '';
+        break;
+      default:
+        match = this.tail.substring(0, index);
+        this.tail = this.tail.substring(index);
     }
 
     this.pos += match.length;
    * Represents a rendering context by wrapping a view object and
    * maintaining a reference to the parent context.
    */
-  function Context(view, parentContext) {
-    this.view = view == null ? {} : view;
+  function Context (view, parentContext) {
+    this.view = view;
     this.cache = { '.': this.view };
     this.parent = parentContext;
   }
    * Creates a new context using the given view with this context
    * as the parent.
    */
-  Context.prototype.push = function (view) {
+  Context.prototype.push = function push (view) {
     return new Context(view, this);
   };
 
    * Returns the value of the given name in this context, traversing
    * up the context hierarchy if the value is absent in this context's view.
    */
-  Context.prototype.lookup = function (name) {
+  Context.prototype.lookup = function lookup (name) {
     var cache = this.cache;
 
     var value;
-    if (name in cache) {
+    if (cache.hasOwnProperty(name)) {
       value = cache[name];
     } else {
-      var context = this, names, index;
+      var context = this, intermediateValue, names, index, lookupHit = false;
 
       while (context) {
         if (name.indexOf('.') > 0) {
-          value = context.view;
+          intermediateValue = context.view;
           names = name.split('.');
           index = 0;
 
-          while (value != null && index < names.length)
-            value = value[names[index++]];
-        } else if (typeof context.view == 'object') {
-          value = context.view[name];
+          /**
+           * Using the dot notion path in `name`, we descend through the
+           * nested objects.
+           *
+           * To be certain that the lookup has been successful, we have to
+           * check if the last object in the path actually has the property
+           * we are looking for. We store the result in `lookupHit`.
+           *
+           * This is specially necessary for when the value has been set to
+           * `undefined` and we want to avoid looking up parent contexts.
+           *
+           * In the case where dot notation is used, we consider the lookup
+           * to be successful even if the last "object" in the path is
+           * not actually an object but a primitive (e.g., a string, or an
+           * integer), because it is sometimes useful to access a property
+           * of an autoboxed primitive, such as the length of a string.
+           **/
+          while (intermediateValue != null && index < names.length) {
+            if (index === names.length - 1)
+              lookupHit = (
+                hasProperty(intermediateValue, names[index]) 
+                || primitiveHasOwnProperty(intermediateValue, names[index])
+              );
+
+            intermediateValue = intermediateValue[names[index++]];
+          }
+        } else {
+          intermediateValue = context.view[name];
+
+          /**
+           * Only checking against `hasProperty`, which always returns `false` if
+           * `context.view` is not an object. Deliberately omitting the check
+           * against `primitiveHasOwnProperty` if dot notation is not used.
+           *
+           * Consider this example:
+           * ```
+           * Mustache.render("The length of a football field is {{#length}}{{length}}{{/length}}.", {length: "100 yards"})
+           * ```
+           *
+           * If we were to check also against `primitiveHasOwnProperty`, as we do
+           * in the dot notation case, then render call would return:
+           *
+           * "The length of a football field is 9."
+           *
+           * rather than the expected:
+           *
+           * "The length of a football field is 100 yards."
+           **/
+          lookupHit = hasProperty(context.view, name);
         }
 
-        if (value != null)
+        if (lookupHit) {
+          value = intermediateValue;
           break;
+        }
 
         context = context.parent;
       }
    * string, given a context. It also maintains a cache of templates to
    * avoid the need to parse the same template twice.
    */
-  function Writer() {
+  function Writer () {
     this.cache = {};
   }
 
   /**
    * Clears all cached templates in this writer.
    */
-  Writer.prototype.clearCache = function () {
+  Writer.prototype.clearCache = function clearCache () {
     this.cache = {};
   };
 
   /**
-   * Parses and caches the given `template` and returns the array of tokens
+   * Parses and caches the given `template` according to the given `tags` or
+   * `mustache.tags` if `tags` is omitted,  and returns the array of tokens
    * that is generated from the parse.
    */
-  Writer.prototype.parse = function (template, tags) {
+  Writer.prototype.parse = function parse (template, tags) {
     var cache = this.cache;
-    var tokens = cache[template];
+    var cacheKey = template + ':' + (tags || mustache.tags).join(':');
+    var tokens = cache[cacheKey];
 
     if (tokens == null)
-      tokens = cache[template] = parseTemplate(template, tags);
+      tokens = cache[cacheKey] = parseTemplate(template, tags);
 
     return tokens;
   };
    * names and templates of partials that are used in the template. It may
    * also be a function that is used to load partial templates on the fly
    * that takes a single argument: the name of the partial.
+   *
+   * If the optional `tags` argument is given here it must be an array with two
+   * string values: the opening and closing tags used in the template (e.g.
+   * [ "<%", "%>" ]). The default is to mustache.tags.
    */
-  Writer.prototype.render = function (template, view, partials) {
-    var tokens = this.parse(template);
+  Writer.prototype.render = function render (template, view, partials, tags) {
+    var tokens = this.parse(template, tags);
     var context = (view instanceof Context) ? view : new Context(view);
-    return this.renderTokens(tokens, context, partials, template);
+    return this.renderTokens(tokens, context, partials, template, tags);
   };
 
   /**
    * If the template doesn't use higher-order sections, this argument may
    * be omitted.
    */
-  Writer.prototype.renderTokens = function (tokens, context, partials, originalTemplate) {
+  Writer.prototype.renderTokens = function renderTokens (tokens, context, partials, originalTemplate, tags) {
     var buffer = '';
 
-    // This function is used to render an arbitrary template
-    // in the current context by higher-order sections.
-    var self = this;
-    function subRender(template) {
-      return self.render(template, context, partials);
-    }
-
-    var token, value;
+    var token, symbol, value;
     for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) {
+      value = undefined;
       token = tokens[i];
+      symbol = token[0];
 
-      switch (token[0]) {
-      case '#':
-        value = context.lookup(token[1]);
+      if (symbol === '#') value = this.renderSection(token, context, partials, originalTemplate);
+      else if (symbol === '^') value = this.renderInverted(token, context, partials, originalTemplate);
+      else if (symbol === '>') value = this.renderPartial(token, context, partials, tags);
+      else if (symbol === '&') value = this.unescapedValue(token, context);
+      else if (symbol === 'name') value = this.escapedValue(token, context);
+      else if (symbol === 'text') value = this.rawValue(token);
 
-        if (!value)
-          continue;
+      if (value !== undefined)
+        buffer += value;
+    }
 
-        if (isArray(value)) {
-          for (var j = 0, valueLength = value.length; j < valueLength; ++j) {
-            buffer += this.renderTokens(token[4], context.push(value[j]), partials, originalTemplate);
-          }
-        } else if (typeof value === 'object' || typeof value === 'string') {
-          buffer += this.renderTokens(token[4], context.push(value), partials, originalTemplate);
-        } else if (isFunction(value)) {
-          if (typeof originalTemplate !== 'string')
-            throw new Error('Cannot use higher-order sections without the original template');
+    return buffer;
+  };
 
-          // Extract the portion of the original template that the section contains.
-          value = value.call(context.view, originalTemplate.slice(token[3], token[5]), subRender);
+  Writer.prototype.renderSection = function renderSection (token, context, partials, originalTemplate) {
+    var self = this;
+    var buffer = '';
+    var value = context.lookup(token[1]);
 
-          if (value != null)
-            buffer += value;
-        } else {
-          buffer += this.renderTokens(token[4], context, partials, originalTemplate);
-        }
+    // This function is used to render an arbitrary template
+    // in the current context by higher-order sections.
+    function subRender (template) {
+      return self.render(template, context, partials);
+    }
 
-        break;
-      case '^':
-        value = context.lookup(token[1]);
+    if (!value) return;
 
-        // Use JavaScript's definition of falsy. Include empty arrays.
-        // See https://github.com/janl/mustache.js/issues/186
-        if (!value || (isArray(value) && value.length === 0))
-          buffer += this.renderTokens(token[4], context, partials, originalTemplate);
+    if (isArray(value)) {
+      for (var j = 0, valueLength = value.length; j < valueLength; ++j) {
+        buffer += this.renderTokens(token[4], context.push(value[j]), partials, originalTemplate);
+      }
+    } else if (typeof value === 'object' || typeof value === 'string' || typeof value === 'number') {
+      buffer += this.renderTokens(token[4], context.push(value), partials, originalTemplate);
+    } else if (isFunction(value)) {
+      if (typeof originalTemplate !== 'string')
+        throw new Error('Cannot use higher-order sections without the original template');
 
-        break;
-      case '>':
-        if (!partials)
-          continue;
+      // Extract the portion of the original template that the section contains.
+      value = value.call(context.view, originalTemplate.slice(token[3], token[5]), subRender);
 
-        value = isFunction(partials) ? partials(token[1]) : partials[token[1]];
+      if (value != null)
+        buffer += value;
+    } else {
+      buffer += this.renderTokens(token[4], context, partials, originalTemplate);
+    }
+    return buffer;
+  };
 
-        if (value != null)
-          buffer += this.renderTokens(this.parse(value), context, partials, value);
+  Writer.prototype.renderInverted = function renderInverted (token, context, partials, originalTemplate) {
+    var value = context.lookup(token[1]);
 
-        break;
-      case '&':
-        value = context.lookup(token[1]);
+    // Use JavaScript's definition of falsy. Include empty arrays.
+    // See https://github.com/janl/mustache.js/issues/186
+    if (!value || (isArray(value) && value.length === 0))
+      return this.renderTokens(token[4], context, partials, originalTemplate);
+  };
 
-        if (value != null)
-          buffer += value;
+  Writer.prototype.renderPartial = function renderPartial (token, context, partials, tags) {
+    if (!partials) return;
 
-        break;
-      case 'name':
-        value = context.lookup(token[1]);
+    var value = isFunction(partials) ? partials(token[1]) : partials[token[1]];
+    if (value != null)
+      return this.renderTokens(this.parse(value, tags), context, partials, value);
+  };
 
-        if (value != null)
-          buffer += mustache.escape(value);
+  Writer.prototype.unescapedValue = function unescapedValue (token, context) {
+    var value = context.lookup(token[1]);
+    if (value != null)
+      return value;
+  };
 
-        break;
-      case 'text':
-        buffer += token[1];
-        break;
-      }
-    }
+  Writer.prototype.escapedValue = function escapedValue (token, context) {
+    var value = context.lookup(token[1]);
+    if (value != null)
+      return mustache.escape(value);
+  };
 
-    return buffer;
+  Writer.prototype.rawValue = function rawValue (token) {
+    return token[1];
   };
 
-  mustache.name = "mustache.js";
-  mustache.version = "1.0.0";
-  mustache.tags = [ "{{", "}}" ];
+  mustache.name = 'mustache.js';
+  mustache.version = '3.0.1';
+  mustache.tags = [ '{{', '}}' ];
 
   // All high-level mustache.* functions use this writer.
   var defaultWriter = new Writer();
   /**
    * Clears all cached templates in the default writer.
    */
-  mustache.clearCache = function () {
+  mustache.clearCache = function clearCache () {
     return defaultWriter.clearCache();
   };
 
    * array of tokens it contains. Doing this ahead of time avoids the need to
    * parse templates on the fly as they are rendered.
    */
-  mustache.parse = function (template, tags) {
+  mustache.parse = function parse (template, tags) {
     return defaultWriter.parse(template, tags);
   };
 
   /**
    * Renders the `template` with the given `view` and `partials` using the
-   * default writer.
+   * default writer. If the optional `tags` argument is given here it must be an
+   * array with two string values: the opening and closing tags used in the
+   * template (e.g. [ "<%", "%>" ]). The default is to mustache.tags.
    */
-  mustache.render = function (template, view, partials) {
-    return defaultWriter.render(template, view, partials);
+  mustache.render = function render (template, view, partials, tags) {
+    if (typeof template !== 'string') {
+      throw new TypeError('Invalid template! Template should be a "string" ' +
+                          'but "' + typeStr(template) + '" was given as the first ' +
+                          'argument for mustache#render(template, view, partials)');
+    }
+
+    return defaultWriter.render(template, view, partials, tags);
   };
 
-  // This is here for backwards compatibility with 0.4.x.
-  mustache.to_html = function (template, view, partials, send) {
+  // This is here for backwards compatibility with 0.4.x.,
+  /*eslint-disable */ // eslint wants camel cased function name
+  mustache.to_html = function to_html (template, view, partials, send) {
+    /*eslint-enable*/
+
     var result = mustache.render(template, view, partials);
 
     if (isFunction(send)) {
   mustache.Context = Context;
   mustache.Writer = Writer;
 
+  return mustache;
 }));
index 22fe3ce..9443b19 100644 (file)
@@ -14,6 +14,7 @@ class GlobalTest extends MediaWikiTestCase {
                unlink( $readOnlyFile );
 
                $this->setMwGlobals( [
+                       'wgReadOnly' => null,
                        'wgReadOnlyFile' => $readOnlyFile,
                        'wgUrlProtocols' => [
                                'http://',
@@ -108,10 +109,6 @@ class GlobalTest extends MediaWikiTestCase {
         * @covers ::wfReadOnly
         */
        public function testReadOnlyEmpty() {
-               global $wgReadOnly;
-               $wgReadOnly = null;
-
-               MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode()->clearCache();
                $this->assertFalse( wfReadOnly() );
                $this->assertFalse( wfReadOnly() );
        }
@@ -121,23 +118,17 @@ class GlobalTest extends MediaWikiTestCase {
         * @covers ::wfReadOnly
         */
        public function testReadOnlySet() {
-               global $wgReadOnly, $wgReadOnlyFile;
-
-               $readOnlyMode = MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode();
-               $readOnlyMode->clearCache();
+               global $wgReadOnlyFile;
 
                $f = fopen( $wgReadOnlyFile, "wt" );
                fwrite( $f, 'Message' );
                fclose( $f );
-               $wgReadOnly = null; # Check on $wgReadOnlyFile
+
+               // Reset the service to avoid cached results
+               $this->overrideMwServices();
 
                $this->assertTrue( wfReadOnly() );
                $this->assertTrue( wfReadOnly() ); # Check cached
-
-               unlink( $wgReadOnlyFile );
-               $readOnlyMode->clearCache();
-               $this->assertFalse( wfReadOnly() );
-               $this->assertFalse( wfReadOnly() );
        }
 
        /**
@@ -146,9 +137,12 @@ class GlobalTest extends MediaWikiTestCase {
         */
        public function testReadOnlyGlobalChange() {
                $this->assertFalse( wfReadOnlyReason() );
+
                $this->setMwGlobals( [
                        'wgReadOnly' => 'reason'
                ] );
+               $this->overrideMwServices();
+
                $this->assertSame( 'reason', wfReadOnlyReason() );
        }
 
index b14424f..2d91d4d 100644 (file)
@@ -167,28 +167,4 @@ class ReadOnlyModeTest extends MediaWikiTestCase {
                $rom->setReason( 'override' );
                $this->assertSame( 'override', $rom->getReason() );
        }
-
-       /**
-        * @covers ReadOnlyMode::clearCache
-        * @covers ConfiguredReadOnlyMode::clearCache
-        */
-       public function testClearCache() {
-               $fileName = $this->getNewTempFile();
-               unlink( $fileName );
-               $config = new HashConfig( [
-                       'ReadOnly' => null,
-                       'ReadOnlyFile' => $fileName,
-               ] );
-               $cro = new ConfiguredReadOnlyMode( $config );
-               $lb = $this->createLB( [ 'lbMessage' => false ] );
-               $rom = new ReadOnlyMode( $cro, $lb );
-
-               $this->assertSame( false, $rom->getReason(), 'initial' );
-
-               file_put_contents( $fileName, 'file' );
-               $this->assertSame( false, $rom->getReason(), 'stale' );
-
-               $rom->clearCache();
-               $this->assertSame( 'file', $rom->getReason(), 'fresh' );
-       }
 }
index a6a92c6..b0d89a6 100644 (file)
@@ -10,11 +10,11 @@ class SiteStatsTest extends MediaWikiTestCase {
                $this->setService( 'MainWANObjectCache', $cache );
                $jobq = JobQueueGroup::singleton();
 
-               $jobq->push( new NullJob( Title::newMainPage(), [] ) );
+               $jobq->push( Job::factory( 'null', Title::newMainPage(), [] ) );
                $this->assertEquals( 1, SiteStats::jobs(),
                         'A single job enqueued bumps jobscount stat to 1' );
 
-               $jobq->push( new NullJob( Title::newMainPage(), [] ) );
+               $jobq->push( Job::factory( 'null', Title::newMainPage(), [] ) );
                $this->assertEquals( 1, SiteStats::jobs(),
                        'SiteStats::jobs() count does not reflect addition ' .
                        'of a second job (cached)'
index 4c2494a..fab49fa 100644 (file)
@@ -216,17 +216,23 @@ class NameTableStoreTest extends MediaWikiTestCase {
        /**
         * @dataProvider provideGetName
         */
-       public function testGetName( $cacheBag, $insertCalls, $selectCalls ) {
+       public function testGetName( BagOStuff $cacheBag, $insertCalls, $selectCalls ) {
+               $now = microtime( true );
+               $cacheBag->setMockTime( $now );
                // Check for operations to in-memory cache (IMC) and persistent cache (PC)
                $store = $this->getNameTableSqlStore( $cacheBag, $insertCalls, $selectCalls );
 
                // Get 1 ID and make sure getName returns correctly
                $fooId = $store->acquireId( 'foo' ); // regen PC, set IMC, update IMC, tombstone PC
+               $now += 0.01;
                $this->assertSame( 'foo', $store->getName( $fooId ) ); // use IMC
+               $now += 0.01;
 
                // Get another ID and make sure getName returns correctly
                $barId = $store->acquireId( 'bar' ); // update IMC, tombstone PC
+               $now += 0.01;
                $this->assertSame( 'bar', $store->getName( $barId ) ); // use IMC
+               $now += 0.01;
 
                // Blitz the cache and make sure it still returns
                TestingAccessWrapper::newFromObject( $store )->tableCache = null; // clear IMC
@@ -236,6 +242,7 @@ class NameTableStoreTest extends MediaWikiTestCase {
                // Blitz the cache again and get another ID and make sure getName returns correctly
                TestingAccessWrapper::newFromObject( $store )->tableCache = null; // clear IMC
                $bazId = $store->acquireId( 'baz' ); // set IMC using interim PC, update IMC, tombstone PC
+               $now += 0.01;
                $this->assertSame( 'baz', $store->getName( $bazId ) ); // uses IMC
                $this->assertSame( 'baz', $store->getName( $bazId ) ); // uses IMC
        }
index 81a80b6..1baaa54 100644 (file)
@@ -380,12 +380,12 @@ class JobQueueTest extends MediaWikiTestCase {
        }
 
        function newJob( $i = 0, $rootJob = [] ) {
-               return new NullJob( Title::newMainPage(),
+               return Job::factory( 'null', Title::newMainPage(),
                        [ 'lives' => 0, 'usleep' => 0, 'removeDuplicates' => 0, 'i' => $i ] + $rootJob );
        }
 
        function newDedupedJob( $i = 0, $rootJob = [] ) {
-               return new NullJob( Title::newMainPage(),
+               return Job::factory( 'null', Title::newMainPage(),
                        [ 'lives' => 0, 'usleep' => 0, 'removeDuplicates' => 1, 'i' => $i ] + $rootJob );
        }
 }
index 769b193..9fe3e3d 100644 (file)
@@ -29,31 +29,31 @@ class JobTest extends MediaWikiTestCase {
                return [
                        [
                                $this->getMockJob( false ),
-                               'someCommand  ' . $requestId
+                               'someCommand Special: ' . $requestId
                        ],
                        [
                                $this->getMockJob( [ 'key' => 'val' ] ),
-                               'someCommand  key=val ' . $requestId
+                               'someCommand Special: key=val ' . $requestId
                        ],
                        [
                                $this->getMockJob( [ 'key' => [ 'inkey' => 'inval' ] ] ),
-                               'someCommand  key={"inkey":"inval"} ' . $requestId
+                               'someCommand Special: key={"inkey":"inval"} ' . $requestId
                        ],
                        [
                                $this->getMockJob( [ 'val1' ] ),
-                               'someCommand  0=val1 ' . $requestId
+                               'someCommand Special: 0=val1 ' . $requestId
                        ],
                        [
                                $this->getMockJob( [ 'val1', 'val2' ] ),
-                               'someCommand  0=val1 1=val2 ' . $requestId
+                               'someCommand Special: 0=val1 1=val2 ' . $requestId
                        ],
                        [
                                $this->getMockJob( [ new stdClass() ] ),
-                               'someCommand  0=object(stdClass) ' . $requestId
+                               'someCommand Special: 0=object(stdClass) ' . $requestId
                        ],
                        [
                                $this->getMockJob( [ $mockToStringObj ] ),
-                               'someCommand  0={STRING_OBJ_VAL} ' . $requestId
+                               'someCommand Special: 0={STRING_OBJ_VAL} ' . $requestId
                        ],
                        [
                                $this->getMockJob( [
@@ -72,7 +72,7 @@ class JobTest extends MediaWikiTestCase {
                                        ],
                                        "triggeredRecursive" => true
                                ] ),
-                               'someCommand  pages={"932737":[0,"Robert_James_Waller"]} ' .
+                               'someCommand Special: pages={"932737":[0,"Robert_James_Waller"]} ' .
                                'rootJobSignature=45868e99bba89064e4483743ebb9b682ef95c1a7 ' .
                                'rootJobTimestamp=20160309110158 masterPos=' .
                                '{"file":"db1023-bin.001288","pos":"308257743","asOfTime":' .
@@ -85,11 +85,13 @@ class JobTest extends MediaWikiTestCase {
        }
 
        public function getMockJob( $params ) {
+               $title = new Title();
                $mock = $this->getMockForAbstractClass(
                        Job::class,
-                       [ 'someCommand', new Title(), $params ],
+                       [ 'someCommand', $title, $params ],
                        'SomeJob'
                );
+
                return $mock;
        }
 
@@ -115,7 +117,7 @@ class JobTest extends MediaWikiTestCase {
                return [
                        'class name' => [ 'NullJob' ],
                        'closure' => [ function ( Title $title, array $params ) {
-                               return new NullJob( $title, $params );
+                               return Job::factory( 'null', $title, $params );
                        } ],
                        'function' => [ [ $this, 'newNullJob' ] ],
                        'static function' => [ self::class . '::staticNullJob' ]
@@ -123,11 +125,91 @@ class JobTest extends MediaWikiTestCase {
        }
 
        public function newNullJob( Title $title, array $params ) {
-               return new NullJob( $title, $params );
+               return Job::factory( 'null', $title, $params );
        }
 
        public static function staticNullJob( Title $title, array $params ) {
-               return new NullJob( $title, $params );
+               return Job::factory( 'null', $title, $params );
+       }
+
+       /**
+        * @covers Job::factory
+        * @covers Job::__construct()
+        */
+       public function testJobSignatureGeneric() {
+               $testPage = Title::makeTitle( NS_PROJECT, 'x' );
+               $blankTitle = Title::makeTitle( NS_SPECIAL, '' );
+               $params = [ 'z' => 1, 'lives' => 1, 'usleep' => 0 ];
+               $paramsWithTitle = $params + [ 'namespace' => NS_PROJECT, 'title' => 'x' ];
+
+               $job = new NullJob( [ 'namespace' => NS_PROJECT, 'title' => 'x' ] + $params );
+               $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() );
+               $this->assertJobParamsMatch( $job, $paramsWithTitle );
+
+               $job = Job::factory( 'null', $testPage, $params );
+               $this->assertEquals( $blankTitle->getPrefixedText(), $job->getTitle()->getPrefixedText() );
+               $this->assertJobParamsMatch( $job, $params );
+
+               $job = Job::factory( 'null', $paramsWithTitle );
+               $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() );
+               $this->assertJobParamsMatch( $job, $paramsWithTitle );
+
+               $job = Job::factory( 'null', $params );
+               $this->assertTrue( $blankTitle->equals( $job->getTitle() ) );
+               $this->assertJobParamsMatch( $job, $params );
+       }
+
+       /**
+        * @covers Job::factory
+        * @covers Job::__construct()
+        */
+       public function testJobSignatureTitleBased() {
+               $testPage = Title::makeTitle( NS_PROJECT, 'x' );
+               $blankTitle = Title::makeTitle( NS_SPECIAL, '' );
+               $params = [ 'z' => 1, 'causeAction' => 'unknown', 'causeAgent' => 'unknown' ];
+               $paramsWithTitle = $params + [ 'namespace' => NS_PROJECT, 'title' => 'x' ];
+
+               $job = new RefreshLinksJob( $testPage, $params );
+               $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() );
+               $this->assertSame( $testPage, $job->getTitle() );
+               $this->assertJobParamsMatch( $job, $paramsWithTitle );
+               $this->assertSame( $testPage, $job->getTitle() );
+
+               $job = Job::factory( 'refreshLinks', $testPage, $params );
+               $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() );
+               $this->assertJobParamsMatch( $job, $paramsWithTitle );
+
+               $job = Job::factory( 'refreshLinks', $paramsWithTitle );
+               $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() );
+               $this->assertJobParamsMatch( $job, $paramsWithTitle );
+
+               $job = Job::factory( 'refreshLinks', $params );
+               $this->assertTrue( $blankTitle->equals( $job->getTitle() ) );
+               $this->assertJobParamsMatch( $job, $params );
+       }
+
+       /**
+        * @covers Job::factory
+        * @covers Job::__construct()
+        */
+       public function testJobSignatureTitleBasedIncomplete() {
+               $testPage = Title::makeTitle( NS_PROJECT, 'x' );
+               $blankTitle = Title::makeTitle( NS_SPECIAL, '' );
+               $params = [ 'z' => 1, 'causeAction' => 'unknown', 'causeAgent' => 'unknown' ];
+
+               $job = new RefreshLinksJob( $testPage, $params + [ 'namespace' => 0 ] );
+               $this->assertEquals( $blankTitle->getPrefixedText(), $job->getTitle()->getPrefixedText() );
+               $this->assertJobParamsMatch( $job, $params + [ 'namespace' => 0 ] );
+
+               $job = new RefreshLinksJob( $testPage, $params + [ 'title' => 'x' ] );
+               $this->assertEquals( $blankTitle->getPrefixedText(), $job->getTitle()->getPrefixedText() );
+               $this->assertJobParamsMatch( $job, $params + [ 'title' => 'x' ] );
        }
 
+       private function assertJobParamsMatch( IJobSpecification $job, array $params ) {
+               $actual = $job->getParams();
+               unset( $actual['requestId'] );
+
+               $this->assertEquals( $actual, $params );
+       }
 }
index 27cae8a..1a2941d 100644 (file)
@@ -51,13 +51,9 @@ class ClearUserWatchlistJobTest extends MediaWikiTestCase {
                $this->setMwGlobals( 'wgUpdateRowsPerQuery', 2 );
 
                JobQueueGroup::singleton()->push(
-                       new ClearUserWatchlistJob(
-                               null,
-                               [
-                                       'userId' => $user->getId(),
-                                       'maxWatchlistId' => $maxId,
-                               ]
-                       )
+                       new ClearUserWatchlistJob( [
+                               'userId' => $user->getId(), 'maxWatchlistId' => $maxId,
+                       ] )
                );
 
                $this->assertEquals( 1, JobQueueGroup::singleton()->getQueueSizes()['clearUserWatchlist'] );